tweb/src/lib/rlottie/rlottiePlayer.ts
Eduard Kuzmenko 8317d3b566 misc
2022-03-25 01:16:03 +02:00

675 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
import CAN_USE_TRANSFERABLES from "../../environment/canUseTransferables";
import { IS_ANDROID, IS_APPLE_MOBILE, IS_APPLE, IS_SAFARI } from "../../environment/userAgent";
import EventListenerBase from "../../helpers/eventListenerBase";
import mediaSizes from "../../helpers/mediaSizes";
import clamp from "../../helpers/number/clamp";
import lottieLoader from "./lottieLoader";
import QueryableWorker from "./queryableWorker";
export type RLottieOptions = {
container: HTMLElement,
canvas?: HTMLCanvasElement,
autoplay?: boolean,
animationData: Blob,
loop?: boolean,
width?: number,
height?: number,
group?: string,
noCache?: boolean,
needUpscale?: boolean,
skipRatio?: number,
initFrame?: number, // index
color?: RLottieColor,
inverseColor?: RLottieColor,
name?: string,
skipFirstFrameRendering?: boolean,
toneIndex?: number
};
type RLottieCacheMap = Map<number, Uint8ClampedArray>;
class RLottieCache {
private cache: Map<string, {frames: RLottieCacheMap, counter: number}>;
constructor() {
this.cache = new Map();
}
public getCache(name: string) {
let cache = this.cache.get(name);
if(!cache) {
this.cache.set(name, cache = {frames: new Map(), counter: 0});
} else {
// console.warn('[RLottieCache] cache will be reused', cache);
}
++cache.counter;
return cache.frames;
}
public releaseCache(name: string) {
const cache = this.cache.get(name);
if(cache && !--cache.counter) {
this.cache.delete(name);
// console.warn('[RLottieCache] released cache', cache);
}
}
public getCacheCounter(name: string) {
const cache = this.cache.get(name);
return cache?.counter;
}
public generateName(name: string, width: number, height: number, color: RLottieColor, toneIndex: number) {
return [
name,
width,
height,
// color ? rgbaToHexa(color) : ''
color ? 'colored' : '',
toneIndex || ''
].filter(Boolean).join('-');
}
}
const cache = new RLottieCache();
export type RLottieColor = [number, number, number];
export default class RLottiePlayer extends EventListenerBase<{
enterFrame: (frameNo: number) => void,
ready: () => void,
firstFrame: () => void,
cached: () => void
}> {
private static reqId = 0;
public reqId = 0;
public curFrame: number;
private frameCount: number;
private fps: number;
private skipDelta: number;
private name: string;
private cacheName: string;
private toneIndex: number;
private worker: QueryableWorker;
private width = 0;
private height = 0;
public el: HTMLElement;
public canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
public paused = true;
//public paused = false;
public direction = 1;
private speed = 1;
public autoplay = true;
public _autoplay: boolean; // ! will be used to store original value for settings.stickers.loop
public loop = true;
private _loop: boolean; // ! will be used to store original value for settings.stickers.loop
private group = '';
private frInterval: number;
private frThen: number;
private rafId: number;
//private caching = false;
//private removed = false;
private frames: RLottieCacheMap;
private imageData: ImageData;
public clamped: Uint8ClampedArray;
private cachingDelta = 0;
private initFrame: number;
private color: RLottieColor;
private inverseColor: RLottieColor;
public minFrame: number;
public maxFrame: number;
//private playedTimes = 0;
private currentMethod: RLottiePlayer['mainLoopForwards'] | RLottiePlayer['mainLoopBackwards'];
private frameListener: (currentFrame: number) => void;
private skipFirstFrameRendering: boolean;
private playToFrameOnFrameCallback: (frameNo: number) => void;
constructor({el, worker, options}: {
el: HTMLElement,
worker: QueryableWorker,
options: RLottieOptions
}) {
super(true);
this.reqId = ++RLottiePlayer['reqId'];
this.el = el;
this.worker = worker;
for(let i in options) {
if(this.hasOwnProperty(i)) {
// @ts-ignore
this[i] = options[i];
}
}
this._loop = this.loop;
this._autoplay = this.autoplay;
// ! :(
this.initFrame = options.initFrame;
this.color = options.color;
this.inverseColor = options.inverseColor;
this.name = options.name;
this.skipFirstFrameRendering = options.skipFirstFrameRendering;
this.toneIndex = options.toneIndex;
// * Skip ratio (30fps)
let skipRatio: number;
if(options.skipRatio !== undefined) skipRatio = options.skipRatio;
else if((IS_ANDROID || IS_APPLE_MOBILE || (IS_APPLE && !IS_SAFARI)) && this.width < 100 && this.height < 100 && !options.needUpscale) {
skipRatio = 0.5;
}
this.skipDelta = skipRatio !== undefined ? 1 / skipRatio | 0 : 1;
//options.needUpscale = true;
// * Pixel ratio
//const pixelRatio = window.devicePixelRatio;
const pixelRatio = clamp(window.devicePixelRatio, 1, 2);
if(pixelRatio > 1) {
//this.cachingEnabled = true;//this.width < 100 && this.height < 100;
if(options.needUpscale) {
this.width = Math.round(this.width * pixelRatio);
this.height = Math.round(this.height * pixelRatio);
} else if(pixelRatio > 1) {
if(this.width > 100 && this.height > 100) {
if(IS_APPLE || !mediaSizes.isMobile) {
/* this.width = Math.round(this.width * (pixelRatio - 1));
this.height = Math.round(this.height * (pixelRatio - 1)); */
this.width = Math.round(this.width * pixelRatio);
this.height = Math.round(this.height * pixelRatio);
} else if(pixelRatio > 2.5) {
this.width = Math.round(this.width * (pixelRatio - 1.5));
this.height = Math.round(this.height * (pixelRatio - 1.5));
}
} else {
this.width = Math.round(this.width * Math.max(1.5, pixelRatio - 1.5));
this.height = Math.round(this.height * Math.max(1.5, pixelRatio - 1.5));
}
}
}
//options.noCache = true;
// * Cache frames params
if(!options.noCache/* && false */) {
// проверка на размер уже после скейлинга, сделано для попапа и сайдбара, где стикеры 80х80 и 68х68, туда нужно 75%
if(IS_APPLE && this.width > 100 && this.height > 100) {
this.cachingDelta = 2; //2 // 50%
} else if(this.width < 100 && this.height < 100) {
this.cachingDelta = Infinity; // 100%
} else {
this.cachingDelta = 4; // 75%
}
}
// this.cachingDelta = Infinity;
// if(isApple) {
// this.cachingDelta = 0; //2 // 50%
// }
/* this.width *= 0.8;
this.height *= 0.8; */
//console.log("RLottiePlayer width:", this.width, this.height, options);
if(!this.canvas) {
this.canvas = document.createElement('canvas');
this.canvas.classList.add('rlottie');
this.canvas.width = this.width;
this.canvas.height = this.height;
}
this.context = this.canvas.getContext('2d');
if(CAN_USE_TRANSFERABLES) {
this.clamped = new Uint8ClampedArray(this.width * this.height * 4);
}
this.imageData = new ImageData(this.width, this.height);
if(this.name) {
this.cacheName = cache.generateName(this.name, this.width, this.height, this.color, this.toneIndex);
this.frames = cache.getCache(this.cacheName);
} else {
this.frames = new Map();
}
}
public clearCache() {
if(this.cachingDelta === Infinity) {
return;
}
if(this.cacheName && cache.getCacheCounter(this.cacheName) > 1) { // skip clearing because same sticker can be still visible
return;
}
this.frames.clear();
}
public sendQuery(methodName: string, ...args: any[]) {
//console.trace('RLottie sendQuery:', methodName);
this.worker.sendQuery(methodName, this.reqId, ...args);
}
public loadFromData(data: RLottieOptions['animationData']) {
this.sendQuery('loadFromData', data, this.width, this.height, this.toneIndex/* , this.canvas.transferControlToOffscreen() */);
}
public play() {
if(!this.paused) {
return;
}
//return;
//console.log('RLOTTIE PLAY' + this.reqId);
this.paused = false;
this.setMainLoop();
}
public pause(clearPendingRAF = true) {
if(this.paused) {
return;
}
this.paused = true;
if(clearPendingRAF) {
clearTimeout(this.rafId);
}
//window.cancelAnimationFrame(this.rafId);
}
private resetCurrentFrame() {
return this.curFrame = this.initFrame ?? (this.direction === 1 ? this.minFrame : this.maxFrame);
}
public stop(renderFirstFrame = true) {
this.pause();
const curFrame = this.resetCurrentFrame();
if(renderFirstFrame) {
this.requestFrame(curFrame);
//this.sendQuery('renderFrame', this.curFrame);
}
}
public restart() {
this.stop(false);
this.play();
}
public setSpeed(speed: number) {
if(this.speed === speed) {
return;
}
this.speed = speed;
if(!this.paused) {
this.setMainLoop();
}
}
public setDirection(direction: number) {
if(this.direction === direction) {
return;
}
this.direction = direction;
if(!this.paused) {
this.setMainLoop();
}
}
public remove() {
//alert('remove');
lottieLoader.onDestroy(this.reqId);
this.pause();
this.sendQuery('destroy');
if(this.cacheName) cache.releaseCache(this.cacheName);
this.cleanup();
//this.removed = true;
}
private applyColor(frame: Uint8ClampedArray) {
const [r, g, b] = this.color;
for(let i = 0, length = frame.length; i < length; i += 4) {
if(frame[i + 3] !== 0) {
frame[i] = r;
frame[i + 1] = g;
frame[i + 2] = b;
}
}
}
private applyInversing(frame: Uint8ClampedArray) {
const [r, g, b] = this.inverseColor;
for(let i = 0, length = frame.length; i < length; i += 4) {
if(frame[i + 3] === 0) {
frame[i] = r;
frame[i + 1] = g;
frame[i + 2] = b;
frame[i + 3] = 255;
} else {
frame[i + 3] = 0;
}
}
}
public renderFrame2(frame: Uint8ClampedArray, frameNo: number) {
/* this.setListenerResult('enterFrame', frameNo);
return; */
try {
if(this.color) {
this.applyColor(frame);
}
if(this.inverseColor) {
this.applyInversing(frame);
}
this.imageData.data.set(frame);
//this.context.putImageData(new ImageData(frame, this.width, this.height), 0, 0);
//let perf = performance.now();
this.context.putImageData(this.imageData, 0, 0);
//console.log('renderFrame2 perf:', performance.now() - perf);
} catch(err) {
console.error('RLottiePlayer renderFrame error:', err/* , frame */, this.width, this.height);
this.autoplay = false;
this.pause();
return;
}
//console.log('set result enterFrame', frameNo);
this.dispatchEvent('enterFrame', frameNo);
}
public renderFrame(frame: Uint8ClampedArray, frameNo: number) {
//console.log('renderFrame', frameNo, this);
if(this.cachingDelta && (frameNo % this.cachingDelta || !frameNo) && !this.frames.has(frameNo)) {
this.frames.set(frameNo, new Uint8ClampedArray(frame));//frame;
}
/* if(!this.listenerResults.hasOwnProperty('cached')) {
this.setListenerResult('enterFrame', frameNo);
if(frameNo === (this.frameCount - 1)) {
this.setListenerResult('cached');
}
return;
} */
if(this.frInterval) {
const now = Date.now(), delta = now - this.frThen;
//console.log(`renderFrame delta${this.reqId}:`, this, delta, this.frInterval);
if(delta < 0) {
if(this.rafId) clearTimeout(this.rafId);
return this.rafId = window.setTimeout(() => {
this.renderFrame2(frame, frameNo);
}, this.frInterval > -delta ? -delta % this.frInterval : this.frInterval);
//await new Promise((resolve) => setTimeout(resolve, -delta % this.frInterval));
}
}
this.renderFrame2(frame, frameNo);
}
public requestFrame(frameNo: number) {
const frame = this.frames.get(frameNo);
if(frame) {
this.renderFrame(frame, frameNo);
} else {
if(this.clamped && !this.clamped.length) { // fix detached
this.clamped = new Uint8ClampedArray(this.width * this.height * 4);
}
this.sendQuery('renderFrame', frameNo, this.clamped);
}
}
private onLap() {
//this.playedTimes++;
if(!this.loop) {
this.pause(false);
return false;
}
return true;
}
private mainLoopForwards() {
const {skipDelta, maxFrame} = this;
const frame = (this.curFrame + skipDelta) > maxFrame ? this.curFrame = (this.loop ? this.minFrame : this.maxFrame) : this.curFrame += skipDelta;
// console.log('mainLoopForwards', this.curFrame, skipDelta, frame);
this.requestFrame(frame);
if((frame + skipDelta) > maxFrame) {
return this.onLap();
}
return true;
}
private mainLoopBackwards() {
const {skipDelta, minFrame} = this;
const frame = (this.curFrame - skipDelta) < minFrame ? this.curFrame = (this.loop ? this.maxFrame : this.minFrame) : this.curFrame -= skipDelta;
// console.log('mainLoopBackwards', this.curFrame, skipDelta, frame);
this.requestFrame(frame);
if((frame - skipDelta) < minFrame) {
return this.onLap();
}
return true;
}
public setMainLoop() {
//window.cancelAnimationFrame(this.rafId);
clearTimeout(this.rafId);
this.frInterval = 1000 / this.fps / this.speed * this.skipDelta;
this.frThen = Date.now() - this.frInterval;
//console.trace('setMainLoop', this.frInterval, this.direction, this, JSON.stringify(this.listenerResults), this.listenerResults);
const method = (this.direction === 1 ? this.mainLoopForwards : this.mainLoopBackwards).bind(this);
this.currentMethod = method;
//this.frameListener && this.removeListener('enterFrame', this.frameListener);
//setTimeout(() => {
//this.addListener('enterFrame', this.frameListener);
//}, 0);
if(this.frameListener) {
const lastResult = this.listenerResults.enterFrame;
if(lastResult !== undefined) {
this.frameListener(this.curFrame);
}
}
//this.mainLoop(method);
//this.r(method);
//method();
}
public playPart(options: {
from: number,
to: number,
callback?: () => void
}) {
this.pause();
const {from, to, callback} = options;
this.curFrame = from - 1;
return this.playToFrame({
frame: to,
direction: to > from ? 1 : -1,
callback
});
}
public playToFrame(options: {
frame: number,
speed?: number,
direction?: number,
callback?: () => void
}) {
this.pause();
const {frame, speed, callback, direction} = options;
this.setDirection(direction === undefined ? this.curFrame > frame ? -1 : 1 : direction);
speed !== undefined && this.setSpeed(speed);
const bounds = [this.curFrame, frame];
if(this.direction === -1) bounds.reverse();
this.loop = false;
this.setMinMax(bounds[0], bounds[1]);
if(this.playToFrameOnFrameCallback) {
this.removeEventListener('enterFrame', this.playToFrameOnFrameCallback);
}
if(callback) {
this.playToFrameOnFrameCallback = (frameNo: number) => {
if(frameNo === frame) {
this.removeEventListener('enterFrame', this.playToFrameOnFrameCallback);
callback();
}
};
this.addEventListener('enterFrame', this.playToFrameOnFrameCallback);
}
this.play();
}
public setColor(color: RLottieColor, renderIfPaused: boolean) {
this.color = color;
if(renderIfPaused && this.paused) {
this.renderFrame2(this.imageData.data, this.curFrame);
}
}
public setInverseColor(color: RLottieColor) {
this.inverseColor = color;
}
private setMinMax(minFrame = 0, maxFrame = this.frameCount - 1) {
this.minFrame = minFrame;
this.maxFrame = maxFrame;
}
public async onLoad(frameCount: number, fps: number) {
this.frameCount = frameCount;
this.fps = fps;
this.setMinMax();
if(this.initFrame !== undefined) {
this.initFrame = clamp(this.initFrame, this.minFrame, this.maxFrame);
}
const curFrame = this.resetCurrentFrame();
// * Handle 30fps stickers if 30fps set
if(this.fps < 60 && this.skipDelta !== 1) {
const diff = 60 / fps;
this.skipDelta = this.skipDelta / diff | 0;
}
this.frInterval = 1000 / this.fps / this.speed * this.skipDelta;
this.frThen = Date.now() - this.frInterval;
//this.sendQuery('renderFrame', 0);
// Кешировать сразу не получится, рендер стикера (тайгер) занимает 519мс,
// если рендерить 75% с получением каждого кадра из воркера, будет 475мс, т.е. при 100% было бы 593мс, потеря на передаче 84мс.
/* console.time('cache' + this.reqId);
for(let i = 0; i < frameCount; ++i) {
//if(this.removed) return;
if(i % 4) {
await new Promise((resolve) => {
delete this.listenerResults.enterFrame;
this.addListener('enterFrame', resolve, true);
this.requestFrame(i);
});
}
}
console.timeEnd('cache' + this.reqId); */
//console.log('cached');
/* this.el.innerHTML = '';
this.el.append(this.canvas);
return; */
!this.skipFirstFrameRendering && this.requestFrame(curFrame);
this.dispatchEvent('ready');
this.addEventListener('enterFrame', () => {
this.dispatchEvent('firstFrame');
if(!this.canvas.parentNode && this.el) {
this.el.appendChild(this.canvas);
}
//console.log('enterFrame firstFrame');
//let lastTime = this.frThen;
this.frameListener = () => {
if(this.paused) {
return;
}
const time = Date.now();
//console.log(`enterFrame handle${this.reqId}`, time, (time - lastTime), this.frInterval);
/* if(Math.round(time - lastTime + this.frInterval * 0.25) < Math.round(this.frInterval)) {
return;
} */
//lastTime = time;
this.frThen = time + this.frInterval;
const canContinue = this.currentMethod();
if(!canContinue && !this.loop && this.autoplay) {
this.autoplay = false;
}
};
this.addEventListener('enterFrame', this.frameListener);
// ! fix autoplaying since there will be no animationIntersector for it,
if(this.group === 'none' && this.autoplay) {
this.play();
}
}, {once: true});
}
}