| /** |
| * @module skottie-exporter-png-sk |
| * @description <h2><code>skottie-exporter-png-sk</code></h2> |
| * |
| * <p> |
| * The skottie PNG exporter. |
| * It will output either a single PNG file or a ZIP if it's more than one frame. |
| * </p> |
| * |
| */ |
| import { define } from '../../../../elements-sk/modules/define'; |
| import { html, TemplateResult } from 'lit-html'; |
| import '../../skottie-dropdown-sk'; |
| import { |
| DropdownOption, |
| DropdownSelectEvent, |
| } from '../../skottie-dropdown-sk/skottie-dropdown-sk'; |
| import { SkottiePlayerSk } from '../../skottie-player-sk/skottie-player-sk'; |
| import '../../../../elements-sk/modules/icons/info-icon-sk'; |
| import '../../skottie-button-sk'; |
| import JSZip from 'jszip'; |
| import { |
| SkottieExporterBaseSk, |
| Quality, |
| } from '../skottie-exporter-base-sk/skottie-exporter-base-sk'; |
| import delay from '../../../modules/helpers/delay'; |
| |
| interface ExportConfig { |
| quality: Quality; |
| rangeStart: number; |
| rangeEnd: number; |
| } |
| |
| interface Detail { |
| scale: number; |
| } |
| |
| const qualityDetails: Record<Quality, Detail> = { |
| low: { |
| scale: 0.25, |
| }, |
| medium: { |
| scale: 0.5, |
| }, |
| high: { |
| scale: 1, |
| }, |
| }; |
| |
| export class SkottieExporterPNGSk extends SkottieExporterBaseSk { |
| protected _config: ExportConfig = { |
| quality: 'high', |
| rangeStart: 0, |
| rangeEnd: -1, |
| }; |
| |
| constructor() { |
| super('zip'); |
| } |
| |
| _render() { |
| this.updateRanges(); |
| super._render(); |
| } |
| |
| private updateRanges(): void { |
| if (!this.player) { |
| return; |
| } |
| const fps = this.player.fps(); |
| const duration = this.player.duration(); |
| const totalFrames = Math.round(duration * (fps / 1000)); |
| if (this._config.rangeEnd === -1 || this._config.rangeEnd > totalFrames) { |
| this._config.rangeEnd = totalFrames - 1; |
| } |
| } |
| |
| private renderDetails(): TemplateResult | null { |
| if (!this.player) { |
| return null; |
| } |
| const detail: Detail = qualityDetails[this._config.quality]; |
| const canvas = this.player.canvas()!; |
| let finalWidth = Math.round(canvas.width * detail.scale); |
| let finalHeight = Math.round(canvas.height * detail.scale); |
| return html` |
| <article> |
| <div class="detail"> |
| <div class="detail__property">Exported image size</div> |
| <div class="detail__value">${finalWidth}x${finalHeight}</div> |
| <div class="detail__info"> |
| <info-icon-sk></info-icon-sk> |
| <span class="detail__info__tooltip">Exported size</span> |
| </div> |
| </div> |
| </article> |
| `; |
| } |
| |
| protected renderIdle(): TemplateResult { |
| return html` |
| <form class="export-form" @keydown=${this.handleKeyDown}> |
| <input |
| type="text" |
| .value=${this._downloadFileName} |
| class="export-form__filename" |
| @input=${this.onNameChange} |
| @change=${this.onNameChange} /> |
| <div class="export-form__label">Quality</div> |
| <skottie-dropdown-sk |
| .name=${'png-quality'} |
| .options=${[ |
| { |
| id: 'low', |
| value: 'Low', |
| selected: this._config.quality === 'low', |
| }, |
| { |
| id: 'medium', |
| value: 'Medium', |
| selected: this._config.quality === 'medium', |
| }, |
| { |
| id: 'high', |
| value: 'High', |
| selected: this._config.quality === 'high', |
| }, |
| ]} |
| @select=${this.qualitySelectHandler} |
| full> |
| </skottie-dropdown-sk> |
| <div class="separator"></div> |
| ${this.renderDetails()} |
| <div class="separator"></div> |
| <div class="label">Select range start</div> |
| <skottie-dropdown-sk |
| .name=${'range-start'} |
| .options=${this.buildRangeStartOptions()} |
| @select=${this.rangeStartHandler} |
| full></skottie-dropdown-sk> |
| <div class="label">Select range end</div> |
| <skottie-dropdown-sk |
| .name=${'range-end'} |
| .options=${this.buildRangeEndOptions()} |
| @select=${this.rangeEndHandler} |
| full></skottie-dropdown-sk> |
| <div class="navigation"> |
| <skottie-button-sk |
| type="plain" |
| @select=${this.cancel} |
| .content=${'Cancel'} |
| .classes=${['navigation__button']}></skottie-button-sk> |
| <skottie-button-sk |
| type="filled" |
| @select=${this.start} |
| .content=${'Export'} |
| .classes=${['navigation__button']}></skottie-button-sk> |
| </div> |
| </form> |
| `; |
| } |
| |
| private buildRangeStartOptions(): DropdownOption[] { |
| let start = 0; |
| let end = this._config.rangeEnd; |
| const options = []; |
| while (start <= end) { |
| const option: DropdownOption = { |
| id: start.toString(), |
| value: `${start + 1}`, |
| }; |
| if (start === this._config.rangeStart) { |
| option.selected = true; |
| } |
| options.push(option); |
| start += 1; |
| } |
| return options; |
| } |
| |
| private buildRangeEndOptions(): DropdownOption[] { |
| if (!this.player) { |
| return []; |
| } |
| const options: DropdownOption[] = []; |
| let start = this._config.rangeStart; |
| const fps = this.player.fps(); |
| const duration = this.player.duration(); |
| const totalFrames = Math.round(duration * (fps / 1000)); |
| while (start < totalFrames) { |
| const option: DropdownOption = { |
| id: start.toString(), |
| value: `${start + 1}`, |
| }; |
| if (start === this._config.rangeEnd) { |
| option.selected = true; |
| } |
| options.push(option); |
| start += 1; |
| } |
| return options; |
| } |
| |
| private renderRatio(): TemplateResult { |
| if (this.progress.ratio) { |
| return html`<div class="running__message"> |
| ${(this.progress.ratio * 100).toFixed(1)} % complete |
| </div>`; |
| } else { |
| return html`<div class="running__message">${this.progress.message}</div>`; |
| } |
| } |
| |
| protected renderRunning(): TemplateResult { |
| return html` |
| <div class="running"> |
| ${this.renderRatio()} |
| <div class="running__details">${this.progress.details}</div> |
| <div class="navigation"> |
| <skottie-button-sk |
| type="filled" |
| @select=${this.stop} |
| .content=${'Stop'} |
| .classes=${['navigation__button']}></skottie-button-sk> |
| </div> |
| </div> |
| `; |
| } |
| |
| private getExtension(): string { |
| return this._config.rangeStart === this._config.rangeEnd ? 'png' : 'zip'; |
| } |
| |
| private rangeStartHandler(e: CustomEvent<DropdownSelectEvent>): void { |
| this._config.rangeStart = parseInt(e.detail.value); |
| this.updateFileName(this._downloadFileName, this.getExtension()); |
| this._render(); |
| } |
| |
| private rangeEndHandler(e: CustomEvent<DropdownSelectEvent>): void { |
| this._config.rangeEnd = parseInt(e.detail.value); |
| this.updateFileName(this._downloadFileName, this.getExtension()); |
| this._render(); |
| } |
| |
| private getOutputCanvas(canvasElement: HTMLCanvasElement): HTMLCanvasElement { |
| let outputCanvas: HTMLCanvasElement; |
| const detail: Detail = qualityDetails[this._config.quality]; |
| if (detail.scale !== 1) { |
| outputCanvas = document.createElement('canvas'); |
| outputCanvas.width = Math.round(canvasElement.width * detail.scale); |
| outputCanvas.height = Math.round(canvasElement.height * detail.scale); |
| } else { |
| outputCanvas = canvasElement; |
| } |
| return outputCanvas; |
| } |
| |
| private async exportMultiFile(player: SkottiePlayerSk): Promise<string> { |
| const fps = player.fps(); |
| const duration = player.duration(); |
| const canvasElement = player.canvas()!; |
| let currentTime = (this._config.rangeStart * 1000) / fps; |
| const endTime = (this._config.rangeEnd * 1000) / fps; |
| let counter = 1; |
| const increment = 1000 / fps; |
| const zip = new JSZip(); |
| const fileName = this._downloadFileName.substr( |
| 0, |
| this._downloadFileName.lastIndexOf('.') |
| ); |
| const detail: Detail = qualityDetails[this._config.quality]; |
| let outputCanvas = this.getOutputCanvas(canvasElement); |
| while (currentTime <= endTime) { |
| if (this.renderState !== 'running') { |
| return ''; |
| } |
| // This delay helps to maintain the browser responsive during export. |
| await delay(1); // eslint-disable-line no-await-in-loop |
| player.seek(currentTime / duration, true); |
| // Only copying canvas if it has a different output size |
| if (detail.scale !== 1) { |
| const outputCanvasContext = outputCanvas.getContext('2d'); |
| outputCanvasContext?.clearRect( |
| 0, |
| 0, |
| outputCanvas.width, |
| outputCanvas.height |
| ); |
| outputCanvasContext?.drawImage( |
| canvasElement, |
| 0, |
| 0, |
| canvasElement.width, |
| canvasElement.height, |
| 0, |
| 0, |
| outputCanvas.width, |
| outputCanvas.height |
| ); |
| } |
| const blob: Blob | null = await new Promise((res) => |
| outputCanvas.toBlob(res) |
| ); |
| if (blob) { |
| const file = `${fileName}_${String(counter).padStart(4, '0')}.png`; |
| zip.file(file, blob); |
| } |
| currentTime += increment; |
| this.progress.ratio = 0; |
| this.progress.message = `Creating frame ${counter}`; |
| counter += 1; |
| this._render(); |
| } |
| const content = await zip.generateAsync({ type: 'base64' }); |
| return `data:application/zip;base64,${content}`; |
| } |
| |
| private async exportSingleFile(player: SkottiePlayerSk): Promise<string> { |
| const fps = player.fps(); |
| const duration = player.duration(); |
| const canvasElement = player.canvas()!; |
| let currentTime = (this._config.rangeStart * 1000) / fps; |
| player.seek(currentTime / duration, true); |
| const detail: Detail = qualityDetails[this._config.quality]; |
| let outputCanvas = this.getOutputCanvas(canvasElement); |
| // Only copying canvas if it has a different output size |
| if (detail.scale !== 1) { |
| const outputCanvasContext = outputCanvas.getContext('2d'); |
| outputCanvasContext?.clearRect( |
| 0, |
| 0, |
| outputCanvas.width, |
| outputCanvas.height |
| ); |
| outputCanvasContext?.drawImage( |
| canvasElement, |
| 0, |
| 0, |
| canvasElement.width, |
| canvasElement.height, |
| 0, |
| 0, |
| outputCanvas.width, |
| outputCanvas.height |
| ); |
| } |
| const blob: Blob | null = await new Promise((res) => |
| outputCanvas.toBlob(res) |
| ); |
| if (blob) { |
| return URL.createObjectURL(blob); |
| } |
| return ''; |
| } |
| |
| protected async start(): Promise<void> { |
| if (!this.player) { |
| return; |
| } |
| super.start(); |
| const player = this.player; |
| let data; |
| if (this._config.rangeStart === this._config.rangeEnd) { |
| data = await this.exportSingleFile(player); |
| } else { |
| data = await this.exportMultiFile(player); |
| } |
| this.complete(data); |
| } |
| } |
| |
| define('skottie-exporter-png-sk', SkottieExporterPNGSk); |