| /** |
| * @module skottie-gif-exporter |
| * @description <h2><code>skottie-gif-exporter</code></h2> |
| * |
| * <p> |
| * A skottie gif exporter |
| * </p> |
| * |
| * @evt start - This event is generated when the saving process starts. |
| * |
| */ |
| import { define } from 'elements-sk/define'; |
| import 'elements-sk/select-sk'; |
| import { html } from 'lit-html'; |
| import { bytes, diffDate } from 'common-sk/modules/human'; |
| import { SelectSkSelectionChangedEventDetail } from 'elements-sk/select-sk/select-sk'; |
| import gifStorage from '../helpers/gifStorage'; |
| import { ElementSk } from '../../../infra-sk/modules/ElementSk'; |
| import { SkottiePlayerSk } from '../skottie-player-sk/skottie-player-sk'; |
| |
| interface GIFLibraryOptions { |
| workerScript?: string; |
| workers?: number; |
| repeat?: number; |
| background?: string; |
| quality?: number; |
| width?: number; |
| height?: number; |
| transparent?: number | null; |
| debug?: boolean; |
| dither?: boolean | string; |
| } |
| |
| interface GIFLibrary { |
| abort(): unknown; |
| addFrame(canvasElement: HTMLCanvasElement|ImageData|CanvasRenderingContext2D, options: { delay: number; copy: boolean; }): void; |
| on(event: string, callback: (_: unknown)=> void): void; |
| render(): unknown; |
| } |
| |
| interface BackgroundOption { |
| id: string; |
| label: string; |
| color: string; |
| } |
| |
| declare const GIF: new (_: GIFLibraryOptions)=> GIFLibrary; |
| |
| const QUALITY_SCRUBBER_RANGE = 50; |
| |
| const WORKERS_COUNT = 4; |
| |
| const ditherOptions = [ |
| 'FloydSteinberg', |
| 'FalseFloydSteinberg', |
| 'Stucki', |
| 'Atkinson', |
| ]; |
| |
| const backgroundOptions: BackgroundOption[] = [ |
| { |
| id: '1', |
| label: 'White', |
| color: '#ffffff', |
| }, |
| { |
| id: '2', |
| label: 'Black', |
| color: '#000000', |
| }, |
| ]; |
| |
| type ExportState = 'idle' | 'gif processing' | 'image processing' | 'complete'; |
| |
| const renderRepeatsLabel = (val: number) => { |
| switch (val) { |
| case -1: |
| return 'No repeats'; |
| case 0: |
| return 'Infinite repeats'; |
| case 1: |
| return `${val} Repeat`; |
| default: |
| return `${val} Repeats`; |
| } |
| }; |
| |
| export class SkottieGifExporterSk extends ElementSk { |
| private static template = (ele: SkottieGifExporterSk) => html` |
| <div> |
| <header class="editor-header"> |
| <div class="editor-header-title">Gif Exporter</div> |
| <div class="editor-header-separator"></div> |
| ${ele.renderHeader()} |
| </header> |
| <section class=main> |
| ${ele.renderMain()} |
| </section> |
| </div> |
| `; |
| |
| private renderMain = () => { |
| switch (this.state) { |
| default: |
| case 'idle': return this.renderIdle(); |
| case 'image processing': return this.renderImage(); |
| case 'gif processing': return this.renderGif(); |
| case 'complete': return this.renderComplete(); |
| } |
| } |
| |
| private renderIdle = () => html` |
| <div class=form> |
| <div class=form-elem> |
| <div>Sample (${this.quality})</div> |
| <input id=sampleScrub type=range min=1 max=${QUALITY_SCRUBBER_RANGE} step=1 |
| @input=${this.updateQuality} @change=${this.updateQuality} |
| .value=${this.quality}> |
| </div> |
| <div class=form-elem> |
| <label class=number> |
| <input |
| type=number |
| id=repeats |
| .value=${this.repeat} |
| min=-1 |
| @input=${this.onRepeatChange} |
| @change=${this.onRepeatChange} |
| /> Repeats (${renderRepeatsLabel(this.repeat)}) |
| </label> |
| </div> |
| <div class=form-elem> |
| <checkbox-sk label="Dither" |
| ?checked=${this.dither} |
| @click=${this.toggleDither}> |
| </checkbox-sk> |
| ${this.renderDither()} |
| </div> |
| <div class=form-elem> |
| <checkbox-sk label="Include Transparent Background" |
| ?checked=${this.transparent} |
| @click=${this.toggleTransparent}> |
| </checkbox-sk> |
| </div> |
| <div class=form-elem> |
| <div class=form-elem-label>Select Background Color to compose on Transparent</div> |
| ${this.renderBackgroundSelect()} |
| </div> |
| </div> |
| `; |
| |
| private renderImage = () => html` |
| <section class=exporting> |
| <div> |
| Creating snapshots: ${this.progress}% |
| </div> |
| </section> |
| `; |
| |
| private renderGif = () => html` |
| <section class=exporting> |
| <div> |
| Creating GIF: ${this.progress}% |
| </div> |
| </section> |
| `; |
| |
| renderComplete = () => html` |
| <section class=complete> |
| <div class=export-info> |
| <div class=export-info-row> |
| Render Complete |
| </div> |
| <div class=export-info-row> |
| Export Duration: ${this.exportDuration} |
| </div> |
| <div class=export-info-row> |
| File size: ${bytes(this.blob ? this.blob.size : 0)} |
| </div> |
| </div> |
| <a |
| class=download |
| href=${this.blobURL} |
| download=${this.getDownloadFileName()} |
| > |
| Download |
| </a> |
| </section> |
| `; |
| |
| private renderHeader = () => { |
| if (this.state === 'idle') { |
| return html` |
| <button class="editor-header-save-button" @click=${this.save}>Save</button> |
| `; |
| } |
| if (this.state === 'complete') { |
| return html` |
| <button class="editor-header-save-button" @click=${this.cancel}>Back</button> |
| `; |
| } |
| return html` |
| <button class="editor-header-save-button" @click=${this.cancel}>Cancel</button> |
| `; |
| }; |
| |
| private renderDither = () => { |
| if (this.dither) { |
| return html` |
| <select-sk |
| role="listbox" |
| @selection-changed=${this.ditherOptionChange} |
| > |
| ${ditherOptions.map((item: string, index: number) => this.renderOption(item, index))} |
| </select-sk> |
| `; |
| } |
| return null; |
| }; |
| |
| private renderOption = (item: string, index: number) => html` |
| <div |
| role="option" |
| ?selected=${this.ditherValue === index} |
| > |
| ${item} |
| </div> |
| `; |
| |
| private renderBackgroundOption = (item: BackgroundOption) => html` |
| <div |
| role="option" |
| ?selected=${this.backgroundValue.id === item.id} |
| > |
| ${item.label} |
| </div> |
| `; |
| |
| private renderBackgroundSelect = () => html` |
| <select-sk |
| role="listbox" |
| @selection-changed=${this.backgroundOptionChange} |
| > |
| ${backgroundOptions.map((item: BackgroundOption) => this.renderBackgroundOption(item))} |
| </select-sk> |
| `; |
| |
| private backgroundValue: BackgroundOption = backgroundOptions[0]; |
| |
| private backgroundValueIndex: number = 0; |
| |
| private blob: Blob | null = null; |
| |
| private dither: boolean | string = false; |
| |
| private ditherValue: number = 0; |
| |
| private exportDuration: string = ''; |
| |
| private gif: GIFLibrary | null = null; |
| |
| private progress: number = 0; |
| |
| private quality: number = 0; |
| |
| private repeat: number = 0; |
| |
| private state: ExportState = 'idle'; |
| |
| private transparent: boolean = false; |
| |
| private startTime: number = 0; |
| |
| private blobURL: string = ''; |
| |
| private _player: SkottiePlayerSk | null = null; |
| |
| constructor() { |
| super(SkottieGifExporterSk.template); |
| this.repeat = gifStorage.get('repeat', 0); |
| this.quality = gifStorage.get('quality', 50); |
| this.backgroundValueIndex = gifStorage.get('backgroundIndex', 0); |
| this.transparent = gifStorage.get('transparent', true); |
| this.dither = gifStorage.get('dither', false); |
| this.ditherValue = gifStorage.get('ditherValue', 0); |
| this.backgroundValue = backgroundOptions[this.backgroundValueIndex]; |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| this._render(); |
| } |
| |
| private delay(time: number) { |
| return new Promise((resolve) => setTimeout(resolve, time)); |
| } |
| |
| private updateQuality(ev: Event): void { |
| this.quality = +(ev.target as HTMLInputElement).value; |
| gifStorage.set('quality', this.quality); |
| this._render(); |
| } |
| |
| private onRepeatChange(ev: Event) { |
| this.repeat = +(ev.target as HTMLInputElement).value; |
| gifStorage.set('repeat', this.repeat); |
| this._render(); |
| } |
| |
| private toggleDither(e: Event) { |
| e.preventDefault(); |
| this.dither = !this.dither; |
| gifStorage.set('dither', this.dither); |
| this._render(); |
| } |
| |
| private toggleTransparent(e: Event) { |
| e.preventDefault(); |
| this.transparent = !this.transparent; |
| gifStorage.set('transparent', this.transparent); |
| this._render(); |
| } |
| |
| private ditherOptionChange(e: CustomEvent<SelectSkSelectionChangedEventDetail>) { |
| e.preventDefault(); |
| this.ditherValue = e.detail.selection; |
| gifStorage.set('ditherValue', this.ditherValue); |
| this._render(); |
| } |
| |
| private backgroundOptionChange(e: CustomEvent<SelectSkSelectionChangedEventDetail>) { |
| e.preventDefault(); |
| this.backgroundValue = backgroundOptions[e.detail.selection]; |
| gifStorage.set('backgroundIndex', e.detail.selection); |
| this._render(); |
| } |
| |
| private getDownloadFileName() { |
| return this.player?.animationName() || 'animation.gif'; |
| } |
| |
| /* |
| * |
| * This method takes care of traversing all frames from the passed animation |
| * it adds all frames to the gif instance with a 1 ms delay between frames |
| * to prevent blocking the main thread. |
| */ |
| private async processFrames() { |
| const fps = this.player.fps(); |
| const duration = this.player.duration(); |
| const canvasElement = this.player.canvas(); |
| let currentTime = 0; |
| const increment = 1000 / fps; |
| this.state = 'image processing'; |
| this._render(); |
| while (currentTime < duration) { |
| if (this.state !== 'image processing') { |
| return; |
| } |
| await this.delay(1); // eslint-disable-line no-await-in-loop |
| this.player.seek(currentTime / duration, true); |
| this.gif!.addFrame(canvasElement!, { delay: increment, copy: true }); |
| this.progress = Math.round((currentTime / duration) * 100); |
| currentTime += increment; |
| this._render(); |
| } |
| this.state = 'gif processing'; |
| // Note: this render method belongs to the gif.js library, not the html-lit |
| this.gif!.render(); |
| } |
| |
| private cancel() { |
| if (this.state === 'gif processing') { |
| this.gif!.abort(); |
| } |
| this.state = 'idle'; |
| this._render(); |
| } |
| |
| private createGifExporter() { |
| this.gif = new GIF({ |
| workers: WORKERS_COUNT, |
| quality: this.quality, |
| repeat: this.repeat, |
| dither: this.dither ? ditherOptions[this.ditherValue] : false, |
| transparent: this.transparent ? 0x00000000 : undefined, |
| background: this.backgroundValue.color, |
| workerScript: '/static/gif.worker.js', |
| }); |
| this.gif.on('finished', (blob: unknown) => { |
| this.state = 'complete'; |
| this.blob = blob as Blob; |
| this.exportDuration = diffDate(this.startTime); |
| this.blobURL = URL.createObjectURL(blob); |
| this._render(); |
| }); |
| this.gif.on('progress', (value: unknown) => { |
| this.progress = Math.round((value as number) * 100); |
| this._render(); |
| }); |
| } |
| |
| private start() { |
| this.progress = 0; |
| this.startTime = Date.now(); |
| this.dispatchEvent(new CustomEvent('start')); |
| } |
| |
| private async save(): Promise<void> { |
| this.start(); |
| this.createGifExporter(); |
| await this.processFrames(); |
| } |
| |
| get player(): SkottiePlayerSk { return this._player!; } |
| |
| set player(val: SkottiePlayerSk) { |
| this._player = val; |
| } |
| } |
| |
| define('skottie-gif-exporter-sk', SkottieGifExporterSk); |