blob: 25c4f94814da1d78b42aae3ba8b72e56d95f1da5 [file] [log] [blame]
/**
* @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);