[Perf] Introduce Offscreen Canvas for blurred thumbs

This commit is contained in:
Alexander Zinchuk 2024-09-06 15:42:57 +02:00
parent 2b773d7e85
commit f2e8768123
16 changed files with 102 additions and 49 deletions

View File

@ -120,7 +120,7 @@ const File: FC<OwnProps> = ({
{withThumb && ( {withThumb && (
<canvas <canvas
ref={thumbRef} ref={thumbRef}
className={buildClassName('thumbnail', 'canvas-blur-setup', thumbClassNames)} className={buildClassName('thumbnail', thumbClassNames)}
/> />
)} )}
</div> </div>

View File

@ -157,7 +157,7 @@ const GifButton: FC<OwnProps> = ({
{withThumb && ( {withThumb && (
<canvas <canvas
ref={thumbRef} ref={thumbRef}
className="thumbnail canvas-blur-setup" className="thumbnail"
/> />
)} )}
{previewBlobUrl && !isVideoReady && ( {previewBlobUrl && !isVideoReady && (

View File

@ -63,7 +63,7 @@ const MediaSpoiler: FC<OwnProps> = ({
> >
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className={buildClassName(styles.canvas, 'canvas-blur-setup')} className={styles.canvas}
width={width} width={width}
height={height} height={height}
/> />

View File

@ -123,7 +123,7 @@ const ProfilePhoto: FC<OwnProps> = ({
content = ( content = (
<> <>
{isBlurredThumb ? ( {isBlurredThumb ? (
<canvas ref={blurredThumbCanvasRef} className="thumb canvas-blur-setup" /> <canvas ref={blurredThumbCanvasRef} className="thumb" />
) : ( ) : (
<img src={previewBlobUrl} draggable={false} className="thumb" alt="" /> <img src={previewBlobUrl} draggable={false} className="thumb" alt="" />
)} )}

View File

@ -84,7 +84,7 @@ function BaseStory({
onClick={isConnected ? handleClick : undefined} onClick={isConnected ? handleClick : undefined}
> >
{!isExpired && isPreview && ( {!isExpired && isPreview && (
<canvas ref={blurredBackgroundRef} className="thumbnail canvas-blur-setup blurred-bg" /> <canvas ref={blurredBackgroundRef} className="thumbnail blurred-bg" />
)} )}
{shouldRender && ( {shouldRender && (
<> <>

View File

@ -216,7 +216,7 @@ const Photo = <T,>({
onClick={isUploading ? undefined : handleClick} onClick={isUploading ? undefined : handleClick}
> >
{withBlurredBackground && ( {withBlurredBackground && (
<canvas ref={blurredBackgroundRef} className="thumbnail canvas-blur-setup blurred-bg" /> <canvas ref={blurredBackgroundRef} className="thumbnail blurred-bg" />
)} )}
<img <img
src={fullMediaData} src={fullMediaData}
@ -228,7 +228,7 @@ const Photo = <T,>({
{withThumb && ( {withThumb && (
<canvas <canvas
ref={thumbRef} ref={thumbRef}
className={buildClassName('thumbnail', !noThumb && 'canvas-blur-setup', thumbClassNames)} className={buildClassName('thumbnail', thumbClassNames)}
/> />
)} )}
{isProtected && <span className="protector" />} {isProtected && <span className="protector" />}

View File

@ -260,7 +260,7 @@ const RoundVideo: FC<OwnProps> = ({
{!shouldRenderSpoiler && ( {!shouldRenderSpoiler && (
<canvas <canvas
ref={thumbRef} ref={thumbRef}
className={buildClassName('thumbnail', 'canvas-blur-setup', thumbClassNames)} className={buildClassName('thumbnail', thumbClassNames)}
style={`width: ${ROUND_VIDEO_DIMENSIONS_PX}px; height: ${ROUND_VIDEO_DIMENSIONS_PX}px`} style={`width: ${ROUND_VIDEO_DIMENSIONS_PX}px; height: ${ROUND_VIDEO_DIMENSIONS_PX}px`}
/> />
)} )}

View File

@ -235,7 +235,7 @@ const Video = <T,>({
onClick={isUploading ? undefined : (e) => handleClick(e)} onClick={isUploading ? undefined : (e) => handleClick(e)}
> >
{withBlurredBackground && ( {withBlurredBackground && (
<canvas ref={blurredBackgroundRef} className="thumbnail canvas-blur-setup blurred-bg" /> <canvas ref={blurredBackgroundRef} className="thumbnail blurred-bg" />
)} )}
{isInline && ( {isInline && (
<OptimizedVideo <OptimizedVideo
@ -262,7 +262,7 @@ const Video = <T,>({
{hasThumb && !isPreviewPreloaded && ( {hasThumb && !isPreviewPreloaded && (
<canvas <canvas
ref={thumbRef} ref={thumbRef}
className={buildClassName('thumbnail', !noThumb && 'canvas-blur-setup', thumbClassNames)} className={buildClassName('thumbnail', thumbClassNames)}
/> />
)} )}
{isProtected && <span className="protector" />} {isProtected && <span className="protector" />}

View File

@ -1,10 +1,8 @@
import { getMediaThumbUri, type MediaWithThumbs } from '../../../../global/helpers'; import { getMediaThumbUri, type MediaWithThumbs } from '../../../../global/helpers';
import { IS_CANVAS_FILTER_SUPPORTED } from '../../../../util/windowEnvironment';
import useAppLayout from '../../../../hooks/useAppLayout'; import useOffscreenCanvasBlur from '../../../../hooks/useOffscreenCanvasBlur';
import useCanvasBlur from '../../../../hooks/useCanvasBlur';
type CanvasBlurReturnType = ReturnType<typeof useCanvasBlur>; type CanvasBlurReturnType = ReturnType<typeof useOffscreenCanvasBlur>;
export default function useBlurredMediaThumbRef( export default function useBlurredMediaThumbRef(
forcedUri: string | undefined, isDisabled: boolean, forcedUri: string | undefined, isDisabled: boolean,
@ -14,13 +12,7 @@ export default function useBlurredMediaThumbRef(
media: MediaWithThumbs | string | undefined, media: MediaWithThumbs | string | undefined,
isDisabled?: boolean, isDisabled?: boolean,
) { ) {
const { isMobile } = useAppLayout();
const dataUri = media ? typeof media === 'string' ? media : getMediaThumbUri(media) : undefined; const dataUri = media ? typeof media === 'string' ? media : getMediaThumbUri(media) : undefined;
return useCanvasBlur( return useOffscreenCanvasBlur(dataUri, isDisabled);
dataUri,
isDisabled,
isMobile && !IS_CANVAS_FILTER_SUPPORTED,
);
} }

View File

@ -52,8 +52,6 @@ export default function useCanvasBlur(
ctx.drawImage(img, -radius * 2, -radius * 2, width + radius * 4, height + radius * 4); ctx.drawImage(img, -radius * 2, -radius * 2, width + radius * 4, height + radius * 4);
canvas.classList.remove('canvas-blur-setup');
if (!IS_CANVAS_FILTER_SUPPORTED) { if (!IS_CANVAS_FILTER_SUPPORTED) {
fastBlur(ctx, 0, 0, width, height, radius, ITERATIONS); fastBlur(ctx, 0, 0, width, height, radius, ITERATIONS);
} }

View File

@ -0,0 +1,36 @@
import { useLayoutEffect, useMemo, useRef } from '../lib/teact/teact';
import cycleRestrict from '../util/cycleRestrict';
import launchMediaWorkers, { MAX_WORKERS } from '../util/launchMediaWorkers';
const RADIUS = 7;
let lastWorkerIndex = -1;
export default function useOffscreenCanvasBlur(
dataUri?: string,
isDisabled = false,
radius = RADIUS,
) {
// eslint-disable-next-line no-null/no-null
const canvasRef = useRef<HTMLCanvasElement>(null);
const workerIndex = useMemo(() => cycleRestrict(MAX_WORKERS, ++lastWorkerIndex), []);
useLayoutEffect(() => {
if (!dataUri || isDisabled) return;
const canvas = canvasRef.current;
if (!canvas) return;
const offscreen = canvas.transferControlToOffscreen();
const { connector } = launchMediaWorkers()[workerIndex];
connector.request({
name: 'blurThumb',
args: [offscreen, dataUri, radius],
transferables: [offscreen],
});
}, [dataUri, isDisabled, radius, workerIndex]);
return canvasRef;
}

View File

@ -1,9 +1,12 @@
import '../rlottie/rlottie.worker'; import '../rlottie/rlottie.worker';
import '../video-preview/video-preview.worker'; import '../video-preview/video-preview.worker';
import '../offscreen-canvas/offscreen-canvas.worker';
import type { OffscreenCanvasApi } from '../offscreen-canvas/offscreen-canvas.worker';
import type { RLottieApi } from '../rlottie/rlottie.worker'; import type { RLottieApi } from '../rlottie/rlottie.worker';
import type { VideoPreviewApi } from '../video-preview/video-preview.worker'; import type { VideoPreviewApi } from '../video-preview/video-preview.worker';
export type MediaWorkerApi = export type MediaWorkerApi =
RLottieApi RLottieApi
& VideoPreviewApi; & VideoPreviewApi
& OffscreenCanvasApi;

View File

@ -0,0 +1,33 @@
import { createWorkerInterface } from '../../util/createPostMessageInterface';
export async function blurThumb(canvas: OffscreenCanvas, dataUri: string, radius: number) {
const imageBitmap = await dataUriToImageBitmap(dataUri);
const { width, height } = canvas;
const ctx = canvas.getContext('2d')!;
// Draw image twice to battle white-ish edges
ctx.drawImage(imageBitmap, 0, 0, width, height);
ctx.filter = `blur(${radius}px)`;
ctx.drawImage(imageBitmap, 0, 0, width, height);
}
function dataUriToImageBitmap(dataUri: string) {
const byteString = atob(dataUri.split(',')[1]);
const mimeString = dataUri.split(',')[0].split(':')[1].split(';')[0];
const buffer = new ArrayBuffer(byteString.length);
const dataArray = new Uint8Array(buffer);
for (let i = 0; i < byteString.length; i++) {
dataArray[i] = byteString.charCodeAt(i);
}
const blob = new Blob([buffer], { type: mimeString });
return createImageBitmap(blob);
}
const api = { blurThumb };
createWorkerInterface(api, 'media');
export type OffscreenCanvasApi = typeof api;

View File

@ -201,10 +201,6 @@ body:not(.is-ios) {
} }
} }
.canvas-blur-setup {
will-change: width, height;
}
.emoji-small { .emoji-small {
background: no-repeat; background: no-repeat;
background-size: var(--emoji-size); background-size: var(--emoji-size);

View File

@ -97,6 +97,7 @@ export type RequestTypes<T extends InputRequestTypes> = Values<{
[Name in keyof (T)]: { [Name in keyof (T)]: {
name: Name & string; name: Name & string;
args: Parameters<T[Name]>; args: Parameters<T[Name]>;
transferables?: Transferable[];
} }
}>; }>;
@ -107,6 +108,8 @@ class ConnectorClass<T extends InputRequestTypes> {
private pendingPayloads: OriginPayload[] = []; private pendingPayloads: OriginPayload[] = [];
private pendingTransferables: Transferable[] = [];
constructor( constructor(
public target: Worker, public target: Worker,
private onUpdate?: (update: ApiUpdate) => void, private onUpdate?: (update: ApiUpdate) => void,
@ -127,12 +130,13 @@ class ConnectorClass<T extends InputRequestTypes> {
request(messageData: RequestTypes<T>) { request(messageData: RequestTypes<T>) {
const { requestStates, requestStatesByCallback } = this; const { requestStates, requestStatesByCallback } = this;
const { transferables, ...restMessageData } = messageData;
const messageId = generateUniqueId(); const messageId = generateUniqueId();
const payload: CallMethodPayload = { const payload: CallMethodPayload = {
type: 'callMethod', type: 'callMethod',
messageId, messageId,
...messageData, ...restMessageData,
}; };
const requestState = { messageId } as RequestState; const requestState = { messageId } as RequestState;
@ -161,7 +165,7 @@ class ConnectorClass<T extends InputRequestTypes> {
} }
}); });
this.postMessageOnTickEnd(payload); this.postMessageOnTickEnd(payload, transferables);
return promise; return promise;
} }
@ -208,20 +212,25 @@ class ConnectorClass<T extends InputRequestTypes> {
}); });
} }
private postMessageOnTickEnd(payload: OriginPayload) { private postMessageOnTickEnd(payload: OriginPayload, transferables?: Transferable[]) {
this.pendingPayloads.push(payload); this.pendingPayloads.push(payload);
if (transferables) {
this.pendingTransferables.push(...transferables);
}
this.postMessagesOnTickEnd(); this.postMessagesOnTickEnd();
} }
private postMessagesOnTickEnd = throttleWithTickEnd(() => { private postMessagesOnTickEnd = throttleWithTickEnd(() => {
const { channel } = this;
const payloads = this.pendingPayloads; const payloads = this.pendingPayloads;
const transferables = this.pendingTransferables;
this.pendingPayloads = []; this.pendingPayloads = [];
this.pendingTransferables = [];
this.target.postMessage({ this.target.postMessage({ channel, payloads }, transferables);
channel: this.channel,
payloads,
});
}); });
} }

View File

@ -21,20 +21,6 @@ if (typeof File === 'undefined') {
} as typeof File; } as typeof File;
} }
export function dataUriToBlob(dataUri: string) {
const arr = dataUri.split(',');
const mime = arr[0].match(/:(.*?);/)![1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
}
export function blobToDataUri(blob: Blob): Promise<string> { export function blobToDataUri(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();