tweb/src/lib/lottieLoader.ts

694 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.

import { logger, LogLevels } from "./logger";
import animationIntersector from "../components/animationIntersector";
import apiManager from "./mtproto/mtprotoworker";
import { copy } from "./utils";
import EventListenerBase from "../helpers/eventListenerBase";
import mediaSizes from "../helpers/mediaSizes";
import { isApple, isSafari } from "../helpers/userAgent";
let convert = (value: number) => {
return Math.round(Math.min(Math.max(value, 0), 1) * 255);
};
type RLottiePlayerListeners = 'enterFrame' | 'ready' | 'firstFrame' | 'cached';
type RLottieOptions = {
container: HTMLElement,
autoplay?: boolean,
animationData: any,
loop?: boolean,
width?: number,
height?: number,
group?: string,
noCache?: true,
needUpscale?: true
};
export class RLottiePlayer extends EventListenerBase<{
enterFrame: (frameNo: number) => void,
ready: () => void,
firstFrame: () => void,
cached: () => void
}> {
public static reqId = 0;
public reqId = 0;
public curFrame: number;
public frameCount: number;
public fps: number;
public worker: QueryableWorker;
public width = 0;
public height = 0;
public el: HTMLElement;
public canvas: HTMLCanvasElement;
public context: CanvasRenderingContext2D;
public paused = true;
//public paused = false;
public direction = 1;
public speed = 1;
public autoplay = true;
public loop = true;
public group = '';
private frInterval: number;
private frThen: number;
private rafId: number;
//private caching = false;
//private removed = false;
private frames: {[frameNo: string]: Uint8ClampedArray} = {};
public imageData: ImageData;
public clamped: Uint8ClampedArray;
public cachingDelta = 0;
//private playedTimes = 0;
private currentMethod: RLottiePlayer['mainLoopForwards'] | RLottiePlayer['mainLoopBackwards'];
private frameListener: () => 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];
}
}
const pixelRatio = window.devicePixelRatio;
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 && this.width > 100 && this.height > 100) {
if(isApple || !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));
}
}
}
if(!options.noCache) {
// проверка на размер уже после скейлинга, сделано для попапа и сайдбарfа, где стикеры 80х80 и 68х68, туда нужно 75%
if(isApple && 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%
}
}
// if(isApple) {
// this.cachingDelta = 0; //2 // 50%
// }
/* this.width *= 0.8;
this.height *= 0.8; */
//console.log("RLottiePlayer width:", this.width, this.height, options);
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');
this.clamped = new Uint8ClampedArray(this.width * this.height * 4);
this.imageData = new ImageData(this.width, this.height);
}
public clearCache() {
this.frames = {};
}
public sendQuery(methodName: string, ...args: any[]) {
//console.trace('RLottie sendQuery:', methodName);
this.worker.sendQuery(methodName, this.reqId, ...args);
}
public loadFromData(json: any) {
this.sendQuery('loadFromData', json, this.width, this.height, {
paused: this.paused,
direction: this.direction,
speed: this.speed
});
}
public play() {
if(!this.paused) return;
//console.log('RLOTTIE PLAY' + this.reqId);
this.paused = false;
this.setMainLoop();
}
public pause() {
if(this.paused) return;
this.paused = true;
clearTimeout(this.rafId);
//window.cancelAnimationFrame(this.rafId);
}
public stop(renderFirstFrame = true) {
this.pause();
this.curFrame = this.direction == 1 ? 0 : this.frameCount;
if(renderFirstFrame) {
this.requestFrame(this.curFrame);
//this.sendQuery('renderFrame', this.curFrame);
}
}
public restart() {
this.stop(false);
this.play();
}
public setSpeed(speed: number) {
this.speed = speed;
if(!this.paused) {
this.setMainLoop();
}
}
public setDirection(direction: number) {
this.direction = direction;
if(!this.paused) {
this.setMainLoop();
}
}
public remove() {
//alert('remove');
lottieLoader.onDestroy(this.reqId);
this.pause();
this.sendQuery('destroy');
//this.removed = true;
}
public renderFrame2(frame: Uint8ClampedArray, frameNo: number) {
/* this.setListenerResult('enterFrame', frameNo);
return; */
try {
this.imageData.data.set(frame);
//this.context.putImageData(new ImageData(frame, this.width, this.height), 0, 0);
this.context.putImageData(this.imageData, 0, 0);
} 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.setListenerResult('enterFrame', frameNo);
}
public renderFrame(frame: Uint8ClampedArray, frameNo: number) {
//console.log('renderFrame', frameNo, this);
if(this.cachingDelta && (frameNo % this.cachingDelta || !frameNo) && !this.frames[frameNo]) {
this.frames[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) {
if(this.frames[frameNo]) {
this.renderFrame(this.frames[frameNo], frameNo);
} else if(isSafari) {
this.sendQuery('renderFrame', frameNo);
} else {
if(!this.clamped.length) { // fix detached
this.clamped = new Uint8ClampedArray(this.width * this.height * 4);
}
this.sendQuery('renderFrame', frameNo, this.clamped);
}
}
private mainLoopForwards() {
this.requestFrame(this.curFrame++);
if(this.curFrame >= this.frameCount) {
//this.playedTimes++;
if(!this.loop) {
this.pause();
return false;
}
this.curFrame = 0;
}
return true;
}
private mainLoopBackwards() {
this.requestFrame(this.curFrame--);
if(this.curFrame < 0) {
//this.playedTimes++;
if(!this.loop) {
this.pause();
return false;
}
this.curFrame = this.frameCount - 1;
}
return true;
}
public setMainLoop() {
//window.cancelAnimationFrame(this.rafId);
clearTimeout(this.rafId);
this.frInterval = 1000 / this.fps / this.speed;
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 && this.listenerResults.hasOwnProperty('enterFrame')) {
this.frameListener();
}
//this.mainLoop(method);
//this.r(method);
//method();
}
public async onLoad(frameCount: number, fps: number) {
this.curFrame = this.direction == 1 ? 0 : frameCount - 1;
this.frameCount = frameCount;
this.fps = fps;
this.frInterval = 1000 / this.fps / this.speed;
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');
//return;
this.requestFrame(0);
this.setListenerResult('ready');
this.addListener('enterFrame', () => {
this.setListenerResult('firstFrame');
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.addListener('enterFrame', this.frameListener);
}, true);
}
}
class QueryableWorker extends EventListenerBase<any> {
private worker: Worker;
constructor(url: string, private defaultListener: (data: any) => void = () => {}, onError?: (error: any) => void) {
super();
this.worker = new Worker(url);
if(onError) {
this.worker.onerror = onError;
}
this.worker.onmessage = (event) => {
//return;
//console.log('worker onmessage', event.data);
if(event.data instanceof Object &&
event.data.hasOwnProperty('queryMethodListener') &&
event.data.hasOwnProperty('queryMethodArguments')) {
/* if(event.data.queryMethodListener == 'frame') {
return;
} */
this.setListenerResult(event.data.queryMethodListener, ...event.data.queryMethodArguments);
} else {
this.defaultListener.call(this, event.data);
}
};
}
public postMessage(message: any) {
this.worker.postMessage(message);
}
public terminate() {
this.worker.terminate();
}
public sendQuery(queryMethod: string, ...args: any[]) {
var args = Array.prototype.slice.call(arguments, 1);
if(isSafari) {
this.worker.postMessage({
'queryMethod': queryMethod,
'queryMethodArguments': args
});
} else {
var transfer = [];
for(var i = 0; i < args.length; i++) {
if(args[i] instanceof ArrayBuffer) {
transfer.push(args[i]);
}
if(args[i].buffer && args[i].buffer instanceof ArrayBuffer) {
transfer.push(args[i].buffer);
}
}
//console.log('transfer', transfer);
this.worker.postMessage({
'queryMethod': queryMethod,
'queryMethodArguments': args
}, transfer);
}
}
}
class LottieLoader {
public loadPromise: Promise<void>;
public loaded = false;
// https://github.com/telegramdesktop/tdesktop/blob/35e575c2d7b56446be95561e4565628859fb53d3/Telegram/SourceFiles/chat_helpers/stickers_emoji_pack.cpp#L65
private static COLORREPLACEMENTS = [
[
[0xf77e41, 0xca907a],
[0xffb139, 0xedc5a5],
[0xffd140, 0xf7e3c3],
[0xffdf79, 0xfbefd6],
],
[
[0xf77e41, 0xaa7c60],
[0xffb139, 0xc8a987],
[0xffd140, 0xddc89f],
[0xffdf79, 0xe6d6b2],
],
[
[0xf77e41, 0x8c6148],
[0xffb139, 0xad8562],
[0xffd140, 0xc49e76],
[0xffdf79, 0xd4b188],
],
[
[0xf77e41, 0x6e3c2c],
[0xffb139, 0x925a34],
[0xffd140, 0xa16e46],
[0xffdf79, 0xac7a52],
],
[
[0xf77e41, 0x291c12],
[0xffb139, 0x472a22],
[0xffd140, 0x573b30],
[0xffdf79, 0x68493c],
]
];
private workersLimit = 4;
private players: {[reqId: number]: RLottiePlayer} = {};
private workers: QueryableWorker[] = [];
private curWorkerNum = 0;
private log = logger('LOTTIE', LogLevels.error);
public getAnimation(element: HTMLElement) {
for(let i in this.players) {
if(this.players[i].el == element) {
return this.players[i];
}
}
return null;
}
public loadLottieWorkers() {
if(typeof(WebAssembly) === 'undefined') return Promise.reject();
if(this.loadPromise) return this.loadPromise;
const onFrame = this.onFrame.bind(this);
const onPlayerLoaded = this.onPlayerLoaded.bind(this);
return this.loadPromise = new Promise((resolve, reject) => {
let remain = this.workersLimit;
for(let i = 0; i < this.workersLimit; ++i) {
const worker = this.workers[i] = new QueryableWorker('rlottie.worker.js');
worker.addListener('ready', () => {
this.log('worker #' + i + ' ready');
worker.addListener('frame', onFrame);
worker.addListener('loaded', onPlayerLoaded);
--remain;
if(!remain) {
this.log('workers ready');
resolve();
this.loaded = true;
}
});
}
});
}
private applyReplacements(object: any, toneIndex: number) {
const replacements = LottieLoader.COLORREPLACEMENTS[Math.max(toneIndex - 1, 0)];
const iterateIt = (it: any) => {
for(let smth of it) {
switch(smth.ty) {
case 'st':
case 'fl':
let k = smth.c.k;
let color = convert(k[2]) | (convert(k[1]) << 8) | (convert(k[0]) << 16);
let foundReplacement = replacements.find(p => p[0] == color);
if(foundReplacement) {
k[0] = ((foundReplacement[1] >> 16) & 255) / 255;
k[1] = ((foundReplacement[1] >> 8) & 255) / 255;
k[2] = (foundReplacement[1] & 255) / 255;
}
//console.log('foundReplacement!', foundReplacement, color.toString(16), k);
break;
}
if(smth.hasOwnProperty('it')) {
iterateIt(smth.it);
}
}
};
for(let layer of object.layers) {
if(!layer.shapes) continue;
for(let shape of layer.shapes) {
iterateIt(shape.it);
}
}
}
public loadAnimationFromURL(params: Omit<RLottieOptions, 'animationData'>, url: string) {
if(!this.loaded) {
this.loadLottieWorkers();
}
return fetch(url)
.then(res => res.arrayBuffer())
.then(data => apiManager.gzipUncompress<string>(data, true))
.then(str => {
return this.loadAnimationWorker(Object.assign(params, {animationData: JSON.parse(str), needUpscale: true}));
});
}
public async loadAnimationWorker(params: RLottieOptions, group = '', toneIndex = -1) {
//params.autoplay = true;
if(toneIndex >= 1 && toneIndex <= 5) {
params.animationData = copy(params.animationData);
this.applyReplacements(params.animationData, toneIndex);
}
if(!this.loaded) {
await this.loadLottieWorkers();
}
if(!params.width || !params.height) {
params.width = parseInt(params.container.style.width);
params.height = parseInt(params.container.style.height);
}
if(!params.width || !params.height) {
throw new Error('No size for sticker!');
}
params.group = group;
const player = this.initPlayer(params.container, params);
animationIntersector.addAnimation(player, group);
return player;
}
private onPlayerLoaded(reqId: number, frameCount: number, fps: number) {
const rlPlayer = this.players[reqId];
if(!rlPlayer) {
this.log.warn('onPlayerLoaded on destroyed player:', reqId, frameCount);
return;
}
this.log.debug('onPlayerLoaded');
rlPlayer.onLoad(frameCount, fps);
//rlPlayer.addListener('firstFrame', () => {
//animationIntersector.addAnimation(player, group);
//}, true);
}
private onFrame(reqId: number, frameNo: number, frame: Uint8ClampedArray) {
const rlPlayer = this.players[reqId];
if(!rlPlayer) {
this.log.warn('onFrame on destroyed player:', reqId, frameNo);
return;
}
rlPlayer.clamped = frame;
rlPlayer.renderFrame(frame, frameNo);
}
public onDestroy(reqId: number) {
delete this.players[reqId];
}
public destroyWorkers() {
this.workers.forEach((worker, idx) => {
worker.terminate();
this.log('worker #' + idx + ' terminated');
});
this.log('workers destroyed');
this.workers.length = 0;
}
private initPlayer(el: HTMLElement, options: RLottieOptions) {
const rlPlayer = new RLottiePlayer({
el,
worker: this.workers[this.curWorkerNum++],
options
});
this.players[rlPlayer.reqId] = rlPlayer;
if(this.curWorkerNum >= this.workers.length) {
this.curWorkerNum = 0;
}
rlPlayer.loadFromData(options.animationData);
return rlPlayer;
}
}
const lottieLoader = new LottieLoader();
// @ts-ignore
if(process.env.NODE_ENV != 'production') {
(window as any).lottieLoader = lottieLoader;
}
export default lottieLoader;