import { DPR, IS_SAFARI, IS_ANDROID } from '../../util/environment'; import WorkerConnector from '../../util/WorkerConnector'; import { animate } from '../../util/animation'; import cycleRestrict from '../../util/cycleRestrict'; import { fastRaf } from '../../util/schedulers'; interface Params { noLoop?: boolean; size?: number; quality?: number; isLowPriority?: boolean; isMobile?: boolean; coords?: { x: number; y: number }; } const WAITING = Symbol('WAITING'); type Frame = undefined | typeof WAITING | ImageBitmap; const MAX_WORKERS = 4; const HIGH_PRIORITY_QUALITY_MOBILE = 0.75; const HIGH_PRIORITY_QUALITY_DESKTOP = 1; const LOW_PRIORITY_QUALITY = IS_ANDROID ? 0.5 : 0.75; const LOW_PRIORITY_QUALITY_SIZE_THRESHOLD = 24; const HIGH_PRIORITY_CACHE_MODULO = IS_SAFARI ? 2 : 4; const LOW_PRIORITY_CACHE_MODULO = 0; const instancesById = new Map(); const workers = new Array(MAX_WORKERS).fill(undefined).map( () => new WorkerConnector(new Worker(new URL('./rlottie.worker.ts', import.meta.url))), ); let lastWorkerIndex = -1; class RLottie { // Config private containers = new Map(); private imgSize!: number; private imageData!: ImageData; private msPerFrame = 1000 / 60; private reduceFactor = 1; private cacheModulo!: number; private workerIndex!: number; private frames: Frame[] = []; private framesCount?: number; // State private isAnimating = false; private isWaiting = true; private isEnded = false; private isDestroyed = false; private isRendererInited = false; private approxFrameIndex = 0; private prevFrameIndex = -1; private stopFrameIndex? = 0; private speed = 1; private direction: 1 | -1 = 1; private lastRenderAt?: number; static init(...args: ConstructorParameters) { const [container, canvas, onLoad, id, , params] = args; let instance = instancesById.get(id); if (!instance) { // eslint-disable-next-line prefer-rest-params instance = new RLottie(...args); instancesById.set(id, instance); } else { instance.addContainer(container, canvas, onLoad, params?.coords, params?.isMobile); } return instance; } constructor( containerId: string, container: HTMLDivElement | HTMLCanvasElement, onLoad: NoneToVoidFunction | undefined, private id: string, private tgsUrl: string, private params: Params = {}, private customColor?: [number, number, number], private onEnded?: (isDestroyed?: boolean) => void, private onLoop?: () => void, ) { this.addContainer(containerId, container, onLoad, params.coords, params.isMobile); this.initConfig(); this.initRenderer(); } public removeContainer(containerId: string) { const { canvas, ctx, isSharedCanvas, coords, } = this.containers.get(containerId)!; if (isSharedCanvas) { ctx.clearRect(coords!.x, coords!.y, this.imgSize, this.imgSize); } else { canvas.remove(); } this.containers.delete(containerId); if (!this.containers.size) { this.destroy(); } } isPlaying() { return this.isAnimating || this.isWaiting; } play(forceRestart = false, containerId?: string) { if (containerId) { this.containers.get(containerId)!.isPaused = false; } if (this.isEnded && forceRestart) { this.approxFrameIndex = Math.floor(0); } this.stopFrameIndex = undefined; this.direction = 1; this.doPlay(); } pause(containerId?: string) { if (containerId) { this.containers.get(containerId)!.isPaused = true; const areAllContainersPaused = Array.from(this.containers.values()).every(({ isPaused }) => isPaused); if (!areAllContainersPaused) { return; } } if (this.isWaiting) { this.stopFrameIndex = this.approxFrameIndex; } else { this.isAnimating = false; } if (!this.params.isLowPriority) { this.frames = this.frames.map((frame, i) => { if (i === this.prevFrameIndex) { return frame; } else { if (frame && frame !== WAITING) { frame.close(); } return undefined; } }); } } playSegment([startFrameIndex, stopFrameIndex]: [number, number]) { this.approxFrameIndex = Math.floor(startFrameIndex / this.reduceFactor); this.stopFrameIndex = Math.floor(stopFrameIndex / this.reduceFactor); this.direction = startFrameIndex < stopFrameIndex ? 1 : -1; this.doPlay(); } setSpeed(speed: number) { this.speed = speed; } setNoLoop(noLoop?: boolean) { this.params.noLoop = noLoop; } setIsMobile(isMobile?: Params['isMobile']) { this.params.isMobile = isMobile; } setSharedCanvasCoords(containerId: string, newCoords: Params['coords'], isMobile?: Params['isMobile']) { const containerInfo = this.containers.get(containerId)!; const { canvas, ctx, } = containerInfo; if (!canvas.dataset.isJustCleaned || canvas.dataset.isJustCleaned === 'false') { this.setIsMobile(isMobile); const sizeFactor = this.calcSizeFactor(); ensureCanvasSize(canvas, sizeFactor); ctx.clearRect(0, 0, canvas.width, canvas.height); canvas.dataset.isJustCleaned = 'true'; fastRaf(() => { canvas.dataset.isJustCleaned = 'false'; }); } containerInfo.coords = { x: Math.round((newCoords?.x || 0) * canvas.width), y: Math.round((newCoords?.y || 0) * canvas.height), }; const frame = this.getFrame(this.prevFrameIndex) || this.getFrame(Math.round(this.approxFrameIndex)); if (frame && frame !== WAITING) { ctx.drawImage(frame, containerInfo.coords.x, containerInfo.coords.y); } } private addContainer( containerId: string, container: HTMLDivElement | HTMLCanvasElement, onLoad?: NoneToVoidFunction, coords?: Params['coords'], isMobile?: Params['isMobile'], ) { this.setIsMobile(isMobile); const sizeFactor = this.calcSizeFactor(); let imgSize: number; if (container instanceof HTMLDivElement) { if (!(container.parentNode instanceof HTMLElement)) { throw new Error('[RLottie] Container is not mounted'); } let { size } = this.params; if (!size) { size = ( container.offsetWidth || parseInt(container.style.width, 10) || container.parentNode.offsetWidth ); if (!size) { throw new Error('[RLottie] Failed to detect width from container'); } } const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d')!; canvas.style.width = `${size}px`; canvas.style.height = `${size}px`; imgSize = Math.round(size * sizeFactor); canvas.width = imgSize; canvas.height = imgSize; container.appendChild(canvas); this.containers.set(containerId, { canvas, ctx, onLoad, }); } else { if (!container.isConnected) { throw new Error('[RLottie] Shared canvas is not mounted'); } const canvas = container; const ctx = canvas.getContext('2d')!; ensureCanvasSize(canvas, sizeFactor); imgSize = Math.round(this.params.size! * sizeFactor); this.containers.set(containerId, { canvas, ctx, isSharedCanvas: true, coords: { x: Math.round((coords?.x || 0) * canvas.width), y: Math.round((coords?.y || 0) * canvas.height), }, onLoad, }); } if (!this.imgSize) { this.imgSize = imgSize; this.imageData = new ImageData(imgSize, imgSize); } if (this.isRendererInited) { this.doPlay(); } } private calcSizeFactor() { const { isLowPriority, size, isMobile, // Reduced quality only looks acceptable on big enough images quality = isLowPriority && (!size || size > LOW_PRIORITY_QUALITY_SIZE_THRESHOLD) ? LOW_PRIORITY_QUALITY : (isMobile ? HIGH_PRIORITY_QUALITY_MOBILE : HIGH_PRIORITY_QUALITY_DESKTOP), } = this.params; // Reduced quality only looks acceptable on high DPR screens return Math.max(DPR * quality, 1); } private destroy() { this.isDestroyed = true; this.pause(); this.clearCache(); this.destroyRenderer(); instancesById.delete(this.id); } private clearCache() { this.frames.forEach((frame) => { if (frame && frame !== WAITING) { frame.close(); } }); // Help GC this.imageData = undefined as any; this.frames = []; } private initConfig() { const { isLowPriority } = this.params; this.cacheModulo = isLowPriority ? LOW_PRIORITY_CACHE_MODULO : HIGH_PRIORITY_CACHE_MODULO; } setColor(newColor: [number, number, number] | undefined) { this.customColor = newColor; } private initRenderer() { this.workerIndex = cycleRestrict(MAX_WORKERS, ++lastWorkerIndex); workers[this.workerIndex].request({ name: 'init', args: [ this.id, this.tgsUrl, this.imgSize, this.params.isLowPriority, this.customColor, this.onRendererInit.bind(this), ], }); } private destroyRenderer() { workers[this.workerIndex].request({ name: 'destroy', args: [this.id], }); } private onRendererInit(reduceFactor: number, msPerFrame: number, framesCount: number) { this.isRendererInited = true; this.reduceFactor = reduceFactor; this.msPerFrame = msPerFrame; this.framesCount = framesCount; if (this.isWaiting) { this.doPlay(); } } changeData(tgsUrl: string) { this.pause(); this.tgsUrl = tgsUrl; this.initConfig(); workers[this.workerIndex].request({ name: 'changeData', args: [ this.id, this.tgsUrl, this.params.isLowPriority, this.onChangeData.bind(this), ], }); } private onChangeData(reduceFactor: number, msPerFrame: number, framesCount: number) { this.reduceFactor = reduceFactor; this.msPerFrame = msPerFrame; this.framesCount = framesCount; this.isWaiting = false; this.isAnimating = false; this.doPlay(); } private doPlay() { if (!this.framesCount) { return; } if (this.isDestroyed) { return; } if (this.isAnimating) { return; } if (!this.isWaiting) { this.lastRenderAt = undefined; } this.isEnded = false; this.isAnimating = true; this.isWaiting = false; animate(() => { if (this.isDestroyed) { return false; } // Paused from outside if (!this.isAnimating) { const areAllLoaded = Array.from(this.containers.values()).every(({ isLoaded }) => isLoaded); if (areAllLoaded) { return false; } } const frameIndex = Math.round(this.approxFrameIndex); const frame = this.getFrame(frameIndex); if (!frame || frame === WAITING) { if (!frame) { this.requestFrame(frameIndex); } this.isAnimating = false; this.isWaiting = true; return false; } if (this.cacheModulo && frameIndex % this.cacheModulo === 0) { this.cleanupPrevFrame(frameIndex); } if (frameIndex !== this.prevFrameIndex) { this.containers.forEach((containerData) => { const { ctx, isLoaded, isPaused, coords: { x, y } = {}, onLoad, } = containerData; if (!isLoaded || !isPaused) { ctx.clearRect(x || 0, y || 0, this.imgSize, this.imgSize); ctx.drawImage(frame, x || 0, y || 0); } if (!isLoaded) { containerData.isLoaded = true; onLoad?.(); } }); this.prevFrameIndex = frameIndex; } const now = Date.now(); const currentSpeed = this.lastRenderAt ? this.msPerFrame / (now - this.lastRenderAt) : 1; const delta = Math.min(1, (this.direction * this.speed) / currentSpeed); const expectedNextFrameIndex = Math.round(this.approxFrameIndex + delta); this.lastRenderAt = now; // Forward animation finished if (delta > 0 && (frameIndex === this.framesCount! - 1 || expectedNextFrameIndex > this.framesCount! - 1)) { if (this.params.noLoop) { this.isAnimating = false; this.isEnded = true; this.onEnded?.(); return false; } this.onLoop?.(); this.approxFrameIndex = 0; // Backward animation finished } else if (delta < 0 && (frameIndex === 0 || expectedNextFrameIndex < 0)) { if (this.params.noLoop) { this.isAnimating = false; this.isEnded = true; this.onEnded?.(); return false; } this.onLoop?.(); this.approxFrameIndex = this.framesCount! - 1; // Stop frame reached } else if ( this.stopFrameIndex !== undefined && (frameIndex === this.stopFrameIndex || ( (delta > 0 && expectedNextFrameIndex > this.stopFrameIndex) || (delta < 0 && expectedNextFrameIndex < this.stopFrameIndex) )) ) { this.stopFrameIndex = undefined; this.isAnimating = false; return false; // Preparing next frame } else { this.approxFrameIndex += delta; } const nextFrameIndex = Math.round(this.approxFrameIndex); if (!this.getFrame(nextFrameIndex)) { this.requestFrame(nextFrameIndex); this.isWaiting = true; this.isAnimating = false; return false; } return true; }); } private getFrame(frameIndex: number) { return this.frames[frameIndex]; } private requestFrame(frameIndex: number) { this.frames[frameIndex] = WAITING; workers[this.workerIndex].request({ name: 'renderFrames', args: [this.id, frameIndex, this.onFrameLoad.bind(this)], }); } private cleanupPrevFrame(frameIndex: number) { if (this.framesCount! < 3) { return; } const prevFrameIndex = cycleRestrict(this.framesCount!, frameIndex - 1); this.frames[prevFrameIndex] = undefined; } private onFrameLoad(frameIndex: number, imageBitmap: ImageBitmap) { if (this.frames[frameIndex] !== WAITING) { return; } this.frames[frameIndex] = imageBitmap; if (this.isWaiting) { this.doPlay(); } } } function ensureCanvasSize(canvas: HTMLCanvasElement, sizeFactor: number) { const expectedWidth = Math.round(canvas.offsetWidth * sizeFactor); const expectedHeight = Math.round(canvas.offsetHeight * sizeFactor); if (canvas.width !== expectedWidth || canvas.height !== expectedHeight) { canvas.width = expectedWidth; canvas.height = expectedHeight; } } export default RLottie;