perf(app): play gif embeds from the viewport not a js decoder (#1877)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:36:38 +02:00
1 parent 69e0086144
commit 1f0f7d1222
14 files changed
+550 -1837

No files matched your search

@@ -1,55 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {getNextAnimatedImageFrame} from './useAnimatedImageDecoder';
describe('getNextAnimatedImageFrame', () => {
it('stops on the final frame when the image has no repetitions', () => {
let state = {frameIndex: 0, frameCount: 3, repetitionCount: 0, completedRepetitions: 0};
const secondFrame = getNextAnimatedImageFrame(state);
expect(secondFrame).toEqual({frameIndex: 1, completedRepetitions: 0});
state = {...state, ...secondFrame!};
const finalFrame = getNextAnimatedImageFrame(state);
expect(finalFrame).toEqual({frameIndex: 2, completedRepetitions: 0});
state = {...state, ...finalFrame!};
expect(getNextAnimatedImageFrame(state)).toBeNull();
});
it('allows the configured number of finite repetitions before stopping', () => {
let state = {frameIndex: 0, frameCount: 2, repetitionCount: 1, completedRepetitions: 0};
const finalFrame = getNextAnimatedImageFrame(state);
expect(finalFrame).toEqual({frameIndex: 1, completedRepetitions: 0});
state = {...state, ...finalFrame!};
const repeatedFirstFrame = getNextAnimatedImageFrame(state);
expect(repeatedFirstFrame).toEqual({frameIndex: 0, completedRepetitions: 1});
state = {...state, ...repeatedFirstFrame!};
const repeatedFinalFrame = getNextAnimatedImageFrame(state);
expect(repeatedFinalFrame).toEqual({frameIndex: 1, completedRepetitions: 1});
state = {...state, ...repeatedFinalFrame!};
expect(getNextAnimatedImageFrame(state)).toBeNull();
});
it('keeps advancing indefinitely for infinite repetitions', () => {
let state = {frameIndex: 1, frameCount: 2, repetitionCount: Infinity, completedRepetitions: 99};
const firstFrame = getNextAnimatedImageFrame(state);
expect(firstFrame).toEqual({frameIndex: 0, completedRepetitions: 100});
state = {...state, ...firstFrame!};
expect(getNextAnimatedImageFrame(state)).toEqual({frameIndex: 1, completedRepetitions: 100});
});
it('does not loop single-frame images or invalid repetition counts', () => {
expect(
getNextAnimatedImageFrame({
frameIndex: 0,
frameCount: 1,
repetitionCount: Infinity,
completedRepetitions: 0,
}),
).toBeNull();
expect(
getNextAnimatedImageFrame({
frameIndex: 1,
frameCount: 2,
repetitionCount: Number.NaN,
completedRepetitions: 0,
}),
).toBeNull();
});
});
@@ -1,730 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
drawVideoFrameToCanvas,
type FluxerImageDecoderConstructor,
type FluxerImageDecoderInstance,
getImageDecoderConstructor,
} from '@app/features/platform/utils/ImageDecoderInterop';
import {useEffect, useRef, useState} from 'react';
interface UseAnimatedImageDecoderOptions {
src: string | null;
playing: boolean;
canvas: HTMLCanvasElement | null;
maxCachedFrames?: number;
}
export interface AnimatedImageDecoderState {
supported: boolean;
loaded: boolean;
error: boolean;
naturalWidth: number;
naturalHeight: number;
}
interface DecoderSourceIdentity {
src: string | null;
canvas: HTMLCanvasElement | null;
maxCachedFrames: number | undefined;
}
export interface AnimatedImageFrameAdvanceState {
frameIndex: number;
frameCount: number;
repetitionCount: number;
completedRepetitions: number;
}
export interface AnimatedImageFrameAdvanceResult {
frameIndex: number;
completedRepetitions: number;
}
const DEFAULT_FRAME_DURATION_MS = 100;
const DEFAULT_MAX_CACHED_FRAMES = 24;
const MAX_CACHED_FRAME_BYTES = 64 * 1024 * 1024;
const MAXIMUM_FRAME_TIMER_DELAY_MS = 2_147_000_000;
const MAX_ANIMATED_IMAGE_ENCODED_BYTES = 16 * 1024 * 1024;
const MAX_ANIMATED_IMAGE_RESPONSE_CHUNKS = 4096;
const ANIMATED_IMAGE_REQUEST_TIMEOUT_MS = 30_000;
function createIdleDecoderState(): AnimatedImageDecoderState {
return {
supported: getImageDecoderConstructor() !== null,
loaded: false,
error: false,
naturalWidth: 0,
naturalHeight: 0,
};
}
function sameDecoderSourceIdentity(current: DecoderSourceIdentity, next: DecoderSourceIdentity): boolean {
return current.src === next.src && current.canvas === next.canvas && current.maxCachedFrames === next.maxCachedFrames;
}
interface CachedAnimatedImageFrame {
image: CanvasImageSource;
width: number;
height: number;
byteSize: number;
durationMs: number;
close: () => void;
}
interface AnimatedImageResponseData {
type: string;
encodedBytes: Uint8Array;
}
const guessMimeFromUrl = (url: string): string => {
const path = url.split('?')[0];
const lower = path === undefined ? '' : path.toLowerCase();
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.apng') || lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.avif')) return 'image/avif';
return 'image/webp';
};
function resolveMimeFromResponse(contentType: string | null, source: string): string {
if (contentType === null) return guessMimeFromUrl(source);
const separator = contentType.indexOf(';');
const mediaType = separator < 0 ? contentType : contentType.slice(0, separator);
const normalized = mediaType.trim().toLowerCase();
return normalized.length === 0 ? guessMimeFromUrl(source) : normalized;
}
function decodedFrameByteSize(width: number, height: number): number | null {
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) return null;
if (width <= 0 || height <= 0) return null;
const byteSize = width * height * 4;
if (!Number.isSafeInteger(byteSize)) return null;
return byteSize;
}
function frameDurationMs(durationMicroseconds: number | null | undefined): number {
if (durationMicroseconds === null || durationMicroseconds === undefined) return DEFAULT_FRAME_DURATION_MS;
if (!Number.isFinite(durationMicroseconds) || durationMicroseconds <= 0) return DEFAULT_FRAME_DURATION_MS;
return Math.min(MAXIMUM_FRAME_TIMER_DELAY_MS, Math.max(16, durationMicroseconds / 1000));
}
function cancelResponseBody(response: Response): void {
const body = response.body;
if (body == null) return;
try {
void body.cancel().catch(() => {});
} catch {}
}
function closeVideoFrame(image: VideoFrame): void {
try {
image.close();
} catch {}
}
async function readBoundedResponseBytes(response: Response): Promise<Uint8Array> {
const body = response.body;
if (body == null) throw new Error('Animated image response did not provide a body');
const contentLength = response.headers.get('content-length');
if (contentLength !== null) {
const maximumContentLengthDigits = Number.MAX_SAFE_INTEGER.toString().length;
if (
contentLength.length === 0 ||
contentLength.length > maximumContentLengthDigits ||
!/^[0-9]+$/.test(contentLength)
) {
cancelResponseBody(response);
throw new Error('Animated image response has an invalid Content-Length header');
}
const declaredBytes = Number(contentLength);
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ANIMATED_IMAGE_ENCODED_BYTES) {
cancelResponseBody(response);
throw new Error('Animated image response exceeded its byte limit');
}
}
const reader = body.getReader();
const chunks: Array<Uint8Array> = [];
let totalBytes = 0;
try {
while (true) {
const result = await reader.read();
if (result.done) break;
const chunk = result.value;
if (chunk == null) continue;
if (chunks.length >= MAX_ANIMATED_IMAGE_RESPONSE_CHUNKS) {
throw new Error('Animated image response exceeded its chunk limit');
}
totalBytes += chunk.byteLength;
if (totalBytes > MAX_ANIMATED_IMAGE_ENCODED_BYTES) {
throw new Error('Animated image response exceeded its byte limit');
}
chunks.push(chunk);
}
} catch (error) {
try {
await reader.cancel();
} catch {}
throw error;
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
function normalizeRepetitionCount(repetitionCount: number): number {
if (repetitionCount === Infinity) return Infinity;
if (!Number.isFinite(repetitionCount)) return 0;
return Math.max(0, Math.floor(repetitionCount));
}
async function createCachedAnimatedImageFrame(
image: VideoFrame,
byteSize: number,
durationMs: number,
ownerWindow: Window,
): Promise<CachedAnimatedImageFrame> {
const width = image.displayWidth;
const height = image.displayHeight;
if (typeof ownerWindow.createImageBitmap === 'function') {
try {
const bitmap = await ownerWindow.createImageBitmap(image as unknown as ImageBitmapSource);
closeVideoFrame(image);
return {
image: bitmap,
width,
height,
byteSize,
durationMs,
close: () => bitmap.close(),
};
} catch {}
}
return {
image: image as unknown as CanvasImageSource,
width,
height,
byteSize,
durationMs,
close: () => image.close(),
};
}
export function getNextAnimatedImageFrame({
frameIndex,
frameCount,
repetitionCount,
completedRepetitions,
}: AnimatedImageFrameAdvanceState): AnimatedImageFrameAdvanceResult | null {
const normalizedFrameCount = Math.max(0, Math.floor(frameCount));
if (normalizedFrameCount <= 1) return null;
const currentFrameIndex = Math.max(0, Math.min(normalizedFrameCount - 1, Math.floor(frameIndex)));
const normalizedCompletedRepetitions = Math.max(0, Math.floor(completedRepetitions));
if (currentFrameIndex < normalizedFrameCount - 1) {
return {frameIndex: currentFrameIndex + 1, completedRepetitions: normalizedCompletedRepetitions};
}
const normalizedRepetitionCount = normalizeRepetitionCount(repetitionCount);
if (normalizedCompletedRepetitions >= normalizedRepetitionCount) return null;
return {frameIndex: 0, completedRepetitions: normalizedCompletedRepetitions + 1};
}
export function useAnimatedImageDecoder({
src,
playing,
canvas,
maxCachedFrames = DEFAULT_MAX_CACHED_FRAMES,
}: UseAnimatedImageDecoderOptions): AnimatedImageDecoderState {
const sourceIdentity: DecoderSourceIdentity = {src, canvas, maxCachedFrames};
const [state, setState] = useState<AnimatedImageDecoderState>(createIdleDecoderState);
const [stateIdentity, setStateIdentity] = useState<DecoderSourceIdentity>(sourceIdentity);
const runnerRef = useRef<{
kick: () => void;
pause: () => void;
cancelled: boolean;
} | null>(null);
const playingRef = useRef(playing);
playingRef.current = playing;
const stateIsCurrent = sameDecoderSourceIdentity(stateIdentity, sourceIdentity);
const visibleState = stateIsCurrent ? state : createIdleDecoderState();
useEffect(() => {
setStateIdentity(sourceIdentity);
const Ctor = getImageDecoderConstructor();
setState(createIdleDecoderState);
if (!Ctor || !src || !canvas) {
if (!Ctor) setState((prev) => ({...prev, supported: false}));
return;
}
const ownerWindow = canvas.ownerDocument.defaultView;
if (ownerWindow == null) {
setState((prev) => ({...prev, error: true, supported: false}));
return;
}
const ctx = canvas.getContext('2d', {alpha: true});
if (!ctx) {
setState((prev) => ({...prev, error: true}));
return;
}
const normalizedMaxCachedFrames = Number.isFinite(maxCachedFrames)
? Math.max(2, Math.floor(maxCachedFrames))
: DEFAULT_MAX_CACHED_FRAMES;
const runner = {cancelled: false, kick: () => {}, pause: () => {}};
runnerRef.current = runner;
const fetchController = new ownerWindow.AbortController();
let decoder: FluxerImageDecoderInstance | null = null;
let frameCount = Number.POSITIVE_INFINITY;
let repetitionCount = 0;
let completedRepetitions = 0;
let frameIndex = 0;
let timer: number | null = null;
let resolveTimer: (() => void) | null = null;
let advancing = false;
let cachedFrameBytes = 0;
const frameCache = new Map<number, CachedAnimatedImageFrame>();
const isCurrentRunner = () => !runner.cancelled && runnerRef.current === runner;
const publishState = (
nextState: AnimatedImageDecoderState | ((previous: AnimatedImageDecoderState) => AnimatedImageDecoderState),
) => {
if (!isCurrentRunner()) return;
setState(nextState);
};
const closeFrameCache = () => {
for (const frame of frameCache.values()) {
try {
frame.close();
} catch {}
}
frameCache.clear();
cachedFrameBytes = 0;
};
const closeDecoder = () => {
if (decoder == null) return;
try {
decoder.close();
} catch {}
decoder = null;
};
const failRunner = () => {
if (runner.cancelled) return;
publishState((prev) => ({...prev, error: true}));
runner.cancelled = true;
try {
fetchController.abort();
} catch {}
clearTimer();
closeFrameCache();
closeDecoder();
};
publishState((prev) => ({...prev, loaded: false, error: false, supported: true}));
const clearTimer = () => {
if (timer != null) {
ownerWindow.clearTimeout(timer);
timer = null;
}
const resolve = resolveTimer;
resolveTimer = null;
if (resolve != null) resolve();
};
const draw = (frame: CachedAnimatedImageFrame): boolean => {
if (!isCurrentRunner()) return false;
const w = frame.width;
const h = frame.height;
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
try {
ctx.clearRect(0, 0, w, h);
ctx.drawImage(frame.image, 0, 0, w, h);
return true;
} catch {
return false;
}
};
const getFrame = async (index: number): Promise<CachedAnimatedImageFrame | null> => {
const cached = frameCache.get(index);
if (cached) {
frameCache.delete(index);
frameCache.set(index, cached);
return cached;
}
const activeDecoder = decoder;
if (activeDecoder == null) return null;
let decodedImage: VideoFrame | null = null;
try {
const result = await activeDecoder.decode({frameIndex: index, completeFramesOnly: true});
decodedImage = result.image;
if (!isCurrentRunner()) {
closeVideoFrame(decodedImage);
decodedImage = null;
return null;
}
const byteSize = decodedFrameByteSize(decodedImage.displayWidth, decodedImage.displayHeight);
if (byteSize == null || byteSize > MAX_CACHED_FRAME_BYTES) {
closeVideoFrame(decodedImage);
decodedImage = null;
failRunner();
return null;
}
const durationMs = frameDurationMs(decodedImage.duration);
const entry = await createCachedAnimatedImageFrame(decodedImage, byteSize, durationMs, ownerWindow);
decodedImage = null;
if (!isCurrentRunner()) {
entry.close();
return null;
}
frameCache.set(index, entry);
cachedFrameBytes += entry.byteSize;
while (frameCache.size > normalizedMaxCachedFrames || cachedFrameBytes > MAX_CACHED_FRAME_BYTES) {
const oldestIndex = frameCache.keys().next().value;
if (oldestIndex === undefined) break;
const oldest = frameCache.get(oldestIndex);
frameCache.delete(oldestIndex);
if (oldest != null) {
cachedFrameBytes -= oldest.byteSize;
try {
oldest.close();
} catch {}
}
}
return entry;
} catch {
if (decodedImage != null) {
closeVideoFrame(decodedImage);
}
failRunner();
return null;
}
};
const advance = async () => {
if (advancing || runner.cancelled) return;
advancing = true;
try {
while (!runner.cancelled && playingRef.current) {
const next = getNextAnimatedImageFrame({
frameIndex,
frameCount,
repetitionCount,
completedRepetitions,
});
if (!next) return;
const frame = await getFrame(next.frameIndex);
if (!frame || runner.cancelled || !playingRef.current) return;
frameIndex = next.frameIndex;
completedRepetitions = next.completedRepetitions;
if (!draw(frame)) {
failRunner();
return;
}
await new Promise<void>((resolve) => {
clearTimer();
resolveTimer = resolve;
timer = ownerWindow.setTimeout(
() => {
timer = null;
resolveTimer = null;
resolve();
},
Math.max(16, frame.durationMs),
);
});
if (runner.cancelled || !playingRef.current) return;
}
} finally {
advancing = false;
}
};
runner.kick = () => {
if (runner.cancelled) return;
void advance();
};
runner.pause = () => {
clearTimer();
};
const loadAnimatedImage = async (): Promise<AnimatedImageResponseData | null> => {
let response: Response | null = null;
let timeout: number | null = null;
const operation = (async (): Promise<AnimatedImageResponseData | null> => {
const fetchedResponse = await ownerWindow.fetch(src, {
cache: 'force-cache',
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
signal: fetchController.signal,
});
response = fetchedResponse;
if (!fetchedResponse.ok || fetchedResponse.body == null) {
cancelResponseBody(fetchedResponse);
throw new Error('Animated image request failed');
}
const type = resolveMimeFromResponse(fetchedResponse.headers.get('content-type'), src);
const isSupported = await Ctor.isTypeSupported(type).catch(() => false);
if (!isCurrentRunner()) return null;
if (!isSupported) {
cancelResponseBody(fetchedResponse);
return null;
}
const encodedBytes = await readBoundedResponseBytes(fetchedResponse);
return {type, encodedBytes};
})();
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeout = ownerWindow.setTimeout(() => {
try {
fetchController.abort();
} catch {}
if (response != null) cancelResponseBody(response);
reject(new Error('Animated image request timed out'));
}, ANIMATED_IMAGE_REQUEST_TIMEOUT_MS);
});
try {
return await Promise.race([operation, timeoutPromise]);
} finally {
if (timeout != null) ownerWindow.clearTimeout(timeout);
}
};
const start = async () => {
try {
const animatedImage = await loadAnimatedImage();
if (!isCurrentRunner()) return;
if (animatedImage == null) {
publishState((prev) => ({...prev, supported: false}));
runner.cancelled = true;
return;
}
const {type, encodedBytes} = animatedImage;
const decoderBuffer = new ArrayBuffer(encodedBytes.byteLength);
new Uint8Array(decoderBuffer).set(encodedBytes);
const decoderBody = new ownerWindow.Response(decoderBuffer).body;
if (decoderBody == null) throw new Error('Animated image decoder body was unavailable');
const createdDecoder = new Ctor({data: decoderBody, type, preferAnimation: true});
decoder = createdDecoder;
await createdDecoder.completed;
if (!isCurrentRunner()) return;
const activeDecoder = decoder;
if (activeDecoder == null) throw new Error('Animated image decoder was closed before completion');
const track = activeDecoder.tracks.selectedTrack;
if (track == null || !Number.isSafeInteger(track.frameCount) || track.frameCount < 1) {
throw new Error('Animated image decoder returned an invalid frame count');
}
frameCount = track.frameCount;
if (track.repetitionCount === undefined) {
repetitionCount = 0;
} else if (
track.repetitionCount !== Infinity &&
(!Number.isSafeInteger(track.repetitionCount) || track.repetitionCount < 0)
) {
throw new Error('Animated image decoder returned an invalid repetition count');
} else {
repetitionCount = track.repetitionCount;
}
const first = await getFrame(0);
if (!first || !isCurrentRunner()) return;
if (!draw(first)) {
failRunner();
return;
}
publishState({
supported: true,
loaded: true,
error: false,
naturalWidth: first.width,
naturalHeight: first.height,
});
if (playingRef.current && frameCount > 1) {
void advance();
}
} catch {
if (!isCurrentRunner()) return;
failRunner();
}
};
void start();
return () => {
runner.cancelled = true;
try {
fetchController.abort();
} catch {}
clearTimer();
closeFrameCache();
closeDecoder();
if (runnerRef.current === runner) runnerRef.current = null;
};
}, [canvas, maxCachedFrames, src]);
useEffect(() => {
const runner = runnerRef.current;
if (runner == null) return;
if (playing) {
runner.kick();
return;
}
runner.pause();
}, [playing]);
return visibleState;
}
export interface DecodedImageFrames {
frames: Array<ImageData>;
delays: Array<number>;
width: number;
height: number;
ready: boolean;
error: Error | null;
}
interface UseDecodedImageFramesOptions {
bytes: Uint8Array | null;
mime: string | null;
}
const HEIC_MIMES = new Set(['image/heic', 'image/heif']);
export function useDecodedImageFrames({bytes, mime}: UseDecodedImageFramesOptions): DecodedImageFrames {
const [state, setState] = useState<DecodedImageFrames>(() => ({
frames: [],
delays: [],
width: 0,
height: 0,
ready: false,
error: null,
}));
useEffect(() => {
if (!bytes || !mime) return;
let cancelled = false;
(async () => {
try {
const result = await decodeAllFrames(bytes, mime);
if (cancelled) return;
setState({...result, ready: true, error: null});
} catch (err) {
if (cancelled) return;
setState({
frames: [],
delays: [],
width: 0,
height: 0,
ready: false,
error: err instanceof Error ? err : new Error(String(err)),
});
}
})();
return () => {
cancelled = true;
};
}, [bytes, mime]);
return state;
}
async function decodeAllFrames(
bytes: Uint8Array,
mime: string,
): Promise<{
frames: Array<ImageData>;
delays: Array<number>;
width: number;
height: number;
}> {
const Cls = getImageDecoderConstructor();
const lower = mime.toLowerCase();
if (HEIC_MIMES.has(lower)) {
const frame = await decodeStaticViaImage(bytes, lower);
return {frames: [frame.image], delays: [0], width: frame.width, height: frame.height};
}
if (Cls && (await Cls.isTypeSupported(lower).catch(() => false))) {
return decodeAllFramesViaImageDecoder(bytes, lower, Cls);
}
const frame = await decodeStaticViaImage(bytes, lower);
return {frames: [frame.image], delays: [0], width: frame.width, height: frame.height};
}
async function decodeAllFramesViaImageDecoder(
bytes: Uint8Array,
type: string,
Cls: FluxerImageDecoderConstructor,
): Promise<{
frames: Array<ImageData>;
delays: Array<number>;
width: number;
height: number;
}> {
const decoder = new Cls({data: bytes, type, preferAnimation: true});
try {
await decoder.completed;
const track = decoder.tracks.selectedTrack;
const trackFrameCount =
track == null || !Number.isSafeInteger(track.frameCount) || track.frameCount < 1 ? 1 : track.frameCount;
const count = Math.max(1, trackFrameCount);
const frames: Array<ImageData> = [];
const delays: Array<number> = [];
let width = 0;
let height = 0;
for (let i = 0; i < count; i++) {
const {image} = await decoder.decode({frameIndex: i, completeFramesOnly: true});
try {
const w = image.displayWidth;
const h = image.displayHeight;
width = w;
height = h;
const canvas = typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(w, h) : null;
if (!canvas) throw new Error('OffscreenCanvas unavailable for ImageDecoder fallback');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('failed to acquire 2d context');
drawVideoFrameToCanvas(ctx, image);
const data = ctx.getImageData(0, 0, w, h);
frames.push(new ImageData(new Uint8ClampedArray(data.data), w, h));
const duration = image.duration;
delays.push(duration === null || duration === undefined ? 0 : duration / 1000);
} finally {
image.close();
}
}
return {frames, delays, width, height};
} finally {
decoder.close();
}
}
async function decodeStaticViaImage(
bytes: Uint8Array,
mime: string,
): Promise<{
image: ImageData;
width: number;
height: number;
}> {
if (typeof Image === 'undefined' || typeof URL === 'undefined') {
throw new Error('static decode unavailable: no Image/URL globals');
}
const blob = new Blob([new Uint8Array(bytes)], {type: mime});
const url = URL.createObjectURL(blob);
try {
const img = new Image();
img.src = url;
if (typeof img.decode === 'function') {
await img.decode();
} else {
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error(`failed to decode ${mime}`));
});
}
const w = img.naturalWidth;
const h = img.naturalHeight;
if (w === 0 || h === 0) throw new Error(`failed to decode ${mime}: zero dimensions`);
const canvas =
typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(w, h) : document.createElement('canvas');
if (canvas instanceof HTMLCanvasElement) {
canvas.width = w;
canvas.height = h;
}
const ctx = (canvas as OffscreenCanvas | HTMLCanvasElement).getContext('2d');
if (!ctx) throw new Error('failed to acquire 2d context for static decode');
(ctx as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D).drawImage(img, 0, 0);
const data = (ctx as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D).getImageData(0, 0, w, h);
return {image: new ImageData(new Uint8ClampedArray(data.data), w, h), width: w, height: h};
} finally {
URL.revokeObjectURL(url);
}
}
@@ -20,7 +20,6 @@ export interface UseShouldAnimateOptions {
isHovering?: boolean;
isFocused?: boolean;
entitlementOk?: boolean;
respectPlaybackAllowed?: boolean;
}
function isKeptUnderReducedMotion(kind: ShouldAnimateKind): boolean {
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {NearViewportSurfaceContext, resolveObserverRoot} from '@app/features/messaging/hooks/useNearViewport';
import {trackViewportPlayback} from '@app/features/platform/utils/ViewportPlaybackRegistry';
import {useContext, useEffect, useState} from 'react';
export function useViewportPlayback(element: Element | null): boolean {
const resolveScrollSurface = useContext(NearViewportSurfaceContext);
const [isPlaybackVisible, setIsPlaybackVisible] = useState(() => typeof IntersectionObserver === 'undefined');
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') {
setIsPlaybackVisible(true);
return;
}
if (element == null) {
setIsPlaybackVisible(false);
return;
}
const untrack = trackViewportPlayback(
element,
setIsPlaybackVisible,
resolveObserverRoot(resolveScrollSurface, element),
);
return () => {
untrack();
setIsPlaybackVisible(false);
};
}, [element, resolveScrollSurface]);
return isPlaybackVisible;
}