| import {_testFinish, CanvasSnapshotPlugin, isWebpExportSupported, LoadingScreenPlugin, ThreeViewer} from 'threepipe' | |||||
| import { | |||||
| _testFinish, | |||||
| CanvasSnapshotPlugin, | |||||
| isWebpExportSupported, | |||||
| LoadingScreenPlugin, | |||||
| SSAAPlugin, | |||||
| ThreeViewer, | |||||
| } from 'threepipe' | |||||
| import {createSimpleButtons} from '../examples-utils/simple-bottom-buttons.js' | import {createSimpleButtons} from '../examples-utils/simple-bottom-buttons.js' | ||||
| const viewer = new ThreeViewer({ | const viewer = new ThreeViewer({ | ||||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | ||||
| msaa: true, | msaa: true, | ||||
| renderScale: 'auto', | renderScale: 'auto', | ||||
| plugins: [LoadingScreenPlugin], | |||||
| plugins: [LoadingScreenPlugin, SSAAPlugin], | |||||
| }) | }) | ||||
| async function init() { | async function init() { | ||||
| mimeType: 'image/jpeg', // mime type of the image | mimeType: 'image/jpeg', // mime type of the image | ||||
| quality: 0.9, // quality of the image (0-1) only for jpeg and webp | quality: 0.9, // quality of the image (0-1) only for jpeg and webp | ||||
| displayPixelRatio: 2, // render scale | displayPixelRatio: 2, // render scale | ||||
| waitForProgressive: true, | |||||
| progressiveFrames: 64, // wait for 64 frames of ProgressivePlugin/SSAA before exporting | |||||
| }) | }) | ||||
| btn.disabled = false | btn.disabled = false | ||||
| }, | }, | ||||
| }) | }) | ||||
| btn.disabled = false | btn.disabled = false | ||||
| }, | }, | ||||
| ['Download 3x3 Tiles (png zip)']: async(btn: HTMLButtonElement) => { | |||||
| btn.disabled = true | |||||
| await snapshotPlugin.downloadSnapshot('snapshot', { | |||||
| mimeType: 'image/png', // mime type of the image | |||||
| // scale: 1, // scale the final image | |||||
| // quality: 0.9, // quality of the image (0-1) only for jpeg and webp | |||||
| displayPixelRatio: 4, // render scale | |||||
| tileRows: 3, | |||||
| tileColumns: 3, | |||||
| }) | |||||
| btn.disabled = false | |||||
| }, | |||||
| }) | }) | ||||
| } | } |
| import {serialize, timeout} from 'ts-browser-helpers' | import {serialize, timeout} from 'ts-browser-helpers' | ||||
| import {AViewerPluginSync} from '../../viewer' | import {AViewerPluginSync} from '../../viewer' | ||||
| import {uiButton, uiConfig, uiFolderContainer, uiInput} from 'uiconfig.js' | |||||
| import {uiButton, uiConfig, uiFolderContainer, uiInput, uiVector} from 'uiconfig.js' | |||||
| import {CanvasSnapshot, CanvasSnapshotOptions} from '../../utils/canvas-snapshot' | import {CanvasSnapshot, CanvasSnapshotOptions} from '../../utils/canvas-snapshot' | ||||
| import {ProgressivePlugin} from '../pipeline/ProgressivePlugin' | import {ProgressivePlugin} from '../pipeline/ProgressivePlugin' | ||||
| import {Zippable, zipSync} from 'three/examples/jsm/libs/fflate.module.js' | |||||
| import {Vector4} from 'three' | |||||
| export interface CanvasSnapshotPluginOptions extends CanvasSnapshotOptions{ | |||||
| /** | |||||
| * If true, will wait for progressive rendering(requires {@link ProgressivePlugin}) to finish before taking snapshot | |||||
| * @default true | |||||
| */ | |||||
| waitForProgressive?: boolean | |||||
| /** | |||||
| * Number of progressive frames to wait for before taking snapshot | |||||
| @default 64 or {@link ProgressivePlugin.maxFrameCount}, whichever is higher | |||||
| */ | |||||
| progressiveFrames?: number | |||||
| /** | |||||
| * Time in ms to wait before taking the snapshot. | |||||
| * This timeout is applied before `waitForProgressive` if both are specified. | |||||
| */ | |||||
| timeout?: number, | |||||
| /** | |||||
| * Number of tile rows to split the image into | |||||
| * @default 1 | |||||
| */ | |||||
| tileRows?: number | |||||
| /** | |||||
| * Number of tile columns to split the image into | |||||
| */ | |||||
| tileColumns?: number | |||||
| } | |||||
| @uiFolderContainer('Canvas Snapshot (Image Export)') | @uiFolderContainer('Canvas Snapshot (Image Export)') | ||||
| export class CanvasSnapshotPlugin extends AViewerPluginSync { | export class CanvasSnapshotPlugin extends AViewerPluginSync { | ||||
| * @param filename default is {@link CanvasSnapshotPlugin.filename} | * @param filename default is {@link CanvasSnapshotPlugin.filename} | ||||
| * @param options waitForProgressive: wait for progressive rendering to finish, default: true | * @param options waitForProgressive: wait for progressive rendering to finish, default: true | ||||
| */ | */ | ||||
| async getFile(filename?: string, options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {waitForProgressive: true}): Promise<File|undefined> { | |||||
| async getFile(filename?: string, options: CanvasSnapshotPluginOptions = {waitForProgressive: true}): Promise<File|undefined> { | |||||
| return await this._getFile(filename || this.filename, {...options, getDataUrl: false}) as File | return await this._getFile(filename || this.filename, {...options, getDataUrl: false}) as File | ||||
| } | } | ||||
| * Returns a data url of the screenshot of the viewer canvas | * Returns a data url of the screenshot of the viewer canvas | ||||
| * @param options waitForProgressive: wait for progressive rendering to finish, default: true | * @param options waitForProgressive: wait for progressive rendering to finish, default: true | ||||
| */ | */ | ||||
| async getDataUrl(options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {}): Promise<string> { | |||||
| async getDataUrl(options: CanvasSnapshotPluginOptions = {}): Promise<string> { | |||||
| return await this._getFile('', {...options, getDataUrl: true}) as string ?? '' | return await this._getFile('', {...options, getDataUrl: true}) as string ?? '' | ||||
| } | } | ||||
| private async _getFile(filename: string, options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {}): Promise<File|string|undefined> { | |||||
| private async _getFile(filename: string, options: CanvasSnapshotPluginOptions = {}): Promise<File|string|string[]|undefined> { | |||||
| await this._viewer?.doOnce('postFrame') | await this._viewer?.doOnce('postFrame') | ||||
| const viewer = this._viewer | const viewer = this._viewer | ||||
| const canvas = this._viewer?.canvas | const canvas = this._viewer?.canvas | ||||
| if (!viewer || !canvas) return undefined | if (!viewer || !canvas) return undefined | ||||
| viewer.scene.mainCamera.setInteractions(false, CanvasSnapshotPlugin.PluginType) | |||||
| const dpr = viewer.renderManager.renderScale | const dpr = viewer.renderManager.renderScale | ||||
| if (options.displayPixelRatio !== undefined && options.displayPixelRatio !== dpr) { | if (options.displayPixelRatio !== undefined && options.displayPixelRatio !== dpr) { | ||||
| viewer.renderManager.renderScale = options.displayPixelRatio | viewer.renderManager.renderScale = options.displayPixelRatio | ||||
| } | } | ||||
| if (options.timeout) await timeout(options.timeout) | if (options.timeout) await timeout(options.timeout) | ||||
| const progressive = viewer.getPlugin(ProgressivePlugin) | const progressive = viewer.getPlugin(ProgressivePlugin) | ||||
| if (options.waitForProgressive !== false && progressive) { | |||||
| // todo: disable interactions and all so that frameCount is not affected | |||||
| await new Promise<void>((res)=>{ | |||||
| const listener = () => { | |||||
| if (!progressive.isConverged(true)) return | |||||
| viewer.removeEventListener('postFrame', listener) | |||||
| res() | |||||
| let waitForProgressive = options.waitForProgressive ?? !!progressive | |||||
| if (waitForProgressive && !progressive) { | |||||
| viewer.console.warn('CanvasSnapshotPlugin: ProgressivePlugin required to wait for progressive rendering') | |||||
| waitForProgressive = false | |||||
| } | |||||
| if (options.progressiveFrames && !waitForProgressive) { | |||||
| viewer.console.warn('CanvasSnapshotPlugin: waitForProgressive must be true to use progressiveFrames') | |||||
| } | |||||
| const lastMaxFrames = progressive?.maxFrameCount | |||||
| if (waitForProgressive && progressive) { | |||||
| progressive.maxFrameCount = Math.max(options.progressiveFrames ?? 64, progressive.maxFrameCount) | |||||
| viewer.setDirty() | |||||
| await viewer.doOnce('postFrame') | |||||
| while (!progressive.isConverged(true)) { | |||||
| await viewer.doOnce('postFrame') | |||||
| // console.log(`rendering ${ 100 * this._viewer!.renderer.frameCount / progressive.maxFrameCount }%`) | |||||
| } | |||||
| } else { | |||||
| viewer.setDirty() | |||||
| await viewer.doOnce('postFrame') | |||||
| } | |||||
| delete options.displayPixelRatio | |||||
| // const rect = options.rect | |||||
| // if (rect && viewer.renderManager.renderScale !== 1) { | |||||
| // options.rect = { | |||||
| // ...rect, | |||||
| // x: rect.x * viewer.renderManager.renderScale, | |||||
| // y: rect.y * viewer.renderManager.renderScale, | |||||
| // width: rect.width * viewer.renderManager.renderScale, | |||||
| // height: rect.height * viewer.renderManager.renderScale, | |||||
| // } | |||||
| // } | |||||
| let file | |||||
| if (options.tileRows && options.tileRows > 1 || options.tileColumns && options.tileColumns > 1) { | |||||
| const res = await CanvasSnapshot.GetTiledFiles(canvas, filename, Math.max(1, options.tileRows || 1), Math.max(1, options.tileColumns || 1), options) | |||||
| if (Array.isArray(res)) { | |||||
| if (res.length === 1) file = res[0] | |||||
| else if (res.length === 0) file = undefined | |||||
| else if (!options.getDataUrl) { | |||||
| const zippa: Zippable = {} | |||||
| for (const f of res) { | |||||
| zippa[(f as File).name] = new Uint8Array(await (f as File).arrayBuffer()) | |||||
| } | |||||
| const zipped = zipSync(zippa) | |||||
| file = new File([zipped], filename + '.zip', {type: 'application/zip', lastModified: Date.now()}) | |||||
| } else { | |||||
| file = res as string[] | |||||
| } | } | ||||
| viewer.addEventListener('postFrame', listener) | |||||
| }) | |||||
| } else await viewer.doOnce('postFrame') | |||||
| options.displayPixelRatio = 1 | |||||
| const rect = options.rect | |||||
| if (rect && viewer.renderManager.renderScale !== 1) { | |||||
| options.rect = { | |||||
| ...rect, | |||||
| x: rect.x * viewer.renderManager.renderScale, | |||||
| y: rect.y * viewer.renderManager.renderScale, | |||||
| width: rect.width * viewer.renderManager.renderScale, | |||||
| height: rect.height * viewer.renderManager.renderScale, | |||||
| } else { | |||||
| file = res | |||||
| } | } | ||||
| } else { | |||||
| file = await CanvasSnapshot.GetFile(canvas, filename, options) | |||||
| } | } | ||||
| const file = await CanvasSnapshot.GetFile(canvas, filename, options) | |||||
| options.rect = rect | |||||
| // const file = await CanvasSnapshot.GetFile(canvas, filename, options) | |||||
| // options.rect = rect | |||||
| options.displayPixelRatio = viewer.renderManager.renderScale | options.displayPixelRatio = viewer.renderManager.renderScale | ||||
| if (progressive && lastMaxFrames !== undefined) { | |||||
| progressive.maxFrameCount = lastMaxFrames | |||||
| } | |||||
| viewer.scene.mainCamera.setInteractions(true, CanvasSnapshotPlugin.PluginType) | |||||
| viewer.renderManager.renderScale = dpr | viewer.renderManager.renderScale = dpr | ||||
| return file | return file | ||||
| } | } | ||||
| @uiInput('Filename') | @uiInput('Filename') | ||||
| @serialize() | @serialize() | ||||
| filename = 'snapshot.png' | |||||
| filename = 'snapshot' | |||||
| @uiInput('Frame Count') | |||||
| @serialize() | |||||
| progressiveFrames = 64 | |||||
| @uiInput('Tile Rows') | |||||
| @serialize() | |||||
| tileRows = 1 | |||||
| @uiInput('Tile Columns') | |||||
| @serialize() | |||||
| tileColumns = 1 | |||||
| @uiVector('Crop Rect (x, y, w, h)', [0, 1], 0.001) | |||||
| @serialize() | |||||
| rect = new Vector4(0, 0, 1, 1) | |||||
| private _downloading = false | |||||
| /** | /** | ||||
| * Only for {@link downloadSnapshot} and functions using that | * Only for {@link downloadSnapshot} and functions using that | ||||
| */ | */ | ||||
| @uiConfig() | @uiConfig() | ||||
| @serialize() | @serialize() | ||||
| defaultOptions: CanvasSnapshotOptions&{waitForProgressive?: boolean} = { | |||||
| defaultOptions: CanvasSnapshotPluginOptions = { | |||||
| waitForProgressive: true, | waitForProgressive: true, | ||||
| displayPixelRatio: window.devicePixelRatio, | displayPixelRatio: window.devicePixelRatio, | ||||
| scale: 1, | scale: 1, | ||||
| timeout: 0, | timeout: 0, | ||||
| quality: 0.9, | quality: 0.9, | ||||
| tileRows: 1, | |||||
| tileColumns: 1, | |||||
| progressiveFrames: 64, | |||||
| rect: { | |||||
| x: 0, | |||||
| y: 0, | |||||
| width: 1, | |||||
| height: 1, | |||||
| normalized: true, | |||||
| assumeClientRect: false, | |||||
| }, | |||||
| } | } | ||||
| // @uiButton('Download .png', {sendArgs: false}) | // @uiButton('Download .png', {sendArgs: false}) | ||||
| async downloadSnapshot(filename?: string, options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {waitForProgressive: true}): Promise<void> { | |||||
| async downloadSnapshot(filename?: string, options: CanvasSnapshotPluginOptions = {waitForProgressive: true}): Promise<void> { | |||||
| if (!this._viewer) return | if (!this._viewer) return | ||||
| if (!options.mimeType && !filename) this.filename = this.filename.split('.').slice(0, -1).join('.') + '.png' | |||||
| const file = await this.getFile(filename, {...this.defaultOptions, ...options}) | |||||
| while (this._downloading) { | |||||
| console.warn('CanvasSnipperPlugin: Another rendering already in progress, waiting...') | |||||
| await timeout(100) | |||||
| } | |||||
| this._downloading = true | |||||
| // if (!options.mimeType && !filename) this.filename = this.filename.split('.').slice(0, -1).join('.') + '.png' | |||||
| const file = await this.getFile(filename, {...this.defaultOptions, ...options}).catch(e=>{ | |||||
| this._viewer?.console.error('CanvasSnapshotPlugin: Error exporting file', e) | |||||
| return null | |||||
| }) | |||||
| if (file) await this._viewer.exportBlob(file, file.name) | if (file) await this._viewer.exportBlob(file, file.name) | ||||
| this._downloading = false | |||||
| } | } | ||||
| @uiButton('Download .png') | @uiButton('Download .png') | ||||
| protected async _downloadPng(): Promise<void> { | protected async _downloadPng(): Promise<void> { | ||||
| this.filename = this.filename.split('.').slice(0, -1).join('.') + '.png' | |||||
| // this.filename = this.filename.split('.').slice(0, -1).join('.') + '.png' | |||||
| return this.downloadSnapshot(undefined, {mimeType: 'image/png'}) | return this.downloadSnapshot(undefined, {mimeType: 'image/png'}) | ||||
| } | } | ||||
| @uiButton('Download .jpeg') | @uiButton('Download .jpeg') | ||||
| protected async _downloadJpeg(): Promise<void> { | protected async _downloadJpeg(): Promise<void> { | ||||
| this.filename = this.filename.split('.').slice(0, -1).join('.') + '.jpeg' | |||||
| // this.filename = this.filename.split('.').slice(0, -1).join('.') + '.jpeg' | |||||
| return this.downloadSnapshot(undefined, {mimeType: 'image/jpeg'}) | return this.downloadSnapshot(undefined, {mimeType: 'image/jpeg'}) | ||||
| } | } | ||||
| @uiButton('Download .webp') | @uiButton('Download .webp') | ||||
| protected async _downloadWebp(): Promise<void> { | protected async _downloadWebp(): Promise<void> { | ||||
| this.filename = this.filename.split('.').slice(0, -1).join('.') + '.webp' | |||||
| // this.filename = this.filename.split('.').slice(0, -1).join('.') + '.webp' | |||||
| return this.downloadSnapshot(undefined, {mimeType: 'image/webp'}) | return this.downloadSnapshot(undefined, {mimeType: 'image/webp'}) | ||||
| } | } | ||||
| * | * | ||||
| * The plugin name includes GLTF, but its not really GLTF specific, it can be used to decode any meshopt compressed files. | * The plugin name includes GLTF, but its not really GLTF specific, it can be used to decode any meshopt compressed files. | ||||
| */ | */ | ||||
| export class GLTFMeshOptDecodePlugin extends SimpleEventDispatcher<''> implements IViewerPluginSync { | |||||
| export class GLTFMeshOptDecodePlugin extends SimpleEventDispatcher<'initialized'> implements IViewerPluginSync { | |||||
| declare ['constructor']: typeof GLTFMeshOptDecodePlugin | declare ['constructor']: typeof GLTFMeshOptDecodePlugin | ||||
| public static readonly PluginType = 'GLTFMeshOptDecodePlugin' | public static readonly PluginType = 'GLTFMeshOptDecodePlugin' | ||||
| enabled = true | enabled = true | ||||
| this.rootNode.appendChild(s) | this.rootNode.appendChild(s) | ||||
| this._script = s | this._script = s | ||||
| }) | }) | ||||
| return await this._initializing | |||||
| await this._initializing | |||||
| this.dispatchEvent({type: 'initialized'}) | |||||
| } | } | ||||
| dispose() { | dispose() { |
| // export | // export | ||||
| export {AssetExporterPlugin, type ExportAssetOptions} from './export/AssetExporterPlugin' | export {AssetExporterPlugin, type ExportAssetOptions} from './export/AssetExporterPlugin' | ||||
| export {CanvasSnapshotPlugin, CanvasSnipperPlugin} from './export/CanvasSnapshotPlugin' | |||||
| export {CanvasSnapshotPlugin, CanvasSnipperPlugin, type CanvasSnapshotPluginOptions} from './export/CanvasSnapshotPlugin' | |||||
| export {FileTransferPlugin} from './export/FileTransferPlugin' | export {FileTransferPlugin} from './export/FileTransferPlugin' | ||||
| // postprocessing | // postprocessing |
| import {AViewerPluginSync} from '../../viewer/AViewerPlugin' | import {AViewerPluginSync} from '../../viewer/AViewerPlugin' | ||||
| import {Dropzone} from '../../utils' | import {Dropzone} from '../../utils' | ||||
| import {uiButton, uiConfig, uiFolderContainer, UiObjectConfig, uiToggle} from 'uiconfig.js' | import {uiButton, uiConfig, uiFolderContainer, UiObjectConfig, uiToggle} from 'uiconfig.js' | ||||
| import type {AddAssetOptions, ImportFilesOptions, ImportResult} from '../../assetmanager' | |||||
| import {serialize} from 'ts-browser-helpers' | |||||
| import type {AddAssetOptions, ImportFilesOptions, ImportResult, ImportAddOptions} from '../../assetmanager' | |||||
| import {parseFileExtension, serialize} from 'ts-browser-helpers' | |||||
| export interface DropzonePluginOptions { | export interface DropzonePluginOptions { | ||||
| /** | /** | ||||
| /** | /** | ||||
| * Prompt for file selection using the browser file dialog. | * Prompt for file selection using the browser file dialog. | ||||
| */ | */ | ||||
| @uiButton('Select files') | |||||
| @uiButton('Select Local files') | |||||
| public promptForFile(): void { | public promptForFile(): void { | ||||
| if (this.isDisabled()) return | if (this.isDisabled()) return | ||||
| this.allowedExtensions = this._allowedExtensions | this.allowedExtensions = this._allowedExtensions | ||||
| this._inputEl?.click() | this._inputEl?.click() | ||||
| } | } | ||||
| /** | |||||
| * Prompt for file url. | |||||
| */ | |||||
| @uiButton('Import from URL') | |||||
| public async promptForUrl(): Promise<void> { | |||||
| if (this.isDisabled() || !this._viewer) return | |||||
| const res = await this._viewer.dialog.prompt('Enter URL: Enter a public URL for a 3d file with extension', '', true) | |||||
| if (!res || !res.length) return | |||||
| await this.load(res, {}, true) | |||||
| } | |||||
| async load(res: string, options?: ImportAddOptions, dialog = false) { | |||||
| if (!this._viewer) { | |||||
| console.warn('DropzonePlugin: viewer not set') | |||||
| return | |||||
| } | |||||
| if (this.autoImport) { | |||||
| const manager = this._viewer.assetManager | |||||
| const ext = parseFileExtension(res) | |||||
| if (this._allowedExtensions && !this._allowedExtensions.includes(ext)) { | |||||
| dialog && await this._viewer.dialog.alert(`DropzonePlugin: file extension ${ext} not allowed`) | |||||
| return | |||||
| } | |||||
| const imported = await manager.importer.import(res, { | |||||
| ...this.importOptions, | |||||
| ...options ?? {}, | |||||
| }) | |||||
| const toAdd = [...imported ?? []].flat(2).filter(v => !!v) ?? [] | |||||
| if (this.autoAdd) { | |||||
| return await manager.loadImported(toAdd, { | |||||
| ...this.addOptions, | |||||
| ...options ?? {}, | |||||
| }) | |||||
| } | |||||
| return toAdd | |||||
| } else { | |||||
| dialog && await this._viewer.dialog.alert('DropzonePlugin: autoImport is disabled, file was not imported') | |||||
| } | |||||
| } | |||||
| private _domElement?: HTMLElement | private _domElement?: HTMLElement | ||||
| constructor(options?: DropzonePluginOptions) { | constructor(options?: DropzonePluginOptions) { | ||||
| super() | super() |
| y: number; | y: number; | ||||
| /** | /** | ||||
| * Use if canvas.width !== canvas.clientWidth or height and rect is based on client rect | * Use if canvas.width !== canvas.clientWidth or height and rect is based on client rect | ||||
| * @default false | |||||
| */ | */ | ||||
| assumeClientRect?: boolean; | assumeClientRect?: boolean; | ||||
| /** | |||||
| * If true, assumes x, y, width, height are normalized to 0-1 | |||||
| * @default false | |||||
| */ | |||||
| normalized?: boolean; | |||||
| } | } | ||||
| export interface CanvasSnapshotOptions { | export interface CanvasSnapshotOptions { | ||||
| getDataUrl?: boolean, | getDataUrl?: boolean, | ||||
| mimeType?: string, | mimeType?: string, | ||||
| quality?: number, // between 0 and 1, only for image/jpeg or image/webp | quality?: number, // between 0 and 1, only for image/jpeg or image/webp | ||||
| /** | |||||
| * Crop Region to take snapshot. If not set, the whole canvas is used. | |||||
| */ | |||||
| rect?: CanvasSnapshotRect, | rect?: CanvasSnapshotRect, | ||||
| scale?: number, | scale?: number, | ||||
| timeout?: number, // in ms, if not specified, will be based on progressive rendering or 200ms | |||||
| displayPixelRatio?: number, | displayPixelRatio?: number, | ||||
| cloneCanvas?: boolean, // default = true | |||||
| cloneCanvas?: boolean, // default = true if safari, false otherwise. required for safari where canvas is flipped if premultipliedAlpha is true | |||||
| } | |||||
| function isSafari() { | |||||
| return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome') | |||||
| } | } | ||||
| export class CanvasSnapshot { | export class CanvasSnapshot { | ||||
| public static async GetClonedCanvas( | public static async GetClonedCanvas( | ||||
| canvas: HTMLCanvasElement, | canvas: HTMLCanvasElement, | ||||
| { | { | ||||
| rect = {x: 0, y: 0, width: canvas.width, height: canvas.height, assumeClientRect: false}, | |||||
| rect = {x: 0, y: 0, width: canvas.width, height: canvas.height, assumeClientRect: false, normalized: false}, | |||||
| displayPixelRatio = 1, | displayPixelRatio = 1, | ||||
| scale = 1, | scale = 1, | ||||
| }: CanvasSnapshotOptions): Promise<HTMLCanvasElement> { | }: CanvasSnapshotOptions): Promise<HTMLCanvasElement> { | ||||
| rect = {...rect} | |||||
| // return canvas.toDataURL(mimeType); | // return canvas.toDataURL(mimeType); | ||||
| // in Safari, images are flipped when premultipliedAlpha is true in canvas, so it works with 2d context, see: https://github.com/pixijs/pixi.js/blob/dev/packages/extract/src/Extract.ts and https://github.com/pixijs/pixi.js/issues/2951 | // in Safari, images are flipped when premultipliedAlpha is true in canvas, so it works with 2d context, see: https://github.com/pixijs/pixi.js/blob/dev/packages/extract/src/Extract.ts and https://github.com/pixijs/pixi.js/issues/2951 | ||||
| const destCanvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas') as HTMLCanvasElement | const destCanvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas') as HTMLCanvasElement | ||||
| destCanvas.width = rect.width * scale * displayPixelRatio | |||||
| destCanvas.height = rect.height * scale * displayPixelRatio | |||||
| // const iRect = {...rect} | // const iRect = {...rect} | ||||
| if (rect.assumeClientRect) { | |||||
| rect.x *= canvas.width / (displayPixelRatio * canvas.clientWidth) | |||||
| rect.y *= canvas.height / (displayPixelRatio * canvas.clientHeight) | |||||
| rect.width *= canvas.width / (displayPixelRatio * canvas.clientWidth) | |||||
| rect.height *= canvas.height / (displayPixelRatio * canvas.clientHeight) | |||||
| if (!rect.normalized) { | |||||
| if (rect.assumeClientRect) { | |||||
| rect.x = Math.floor(rect.x * canvas.width / (displayPixelRatio * canvas.clientWidth)) | |||||
| rect.y = Math.floor(rect.y * canvas.height / (displayPixelRatio * canvas.clientHeight)) | |||||
| rect.width = Math.floor(rect.width * canvas.width / (displayPixelRatio * canvas.clientWidth)) | |||||
| rect.height = Math.floor(rect.height * canvas.height / (displayPixelRatio * canvas.clientHeight)) | |||||
| } | |||||
| } else { | |||||
| rect.x = Math.floor(rect.x * canvas.width) | |||||
| rect.y = Math.floor(rect.y * canvas.height) | |||||
| rect.width = Math.floor(rect.width * canvas.width) | |||||
| rect.height = Math.floor(rect.height * canvas.height) | |||||
| if (rect.assumeClientRect) { | |||||
| console.warn('CanvasSnapshot: rect.assumeClientRect is ignored when rect is normalized') | |||||
| } | |||||
| } | } | ||||
| destCanvas.width = Math.floor(rect.width * scale * displayPixelRatio) | |||||
| destCanvas.height = Math.floor(rect.height * scale * displayPixelRatio) | |||||
| const destCtx = destCanvas.getContext('2d') | const destCtx = destCanvas.getContext('2d') | ||||
| if (!destCtx) { | if (!destCtx) { | ||||
| console.error('snapshot: cannot create context') | console.error('snapshot: cannot create context') | ||||
| if (img.complete) resolve() | if (img.complete) resolve() | ||||
| }) | }) | ||||
| destCtx.drawImage(img, | destCtx.drawImage(img, | ||||
| img.width * rect.x * displayPixelRatio / canvas.width, img.height * rect.y * displayPixelRatio / canvas.height, | |||||
| img.width * rect.width * displayPixelRatio / canvas.width, img.height * rect.height * displayPixelRatio / canvas.height, | |||||
| Math.floor(img.width * rect.x * displayPixelRatio / canvas.width), Math.floor(img.height * rect.y * displayPixelRatio / canvas.height), | |||||
| Math.floor(img.width * rect.width * displayPixelRatio / canvas.width), Math.floor(img.height * rect.height * displayPixelRatio / canvas.height), | |||||
| 0, 0, | 0, 0, | ||||
| destCanvas.width, | destCanvas.width, | ||||
| destCanvas.height, | destCanvas.height, | ||||
| destCtx?.drawImage( | destCtx?.drawImage( | ||||
| canvas, | canvas, | ||||
| rect.x * displayPixelRatio, rect.y * displayPixelRatio, rect.width * displayPixelRatio, rect.height * displayPixelRatio, | |||||
| Math.floor(rect.x * displayPixelRatio), Math.floor(rect.y * displayPixelRatio), Math.floor(rect.width * displayPixelRatio), Math.floor(rect.height * displayPixelRatio), | |||||
| 0, 0, destCanvas.width, destCanvas.height, | 0, 0, destCanvas.width, destCanvas.height, | ||||
| ) | ) | ||||
| } | } | ||||
| public static async GetDataUrl(canvas: HTMLCanvasElement, {mimeType = 'image/png', quality, ...options}: CanvasSnapshotOptions): Promise<string> { | public static async GetDataUrl(canvas: HTMLCanvasElement, {mimeType = 'image/png', quality, ...options}: CanvasSnapshotOptions): Promise<string> { | ||||
| const clone = options.cloneCanvas === false ? canvas : await this.GetClonedCanvas(canvas, options) | |||||
| const doClone = isSafari() || options.cloneCanvas || options.rect || options.scale || options.displayPixelRatio | |||||
| if (!doClone && (options.rect || options.scale || options.displayPixelRatio)) console.warn('CanvasSnapshot: rect, scale and displayPixelRatio are ignored when cloneCanvas is false') | |||||
| const clone = !doClone ? canvas : await this.GetClonedCanvas(canvas, options) | |||||
| // const clone = options.cloneCanvas === false ? canvas : await this.GetClonedCanvas(canvas, options) | |||||
| const url = clone.toDataURL(mimeType, quality) | const url = clone.toDataURL(mimeType, quality) | ||||
| if (!this.Debug && clone !== canvas) clone.remove() | if (!this.Debug && clone !== canvas) clone.remove() | ||||
| return url | return url | ||||
| } | } | ||||
| public static async GetBlob(canvas: HTMLCanvasElement, options: CanvasSnapshotOptions = {}): Promise<Blob> { | public static async GetBlob(canvas: HTMLCanvasElement, options: CanvasSnapshotOptions = {}): Promise<Blob> { | ||||
| const clone = options.cloneCanvas === false ? canvas : await this.GetClonedCanvas(canvas, options) | |||||
| const doClone = isSafari() || options.cloneCanvas || options.rect || options.scale || options.displayPixelRatio | |||||
| if (!doClone && (options.rect || options.scale || options.displayPixelRatio)) console.warn('rect, scale and displayPixelRatio are ignored when cloneCanvas is false') | |||||
| const clone = !doClone ? canvas : await this.GetClonedCanvas(canvas, options) | |||||
| // const clone = options.cloneCanvas === false ? canvas : await this.GetClonedCanvas(canvas, options) | |||||
| const blob = await new Promise<Blob>((resolve, reject) => { | const blob = await new Promise<Blob>((resolve, reject) => { | ||||
| clone.toBlob((b) => { | clone.toBlob((b) => { | ||||
| if (b) resolve(b) | if (b) resolve(b) | ||||
| else reject() | |||||
| else reject(new Error('CanvasSnapshot Failed to export blob from canvas')) | |||||
| }, options.mimeType ?? 'image/png', options.quality) | }, options.mimeType ?? 'image/png', options.quality) | ||||
| }) | }) | ||||
| if (!this.Debug && clone !== canvas) clone.remove() | if (!this.Debug && clone !== canvas) clone.remove() | ||||
| return blob | return blob | ||||
| } | } | ||||
| public static async GetFile(canvas: HTMLCanvasElement, filename = 'image.png', options: CanvasSnapshotOptions = {}): Promise<File|string> { | |||||
| return options.getDataUrl ? await this.GetDataUrl(canvas, options) : new File([await this.GetBlob(canvas, options)], filename, { | |||||
| public static async GetFile(canvas: HTMLCanvasElement, filename = 'image', options: CanvasSnapshotOptions = {}): Promise<File|string> { | |||||
| const suffix = '.' + (options.mimeType?.split('/')[1]?.toLowerCase() || 'png') | |||||
| const fname = !filename.toLowerCase().endsWith(suffix) ? filename + suffix : filename | |||||
| return options.getDataUrl ? await this.GetDataUrl(canvas, options) : new File([await this.GetBlob(canvas, options)], fname, { | |||||
| type: options.mimeType ?? 'image/png', | type: options.mimeType ?? 'image/png', | ||||
| lastModified: now(), | lastModified: now(), | ||||
| }) | }) | ||||
| } | } | ||||
| public static async GetTiledFiles(canvas: HTMLCanvasElement, filePrefix = 'image', tileRows = 2, tileCols = 2, options: CanvasSnapshotOptions = {}): Promise<(File|string)[]> { | |||||
| const rect = options.rect ?? {x: 0, y: 0, width: 1, height: 1, assumeClientRect: false, normalized: true} | |||||
| // rect.width *= options.displayPixelRatio ?? 1 | |||||
| // rect.height *= options.displayPixelRatio ?? 1 | |||||
| const files = [] | |||||
| for (let i = 0; i < tileCols; i++) { | |||||
| for (let j = 0; j < tileRows; j++) { | |||||
| const ext = options.mimeType?.split('/')[1] ?? 'png' | |||||
| const file = await this.GetFile(canvas, `${filePrefix}_${i}_${j}.${ext}`, { | |||||
| rect: { | |||||
| x: rect.x + i * rect.width / tileCols, | |||||
| y: rect.y + j * rect.height / tileRows, | |||||
| width: rect.width / tileCols, | |||||
| height: rect.height / tileRows, | |||||
| assumeClientRect: rect.assumeClientRect, | |||||
| normalized: rect.normalized, | |||||
| }, | |||||
| }).catch(e => { | |||||
| console.error(`CanvasSnapshot - Error exporting tiled file ${i}, ${j}`, e) | |||||
| return null | |||||
| }) | |||||
| if (file) | |||||
| files.push(file) | |||||
| } | |||||
| } | |||||
| return files | |||||
| } | |||||
| } | } |
| [Source Code](https://github.com/repalash/threepipe/blob/master/src/plugins/export/CanvasSnapshotPlugin.ts) — | [Source Code](https://github.com/repalash/threepipe/blob/master/src/plugins/export/CanvasSnapshotPlugin.ts) — | ||||
| [API Reference](https://threepipe.org/docs/classes/CanvasSnapshotPlugin.html) | [API Reference](https://threepipe.org/docs/classes/CanvasSnapshotPlugin.html) | ||||
| Canvas Snapshot Plugin adds support for taking snapshots of the canvas and exporting them as images and data urls. It includes options to take snapshot of a region, mime type, quality render scale and scaling the output image. Check out the interface [CanvasSnapshotOptions](https://threepipe.org/docs/interfaces/CanvasSnapshotOptions.html) for more details. | |||||
| Canvas Snapshot Plugin adds support for taking snapshots of the canvas and exporting them as images and data urls. It includes options to take snapshot of a region, mime type, quality render scale, tiled zip export for large resolution, scaling the output image, interfacing with SSAA, Progressive plugins etc. | |||||
| Check out the interface [CanvasSnapshotOptions](https://threepipe.org/docs/interfaces/CanvasSnapshotOptions.html) for more details. | |||||
| ```typescript | ```typescript | ||||
| import {ThreeViewer, CanvasSnapshotPlugin} from 'threepipe' | import {ThreeViewer, CanvasSnapshotPlugin} from 'threepipe' | ||||
| displayPixelRatio: 2, // render scale | displayPixelRatio: 2, // render scale | ||||
| mimeType: 'image/webp', // mime type of the image | mimeType: 'image/webp', // mime type of the image | ||||
| waitForProgressive: true, // wait for progressive rendering to finish (ProgressivePlugin). true by default | waitForProgressive: true, // wait for progressive rendering to finish (ProgressivePlugin). true by default | ||||
| progressiveFrames: 64, // number of frames to wait for progressive rendering to finish (ProgressivePlugin). 64 by default | |||||
| rect: { // region to take snapshot. eg. crop center of the canvas | rect: { // region to take snapshot. eg. crop center of the canvas | ||||
| height: viewer.canvas.clientHeight / 2, | height: viewer.canvas.clientHeight / 2, | ||||
| width: viewer.canvas.clientWidth / 2, | width: viewer.canvas.clientWidth / 2, | ||||
| x: viewer.canvas.clientWidth / 4, | x: viewer.canvas.clientWidth / 4, | ||||
| y: viewer.canvas.clientHeight / 4, | y: viewer.canvas.clientHeight / 4, | ||||
| }, | }, | ||||
| // tileRows: 3, // number of rows to tile the image. If more than one, a zip file will be exported | |||||
| // tileColumns: 3, // number of columns to tile the image | |||||
| }) | }) | ||||
| // get data url (string) | // get data url (string) | ||||
| const dataUrl = await snapshotPlugin.getDataUrl({ // all parameters are optional | |||||
| const dataUrl: string = await snapshotPlugin.getDataUrl({ // all parameters are optional | |||||
| displayPixelRatio: 2, // render scale | displayPixelRatio: 2, // render scale | ||||
| mimeType: 'image/webp', // mime type of the image | mimeType: 'image/webp', // mime type of the image | ||||
| }) | }) | ||||
| // get File | // get File | ||||
| const file = await snapshotPlugin.getFile('file.jpeg', { // all parameters are optional | |||||
| const file: File = await snapshotPlugin.getFile('file.jpeg', { // all parameters are optional | |||||
| mimeType: 'image/jpeg', // mime type of the image | mimeType: 'image/jpeg', // mime type of the image | ||||
| }) | }) | ||||
| ``` | ``` |