blob: f475a919b4eceb2a563031c59c66573a705b5344 [file] [log] [blame]
/**
* @module skottie-sk
* @description <h2><code>skottie-sk</code></h2>
*
* <p>
* The main application element for skottie.
* </p>
*
*/
import '../skottie-config-sk'
import '../skottie-player-sk'
import 'elements-sk/checkbox-sk'
import 'elements-sk/collapse-sk'
import 'elements-sk/error-toast-sk'
import { $$ } from 'common-sk/modules/dom'
import { SKIA_VERSION } from '../../build/version.js'
import { errorMessage } from 'elements-sk/errorMessage'
import { define } from 'elements-sk/define'
import { html, render } from 'lit-html'
import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow'
import { setupListeners, onUserEdit, reannotate} from '../lottie-annotations'
import { stateReflector } from 'common-sk/modules/stateReflector'
import '../skottie-text-editor'
import { SoundMap, AudioPlayer } from '../audio'
const JSONEditor = require('jsoneditor/dist/jsoneditor-minimalist.js');
const bodymovin = require('lottie-web/build/player/lottie.min.js');
const DIALOG_MODE = 1;
const LOADING_MODE = 2;
const LOADED_MODE = 3;
const GOOGLE_WEB_FONTS_HOST = 'https://storage.googleapis.com/skia-cdn/google-web-fonts';
// SCRUBBER_RANGE is the input range for the scrubbing control.
// This is an arbitrary value, and is treated as a re-scaled duration.
const SCRUBBER_RANGE = 1000;
const displayDialog = (ele) => html`
<skottie-config-sk .state=${ele._state} .width=${ele._width}
.height=${ele._height} .fps=${ele._fps} .backgroundColor=${ele._backgroundColor}></skottie-config-sk>
`;
const skottiePlayer = (ele) => html`
<skottie-player-sk paused width=${ele._width} height=${ele._height}>
</skottie-player-sk>
<figcaption>
skottie-wasm
</figcaption>`;
const lottiePlayer = (ele) => {
if (!ele._showLottie) {
return '';
}
return html`
<figure>
<div id=container title=lottie-web
style='width: ${ele._width}px; height: ${ele._height}px; background-color: ${ele._backgroundColor}'></div>
<figcaption>lottie-web (${bodymovin.version})</figcaption>
</figure>`;
}
// TODO(kjlubick): Make the live preview use skottie
const livePreview = (ele) => {
if (!ele._hasEdits || !ele._showLottie) {
return '';
}
if (ele._hasEdits) {
return html`
<figure>
<div id=live title=live-preview
style='width: ${ele._width}px; height: ${ele._height}px'></div>
<figcaption>Preview [lottie-web]</figcaption>
</figure>`;
}
}
const iframeDirections = (ele) => {
return `<iframe width="${ele._width}" height="${ele._height}" src="${window.location.origin}/e/${ele._hash}?w=${ele._width}&h=${ele._height}" scrolling=no>`;
}
const inlineDirections = (ele) => {
return `<skottie-inline-sk width="${ele._width}" height="${ele._height}" src="${window.location.origin}/_/j/${ele._hash}"></skottie-inline-sk>`;
}
const jsonEditor = (ele) => {
if (!ele._showEditor) {
return '';
}
return html`
<section class=editor>
<div id=json_editor></div>
</section>`;
}
const jsonTextEditor = (ele) => {
if (!ele._showTextEditor) {
return '';
}
return html`
<section class=editor>
<skottie-text-editor
.animation=${ele._state.lottie}
@apply=${ele._applyTextEdits}
>
</skottie-text-editor>
</section>`;
}
const displayLoaded = (ele) => html`
<button class=edit-config @click=${ ele._startEdit}>
${ele._state.filename} ${ele._width}x${ele._height} ...
</button>
<div class=controls>
<button @click=${ele._rewind}>Rewind</button>
<button id=playpause @click=${ele._playpause}>Pause</button>
<button ?hidden=${!ele._hasEdits} @click=${ele._applyEdits}>Apply Edits</button>
<div class=download>
<a target=_blank download=${ele._state.filename} href=${ele._downloadUrl}>
JSON
</a>
${ele._hasEdits? '(without edits)': ''}
</div>
<checkbox-sk label="Show lottie-web"
?checked=${ele._showLottie}
@click=${ele._toggleLottie}>
</checkbox-sk>
<checkbox-sk label="Show editor"
?checked=${ele._showEditor}
@click=${ele._toggleEditor}>
</checkbox-sk>
<checkbox-sk label="Show text editor"
?checked=${ele._showTextEditor}
@click=${ele._toggleTextEditor}>
</checkbox-sk>
<button @click=${ele._toggleEmbed}>Embed</button>
<div class=scrub>
<input id=scrub type=range min=0 max=${SCRUBBER_RANGE+1} step=0.1
@input=${ele._onScrub} @change=${ele._onScrubEnd}>
</div>
<collapse-sk id=volume closed>
<p>
Volume:
</p>
<input id=volume-slider type=range min=0 max=1 step=.05 value=1
@input=${ele._onVolumeChange}>
</collapse-sk>
</div>
<collapse-sk id=embed closed>
<p>
<label>
Embed using an iframe: <input size=120 value=${iframeDirections(ele)} scrolling=no>
</label>
</p>
<p>
<label>
Embed on skia.org: <input size=140 value=${inlineDirections(ele)} scrolling=no>
</label>
</p>
</collapse-sk>
<section class=figures>
<figure>
${skottiePlayer(ele)}
</figure>
${lottiePlayer(ele)}
${livePreview(ele)}
</section>
${jsonEditor(ele)}
${jsonTextEditor(ele)}
`;
const displayLoading = (ele) => html`
<div class=loading>
<spinner-sk active></spinner-sk><span>Loading...</span>
</div>
`;
// pick the right part of the UI to display based on ele._ui.
const pick = (ele) => {
switch (ele._ui) {
case DIALOG_MODE:
return displayDialog(ele);
case LOADING_MODE:
return displayLoading(ele);
case LOADED_MODE:
return displayLoaded(ele);
}
};
const redir = (ele) => {
if (window.location.hostname !== 'skottie-internal.skia.org') {
return html`
<div>
Googlers should use <a href="https://skottie-internal.skia.org">skottie-internal.skia.org</a>.
</div>`;
} else {
return html``;
}
};
const template = (ele) => html`
<header>
<h2>Skottie</h2>
<span>
<a href='https://skia.googlesource.com/skia/+show/${SKIA_VERSION}'>
${SKIA_VERSION.slice(0, 7)}
</a>
</span>
</header>
<main>
${pick(ele)}
</main>
<footer>
<error-toast-sk></error-toast-sk>
${redir(ele)}
</footer>
`;
define('skottie-sk', class extends HTMLElement {
constructor() {
super();
this._state = {
filename: '',
lottie: null,
assetsZip: '',
assetsFilename: '',
};
// One of 'dialog', 'loading', or 'loaded'
this._ui = DIALOG_MODE;
this._hash = '';
this._skottiePlayer = null;
this._lottie = null;
this._live = null;
this._playing = true;
this._assetsPath = '/_/a';
this._downloadUrl = null; // The URL to download the lottie JSON from.
this._editor = null;
this._editorLoaded = false;
this._hasEdits = false;
this._showLottie = false;
this._showEditor = false;
this._showTextEditor = false;
this._scrubbing = false;
this._playingOnStartOfScrub = false;
this._width = 0;
this._height = 0;
this._fps = 0;
this._backgroundColor = 'rgba(0,0,0,0)';
this._stateChanged = stateReflector(
/*getState*/() => {
return {
// provide empty values
'l' : this._showLottie,
'e' : this._showEditor,
't' : this._showTextEditor,
'w' : this._width,
'h' : this._height,
'f' : this._fps,
'bg': this._backgroundColor,
}
}, /*setState*/(newState) => {
this._showLottie = newState.l;
this._showEditor = newState.e;
this._showTextEditor = newState.t;
this._width = newState.w;
this._height = newState.h;
this._fps = newState.f;
this._backgroundColor = newState.bg;
this._applyTextEdits = this._applyTextEdits.bind(this);
this.render();
});
this._duration = 0; // _duration = 0 is a sentinel value for "player not loaded yet"
// The wasm animation computes how long it has been since it started and
// use arithmetic to figure out where to seek (i.e. which frame to draw).
this._firstFrameTime = null;
// used for remembering where we were in the animation when paused.
this._wasmTimePassed = 0;
}
connectedCallback() {
this._reflectFromURL();
this.addEventListener('skottie-selected', this)
this.addEventListener('cancelled', this)
window.addEventListener('popstate', this)
this.render();
// Start a continous animation loop.
const drawFrame = () => {
window.requestAnimationFrame(drawFrame);
// Elsewhere, the _firstFrameTime is set to null to restart
// the animation. If null, we assume the user hit re-wind
// and restart both the Skottie animation and the lottie-web one.
// This avoids the (small) boot-up lag while we wait for the
// skottie animation to be parsed and loaded.
if (!this._firstFrameTime && this._playing) {
this._firstFrameTime = Date.now();
}
if (this._playing && this._duration > 0) {
let progress = (Date.now() - this._firstFrameTime) % this._duration;
// If we want to have synchronized playing, it's best to force
// all players to draw the same frame rather than letting them play
// on their own timeline.
this._skottiePlayer && this._skottiePlayer.seek(progress / this._duration);
// lottie player takes the milliseconds from the beginning of the animation.
this._lottie && this._lottie.goToAndStop(progress);
this._live && this._live.goToAndStop(progress);
const scrubber = $$('#scrub', this);
if (scrubber) {
// Scale from time to the arbitrary scrubber range.
scrubber.value = SCRUBBER_RANGE * progress / this._duration;
}
}
}
window.requestAnimationFrame(drawFrame);
}
disconnectedCallback() {
this.removeEventListener('skottie-selected', this)
this.removeEventListener('cancelled', this)
}
attributeChangedCallback(name, oldValue, newValue) {
this.render();
}
_applyTextEdits(event) {
const texts = event.detail.texts;
const currentAnimation = JSON.parse(JSON.stringify(this._state.lottie));
texts.forEach((textData) => {
textData.items.forEach((item) => {
let layers;
// Searches for a composition that contains this layer
if (!item.parentId) {
layers = currentAnimation.layers;
} else {
const asset = currentAnimation.assets.find((assetItem) => assetItem.id === item.parentId);
layers = asset ? asset.layers : [];
}
// Replaces current animation layer with new layer value
layers.forEach((layer, index) => {
if (layer.ind === item.layer.ind
&& layer.nm === item.layer.nm
) {
layers[index] = item.layer;
}
});
});
});
this._state.lottie = currentAnimation;
this._upload();
}
_applyEdits() {
if (!this._editor || !this._editorLoaded || !this._hasEdits) {
return;
}
this._state.lottie = this._editor.get();
this._upload();
}
_autoSize() {
let changed = false;
if (!this._width) {
this._width = this._state.lottie.w;
changed = true;
}
if (!this._height) {
this._height = this._state.lottie.h;
changed = true;
}
// By default, leave FPS at 0, instead of reading them from the lottie,
// because that will cause it to render as smoothly as possible,
// which looks better in most cases. If a user gives a negative value
// for fps (e.g. -1), then we use either what the lottie tells us or
// as fast as possible.
if (this._fps < 0) {
this._fps = this._state.lottie.fr || 0;
}
return changed;
}
handleEvent(e) {
if (e.type === 'skottie-selected') {
this._state = e.detail.state;
this._width = e.detail.width;
this._height = e.detail.height;
this._fps = e.detail.fps;
this._backgroundColor = e.detail.backgroundColor;
this._autoSize();
this._stateChanged();
if (e.detail.fileChanged) {
this._upload();
} else {
this._ui = LOADED_MODE;
this.render();
this._initializePlayer();
// Re-sync all players
this._rewind();
}
} else if (e.type === 'cancelled') {
this._ui = LOADED_MODE;
this.render();
this._initializePlayer();
} else if (e.type === 'popstate') {
this._reflectFromURL();
}
}
_initializePlayer() {
this._skottiePlayer.initialize({
width: this._width,
height: this._height,
lottie: this._state.lottie,
assets: this._state.assets,
soundMap: this._state.soundMap,
fps: this._fps,
}).then(() => {
this._duration = this._skottiePlayer.duration();
// If the user has specified a value for FPS, we want to lock the
// size of the scrubber so it is as discrete as the frame rate.
if (this._fps) {
const scrubber = $$("#scrub", this);
if (scrubber) {
// calculate a scaled version of ms per frame as the step size.
scrubber.step = (1000 / this._fps * SCRUBBER_RANGE / this._duration);
}
}
});
}
_loadAssetsAndRender() {
const toLoad = [];
const lottie = this._state.lottie;
let fonts = [];
let assets = [];
if (lottie.fonts && lottie.fonts.list) {
fonts = lottie.fonts.list;
}
if (lottie.assets && lottie.assets.length) {
assets = lottie.assets;
}
toLoad.push(...this._loadFonts(fonts));
toLoad.push(...this._loadAssets(assets));
Promise.all(toLoad).then((externalAssets) => {
const assets = {};
const sounds = new SoundMap();
for (const asset of externalAssets) {
if (asset && asset.bytes) {
assets[asset.name] = asset.bytes;
} else if (asset && asset.player) {
sounds.setPlayer(asset.name, asset.player);
}
}
if (sounds.map.size > 0) {
this._toggleVolumeSlider();
}
// check fonts
fonts.forEach(font => {
if (!assets[font.fName]) {
console.error(`Could not load font '${font.fName}'.`);
}
});
this._state.assets = assets;
this._state.soundMap = sounds;
this.render();
this._initializePlayer();
// Re-sync all players
this._rewind();
})
.catch(() => {
this.render();
this._initializePlayer();
// Re-sync all players
this._rewind();
});
}
_loadFonts(fonts) {
const promises = [];
for (const font of fonts) {
if (!font.fName) {
continue;
}
const fetchFont = (fontURL) => {
promises.push(fetch(fontURL)
.then((resp) => {
// fetch does not reject on 404
if (!resp.ok) {
return null;
}
return resp.arrayBuffer().then((buffer) => {
return {
'name': font.fName,
'bytes': buffer
};
});
}));
};
// We have a mirror of google web fonts with a flattened directory structure which
// makes them easier to find. Additionally, we can host the full .ttf
// font, instead of the .woff2 font which is served by Google due to
// it's smaller size by being a subset based on what glyphs are rendered.
// Since we don't know all the glyphs we need up front, it's easiest
// to just get the full font as a .ttf file.
fetchFont(`${GOOGLE_WEB_FONTS_HOST}/${font.fName}.ttf`);
// Also try using uploaded assets.
// We may end up with two different blobs for the same font name, in which case
// the user-provided one takes precedence.
fetchFont(`${this._assetsPath}/${this._hash}/${font.fName}.ttf`);
}
return promises;
}
_loadAssets(assets) {
const promises = [];
for (const asset of assets) {
if (asset.id.startsWith('audio_')) {
// Howler handles our audio assets, they don't provide a promise when making a new Howl.
// We push the audio asset as is and hope that it loads before playback starts.
const inline = asset.p && asset.p.startsWith && asset.p.startsWith('data:');
if (inline) {
promises.push({
'name': asset.id,
'player': new AudioPlayer(asset.p)
});
} else {
promises.push({
'name': asset.id,
'player': new AudioPlayer(`${this._assetsPath}/${this._hash}/${asset.p}`)
});
}
}
else {
// asset.p is the filename, if it's an image.
// Don't try to load inline/dataURI images.
const should_load = asset.p && asset.p.startsWith && !asset.p.startsWith('data:');
if (should_load) {
promises.push(fetch(`${this._assetsPath}/${this._hash}/${asset.p}`)
.then((resp) => {
// fetch does not reject on 404
if (!resp.ok) {
console.error(`Could not load ${asset.p}: status ${resp.status}`)
return null;
}
return resp.arrayBuffer().then((buffer) => {
return {
'name': asset.p,
'bytes': buffer
};
});
})
);
}
}
}
return promises;
}
_playpause() {
if (this._playing) {
this._wasmTimePassed = Date.now() - this._firstFrameTime;
this._lottie && this._lottie.pause();
this._live && this._live.pause();
this._state.soundMap && this._state.soundMap.pause();
$$('#playpause').textContent = 'Play';
} else {
this._lottie && this._lottie.play();
this._live && this._live.play();
this._firstFrameTime = Date.now() - (this._wasmTimePassed || 0);
// There is no need call a soundMap.play() function here.
// Skottie invokes the play by calling seek on the needed audio track.
$$('#playpause').textContent = 'Pause';
}
this._playing = !this._playing;
}
_recoverFromError(msg) {
errorMessage(msg);
console.error(msg);
window.history.pushState(null, '', '/');
this._ui = DIALOG_MODE;
this.render();
}
_reflectFromURL() {
// Check URL.
let match = window.location.pathname.match(/\/([a-zA-Z0-9]+)/);
if (!match) {
// Make this the hash of the lottie file you want to play on startup.
this._hash = '1112d01d28a776d777cebcd0632da15b'; // gear.json
} else {
this._hash = match[1];
}
this._ui = LOADING_MODE;
this.render();
// Run this on the next micro-task to allow mocks to be set up if needed.
setTimeout(() => {
fetch(`/_/j/${this._hash}`, {
credentials: 'include',
}).then(jsonOrThrow).then(json => {
this._state = json;
// remove legacy fields from state, if they are there.
delete this._state.width;
delete this._state.height;
delete this._state.fps;
if (this._autoSize()) {
this._stateChanged();
}
this._ui = LOADED_MODE;
this._loadAssetsAndRender();
}).catch((msg) => this._recoverFromError(msg));
});
}
render() {
if (this._downloadUrl) {
URL.revokeObjectURL(this._downloadUrl);
}
this._downloadUrl = URL.createObjectURL(new Blob([JSON.stringify(this._state.lottie)]));
render(template(this), this, {eventContext: this});
this._skottiePlayer = $$('skottie-player-sk', this);
if (this._ui === LOADED_MODE) {
try {
this._renderLottieWeb();
this._renderJSONEditor();
} catch(e) {
console.warn('caught error while rendering third party code', e);
}
}
}
_renderJSONEditor() {
if (!this._showEditor) {
this._editorLoaded = false;
this._editor = null;
return;
}
let editorContainer = $$('#json_editor');
// See https://github.com/josdejong/jsoneditor/blob/master/docs/api.md
// for documentation on this editor.
let editorOptions = {
// Use original key order (this preserves related fields locality).
sortObjectKeys: false,
// There are sometimes a few onChange events that happen
// during the initial .set(), so we have a safety variable
// _editorLoaded to prevent a bunch of recursion
onChange: () => {
if (!this._editorLoaded) {
return;
}
this._hasEdits = true;
onUserEdit(editorContainer, this._editor.get());
this.render();
}
};
if (!this._editor) {
this._editorLoaded = false;
editorContainer.innerHTML = '';
this._editor = new JSONEditor(editorContainer, editorOptions);
setupListeners(editorContainer);
}
if (!this._hasEdits) {
this._editorLoaded = false;
// Only set the JSON when it is loaded, either because it's
// the first time we got it from the server or because the user
// hit applyEdits.
this._editor.set(this._state.lottie);
}
reannotate(editorContainer, this._state.lottie);
// We are now pretty confident that the onChange events will only be
// when the user modifies the JSON.
this._editorLoaded = true;
}
_renderLottieWeb() {
if (!this._showLottie) {
return;
}
// Don't re-start the animation while the user edits.
if (!this._hasEdits) {
$$('#container').innerHTML = '';
this._lottie = bodymovin.loadAnimation({
container: $$('#container'),
renderer: 'svg',
loop: true,
autoplay: this._playing,
assetsPath: `${this._assetsPath}/${this._hash}/`,
// Apparently the lottie player modifies the data as it runs?
animationData: JSON.parse(JSON.stringify(this._state.lottie)),
rendererSettings: {
preserveAspectRatio:'xMidYMid meet'
},
});
this._live = null;
} else {
// we have edits, update the live preview version.
// It will re-start from the very beginning, but the user can
// hit "rewind" to re-sync them.
$$('#live').innerHTML = '';
this._live = bodymovin.loadAnimation({
container: $$('#live'),
renderer: 'svg',
loop: true,
autoplay: this._playing,
assetsPath: `${this._assetsPath}/${this._hash}/`,
// Apparently the lottie player modifies the data as it runs?
animationData: JSON.parse(JSON.stringify(this._editor.get())),
rendererSettings: {
preserveAspectRatio:'xMidYMid meet'
},
});
}
}
// This fires every time the user moves the scrub slider.
_onScrub(e) {
if (!this._scrubbing) {
// Pause the animation while dragging the slider.
this._playingOnStartOfScrub = this._playing;
if (this._playing) {
this._playpause()
}
this._scrubbing = true;
}
let seek = (e.currentTarget.value / SCRUBBER_RANGE);
this._live && this._live.goToAndStop(seek);
this._lottie && this._lottie.goToAndStop(seek * this._duration);
this._skottiePlayer && this._skottiePlayer.seek(seek);
}
// This fires when the user releases the scrub slider.
_onScrubEnd(e) {
if (this._playingOnStartOfScrub) {
this._playpause()
}
this._scrubbing = false;
}
_onVolumeChange(e) {
this._state.soundMap.setVolume(e.currentTarget.value);
}
_rewind(e) {
// Handle rewinding when paused.
this._wasmTimePassed = 0;
if (!this._playing) {
this._skottiePlayer.seek(0);
this._firstFrameTime = null;
this._live && this._live.goToAndStop(0);
this._lottie && this._lottie.goToAndStop(0);
} else {
this._live && this._live.goToAndPlay(0);
this._lottie && this._lottie.goToAndPlay(0);
this._firstFrameTime = null;
}
}
_startEdit() {
this._ui = DIALOG_MODE;
this.render();
}
_toggleEditor(e) {
// avoid double toggles
e.preventDefault();
this._showTextEditor = false;
this._showEditor = !this._showEditor;
this._stateChanged();
this.render();
}
_toggleTextEditor(e) {
e.preventDefault();
this._showEditor = false;
this._showTextEditor = !this._showTextEditor;
this._stateChanged();
this.render();
}
_toggleEmbed() {
let collapse = $$('#embed', this);
collapse.closed = !collapse.closed;
}
_toggleLottie(e) {
// avoid double toggles
e.preventDefault();
this._showLottie = !this._showLottie;
this._stateChanged();
this.render();
}
_toggleVolumeSlider() {
let collapse = $$('#volume', this);
collapse.closed = !collapse;
}
_upload() {
// POST the JSON to /_/upload
this._hash = '';
this._hasEdits = false;
this._editorLoaded = false;
this._editor = null;
// Clean up the old animation and other wasm objects
this.render();
fetch('/_/upload', {
credentials: 'include',
body: JSON.stringify(this._state),
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
}).then(jsonOrThrow).then((json) => {
// Should return with the hash and the lottie file
this._ui = LOADED_MODE;
this._hash = json.hash;
this._state.lottie = json.lottie;
window.history.pushState(null, '', '/' + this._hash);
this._stateChanged();
if (this._state.assetsZip) {
this._loadAssetsAndRender();
}
this.render();
}).catch((msg) => this._recoverFromError(msg));
if (!this._state.assetsZip) {
this._ui = LOADED_MODE;
// Start drawing right away, no need to wait for
// the JSON to make a round-trip to the server, since there
// are no assets that we need to unzip server-side.
// We still need to check for things like webfonts.
this.render();
this._loadAssetsAndRender();
} else {
// We have to wait for the server to process the zip file.
this._ui = LOADING_MODE;
this.render();
}
}
});