tweb/src/lib/lottieLoader.ts

694 lines
19 KiB
TypeScript
Raw Normal View History

2020-06-21 14:25:17 +02:00
import { logger, LogLevels } from "./logger";
import animationIntersector from "../components/animationIntersector";
2020-06-19 13:49:55 +02:00
import apiManager from "./mtproto/mtprotoworker";
2020-08-26 13:59:14 +02:00
import { copy } from "./utils";
import EventListenerBase from "../helpers/eventListenerBase";
import mediaSizes from "../helpers/mediaSizes";
import { isApple, isSafari } from "../helpers/userAgent";
2020-02-06 16:43:07 +01:00
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,
2020-06-20 03:11:24 +02:00
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));
}
}
}
2020-06-20 03:11:24 +02:00
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);
2020-09-20 00:38:00 +02:00
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 {
2020-06-20 03:11:24 +02:00
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);
}
}
2020-06-20 03:11:24 +02:00
//console.log('transfer', transfer);
this.worker.postMessage({
'queryMethod': queryMethod,
'queryMethodArguments': args
}, transfer);
}
}
}
2020-02-06 16:43:07 +01:00
class LottieLoader {
public loadPromise: Promise<void>;
public loaded = false;
2020-06-16 22:48:08 +02:00
// 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],
2020-06-16 22:48:08 +02:00
],
[
[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);
2020-04-14 17:46:31 +02:00
public getAnimation(element: HTMLElement) {
for(let i in this.players) {
if(this.players[i].el == element) {
return this.players[i];
}
}
return null;
2020-04-14 17:46:31 +02:00
}
2020-02-06 16:43:07 +01:00
public loadLottieWorkers() {
2020-06-21 14:25:17 +02:00
if(typeof(WebAssembly) === 'undefined') return Promise.reject();
if(this.loadPromise) return this.loadPromise;
2020-02-06 16:43:07 +01:00
const onFrame = this.onFrame.bind(this);
const onPlayerLoaded = this.onPlayerLoaded.bind(this);
2020-02-06 16:43:07 +01:00
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');
2020-02-06 16:43:07 +01:00
worker.addListener('ready', () => {
this.log('worker #' + i + ' ready');
worker.addListener('frame', onFrame);
worker.addListener('loaded', onPlayerLoaded);
2020-04-14 17:46:31 +02:00
--remain;
if(!remain) {
this.log('workers ready');
resolve();
this.loaded = true;
2020-02-06 16:43:07 +01:00
}
});
2020-02-06 16:43:07 +01:00
}
});
2020-02-06 16:43:07 +01:00
}
private applyReplacements(object: any, toneIndex: number) {
2020-06-16 22:48:08 +02:00
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);
}
}
}
2020-06-19 13:49:55 +02:00
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}));
2020-06-19 13:49:55 +02:00
});
}
public async loadAnimationWorker(params: RLottieOptions, group = '', toneIndex = -1) {
//params.autoplay = true;
if(toneIndex >= 1 && toneIndex <= 5) {
2020-08-26 13:59:14 +02:00
params.animationData = copy(params.animationData);
this.applyReplacements(params.animationData, toneIndex);
}
2020-02-06 16:43:07 +01:00
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;
2020-02-06 16:43:07 +01:00
}
2020-06-19 13:49:55 +02:00
this.log.debug('onPlayerLoaded');
rlPlayer.onLoad(frameCount, fps);
//rlPlayer.addListener('firstFrame', () => {
//animationIntersector.addAnimation(player, group);
//}, true);
}
2020-04-14 17:46:31 +02:00
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;
}
2020-04-14 17:46:31 +02:00
rlPlayer.clamped = frame;
rlPlayer.renderFrame(frame, frameNo);
}
2020-02-06 16:43:07 +01:00
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;
2020-02-06 16:43:07 +01:00
}
private initPlayer(el: HTMLElement, options: RLottieOptions) {
const rlPlayer = new RLottiePlayer({
el,
worker: this.workers[this.curWorkerNum++],
options
});
2020-02-06 16:43:07 +01:00
this.players[rlPlayer.reqId] = rlPlayer;
if(this.curWorkerNum >= this.workers.length) {
this.curWorkerNum = 0;
2020-02-06 16:43:07 +01:00
}
rlPlayer.loadFromData(options.animationData);
return rlPlayer;
2020-02-06 16:43:07 +01:00
}
}
const lottieLoader = new LottieLoader();
2020-06-16 22:48:08 +02:00
// @ts-ignore
if(process.env.NODE_ENV != 'production') {
(window as any).lottieLoader = lottieLoader;
}
2020-02-06 16:43:07 +01:00
export default lottieLoader;