From 5ead8cca2f7c6e8fd01b291d48b5241da9cce215 Mon Sep 17 00:00:00 2001 From: morethanwords Date: Fri, 25 Sep 2020 17:50:57 +0300 Subject: [PATCH] RLottie fixes: Fix displaying some stickers without "tgs": 1 in json Fix double conversion (parsing and stringifying) for render --- .../emoticonsDropdown/tabs/stickers.ts | 2 +- src/components/wrappers.ts | 2 +- src/lib/lottieLoader.ts | 20 +- src/lib/rlottie/rlottie.worker.ts | 193 ++++++++++++++++++ 4 files changed, 206 insertions(+), 11 deletions(-) create mode 100644 src/lib/rlottie/rlottie.worker.ts diff --git a/src/components/emoticonsDropdown/tabs/stickers.ts b/src/components/emoticonsDropdown/tabs/stickers.ts index 38aee93f..c03d50ef 100644 --- a/src/components/emoticonsDropdown/tabs/stickers.ts +++ b/src/components/emoticonsDropdown/tabs/stickers.ts @@ -140,7 +140,7 @@ export default class StickersTab implements EmoticonsTab { if(stickerSet.set.pFlags.animated) { promise .then(readBlobAsText) - .then(JSON.parse) + //.then(JSON.parse) .then(json => { lottieLoader.loadAnimationWorker({ container: li, diff --git a/src/components/wrappers.ts b/src/components/wrappers.ts index 95047f56..63df357b 100644 --- a/src/components/wrappers.ts +++ b/src/components/wrappers.ts @@ -609,7 +609,7 @@ export function wrapSticker({doc, div, middleware, lazyLoadQueue, group, play, o //fetch(doc.url).then(res => res.json()).then(async(json) => { /* return */ await appDocsManager.downloadDocNew(doc) .then(readBlobAsText) - .then(JSON.parse) + //.then(JSON.parse) .then(async(json) => { //console.timeEnd('download sticker' + doc.id); //console.log('loaded sticker:', doc, div/* , blob */); diff --git a/src/lib/lottieLoader.ts b/src/lib/lottieLoader.ts index 80a3df86..caab0be6 100644 --- a/src/lib/lottieLoader.ts +++ b/src/lib/lottieLoader.ts @@ -5,6 +5,7 @@ import { copy } from "./utils"; import EventListenerBase from "../helpers/eventListenerBase"; import mediaSizes from "../helpers/mediaSizes"; import { isApple, isSafari } from "../helpers/userAgent"; +import RLottieWorker from 'worker-loader!./rlottie/rlottie.worker'; let convert = (value: number) => { return Math.round(Math.min(Math.max(value, 0), 1) * 255); @@ -14,7 +15,7 @@ type RLottiePlayerListeners = 'enterFrame' | 'ready' | 'firstFrame' | 'cached'; type RLottieOptions = { container: HTMLElement, autoplay?: boolean, - animationData: any, + animationData: string, loop?: boolean, width?: number, height?: number, @@ -396,12 +397,9 @@ export class RLottiePlayer extends EventListenerBase<{ } class QueryableWorker extends EventListenerBase { - private worker: Worker; - - constructor(url: string, private defaultListener: (data: any) => void = () => {}, onError?: (error: any) => void) { + constructor(private worker: Worker, private defaultListener: (data: any) => void = () => {}, onError?: (error: any) => void) { super(); - this.worker = new Worker(url); if(onError) { this.worker.onerror = onError; } @@ -530,7 +528,7 @@ class LottieLoader { 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'); + const worker = this.workers[i] = new QueryableWorker(new RLottieWorker()); worker.addListener('ready', () => { this.log('worker #' + i + ' ready'); @@ -595,7 +593,7 @@ class LottieLoader { .then(res => res.arrayBuffer()) .then(data => apiManager.gzipUncompress(data, true)) .then(str => { - return this.loadAnimationWorker(Object.assign(params, {animationData: JSON.parse(str), needUpscale: true})); + return this.loadAnimationWorker(Object.assign(params, {animationData: str/* JSON.parse(str) */, needUpscale: true})); }); } @@ -603,8 +601,12 @@ class LottieLoader { //params.autoplay = true; if(toneIndex >= 1 && toneIndex <= 5) { - params.animationData = copy(params.animationData); - this.applyReplacements(params.animationData, toneIndex); + /* params.animationData = copy(params.animationData); + this.applyReplacements(params.animationData, toneIndex); */ + + const newAnimationData = JSON.parse(params.animationData); + this.applyReplacements(newAnimationData, toneIndex); + params.animationData = JSON.stringify(newAnimationData); } if(!this.loaded) { diff --git a/src/lib/rlottie/rlottie.worker.ts b/src/lib/rlottie/rlottie.worker.ts new file mode 100644 index 00000000..0ea361a9 --- /dev/null +++ b/src/lib/rlottie/rlottie.worker.ts @@ -0,0 +1,193 @@ +importScripts('rlottie-wasm.js'); +//import Module, { allocate, intArrayFromString } from './rlottie-wasm'; + +const _Module = Module as any; + +const DEFAULT_FPS = 60; + +export class RLottieItem { + private stringOnWasmHeap: any = null; + private handle: any = null; + private frameCount = 0; + + private dead = false; + + constructor(private reqId: number, jsString: string, private width: number, private height: number, private fps: number) { + this.fps = Math.max(1, Math.min(60, fps || DEFAULT_FPS)); + + this.init(jsString); + + reply('loaded', this.reqId, this.frameCount, this.fps); + } + + private init(jsString: string) { + try { + this.handle = worker.Api.init(); + + // @ts-ignore + this.stringOnWasmHeap = allocate(intArrayFromString(jsString), 'i8', 0); + + this.frameCount = worker.Api.loadFromData(this.handle, this.stringOnWasmHeap); + + worker.Api.resize(this.handle, this.width, this.height); + } catch(e) { + console.error('init RLottieItem error:', e); + } + } + + public render(frameNo: number, clamped: Uint8ClampedArray) { + if(this.dead) return; + //return; + + if(this.frameCount < frameNo || frameNo < 0) { + return; + } + + try { + worker.Api.render(this.handle, frameNo); + + var bufferPointer = worker.Api.buffer(this.handle); + + var data = _Module.HEAPU8.subarray(bufferPointer, bufferPointer + (this.width * this.height * 4)); + + if(!clamped) { + clamped = new Uint8ClampedArray(data); + } else { + clamped.set(data); + } + + reply('frame', this.reqId, frameNo, clamped); + } catch(e) { + console.error('Render error:', e); + this.dead = true; + } + } + + public destroy() { + this.dead = true; + + worker.Api.destroy(this.handle); + } +} + +class RLottieWorker { + public Api: any = {}; + + public initApi() { + this.Api = { + init: _Module.cwrap('lottie_init', '', []), + destroy: _Module.cwrap('lottie_destroy', '', ['number']), + resize: _Module.cwrap('lottie_resize', '', ['number', 'number', 'number']), + buffer: _Module.cwrap('lottie_buffer', 'number', ['number']), + render: _Module.cwrap('lottie_render', '', ['number', 'number']), + loadFromData: _Module.cwrap('lottie_load_from_data', 'number', ['number', 'number']), + }; + } + + public init() { + this.initApi(); + reply('ready'); + } +} + +const worker = new RLottieWorker(); + +Module.onRuntimeInitialized = function() { + worker.init(); +}; + +var items: {[reqId: string]: RLottieItem} = {}; +var queryableFunctions = { + loadFromData: function(reqId: number, jsString: string, width: number, height: number) { + try { + // ! WARNING, с этой проверкой не все стикеры работают, например - ДУРКА + /* if(!/"tgs":\s*?1./.test(jsString)) { + throw new Error('Invalid file'); + } */ + + const match = jsString.match(/"fr":\s*?(\d+?),/); + const frameRate = +match?.[1] || DEFAULT_FPS; + + console.log('Rendering sticker:', reqId, frameRate, 'now rendered:', Object.keys(items).length); + + items[reqId] = new RLottieItem(reqId, jsString, width, height, frameRate); + } catch(e) { + console.error('Invalid file for sticker:', jsString); + } + }, + destroy: function(reqId: number) { + items[reqId].destroy(); + delete items[reqId]; + }, + renderFrame: function(reqId: number, frameNo: number, clamped: Uint8ClampedArray) { + //console.log('worker renderFrame', reqId, frameNo, clamped); + items[reqId].render(frameNo, clamped); + } +}; + +function defaultReply(message: any) { + // your default PUBLIC function executed only when main page calls the queryableWorker.postMessage() method directly + // do something +} + +/** + * Returns true when run in WebKit derived browsers. + * This is used as a workaround for a memory leak in Safari caused by using Transferable objects to + * transfer data between WebWorkers and the main thread. + * https://github.com/mapbox/mapbox-gl-js/issues/8771 + * + * This should be removed once the underlying Safari issue is fixed. + * + * @private + * @param scope {WindowOrWorkerGlobalScope} Since this function is used both on the main thread and WebWorker context, + * let the calling scope pass in the global scope object. + * @returns {boolean} + */ +var _isSafari: boolean = null; +function isSafari(scope: any) { + if(_isSafari == null) { + var userAgent = scope.navigator ? scope.navigator.userAgent : null; + _isSafari = !!scope.safari || + !!(userAgent && (/\b(iPad|iPhone|iPod)\b/.test(userAgent) || (!!userAgent.match('Safari') && !userAgent.match('Chrome')))); + } + return _isSafari; +} + +function reply(...args: any[]) { + if(arguments.length < 1) { + throw new TypeError('reply - not enough arguments'); + } + + //if(arguments[0] == 'frame') return; + + var args = Array.prototype.slice.call(arguments, 1); + if(isSafari(self)) { + postMessage({ 'queryMethodListener': arguments[0], '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); + //args[i] = args[i].buffer; + } + } + + postMessage({ 'queryMethodListener': arguments[0], 'queryMethodArguments': args }, transfer); + } + + //postMessage({ 'queryMethodListener': arguments[0], 'queryMethodArguments': Array.prototype.slice.call(arguments, 1) }); + //console.error(transfer, args); +} + +onmessage = function(oEvent) { + if(oEvent.data instanceof Object && oEvent.data.hasOwnProperty('queryMethod') && oEvent.data.hasOwnProperty('queryMethodArguments')) { + // @ts-ignore + queryableFunctions[oEvent.data.queryMethod].apply(self, oEvent.data.queryMethodArguments); + } else { + defaultReply(oEvent.data); + } +};