Animated Sticker: Use single instance for same stickers

This commit is contained in:
Alexander Zinchuk 2022-11-01 18:53:18 +01:00
parent 1fda24ab92
commit 4c97d37439
4 changed files with 140 additions and 77 deletions

View File

@ -12,9 +12,11 @@ import buildStyle from '../../util/buildStyle';
import useHeavyAnimationCheck from '../../hooks/useHeavyAnimationCheck';
import useBackgroundMode from '../../hooks/useBackgroundMode';
import useOnChange from '../../hooks/useOnChange';
import generateIdFor from '../../util/generateIdFor';
export type OwnProps = {
ref?: RefObject<HTMLDivElement>;
id?: string;
className?: string;
style?: string;
tgsUrl?: string;
@ -41,6 +43,8 @@ let RLottie: RLottieClass;
// Time for the main interface to completely load
const LOTTIE_LOAD_DELAY = 3000;
const ID_STORE = {};
async function ensureLottie() {
if (!lottiePromise) {
lottiePromise = import('../../lib/rlottie/RLottie') as unknown as Promise<RLottieClass>;
@ -54,6 +58,7 @@ setTimeout(ensureLottie, LOTTIE_LOAD_DELAY);
const AnimatedSticker: FC<OwnProps> = ({
ref,
id,
className,
style,
tgsUrl,
@ -97,8 +102,12 @@ const AnimatedSticker: FC<OwnProps> = ({
return;
}
const newAnimation = new RLottie(
const fullId = `${id || generateIdFor(ID_STORE, true)}_${size}${color ? `_${color.join(',')}` : ''}`;
const newAnimation = RLottie.init(
containerRef.current,
onLoad,
fullId,
tgsUrl,
{
noLoop,
@ -107,7 +116,6 @@ const AnimatedSticker: FC<OwnProps> = ({
isLowPriority,
},
color,
onLoad,
onEnded,
onLoop,
);
@ -130,7 +138,7 @@ const AnimatedSticker: FC<OwnProps> = ({
});
});
}
}, [color, animation, tgsUrl, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop]);
}, [color, animation, tgsUrl, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop, id]);
useEffect(() => {
if (!animation) return;
@ -139,9 +147,11 @@ const AnimatedSticker: FC<OwnProps> = ({
}, [color, animation]);
useEffect(() => {
const container = containerRef.current!;
return () => {
if (animation) {
animation.destroy();
animation.removeContainer(container);
}
};
}, [animation]);
@ -151,7 +161,7 @@ const AnimatedSticker: FC<OwnProps> = ({
if (playSegmentRef.current) {
animation.playSegment(playSegmentRef.current);
} else {
animation.play(shouldRestart);
animation.play(shouldRestart, containerRef.current!);
}
}
}, [animation]);
@ -161,7 +171,7 @@ const AnimatedSticker: FC<OwnProps> = ({
return;
}
animation.pause();
animation.pause(containerRef.current!);
}, [animation]);
const freezeAnimation = useCallback(() => {
@ -229,7 +239,7 @@ const AnimatedSticker: FC<OwnProps> = ({
}, [playAnimation, animation, tgsUrl]);
useHeavyAnimationCheck(freezeAnimation, unfreezeAnimation, forceOnHeavyAnimation);
// Pausing frame may not happen in background
// Pausing frame may not happen in background,
// so we need to make sure it happens right after focusing,
// then we can play again.
useBackgroundMode(freezeAnimation, unfreezeAnimationOnRaf);

View File

@ -93,8 +93,8 @@ const StickerView: FC<OwnProps> = ({
const [isPlayerReady, markPlayerReady] = useFlag(Boolean(isLottie && fullMediaData));
const isFullMediaReady = fullMediaData && (isStatic || isPlayerReady);
const thumbClassNames = useMediaTransition(thumbData && !isFullMediaReady);
const fullMediaClassNames = useMediaTransition(isFullMediaReady);
const thumbClassNames = useMediaTransition(!isFullMediaReady);
// Preload preview for Message Input and local message
useMedia(previewMediaHash, !shouldLoad || !shouldPreloadPreview, undefined, cacheBuster);
@ -108,8 +108,9 @@ const StickerView: FC<OwnProps> = ({
/>
{isLottie ? (
<AnimatedSticker
key={customColor?.join(',')}
id={id}
size={size}
key={fullMediaData}
className={buildClassName(styles.media, fullMediaClassName, fullMediaClassNames)}
tgsUrl={fullMediaData}
play={shouldPlay}

View File

@ -7,7 +7,6 @@ import {
import WorkerConnector from '../../util/WorkerConnector';
import { animate } from '../../util/animation';
import cycleRestrict from '../../util/cycleRestrict';
import generateIdFor from '../../util/generateIdFor';
interface Params {
noLoop?: boolean;
@ -26,7 +25,8 @@ const HIGH_PRIORITY_QUALITY = IS_SINGLE_COLUMN_LAYOUT ? 0.75 : 1;
const LOW_PRIORITY_QUALITY = IS_ANDROID ? 0.5 : 0.75;
const HIGH_PRIORITY_CACHE_MODULO = IS_SAFARI ? 2 : 4;
const LOW_PRIORITY_CACHE_MODULO = 0;
const KEY_STORE = {};
const instancesById = new Map<string, RLottie>();
const workers = new Array(MAX_WORKERS).fill(undefined).map(
() => new WorkerConnector(new Worker(new URL('./rlottie.worker.ts', import.meta.url))),
@ -36,9 +36,15 @@ let lastWorkerIndex = -1;
class RLottie {
// Config
private imgSize!: number;
private containers = new Map<HTMLDivElement, {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
onLoad?: NoneToVoidFunction;
isOnLoadFired?: boolean;
isPaused?: boolean;
}>();
private key = generateIdFor(KEY_STORE);
private imgSize!: number;
private msPerFrame = 1000 / 60;
@ -56,12 +62,6 @@ class RLottie {
private chunksCount?: number;
// Container
private canvas = document.createElement('canvas');
private ctx = this.canvas.getContext('2d')!;
// State
private isAnimating = false;
@ -70,10 +70,10 @@ class RLottie {
private isEnded = false;
private isOnLoadFired = false;
private isDestroyed = false;
private isRendererInited = false;
private approxFrameIndex = 0;
private prevFrameIndex = -1;
@ -86,25 +86,59 @@ class RLottie {
private lastRenderAt?: number;
static init(...args: ConstructorParameters<typeof RLottie>) {
const [container, onLoad, id] = 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, onLoad);
}
return instance;
}
constructor(
private container: HTMLDivElement,
container: HTMLDivElement,
onLoad: NoneToVoidFunction | undefined,
private id: string,
private tgsUrl: string,
private params: Params = {},
private customColor?: [number, number, number],
private onLoad?: () => void,
private onEnded?: (isDestroyed?: boolean) => void,
private onLoop?: () => void,
) {
this.initContainer();
this.addContainer(container, onLoad);
this.initConfig();
this.initRenderer();
}
public removeContainer(container: HTMLDivElement) {
this.containers.get(container)!.canvas.remove();
this.containers.delete(container);
if (!this.containers.size) {
this.destroy();
}
}
isPlaying() {
return this.isAnimating || this.isWaiting;
}
play(forceRestart = false) {
play(forceRestart = false, container?: HTMLDivElement) {
if (container) {
this.containers.get(container)!.isPaused = false;
const areAllContainersPlaying = Array.from(this.containers.values()).every(({ isPaused }) => !isPaused);
if (!areAllContainersPlaying) {
return;
}
}
if (this.isEnded && forceRestart) {
this.approxFrameIndex = Math.floor(0);
}
@ -114,7 +148,16 @@ class RLottie {
this.doPlay();
}
pause() {
pause(container?: HTMLDivElement) {
if (container) {
this.containers.get(container)!.isPaused = true;
const areAllContainersPaused = Array.from(this.containers.values()).every(({ isPaused }) => isPaused);
if (!areAllContainersPaused) {
return;
}
}
if (this.isWaiting) {
this.stopFrameIndex = this.approxFrameIndex;
} else {
@ -140,15 +183,8 @@ class RLottie {
this.params.noLoop = noLoop;
}
destroy() {
this.isDestroyed = true;
this.pause();
this.destroyRenderer();
this.destroyContainer();
}
private initContainer() {
if (!(this.container.parentNode instanceof HTMLElement)) {
private addContainer(container: HTMLDivElement, onLoad?: NoneToVoidFunction) {
if (!(container.parentNode instanceof HTMLElement)) {
throw new Error('[RLottie] Container is not mounted');
}
@ -156,9 +192,9 @@ class RLottie {
if (!size) {
size = (
this.container.offsetWidth
|| parseInt(this.container.style.width, 10)
|| this.container.parentNode.offsetWidth
container.offsetWidth
|| parseInt(container.style.width, 10)
|| container.parentNode.offsetWidth
);
if (!size) {
@ -166,19 +202,39 @@ class RLottie {
}
}
this.canvas.style.width = `${size}px`;
this.canvas.style.height = `${size}px`;
const canvas = document.createElement('canvas');
canvas.dataset.id = this.id;
const ctx = canvas.getContext('2d')!;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
const { isLowPriority, quality = isLowPriority ? LOW_PRIORITY_QUALITY : HIGH_PRIORITY_QUALITY } = this.params;
// Reduced quality only looks acceptable on high DPR screens
const imgSize = Math.round(size * Math.max(DPR * quality, 1));
this.canvas.width = imgSize;
this.canvas.height = imgSize;
canvas.width = imgSize;
canvas.height = imgSize;
this.container.appendChild(this.canvas);
container.appendChild(canvas);
this.imgSize = imgSize;
if (!this.imgSize) {
this.imgSize = imgSize;
}
this.containers.set(container, { canvas, ctx, onLoad });
if (this.isRendererInited) {
this.doPlay();
}
}
private destroy() {
this.isDestroyed = true;
this.pause();
this.destroyRenderer();
instancesById.delete(this.id);
}
private initConfig() {
@ -188,26 +244,8 @@ class RLottie {
this.chunkSize = CHUNK_SIZE;
}
private destroyContainer() {
this.canvas.remove();
}
setColor(newColor: [number, number, number] | undefined) {
this.customColor = newColor;
// TODO Remove?
if (this.customColor) {
const imageData = this.ctx.getImageData(0, 0, this.imgSize, this.imgSize);
const arr = imageData.data;
for (let i = 0; i < arr.length; i += 4) {
/* eslint-disable prefer-destructuring */
arr[i] = this.customColor[0];
arr[i + 1] = this.customColor[1];
arr[i + 2] = this.customColor[2];
/* eslint-enable prefer-destructuring */
}
this.ctx.putImageData(imageData, 0, 0);
}
}
private initRenderer() {
@ -216,7 +254,7 @@ class RLottie {
workers[this.workerIndex].request({
name: 'init',
args: [
this.key,
this.id,
this.tgsUrl,
this.imgSize,
this.params.isLowPriority,
@ -228,11 +266,12 @@ class RLottie {
private destroyRenderer() {
workers[this.workerIndex].request({
name: 'destroy',
args: [this.key],
args: [this.id],
});
}
private onRendererInit(reduceFactor: number, msPerFrame: number, framesCount: number) {
this.isRendererInited = true;
this.reduceFactor = reduceFactor;
this.msPerFrame = msPerFrame;
this.framesCount = framesCount;
@ -251,7 +290,7 @@ class RLottie {
workers[this.workerIndex].request({
name: 'changeData',
args: [
this.key,
this.id,
this.tgsUrl,
this.params.isLowPriority,
this.onChangeData.bind(this),
@ -297,7 +336,7 @@ class RLottie {
}
// Paused from outside
if (!this.isAnimating && this.isOnLoadFired) {
if (!this.isAnimating && Array.from(this.containers.values())[0]!.isOnLoadFired) {
return false;
}
@ -335,12 +374,23 @@ class RLottie {
}
}
const imageData = new ImageData(arr, this.imgSize, this.imgSize);
this.ctx.putImageData(imageData, 0, 0);
if (this.onLoad && !this.isOnLoadFired) {
this.isOnLoadFired = true;
this.onLoad();
}
this.containers.forEach((containerData) => {
const {
ctx, onLoad, isOnLoadFired, isPaused,
} = containerData;
if (isPaused) {
return;
}
ctx.putImageData(imageData, 0, 0);
if (onLoad && !isOnLoadFired) {
containerData.isOnLoadFired = true;
onLoad();
}
});
this.prevFrameIndex = frameIndex;
}
@ -439,7 +489,7 @@ class RLottie {
workers[this.workerIndex].request({
name: 'renderFrames',
args: [this.key, fromIndex, toIndex, this.onFrameLoad.bind(this)],
args: [this.id, fromIndex, toIndex, this.onFrameLoad.bind(this)],
});
}

View File

@ -1,11 +1,13 @@
const generateIdFor = (store: AnyLiteral) => {
export default function generateIdFor(store: AnyLiteral, withAutoUpdate = false) {
let id;
do {
id = String(Math.random()).replace('0.', 'id');
} while (store.hasOwnProperty(id));
} while (store[id]);
if (withAutoUpdate) {
store[id] = true;
}
return id;
};
export default generateIdFor;
}