blob: 2d1fbdcec2a4ea9b4dd8ba210bf5689dabf8941d [file]
/**
* @module skottie-exporter-gif-sk
* @description <h2><code>skottie-exporter-sk</code></h2>
*
* <p>
* The skottie Gif exporter. It uses a WASM version of ffmpeg.
* It snapshots a skottie animation frame by frame
* and stores them temporarily as pngs in a virtual ffmpeg file system.
* Finally it executes a ffmpeg command to convert those pngs into a GIF.
* ffmpeg documentation: https://ffmpeg.org/ffmpeg.html
* useful links:
* - http://blog.pkh.me/p/21-high-quality-gif-with-ffmpeg.html
* - https://tyhopp.com/notes/ffmpeg-crosshatch
* - https://superuser.com/questions/1231645
* </p>
*
*/
import { define } from '../../../../elements-sk/modules/define';
import { html, TemplateResult } from 'lit-html';
import '../../skottie-dropdown-sk';
import { createFFmpeg, FFmpeg } from '@ffmpeg/ffmpeg';
import { SkottiePlayerSk } from '../../skottie-player-sk/skottie-player-sk';
import '../../../../elements-sk/modules/icons/info-icon-sk';
import '../../skottie-button-sk';
import {
SkottieExporterBaseSk,
Quality,
ComponentState,
} from '../skottie-exporter-base-sk/skottie-exporter-base-sk';
import frameCollectorFactory, {
FrameCollectorType,
} from '../../../modules/helpers/frameCollectorFactory';
interface Detail {
colors: number;
fps: number;
scale: number;
}
const qualityDetails: Record<Quality, Detail> = {
low: {
colors: 16,
fps: 6,
scale: 540,
},
medium: {
colors: 256,
fps: 12,
scale: 540,
},
high: {
colors: 256,
fps: 12,
scale: 1080,
},
};
export class SkottieExporterGifSk extends SkottieExporterBaseSk {
private _ffmpeg;
private _frameCollector: FrameCollectorType;
constructor() {
super('gif');
this._ffmpeg = createFFmpeg({
log: false,
corePath: `${location.origin}/static/ffmpeg-core.js`,
});
this._ffmpeg.load();
this._frameCollector = frameCollectorFactory(this._ffmpeg, (message) =>
this.updateProgress({ ratio: 0, message })
);
}
private renderDetails(): TemplateResult | null {
if (!this.player) {
return null;
}
const detail: Detail = qualityDetails[this._config.quality];
const canvas = this.player.canvas()!;
let maxSize = detail.scale;
let finalWidth = maxSize;
let finalHeight = maxSize;
if (canvas.width >= canvas.height) {
finalHeight = Math.round((finalWidth * canvas.height) / canvas.width);
} else {
finalWidth = Math.round((finalHeight * canvas.width) / canvas.height);
}
return html`
<article>
<div class="detail">
<div class="detail__property">Colors</div>
<div class="detail__value">${detail.colors}</div>
<div class="detail__info">
<info-icon-sk></info-icon-sk>
<span class="detail__info__tooltip">Total palette colors</span>
</div>
</div>
<div class="detail">
<div class="detail__property">FPS</div>
<div class="detail__value">${detail.fps}</div>
<div class="detail__info">
<info-icon-sk></info-icon-sk>
<span class="detail__info__tooltip">Frames per second</span>
</div>
</div>
<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"
id="export-form-gif"
@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=${'gif-quality'}
.options=${[
{ id: 'low', value: 'Low' },
{ id: 'medium', value: 'Medium' },
{ id: 'high', value: 'High' },
]}
@select=${this.qualitySelectHandler}
full>
</skottie-dropdown-sk>
<div class="separator"></div>
${this.renderDetails()}
<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 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 buildFilters(fps: number, scale: string, colors: number): string {
if (this._config.quality !== 'high') {
return `fps=${fps},scale=${scale}:flags=lanczos,split[s0][s1];\
[s0] palettegen=max_colors=${colors}, split[pal1][pal2];\
[s1][pal1] paletteuse=bayer [s2];\
[s2][pal2] paletteuse=dither=bayer`;
} else {
// For high quality we add a parameter to palettegen
// stats_mode=single that generates a new palette per frame
// better quality but file size will be larger
return `fps=${fps},scale=${scale}:flags=lanczos,split[s0][s1];\
[s0] palettegen=stats_mode=single:max_colors=${colors}, split[pal1][pal2];\
[s1][pal1] paletteuse=bayer [s2];\
[s2][pal2] paletteuse=dither=bayer`;
}
}
private async generateGIF(
player: SkottiePlayerSk,
ffmpeg: FFmpeg
): Promise<Uint8Array | null> {
const canvas = player.canvas();
if (!canvas || this.renderState !== 'running') {
return null;
}
const detail: Detail = qualityDetails[this._config.quality];
let scale =
canvas.width >= canvas.height
? `${detail.scale}:-1`
: `-1:${detail.scale}`;
const fps = player.fps();
await ffmpeg.run(
'-framerate',
`${fps}`,
'-pattern_type',
'glob',
'-i', // input file url
'*.png',
'-pix_fmt',
'pal8',
'-loop',
'0', // infinite loop
'-codec:v',
'gif',
'-gifflags',
'+transdiff',
'-filter_complex',
this.buildFilters(fps, scale, detail.colors),
'-f', // output file format
'gif',
'out.gif'
);
const data = ffmpeg.FS('readFile', 'out.gif');
return data;
}
protected async start(): Promise<void> {
if (!this.player) {
return;
}
super.start();
const ffmpeg = this._ffmpeg;
const player = this.player;
this._frameCollector.player = player;
ffmpeg.setLogger(this.updateLog);
ffmpeg.setProgress(this.updateProgress);
await this._frameCollector.start();
this.updateProgress({ ratio: 0.01, message: '' });
const data = await this.generateGIF(player, ffmpeg);
this.complete(data ? URL.createObjectURL(new Blob([data])) : '');
}
get renderState() {
return super.renderState;
}
set renderState(value: ComponentState) {
super.renderState = value;
if (this.renderState !== 'running') {
this._frameCollector.stop();
}
}
}
define('skottie-exporter-gif-sk', SkottieExporterGifSk);