| /** |
| * @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 { errorMessage } from 'elements-sk/errorMessage'; |
| import { define } from 'elements-sk/define'; |
| import { html } from 'lit-html'; |
| import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow'; |
| import { stateReflector } from 'common-sk/modules/stateReflector'; |
| import JSONEditor from 'jsoneditor'; |
| import LottiePlayer from 'lottie-web'; |
| import { CollapseSk } from 'elements-sk/collapse-sk/collapse-sk'; |
| import { SkottieGifExporterSk } from '../skottie-gif-exporter-sk/skottie-gif-exporter-sk'; |
| import '../skottie-gif-exporter-sk'; |
| import '../skottie-text-editor-sk'; |
| import { replaceTexts } from '../skottie-text-editor-sk/text-replace'; |
| import '../skottie-library-sk'; |
| import { SoundMap, AudioPlayer } from '../audio'; |
| import '../skottie-performance-sk'; |
| import { renderByDomain } from '../helpers/templates'; |
| import { supportedDomains } from '../helpers/domains'; |
| import '../skottie-audio-sk'; |
| import { ElementSk } from '../../../infra-sk/modules/ElementSk'; |
| import { SkottieConfigEventDetail, SkottieConfigState } from '../skottie-config-sk/skottie-config-sk'; |
| import { SkottiePlayerSk } from '../skottie-player-sk/skottie-player-sk'; |
| import { SkottiePerformanceSk } from '../skottie-performance-sk/skottie-performance-sk'; |
| import { |
| FontAsset, LottieAnimation, LottieAsset, ViewMode, |
| } from '../types'; |
| import { SkottieLibrarySk } from '../skottie-library-sk/skottie-library-sk'; |
| import { AudioStartEventDetail, SkottieAudioSk } from '../skottie-audio-sk/skottie-audio-sk'; |
| import { SkottieTextEditorSk, TextEditApplyEventDetail } from '../skottie-text-editor-sk/skottie-text-editor-sk'; |
| import '../skottie-shader-editor-sk'; |
| import { ShaderEditApplyEventDetail, ShaderEditorSk } from '../skottie-shader-editor-sk/skottie-shader-editor-sk'; |
| import '../../../infra-sk/modules/theme-chooser-sk'; |
| import '../../../infra-sk/modules/app-sk'; |
| import { replaceShaders } from '../skottie-shader-editor-sk/shader-replace'; |
| |
| // It is assumed that this symbol is being provided by a version.js file loaded in before this |
| // file. |
| declare const SKIA_VERSION: string; |
| |
| interface BodymovinPlayer { |
| goToAndStop(t: number): void; |
| goToAndPlay(t: number): void; |
| pause(): void; |
| play(): void; |
| } |
| |
| interface LottieLibrary { |
| version: string; |
| loadAnimation(opts: Record<string, unknown>): BodymovinPlayer; |
| } |
| |
| interface LoadedAsset { |
| name: string; |
| bytes?: ArrayBuffer; |
| player?: AudioPlayer; |
| } |
| |
| const GOOGLE_WEB_FONTS_HOST = 'https://storage.googleapis.com/skia-cdn/google-web-fonts'; |
| |
| const PRODUCTION_ASSETS_PATH = '/_/a'; |
| |
| // Make this the hash of the lottie file you want to play on startup. |
| const DEFAULT_LOTTIE_FILE = '1112d01d28a776d777cebcd0632da15b'; // gear.json |
| |
| // 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 AUDIO_SUPPORTED_DOMAINS = [ |
| supportedDomains.SKOTTIE_INTERNAL, |
| supportedDomains.SKOTTIE_TENOR, |
| supportedDomains.LOCALHOST, |
| ]; |
| |
| type UIMode = 'dialog' | 'loading' | 'loaded'; |
| |
| const caption = (text: string, mode: ViewMode) => { |
| if (mode === 'presentation') { |
| return null; |
| } |
| return html` |
| <figcaption> |
| ${text} |
| </figcaption> |
| `; |
| }; |
| |
| const performanceChart = (show: boolean) => { |
| if (!show) { |
| return ''; |
| } |
| return html` |
| <skottie-performance-sk id=chart></skottie-performance-sk>`; |
| }; |
| |
| const redir = () => renderByDomain( |
| html` |
| <div> |
| Googlers should use <a href="https://skottie-internal.skia.org">skottie-internal.skia.org</a>. |
| </div>`, |
| Object.values(supportedDomains).filter((domain: string) => domain !== supportedDomains.SKOTTIE_INTERNAL), |
| ); |
| |
| const displayLoading = () => html` |
| <div class=loading> |
| <spinner-sk active></spinner-sk><span>Loading...</span> |
| </div> |
| `; |
| |
| export class SkottieSk extends ElementSk { |
| private static template = (ele: SkottieSk) => html` |
| <app-sk> |
| <header> |
| <h2>Skottie</h2> |
| <span> |
| <a href='https://skia.googlesource.com/skia/+show/${SKIA_VERSION}'> |
| ${SKIA_VERSION.slice(0, 7)} |
| </a> |
| <theme-chooser-sk></theme-chooser-sk> |
| </span> |
| </header> |
| <main> |
| ${ele.pick()} |
| </main> |
| <footer> |
| <error-toast-sk></error-toast-sk> |
| ${redir()} |
| </footer> |
| </app-sk> |
| `; |
| |
| // pick the right part of the UI to display based on ele._ui. |
| private pick = () => { |
| switch (this.ui) { |
| default: |
| case 'dialog': |
| return this.displayDialog(); |
| case 'loading': |
| return displayLoading(); |
| case 'loaded': |
| return this.displayLoaded(); |
| } |
| }; |
| |
| private displayDialog = () => html` |
| <skottie-config-sk .state=${this.state} .width=${this.width} |
| .height=${this.height} .fps=${this.fps} .backgroundColor=${this.backgroundColor} |
| @skottie-selected=${this.skottieFileSelected} @cancelled=${this.selectionCancelled}></skottie-config-sk> |
| `; |
| |
| private displayLoaded = () => html` |
| ${this.controls()} |
| <collapse-sk id=embed closed> |
| <p> |
| <label> |
| Embed using an iframe: <input size=120 value=${this.iframeDirections()}> |
| </label> |
| </p> |
| <p> |
| <label> |
| Embed on skia.org: <input size=140 value=${this.inlineDirections()}> |
| </label> |
| </p> |
| </collapse-sk> |
| <section class=figures> |
| <figure> |
| ${this.skottiePlayerTemplate()} |
| </figure> |
| ${this.lottiePlayerTemplate()} |
| ${this.audio()} |
| ${this.library()} |
| ${this.livePreview()} |
| </section> |
| |
| <div @click=${this.onChartClick}> |
| ${performanceChart(this.showPerformanceChart)} |
| <div> |
| ${this.jsonEditor()} |
| ${this.gifExporter()} |
| ${this.jsonTextEditor()} |
| ${this.shaderEditor()} |
| `; |
| |
| private controls = () => { |
| if (this.viewMode === 'presentation') { |
| return null; |
| } return html` |
| <button class=edit-config @click=${this.startEdit}> |
| ${this.state.filename} ${this.width}x${this.height} ... |
| </button> |
| <div class=controls> |
| <button id=rewind @click=${this.rewind}>Rewind</button> |
| <button id=playpause @click=${this.playpause}>Pause</button> |
| <button ?hidden=${!this.hasEdits} @click=${this.applyEdits}>Apply Edits</button> |
| <div class=download> |
| <a target=_blank download=${this.state.filename} href=${this.downloadURL}> |
| JSON |
| </a> |
| ${this.hasEdits ? '(without edits)' : ''} |
| </div> |
| <checkbox-sk label="Show lottie-web" |
| ?checked=${this.showLottie} |
| @click=${this.toggleLottie}> |
| </checkbox-sk> |
| <checkbox-sk label="Show editor" |
| ?checked=${this.showJSONEditor} |
| @click=${this.toggleEditor}> |
| </checkbox-sk> |
| <checkbox-sk label="Show gif exporter" |
| ?checked=${this.showGifExporter} |
| @click=${this.toggleGifExporter}> |
| </checkbox-sk> |
| <checkbox-sk label="Show text editor" |
| ?checked=${this.showTextEditor} |
| @click=${this.toggleTextEditor}> |
| </checkbox-sk> |
| <checkbox-sk label="Show shader editor" |
| ?checked=${this.showShaderEditor} |
| @click=${this.toggleShaderEditor}> |
| </checkbox-sk> |
| <checkbox-sk label="Show performance chart" |
| ?checked=${this.showPerformanceChart} |
| @click=${this.togglePerformanceChart}> |
| </checkbox-sk> |
| <checkbox-sk label="Show library" |
| ?checked=${this.showLibrary} |
| @click=${this.toggleLibrary}> |
| </checkbox-sk> |
| ${this.audioButton()} |
| <button id=embed-btn @click=${this.toggleEmbed}>Embed</button> |
| <div class=scrub> |
| <input id=scrub type=range min=0 max=${SCRUBBER_RANGE} step=0.1 |
| @input=${this.onScrub} @change=${this.onScrubEnd}> |
| <label class=number> |
| Go to frame: <input type=number id=frameInput |
| @focus=${this.onFrameFocus} @change=${this.onFrameChange}/> |
| </label> |
| </div> |
| </div> |
| <collapse-sk id=volume closed> |
| <p> |
| Volume: |
| </p> |
| <input id=volume-slider type=range min=0 max=1 step=.05 value=1 |
| @input=${this.onVolumeChange}> |
| </collapse-sk> |
| `; |
| }; |
| |
| private audioButton = () => renderByDomain( |
| html`<checkbox-sk label="Show audio" |
| ?checked=${this.showAudio} |
| @click=${this.toggleAudio}> |
| </checkbox-sk>`, |
| AUDIO_SUPPORTED_DOMAINS, |
| ); |
| |
| private iframeDirections = () => `<iframe width="${this.width}" height="${this.height}" src="${window.location.origin}/e/${this.hash}?w=${this.width}&h=${this.height}" scrolling=no>`; |
| |
| private inlineDirections = () => `<skottie-inline-sk width="${this.width}" height="${this.height}" src="${window.location.origin}/_/j/${this.hash}"></skottie-inline-sk>`; |
| |
| private skottiePlayerTemplate = () => html` |
| <skottie-player-sk paused width=${this.width} height=${this.height}> |
| </skottie-player-sk> |
| ${this.wasmCaption()}`; |
| |
| private lottiePlayerTemplate = () => { |
| if (!this.showLottie) { |
| return ''; |
| } |
| return html` |
| <figure> |
| <div id=container title=lottie-web |
| style='width: ${this.width}px; height: ${this.height}px; background-color: ${this.backgroundColor}'></div> |
| ${caption('lottie-web', this.viewMode)} |
| </figure>`; |
| }; |
| |
| private audio = () => { |
| if (!this.showAudio) { |
| return ''; |
| } |
| return renderByDomain( |
| html` |
| <section class=audio> |
| <skottie-audio-sk |
| .animation=${this.state.lottie} |
| @apply=${this.applyAudioSync} |
| > |
| </skottie-audio-sk> |
| </section>`, |
| AUDIO_SUPPORTED_DOMAINS, |
| ); |
| }; |
| |
| private library = () => { |
| if (!this.showLibrary) { |
| return ''; |
| } |
| return html` |
| <section class=library> |
| <skottie-library-sk |
| @select=${this.updateAnimation} |
| > |
| </skottie-library-sk> |
| </section>`; |
| }; |
| |
| // TODO(kjlubick): Make the live preview use skottie |
| private livePreview = () => { |
| if (!this.hasEdits || !this.showLottie) { |
| return ''; |
| } |
| if (this.hasEdits) { |
| return html` |
| <figure> |
| <div id=live title=live-preview |
| style='width: ${this.width}px; height: ${this.height}px'></div> |
| <figcaption>Preview [lottie-web]</figcaption> |
| </figure>`; |
| } |
| return ''; |
| }; |
| |
| private jsonEditor = () => { |
| if (!this.showJSONEditor) { |
| return ''; |
| } |
| return html` |
| <section class=editor> |
| <div id=json_editor></div> |
| </section>`; |
| }; |
| |
| private gifExporter = () => { |
| if (!this.showGifExporter) { |
| return ''; |
| } |
| return html` |
| <section class=editor> |
| <skottie-gif-exporter-sk |
| @start=${this.onGifExportStart} |
| > |
| </skottie-gif-exporter-sk> |
| </section>`; |
| }; |
| |
| private jsonTextEditor = () => { |
| if (!this.showTextEditor) { |
| return ''; |
| } |
| return html` |
| <section class=editor> |
| <skottie-text-editor-sk |
| .animation=${this.state.lottie} |
| .mode=${this.viewMode} |
| @apply=${this.applyTextEdits} |
| > |
| </skottie-text-editor-sk> |
| </section>`; |
| }; |
| |
| private shaderEditor = () => { |
| if (!this.showShaderEditor) { |
| return ''; |
| } |
| return html` |
| <section class=editor> |
| <skottie-shader-editor-sk |
| .animation=${this.state.lottie} |
| .mode=${this.viewMode} |
| @apply=${this.applyShaderEdits} |
| > |
| </skottie-shader-editor-sk> |
| </section>`; |
| }; |
| |
| private buildFileName = () => { |
| const fileName = this.state.filename || this.state.lottie?.metadata?.filename; |
| if (fileName) { |
| return html`<div title='${fileName}'>${fileName}</div>`; |
| } |
| return null; |
| }; |
| |
| private wasmCaption = () => { |
| if (this.viewMode === 'presentation') { |
| return null; |
| } |
| return html` |
| <figcaption style='max-width: ${this.width}px;'> |
| <div>skottie-wasm</div> |
| ${this.buildFileName()} |
| </figcaption>`; |
| }; |
| |
| private assetsPath = PRODUCTION_ASSETS_PATH; // overridable for testing |
| |
| // The URL referring to the lottie JSON Blob. |
| private backgroundColor: string = 'rgba(0,0,0,0)'; |
| |
| private downloadURL: string = ''; |
| |
| private duration: number = 0; // 0 is a sentinel value for "player not loaded yet" |
| |
| private editor: JSONEditor | null = null; |
| |
| private editorLoaded: boolean = false; |
| |
| // used for remembering the time elapsed while the animation is playing. |
| private elapsedTime: number = 0; |
| |
| private fps: number = 0; |
| |
| private hasEdits: boolean = false; |
| |
| private hash: string = ''; |
| |
| private height: number = 0; |
| |
| private live: BodymovinPlayer | null = null; |
| |
| private lottiePlayer: BodymovinPlayer | null = null; |
| |
| private performanceChart: SkottiePerformanceSk | null = null; |
| |
| private playing: boolean = true; |
| |
| private playingOnStartOfScrub: boolean = false; |
| |
| // The wasm animation computes how long it has been since the previous rendered time and |
| // uses arithmetic to figure out where to seek (i.e. which frame to draw). |
| private previousFrameTime: number = 0; |
| |
| private scrubbing: boolean = false; |
| |
| private showAudio: boolean = false; |
| |
| private showGifExporter: boolean = false; |
| |
| private showJSONEditor: boolean = false; |
| |
| private showLibrary: boolean = false; |
| |
| private showLottie: boolean = false; |
| |
| private showPerformanceChart: boolean = false; |
| |
| private showTextEditor: boolean = false; |
| |
| private showShaderEditor: boolean = false; |
| |
| private skottieLibrary: SkottieLibrarySk | null = null; |
| |
| private skottiePlayer: SkottiePlayerSk | null = null; |
| |
| private speed: number = 1; // this is a playback multiplier |
| |
| private state: SkottieConfigState; |
| |
| private stateChanged: ()=> void; |
| |
| private ui: UIMode = 'dialog'; |
| |
| private viewMode: ViewMode = 'default'; |
| |
| private width: number = 0; |
| |
| constructor() { |
| super(SkottieSk.template); |
| |
| this.state = { |
| filename: '', |
| lottie: null, |
| assetsZip: '', |
| assetsFilename: '', |
| }; |
| |
| this.stateChanged = stateReflector( |
| /* getState */() => ({ |
| // provide empty values |
| l: this.showLottie, |
| e: this.showJSONEditor, |
| g: this.showGifExporter, |
| t: this.showTextEditor, |
| s: this.showShaderEditor, |
| p: this.showPerformanceChart, |
| i: this.showLibrary, |
| a: this.showAudio, |
| w: this.width, |
| h: this.height, |
| f: this.fps, |
| bg: this.backgroundColor, |
| mode: this.viewMode, |
| }), /* setState */(newState) => { |
| this.showLottie = !!newState.l; |
| this.showJSONEditor = !!newState.e; |
| this.showGifExporter = !!newState.g; |
| this.showTextEditor = !!newState.t; |
| this.showShaderEditor = !!newState.s |
| this.showPerformanceChart = !!newState.p; |
| this.showLibrary = !!newState.i; |
| this.showAudio = !!newState.a; |
| this.width = +newState.w; |
| this.height = +newState.h; |
| this.fps = +newState.f; |
| this.viewMode = newState.mode === 'presentation' ? 'presentation' : 'default'; |
| this.backgroundColor = String(newState.bg); |
| this.render(); |
| }, |
| ); |
| } |
| |
| connectedCallback(): void { |
| super.connectedCallback(); |
| this.reflectFromURL(); |
| window.addEventListener('popstate', this.reflectFromURL); |
| this.render(); |
| |
| // Start a continuous animation loop. |
| const drawFrame = () => { |
| window.requestAnimationFrame(drawFrame); |
| |
| // Elsewhere, the _previousFrameTime 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.previousFrameTime && this.playing) { |
| this.previousFrameTime = Date.now(); |
| this.elapsedTime = 0; |
| } |
| if (this.playing && this.duration > 0) { |
| const currentTime = Date.now(); |
| this.elapsedTime += (currentTime - this.previousFrameTime) * this.speed; |
| this.previousFrameTime = currentTime; |
| const progress = this.elapsedTime % 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. |
| const normalizedProgress = progress / this.duration; |
| this.performanceChart?.start(progress, this.duration, this.state.lottie?.fr || 0); |
| this.skottiePlayer?.seek(normalizedProgress); |
| this.performanceChart?.end(); |
| this.skottieLibrary?.seek(normalizedProgress); |
| |
| // lottie player takes the milliseconds from the beginning of the animation. |
| this.lottiePlayer?.goToAndStop(progress); |
| this.live?.goToAndStop(progress); |
| this.updateScrubber(); |
| this.updateFrameLabel(); |
| } |
| }; |
| |
| window.requestAnimationFrame(drawFrame); |
| } |
| |
| disconnectedCallback(): void { |
| super.disconnectedCallback(); |
| window.removeEventListener('popstate', this.reflectFromURL); |
| } |
| |
| attributeChangedCallback(): void { |
| this.render(); |
| } |
| |
| private updateAnimation(e: CustomEvent<LottieAnimation>): void { |
| this.state.lottie = e.detail; |
| this.state.filename = e.detail.metadata?.filename || this.state.filename; |
| this.upload(); |
| } |
| |
| private applyTextEdits(e: CustomEvent<TextEditApplyEventDetail>): void { |
| const texts = e.detail.texts; |
| this.state.lottie = replaceTexts(texts, this.state.lottie!); |
| this.skottieLibrary?.replaceTexts(texts); |
| |
| this.upload(); |
| } |
| |
| private applyShaderEdits(e: CustomEvent<ShaderEditApplyEventDetail>): void { |
| const shaders = e.detail.shaders; |
| this.state.lottie = replaceShaders(shaders, this.state.lottie!); |
| //TODO(jmbetancourt): support skottieLibrary |
| //this.skottieLibrary?.replaceShaders(shaders); |
| |
| this.upload(); |
| } |
| |
| private applyAudioSync(e: CustomEvent<AudioStartEventDetail>): void { |
| const detail = e.detail; |
| this.speed = detail.speed; |
| this.previousFrameTime = Date.now(); |
| this.elapsedTime = 0; |
| if (!this.playing) { |
| this.playpause(); |
| } |
| } |
| |
| private onGifExportStart(): void { |
| if (this.playing) { |
| this.playpause(); |
| } |
| } |
| |
| private applyEdits(): void { |
| if (!this.editor || !this.editorLoaded || !this.hasEdits) { |
| return; |
| } |
| this.state.lottie = this.editor.get(); |
| this.upload(); |
| } |
| |
| private autoSize(): boolean { |
| 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; |
| } |
| |
| private skottieFileSelected(e: CustomEvent<SkottieConfigEventDetail>) { |
| 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'; |
| this.render(); |
| this.initializePlayer(); |
| // Re-sync all players |
| this.rewind(); |
| } |
| } |
| |
| private selectionCancelled() { |
| this.ui = 'loaded'; |
| this.render(); |
| this.initializePlayer(); |
| } |
| |
| private initializePlayer(): Promise<void> { |
| return 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.performanceChart?.reset(); |
| 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 = $$<HTMLInputElement>('#scrub', this); |
| if (scrubber) { |
| // calculate a scaled version of ms per frame as the step size. |
| scrubber.step = String((1000 / this.fps) * (SCRUBBER_RANGE / this.duration)); |
| } |
| } |
| }); |
| } |
| |
| private loadAssetsAndRender(): Promise<void> { |
| const toLoad: Promise<(LoadedAsset | null)>[] = []; |
| |
| const lottie = this.state.lottie!; |
| let fonts: FontAsset[] = []; |
| let assets: LottieAsset[] = []; |
| 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)); |
| |
| return Promise.all(toLoad).then((externalAssets: (LoadedAsset | null)[]) => { |
| const loadedAssets: Record<string, ArrayBuffer> = {}; |
| const sounds = new SoundMap(); |
| for (const asset of externalAssets) { |
| if (asset && asset.bytes) { |
| loadedAssets[asset.name] = asset.bytes; |
| } else if (asset && asset.player) { |
| sounds.setPlayer(asset.name, asset.player); |
| } |
| } |
| |
| // check fonts |
| fonts.forEach((font: FontAsset) => { |
| if (!loadedAssets[font.fName]) { |
| console.error(`Could not load font '${font.fName}'.`); |
| } |
| }); |
| |
| this.state.assets = loadedAssets; |
| this.state.soundMap = sounds; |
| this.render(); |
| return this.initializePlayer().then(() => { |
| // Re-sync all players |
| this.rewind(); |
| }); |
| }) |
| .catch(() => { |
| this.render(); |
| return this.initializePlayer().then(() => { |
| // Re-sync all players |
| this.rewind(); |
| }); |
| }); |
| } |
| |
| private loadFonts(fonts: FontAsset[]): Promise<LoadedAsset | null>[] { |
| const promises: (Promise<LoadedAsset | null>)[] = []; |
| for (const font of fonts) { |
| if (!font.fName) { |
| continue; |
| } |
| |
| const fetchFont = (fontURL: string) => { |
| promises.push(fetch(fontURL) |
| .then((resp: Response) => { |
| // fetch does not reject on 404 |
| if (!resp.ok) { |
| return null; |
| } |
| return resp.arrayBuffer().then((buffer: ArrayBuffer) => ({ |
| 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; |
| } |
| |
| private loadAssets(assets: LottieAsset[]): (Promise<LoadedAsset | null>)[] { |
| const promises: (Promise<LoadedAsset | null>)[] = []; |
| 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(Promise.resolve({ |
| name: asset.id, |
| player: new AudioPlayer(asset.p), |
| })); |
| } else { |
| promises.push(Promise.resolve({ |
| 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: Response) => { |
| // 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) => ({ |
| name: asset.p, |
| bytes: buffer, |
| })); |
| })); |
| } |
| } |
| } |
| return promises; |
| } |
| |
| private playpause(): void { |
| const audioManager = $$<SkottieAudioSk>('skottie-audio-sk'); |
| if (this.playing) { |
| this.lottiePlayer?.pause(); |
| this.live?.pause(); |
| this.state.soundMap?.pause(); |
| $$('#playpause')!.textContent = 'Play'; |
| audioManager?.pause(); |
| } else { |
| this.lottiePlayer?.play(); |
| this.live?.play(); |
| this.previousFrameTime = Date.now(); |
| // 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'; |
| audioManager?.resume(); |
| } |
| this.playing = !this.playing; |
| } |
| |
| private recoverFromError(msg: string): void { |
| errorMessage(msg); |
| console.error(msg); |
| window.history.pushState(null, '', '/'); |
| this.ui = 'dialog'; |
| this.render(); |
| } |
| |
| private reflectFromURL(): void { |
| // Check URL. |
| const match = window.location.pathname.match(/\/([a-zA-Z0-9]+)/); |
| if (!match) { |
| this.hash = DEFAULT_LOTTIE_FILE; |
| } else { |
| this.hash = match[1]; |
| } |
| this.ui = 'loading'; |
| 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) => { |
| // remove legacy fields from state, if they are there. |
| delete json.width; |
| delete json.height; |
| delete json.fps; |
| this.state = json; |
| |
| if (this.autoSize()) { |
| this.stateChanged(); |
| } |
| this.ui = 'loaded'; |
| this.loadAssetsAndRender().then(() => { |
| console.log('loaded'); |
| this.dispatchEvent(new CustomEvent('initial-animation-loaded', { bubbles: true })); |
| }); |
| }).catch((msg) => this.recoverFromError(msg)); |
| }); |
| } |
| |
| private render(): void { |
| if (this.downloadURL) { |
| URL.revokeObjectURL(this.downloadURL); |
| } |
| this.downloadURL = URL.createObjectURL(new Blob([JSON.stringify(this.state.lottie)])); |
| super._render(); |
| |
| this.skottiePlayer = $$<SkottiePlayerSk>('skottie-player-sk', this); |
| this.performanceChart = $$<SkottiePerformanceSk>('skottie-performance-sk', this); |
| this.skottieLibrary = $$<SkottieLibrarySk>('skottie-library-sk', this); |
| |
| const skottieGifExporter = $$<SkottieGifExporterSk>('skottie-gif-exporter-sk', this); |
| if (skottieGifExporter && this.skottiePlayer) { |
| skottieGifExporter.player = this.skottiePlayer; |
| } |
| |
| if (this.ui === 'loaded') { |
| if (this.state.soundMap && this.state.soundMap.map.size > 0) { |
| this.hideVolumeSlider(false); |
| // Stop any audio assets that start playing on frame 0 |
| // Pause the playback to force a user gesture to resume the AudioContext |
| if (this.playing) { |
| this.playpause(); |
| this.rewind(); |
| } |
| this.state.soundMap.stop(); |
| } else { |
| this.hideVolumeSlider(true); |
| } |
| try { |
| this.renderLottieWeb(); |
| this.renderJSONEditor(); |
| this.renderTextEditor(); |
| this.renderShaderEditor(); |
| this.renderAudioManager(); |
| } catch (e) { |
| console.warn('caught error while rendering third party code', e); |
| } |
| } |
| } |
| |
| private renderAudioManager(): void { |
| if (this.showAudio) { |
| const audioManager = $$<SkottieAudioSk>('skottie-audio-sk', this); |
| if (audioManager) { |
| audioManager.animation = this.state.lottie!; |
| } |
| } |
| } |
| |
| private renderTextEditor(): void { |
| if (this.showTextEditor) { |
| const textEditor = $$<SkottieTextEditorSk>('skottie-text-editor-sk', this); |
| if (textEditor) { |
| textEditor.animation = this.state.lottie!; |
| } |
| } |
| } |
| |
| private renderShaderEditor(): void { |
| if (this.showShaderEditor) { |
| const shaderEditor = $$<ShaderEditorSk>('skottie-shader-editor-sk', this); |
| if (shaderEditor) { |
| shaderEditor.animation = this.state.lottie!; |
| } |
| } |
| } |
| |
| private renderJSONEditor(): void { |
| if (!this.showJSONEditor) { |
| this.editorLoaded = false; |
| this.editor = null; |
| return; |
| } |
| const editorContainer = $$<HTMLDivElement>('#json_editor')!; |
| // See https://github.com/josdejong/jsoneditor/blob/master/docs/api.md |
| // for documentation on this editor. |
| const 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; |
| this.render(); |
| }, |
| }; |
| |
| if (!this.editor) { |
| this.editorLoaded = false; |
| editorContainer.innerHTML = ''; |
| this.editor = new JSONEditor(editorContainer, editorOptions); |
| } |
| 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); |
| } |
| // We are now pretty confident that the onChange events will only be |
| // when the user modifies the JSON. |
| this.editorLoaded = true; |
| } |
| |
| private renderLottieWeb(): void { |
| if (!this.showLottie) { |
| return; |
| } |
| // Don't re-start the animation while the user edits. |
| if (!this.hasEdits) { |
| $$<HTMLDivElement>('#container')!.innerHTML = ''; |
| this.lottiePlayer = LottiePlayer.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)) as LottieAnimation, |
| 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. |
| $$<HTMLDivElement>('#live')!.innerHTML = ''; |
| this.live = LottiePlayer.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())) as LottieAnimation, |
| rendererSettings: { |
| preserveAspectRatio: 'xMidYMid meet', |
| }, |
| }); |
| } |
| } |
| |
| // This fires every time the user moves the scrub slider. |
| private onScrub(e: Event): void { |
| if (!this.scrubbing) { |
| // Pause the animation while dragging the slider. |
| this.playingOnStartOfScrub = this.playing; |
| if (this.playing) { |
| this.playpause(); |
| } |
| this.scrubbing = true; |
| } |
| const scrubber = (e.target as HTMLInputElement)!; |
| const seek = (+scrubber.value / SCRUBBER_RANGE); |
| this.seek(seek); |
| this.updateFrameLabel(); |
| } |
| |
| // This fires when the user releases the scrub slider. |
| private onScrubEnd(): void { |
| if (this.playingOnStartOfScrub) { |
| this.playpause(); |
| } |
| this.scrubbing = false; |
| } |
| |
| private onFrameFocus(): void { |
| if (this.playing) { |
| this.playpause(); |
| } |
| } |
| |
| private onFrameChange(e: Event): void { |
| if (this.playing) { |
| this.playpause(); |
| } |
| const frameInput = $$<HTMLInputElement>('#frameInput', this); |
| if (frameInput) { |
| const frame = +frameInput.value; |
| this.seekFrame(frame); |
| } |
| } |
| |
| private onChartClick(e: Event): void { |
| const chart = $$<SkottiePerformanceSk>('#chart', this); |
| const frame: (number|undefined) = chart?.getClickedFrame(e); |
| if (frame !== undefined && frame !== -1) { |
| if (this.playing) { |
| this.playpause(); |
| } |
| const frameInput = $$<HTMLInputElement>('#frameInput', this); |
| if (frameInput) frameInput.value = String(frame); |
| this.seekFrame(frame); |
| } |
| } |
| |
| private seekFrame(frame: number): void { |
| if (frame > 0 && frame < this.duration) { |
| let seek = 0; |
| if (this.state.lottie?.fr) { |
| seek = ((frame / this.state.lottie.fr) * 1000) / this.duration; |
| } |
| this.seek(seek); |
| this.updateScrubber(); |
| } |
| } |
| |
| private updateScrubber(): void { |
| const scrubber = $$<HTMLInputElement>('#scrub', this); |
| if (scrubber) { |
| // Scale from time to the arbitrary scrubber range. |
| const progress = this.elapsedTime % this.duration; |
| scrubber.value = String((SCRUBBER_RANGE * progress) / this.duration); |
| } |
| } |
| |
| private updateFrameLabel(): void { |
| const frameLabel = $$<HTMLInputElement>('#frameInput', this); |
| if (frameLabel) { |
| const progress = this.elapsedTime % this.duration; |
| if (this.state.lottie!.fr) { |
| frameLabel.value = String(Math.round(progress * (this.state.lottie!.fr / 1000))); |
| } |
| } |
| } |
| |
| private seek(t: number): void { |
| // catch case where t = 1 |
| t = Math.min(t, 0.9999); |
| this.elapsedTime = t * this.duration; |
| this.live?.goToAndStop(t); |
| this.lottiePlayer?.goToAndStop(t * this.duration); |
| this.skottiePlayer?.seek(t); |
| this.skottieLibrary?.seek(t); |
| } |
| |
| private onVolumeChange(e: Event): void { |
| const scrubber = (e.target as HTMLInputElement)!; |
| this.state.soundMap?.setVolume(+scrubber.value); |
| } |
| |
| private rewind(): void { |
| // Handle rewinding when paused. |
| if (!this.playing) { |
| this.skottiePlayer!.seek(0); |
| this.skottieLibrary?.seek(0); |
| this.previousFrameTime = 0; |
| this.live?.goToAndStop(0); |
| this.lottiePlayer?.goToAndStop(0); |
| const scrubber = $$<HTMLInputElement>('#scrub', this); |
| if (scrubber) { |
| scrubber.value = '0'; |
| } |
| } else { |
| this.live?.goToAndPlay(0); |
| this.lottiePlayer?.goToAndPlay(0); |
| this.previousFrameTime = 0; |
| const audioManager = $$<SkottieAudioSk>('skottie-audio-sk', this); |
| audioManager?.rewind(); |
| } |
| } |
| |
| private startEdit(): void { |
| this.ui = 'dialog'; |
| this.render(); |
| } |
| |
| private toggleEditor(e: Event): void { |
| // avoid double toggles |
| e.preventDefault(); |
| this.showTextEditor = false; |
| this.showJSONEditor = !this.showJSONEditor; |
| this.stateChanged(); |
| this.render(); |
| } |
| |
| private toggleGifExporter(e: Event): void { |
| // avoid double toggles |
| e.preventDefault(); |
| this.showGifExporter = !this.showGifExporter; |
| this.stateChanged(); |
| this.render(); |
| } |
| |
| private togglePerformanceChart(e: Event): void { |
| // avoid double toggles |
| e.preventDefault(); |
| this.showPerformanceChart = !this.showPerformanceChart; |
| this.stateChanged(); |
| this.render(); |
| } |
| |
| private toggleTextEditor(e: Event): void { |
| e.preventDefault(); |
| this.showJSONEditor = false; |
| this.showTextEditor = !this.showTextEditor; |
| this.stateChanged(); |
| this.render(); |
| } |
| |
| private toggleShaderEditor(e: Event): void { |
| e.preventDefault(); |
| this.showJSONEditor = false; |
| this.showShaderEditor = !this.showShaderEditor; |
| this.stateChanged(); |
| this.render(); |
| } |
| |
| private toggleLibrary(e: Event): void { |
| e.preventDefault(); |
| this.showLibrary = !this.showLibrary; |
| this.stateChanged(); |
| this.render(); |
| } |
| |
| private toggleAudio(e: Event): void { |
| e.preventDefault(); |
| this.showAudio = !this.showAudio; |
| this.stateChanged(); |
| this.render(); |
| } |
| |
| private toggleEmbed(): void { |
| const collapse = $$<CollapseSk>('#embed', this); |
| if (collapse) { |
| collapse.closed = !collapse.closed; |
| } |
| } |
| |
| private toggleLottie(e: Event): void { |
| // avoid double toggles |
| e.preventDefault(); |
| this.showLottie = !this.showLottie; |
| this.stateChanged(); |
| this.render(); |
| } |
| |
| private hideVolumeSlider(v: boolean) { |
| const collapse = $$<CollapseSk>('#volume', this); |
| if (collapse) { |
| collapse.closed = v; |
| } |
| } |
| |
| private upload(): void { |
| // 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'; |
| 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'; |
| // 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'; |
| this.render(); |
| } |
| } |
| |
| overrideAssetsPathForTesting(p: string): void { |
| this.assetsPath = p; |
| } |
| } |
| |
| define('skottie-sk', SkottieSk); |