[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 && (
<canvas
ref={thumbRef}
className={buildClassName('thumbnail', 'canvas-blur-setup', thumbClassNames)}
className={buildClassName('thumbnail', thumbClassNames)}
/>
)}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -260,7 +260,7 @@ const RoundVideo: FC<OwnProps> = ({
{!shouldRenderSpoiler && (
<canvas
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`}
/>
)}

View File

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

View File

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

View File

@ -52,8 +52,6 @@ export default function useCanvasBlur(
ctx.drawImage(img, -radius * 2, -radius * 2, width + radius * 4, height + radius * 4);
canvas.classList.remove('canvas-blur-setup');
if (!IS_CANVAS_FILTER_SUPPORTED) {
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 '../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 { VideoPreviewApi } from '../video-preview/video-preview.worker';
export type MediaWorkerApi =
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 {
background: no-repeat;
background-size: var(--emoji-size);

View File

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

View File

@ -21,20 +21,6 @@ if (typeof File === 'undefined') {
} 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> {
return new Promise((resolve, reject) => {
const reader = new FileReader();