blob: f55311064990f3c4d10736c7c8c5249aee34f6aa [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'
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.
const SCRUBBER_RANGE = 1000;
const displayDialog = (ele) => html`
<skottie-config-sk .state=${ele._state} .width=${ele._width} .height=${ele._height}></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'></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}" 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 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>
<button @click=${ele._toggleEmbed}>Embed</button>
<div class=scrub>
<input id=scrub type=range min=0 max=${SCRUBBER_RANGE} @input=${ele._onScrub} @change=${ele._onScrubEnd}>
</div>
</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)}
`;
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/+/${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._scrubbing = false;
this._playingOnStartOfScrub = false;
this._width = 0;
this._height = 0;
this._stateChanged = stateReflector(
/*getState*/() => {
return {
// provide empty values
'l' : this._showLottie,
'e' : this._showEditor,
'w' : this._width,
'h' : this._height,
}
}, /*setState*/(newState) => {
this._showLottie = newState.l;
this._showEditor = newState.e;
this._width = newState.w;
this._height = newState.h;
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);
this._lottie && this._lottie.goToAndStop(progress);
this._live && this._live.goToAndStop(progress);
const scrubber = $$('#scrub', this);
scrubber && (scrubber.value = Math.floor(SCRUBBER_RANGE * progress / this._duration));
}
}
window.requestAnimationFrame(drawFrame);
}
disconnectedCallback() {
this.removeEventListener('skottie-selected', this)
this.removeEventListener('cancelled', this)
}
attributeChangedCallback(name, oldValue, newValue) {
this.render();
}
_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;
}
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._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,
}).then(() => {
this._duration = this._skottiePlayer.duration();
});
}
_loadAssetsAndRender() {
const toLoad = [];
const lottie = this._state.lottie;
if (lottie.fonts && lottie.fonts.list) {
toLoad.push(...this._loadFonts(lottie.fonts.list));
}
if (lottie.assets && lottie.assets.length) {
toLoad.push(...this._loadAssets(lottie.assets));
}
Promise.all(toLoad).then((externalAssets) => {
const assets = {};
for (const asset of externalAssets) {
if (asset) {
assets[asset.name] = asset.bytes;
}
}
this._state.assets = assets;
this.render();
this._initializePlayer();
// Re-sync all players
this._rewind();
});
}
_loadFonts(fonts) {
const promises = [];
for (const font of fonts) {
if (font.fPath && font.fPath.startsWith('https://fonts.googleapis.com')) {
// 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.
promises.push(fetch(`${GOOGLE_WEB_FONTS_HOST}/${font.fName}.ttf`)
.then((resp) => {
// fetch does not reject on 404
if (!resp.ok) {
console.error(`Could not load webfont ${font.fName}: status ${resp.status}`)
return null;
}
return resp.arrayBuffer().then((buffer) => {
return {
'name': font.fName,
'bytes': buffer
};
});
}));
}
// Look for the fonts in the assets directory with a .ttf extension
else if (font.fName) {
promises.push(fetch(`${this._assetsPath}/${this._hash}/${font.fName}.ttf`)
.then((resp) => {
// fetch does not reject on 404
if (!resp.ok) {
console.error(`Could not load ${font.fName}.ttf: status ${resp.status}`)
return null;
}
return resp.arrayBuffer().then((buffer) => {
return {
'name': font.fName,
'bytes': buffer
};
});
})
);
}
}
return promises;
}
_loadAssets(assets) {
const promises = [];
for (const asset of assets) {
// asset.p is the filename, if it's an image
if (asset.p) {
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();
$$('#playpause').textContent = 'Play';
} else {
this._lottie && this._lottie.play();
this._live && this._live.play();
this._firstFrameTime = Date.now() - (this._wasmTimePassed || 0);
$$('#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, null, ' ')]));
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 = {
sortObjectKeys: true,
// 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;
}
_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._showEditor = !this._showEditor;
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();
}
_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();
}
}
});