| /** |
| * @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/modules/define'; |
| import '../../../elements-sk/modules/select-sk'; |
| import { html } from 'lit-html'; |
| import { bytes, diffDate } from '../../../infra-sk/modules/human'; |
| import { SelectSkSelectionChangedEventDetail } from '../../../elements-sk/modules/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(this.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); |