mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
perf(app): play gif embeds from the viewport not a js decoder (#1877)
This commit is contained in:
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;
|
||||
}
|
||||
@@ -62,7 +62,6 @@
|
||||
max-width: 100%;
|
||||
min-height: 0.0625rem;
|
||||
min-width: 0;
|
||||
aspect-ratio: var(--embed-aspect-ratio, auto);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -73,46 +72,39 @@
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: var(--media-border-radius, 0px);
|
||||
transition: opacity 80ms ease-out;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.posterElement {
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: var(--media-border-radius, 0px);
|
||||
object-fit: contain;
|
||||
background: var(--background-primary);
|
||||
pointer-events: none;
|
||||
transition: opacity 80ms ease-out;
|
||||
transition: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.videoOpacityHidden {
|
||||
opacity: 0;
|
||||
:global(html:not(.reduced-motion)) .placeholder {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.videoOpacityVisible {
|
||||
:global(html:not(.reduced-motion)) .placeholderFastFade {
|
||||
transition: opacity 0.05s ease;
|
||||
}
|
||||
|
||||
.placeholderVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
.gifFreezeFrame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: var(--media-border-radius, 0px);
|
||||
object-fit: contain;
|
||||
|
||||
.placeholderHidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.gifFreezeFrame[data-frozen='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,155 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const GIFVPosterState = Object.freeze({
|
||||
IDLE: 'idle',
|
||||
LOADING: 'loading',
|
||||
READY: 'ready',
|
||||
ERROR: 'error',
|
||||
} as const);
|
||||
|
||||
export type GIFVPosterState = (typeof GIFVPosterState)[keyof typeof GIFVPosterState];
|
||||
|
||||
export const GIFVVideoState = Object.freeze({
|
||||
IDLE: 'idle',
|
||||
LOADING: 'loading',
|
||||
READY: 'ready',
|
||||
PRESENTED: 'presented',
|
||||
ERROR: 'error',
|
||||
} as const);
|
||||
|
||||
export type GIFVVideoState = (typeof GIFVVideoState)[keyof typeof GIFVVideoState];
|
||||
|
||||
export const GIFVPlaybackState = Object.freeze({
|
||||
IDLE: 'idle',
|
||||
REQUESTED: 'requested',
|
||||
PLAYING: 'playing',
|
||||
PAUSED: 'paused',
|
||||
BLOCKED: 'blocked',
|
||||
} as const);
|
||||
|
||||
export type GIFVPlaybackState = (typeof GIFVPlaybackState)[keyof typeof GIFVPlaybackState];
|
||||
|
||||
export const GIFVRenderEventType = Object.freeze({
|
||||
SOURCE_CHANGED: 'SOURCE_CHANGED',
|
||||
POSTER_LOAD_STARTED: 'POSTER_LOAD_STARTED',
|
||||
POSTER_LOADED: 'POSTER_LOADED',
|
||||
POSTER_FAILED: 'POSTER_FAILED',
|
||||
VIDEO_LOAD_STARTED: 'VIDEO_LOAD_STARTED',
|
||||
VIDEO_READY: 'VIDEO_READY',
|
||||
VIDEO_FRAME_PRESENTED: 'VIDEO_FRAME_PRESENTED',
|
||||
VIDEO_FAILED: 'VIDEO_FAILED',
|
||||
PLAY_REQUESTED: 'PLAY_REQUESTED',
|
||||
PLAYING: 'PLAYING',
|
||||
PLAYBACK_PAUSED: 'PLAYBACK_PAUSED',
|
||||
PLAYBACK_BLOCKED: 'PLAYBACK_BLOCKED',
|
||||
} as const);
|
||||
|
||||
export type GIFVRenderEventType = (typeof GIFVRenderEventType)[keyof typeof GIFVRenderEventType];
|
||||
|
||||
export interface GIFVRenderState {
|
||||
sourceKey: string;
|
||||
poster: GIFVPosterState;
|
||||
video: GIFVVideoState;
|
||||
playback: GIFVPlaybackState;
|
||||
}
|
||||
|
||||
export interface GIFVSourceChangedEvent {
|
||||
type: typeof GIFVRenderEventType.SOURCE_CHANGED;
|
||||
sourceKey: string;
|
||||
posterCached: boolean;
|
||||
}
|
||||
|
||||
export type GIFVRenderEvent =
|
||||
| GIFVSourceChangedEvent
|
||||
| {type: typeof GIFVRenderEventType.POSTER_LOAD_STARTED}
|
||||
| {type: typeof GIFVRenderEventType.POSTER_LOADED}
|
||||
| {type: typeof GIFVRenderEventType.POSTER_FAILED}
|
||||
| {type: typeof GIFVRenderEventType.VIDEO_LOAD_STARTED}
|
||||
| {type: typeof GIFVRenderEventType.VIDEO_READY}
|
||||
| {type: typeof GIFVRenderEventType.VIDEO_FRAME_PRESENTED}
|
||||
| {type: typeof GIFVRenderEventType.VIDEO_FAILED}
|
||||
| {type: typeof GIFVRenderEventType.PLAY_REQUESTED}
|
||||
| {type: typeof GIFVRenderEventType.PLAYING}
|
||||
| {type: typeof GIFVRenderEventType.PLAYBACK_PAUSED}
|
||||
| {type: typeof GIFVRenderEventType.PLAYBACK_BLOCKED};
|
||||
|
||||
export interface GIFVRenderLayers {
|
||||
showPlaceholder: boolean;
|
||||
showPoster: boolean;
|
||||
showVideo: boolean;
|
||||
}
|
||||
|
||||
export interface InitialGIFVRenderStateRequest {
|
||||
posterCached: boolean;
|
||||
sourceKey: string;
|
||||
}
|
||||
|
||||
export function createInitialGIFVRenderState({
|
||||
sourceKey,
|
||||
posterCached,
|
||||
}: InitialGIFVRenderStateRequest): GIFVRenderState {
|
||||
let posterState: GIFVPosterState = GIFVPosterState.IDLE;
|
||||
if (posterCached) posterState = GIFVPosterState.READY;
|
||||
return {
|
||||
sourceKey,
|
||||
poster: posterState,
|
||||
video: GIFVVideoState.IDLE,
|
||||
playback: GIFVPlaybackState.IDLE,
|
||||
};
|
||||
}
|
||||
|
||||
function reduceGIFVSourceChange(state: GIFVRenderState, event: GIFVSourceChangedEvent): GIFVRenderState {
|
||||
if (state.sourceKey !== event.sourceKey) {
|
||||
return createInitialGIFVRenderState({
|
||||
sourceKey: event.sourceKey,
|
||||
posterCached: event.posterCached,
|
||||
});
|
||||
}
|
||||
if (!event.posterCached) return state;
|
||||
if (state.poster === GIFVPosterState.READY) return state;
|
||||
return {...state, poster: GIFVPosterState.READY};
|
||||
}
|
||||
|
||||
export function reduceGIFVRenderState(state: GIFVRenderState, event: GIFVRenderEvent): GIFVRenderState {
|
||||
switch (event.type) {
|
||||
case GIFVRenderEventType.SOURCE_CHANGED:
|
||||
return reduceGIFVSourceChange(state, event);
|
||||
case GIFVRenderEventType.POSTER_LOAD_STARTED:
|
||||
if (state.poster === GIFVPosterState.READY) return state;
|
||||
return {...state, poster: GIFVPosterState.LOADING};
|
||||
case GIFVRenderEventType.POSTER_LOADED:
|
||||
if (state.poster === GIFVPosterState.READY) return state;
|
||||
return {...state, poster: GIFVPosterState.READY};
|
||||
case GIFVRenderEventType.POSTER_FAILED:
|
||||
if (state.poster === GIFVPosterState.READY) return state;
|
||||
return {...state, poster: GIFVPosterState.ERROR};
|
||||
case GIFVRenderEventType.VIDEO_LOAD_STARTED:
|
||||
if (state.video === GIFVVideoState.PRESENTED) return state;
|
||||
return {...state, video: GIFVVideoState.LOADING};
|
||||
case GIFVRenderEventType.VIDEO_READY:
|
||||
if (state.video === GIFVVideoState.PRESENTED) return state;
|
||||
return {...state, video: GIFVVideoState.READY};
|
||||
case GIFVRenderEventType.VIDEO_FRAME_PRESENTED:
|
||||
return {...state, video: GIFVVideoState.PRESENTED};
|
||||
case GIFVRenderEventType.VIDEO_FAILED:
|
||||
return {...state, video: GIFVVideoState.ERROR, playback: GIFVPlaybackState.PAUSED};
|
||||
case GIFVRenderEventType.PLAY_REQUESTED:
|
||||
return {...state, playback: GIFVPlaybackState.REQUESTED};
|
||||
case GIFVRenderEventType.PLAYING:
|
||||
return {...state, playback: GIFVPlaybackState.PLAYING};
|
||||
case GIFVRenderEventType.PLAYBACK_PAUSED:
|
||||
return {...state, playback: GIFVPlaybackState.PAUSED};
|
||||
case GIFVRenderEventType.PLAYBACK_BLOCKED:
|
||||
return {...state, playback: GIFVPlaybackState.BLOCKED};
|
||||
}
|
||||
}
|
||||
|
||||
export function getGIFVRenderLayers(state: GIFVRenderState): GIFVRenderLayers {
|
||||
const showVideo = state.video === GIFVVideoState.PRESENTED;
|
||||
const showPoster = !showVideo && state.poster === GIFVPosterState.READY;
|
||||
return {
|
||||
showPlaceholder: !showVideo && !showPoster,
|
||||
showPoster,
|
||||
showVideo,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {useViewportPlayback} from '@app/features/app/hooks/useViewportPlayback';
|
||||
import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport';
|
||||
import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const ANIMATION_ROOT_MARGIN = '600px 0px';
|
||||
|
||||
export interface GifViewportGate {
|
||||
loadMedia: boolean;
|
||||
@@ -34,23 +31,6 @@ export function useGifViewportGate<T extends Element>({
|
||||
shouldBlur: boolean;
|
||||
}): GifViewportGate & {ref: (node: T | null) => void} {
|
||||
const {ref, isNearViewport} = useNearViewport<T>({rememberKey});
|
||||
const [isInViewport, setIsInViewport] = useState(() => typeof IntersectionObserver === 'undefined');
|
||||
useEffect(() => {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setIsInViewport(true);
|
||||
return;
|
||||
}
|
||||
if (element == null) {
|
||||
setIsInViewport(false);
|
||||
return;
|
||||
}
|
||||
return observeIntersection(
|
||||
element,
|
||||
(entry) => {
|
||||
setIsInViewport(entry.isIntersecting || entry.intersectionRatio > 0);
|
||||
},
|
||||
{rootMargin: ANIMATION_ROOT_MARGIN},
|
||||
);
|
||||
}, [element]);
|
||||
const isInViewport = useViewportPlayback(element);
|
||||
return {ref, ...resolveGifViewportGate({isNearViewport, isInViewport, shouldBlur})};
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {LongPressable} from '@app/features/app/components/LongPressable';
|
||||
import mediaStyles from '@app/features/channel/components/embeds/media/MediaContainer.module.css';
|
||||
import {shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaOverlayFit';
|
||||
import {
|
||||
ADD_TO_FAVORITES_DESCRIPTOR,
|
||||
DELETE_ATTACHMENT_DESCRIPTOR,
|
||||
@@ -28,13 +29,6 @@ const DOWNLOAD_MEDIA_DESCRIPTOR = msg({
|
||||
message: 'Download media',
|
||||
comment: 'Button or menu action label in the channel and chat media container. Keep it concise.',
|
||||
});
|
||||
const MIN_SIZE_FOR_OVERLAYS = 120;
|
||||
export const shouldShowOverlays = (renderedWidth?: number, renderedHeight?: number): boolean => {
|
||||
if (renderedWidth === undefined || renderedHeight === undefined) {
|
||||
return true;
|
||||
}
|
||||
return renderedWidth >= MIN_SIZE_FOR_OVERLAYS && renderedHeight >= MIN_SIZE_FOR_OVERLAYS;
|
||||
};
|
||||
|
||||
type LongPressEvent = React.PointerEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>;
|
||||
|
||||
@@ -96,10 +90,7 @@ export const MediaContainer = observer(
|
||||
e.stopPropagation();
|
||||
onDeleteClick?.(e);
|
||||
};
|
||||
const isMediaTooSmall =
|
||||
renderedWidth !== undefined &&
|
||||
renderedHeight !== undefined &&
|
||||
(renderedWidth < MIN_SIZE_FOR_OVERLAYS || renderedHeight < MIN_SIZE_FOR_OVERLAYS);
|
||||
const isMediaTooSmall = !shouldShowOverlays(renderedWidth, renderedHeight);
|
||||
const shouldShowFavorite = showFavoriteButton && (forceShowFavoriteButton || !isMediaTooSmall);
|
||||
const shouldShowDownload = showDownloadButton && !isMediaTooSmall;
|
||||
const shouldShowDelete = showDeleteButton && !isMediaTooSmall;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const OVERLAY_ACTION_ITEM_SIZE = 28;
|
||||
const OVERLAY_ACTION_GAP = 2;
|
||||
const OVERLAY_ACTION_PADDING = 3;
|
||||
const OVERLAY_EDGE_INSET = 8;
|
||||
const MAX_OVERLAY_ACTIONS = 3;
|
||||
|
||||
const OVERLAY_TOOLBAR_WIDTH =
|
||||
OVERLAY_ACTION_PADDING * 2 +
|
||||
MAX_OVERLAY_ACTIONS * OVERLAY_ACTION_ITEM_SIZE +
|
||||
(MAX_OVERLAY_ACTIONS - 1) * OVERLAY_ACTION_GAP;
|
||||
const OVERLAY_TOOLBAR_HEIGHT = OVERLAY_ACTION_PADDING * 2 + OVERLAY_ACTION_ITEM_SIZE;
|
||||
|
||||
export const MIN_WIDTH_FOR_OVERLAYS = OVERLAY_TOOLBAR_WIDTH + OVERLAY_EDGE_INSET * 2;
|
||||
export const MIN_HEIGHT_FOR_OVERLAYS = OVERLAY_TOOLBAR_HEIGHT + OVERLAY_EDGE_INSET * 2;
|
||||
|
||||
export function shouldShowOverlays(renderedWidth?: number, renderedHeight?: number): boolean {
|
||||
if (renderedWidth === undefined || renderedHeight === undefined) {
|
||||
return true;
|
||||
}
|
||||
return renderedWidth >= MIN_WIDTH_FOR_OVERLAYS && renderedHeight >= MIN_HEIGHT_FOR_OVERLAYS;
|
||||
}
|
||||
+50
-1
@@ -1,7 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {getEffectiveVideoLayoutDimensions, hasDifferentAspectRatio, resolveVideoLayout} from './VideoDimensionUtils';
|
||||
import {
|
||||
getEffectiveVideoLayoutDimensions,
|
||||
hasDifferentAspectRatio,
|
||||
isInlinePlayableVideoSize,
|
||||
resolveVideoLayout,
|
||||
} from './VideoDimensionUtils';
|
||||
|
||||
describe('getEffectiveVideoLayoutDimensions', () => {
|
||||
it('prefers decoded dimensions over declared upload dimensions', () => {
|
||||
@@ -55,3 +60,47 @@ describe('resolveVideoLayout', () => {
|
||||
expect(w / h).toBeCloseTo(1920 / 700, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInlinePlayableVideoSize', () => {
|
||||
it('accepts a landscape source sitting exactly on both edges', () => {
|
||||
expect(isInlinePlayableVideoSize({width: 6016, height: 3384})).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a portrait source sitting exactly on the swapped edges', () => {
|
||||
expect(isInlinePlayableVideoSize({width: 3384, height: 6016})).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects one pixel past the landscape long edge', () => {
|
||||
expect(isInlinePlayableVideoSize({width: 6017, height: 3384})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects one pixel past the landscape short edge', () => {
|
||||
expect(isInlinePlayableVideoSize({width: 6016, height: 3385})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects one pixel past the portrait long edge', () => {
|
||||
expect(isInlinePlayableVideoSize({width: 3384, height: 6017})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects one pixel past the portrait short edge', () => {
|
||||
expect(isInlinePlayableVideoSize({width: 3385, height: 6016})).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a source that only fits the swapped clause', () => {
|
||||
expect(isInlinePlayableVideoSize({width: 3000, height: 5000})).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a square source larger than the short edge', () => {
|
||||
expect(isInlinePlayableVideoSize({width: 4000, height: 4000})).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a square source on the short edge', () => {
|
||||
expect(isInlinePlayableVideoSize({width: 3384, height: 3384})).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts unknown dimensions so metadata gaps never silence playback', () => {
|
||||
expect(isInlinePlayableVideoSize(null)).toBe(true);
|
||||
expect(isInlinePlayableVideoSize({width: 0, height: 0})).toBe(true);
|
||||
expect(isInlinePlayableVideoSize({width: Number.NaN, height: Number.NaN})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,8 @@ const DEFAULT_INLINE_VIDEO_LAYOUT_CONSTRAINTS: VideoLayoutConstraints = {
|
||||
maxWidth: 400,
|
||||
maxHeight: 400,
|
||||
};
|
||||
const MAX_INLINE_PLAYBACK_LONG_EDGE = 6016;
|
||||
const MAX_INLINE_PLAYBACK_SHORT_EDGE = 3384;
|
||||
|
||||
export function normalizeVideoDimensions(dimensions?: Partial<VideoDimensions> | null): VideoDimensions | null {
|
||||
const width = dimensions?.width;
|
||||
@@ -39,6 +41,18 @@ export function normalizeVideoDimensions(dimensions?: Partial<VideoDimensions> |
|
||||
};
|
||||
}
|
||||
|
||||
export function isInlinePlayableVideoSize(dimensions?: Partial<VideoDimensions> | null): boolean {
|
||||
const source = normalizeVideoDimensions(dimensions);
|
||||
if (source === null) {
|
||||
return true;
|
||||
}
|
||||
const {width, height} = source;
|
||||
return (
|
||||
(width <= MAX_INLINE_PLAYBACK_LONG_EDGE && height <= MAX_INLINE_PLAYBACK_SHORT_EDGE) ||
|
||||
(width <= MAX_INLINE_PLAYBACK_SHORT_EDGE && height <= MAX_INLINE_PLAYBACK_LONG_EDGE)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeConstraint(value: number | undefined, fallback: number): number {
|
||||
return Number.isFinite(value) && value !== undefined && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
@@ -22,30 +22,24 @@ function thresholdKey(threshold: number | Array<number> | undefined): string {
|
||||
return threshold.join(',');
|
||||
}
|
||||
|
||||
const rootIds = new WeakMap<Element, string>();
|
||||
let rootIdCounter = 0;
|
||||
|
||||
function rootKey(root: Element | null | undefined): string {
|
||||
if (!root) return 'document';
|
||||
const id = (
|
||||
root as Element & {
|
||||
dataset?: DOMStringMap;
|
||||
}
|
||||
).dataset?.sharedIoId;
|
||||
if (id) return `el:${id}`;
|
||||
const generated = `gen-${++rootIdCounter}`;
|
||||
(
|
||||
root as Element & {
|
||||
dataset: DOMStringMap;
|
||||
}
|
||||
).dataset.sharedIoId = generated;
|
||||
return `el:${generated}`;
|
||||
let id = rootIds.get(root);
|
||||
if (id === undefined) {
|
||||
id = `r${++rootIdCounter}`;
|
||||
rootIds.set(root, id);
|
||||
}
|
||||
return `el:${id}`;
|
||||
}
|
||||
|
||||
let rootIdCounter = 0;
|
||||
|
||||
function buildKey(options: ObserverOptions): string {
|
||||
return `${rootKey(options.root ?? null)}|${options.rootMargin ?? '0px'}|${thresholdKey(options.threshold)}`;
|
||||
}
|
||||
|
||||
function getOrCreate(options: ObserverOptions): SharedObserverState {
|
||||
function acquirePooledObserver(options: ObserverOptions): SharedObserverState {
|
||||
const key = buildKey(options);
|
||||
let state = pool.get(key);
|
||||
if (state) return state;
|
||||
@@ -77,7 +71,7 @@ function getOrCreate(options: ObserverOptions): SharedObserverState {
|
||||
}
|
||||
|
||||
export function observeIntersection(element: Element, callback: Callback, options: ObserverOptions = {}): () => void {
|
||||
const state = getOrCreate(options);
|
||||
const state = acquirePooledObserver(options);
|
||||
let handlers = state.callbacks.get(element);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver';
|
||||
|
||||
type PlaybackToggle = (playing: boolean) => void;
|
||||
|
||||
const PLAYBACK_VISIBLE_RATIO = 0.7;
|
||||
const PLAYBACK_MAX_ROOT_HEIGHTS = 8;
|
||||
const MAX_CONCURRENT_PLAYBACK = 100;
|
||||
|
||||
function buildPlaybackRatioSteps(): Array<number> {
|
||||
const steps = [0, PLAYBACK_VISIBLE_RATIO];
|
||||
let ratio = PLAYBACK_VISIBLE_RATIO;
|
||||
while (PLAYBACK_VISIBLE_RATIO / ratio < PLAYBACK_MAX_ROOT_HEIGHTS) {
|
||||
ratio *= PLAYBACK_VISIBLE_RATIO;
|
||||
steps.push(ratio);
|
||||
}
|
||||
return steps.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
const PLAYBACK_RATIO_STEPS = buildPlaybackRatioSteps();
|
||||
|
||||
const trackedNodes = new WeakMap<Element, PlaybackToggle>();
|
||||
const playingNodes = new Set<Element>();
|
||||
|
||||
let onScreenNodes = new Set<Element>();
|
||||
|
||||
function fillsPlaybackRoot(entry: IntersectionObserverEntry): boolean {
|
||||
const rootBounds = entry.rootBounds;
|
||||
if (rootBounds == null || rootBounds.height <= 0) return false;
|
||||
return entry.intersectionRect.height >= rootBounds.height * PLAYBACK_VISIBLE_RATIO;
|
||||
}
|
||||
|
||||
function isPlaybackVisible(entry: IntersectionObserverEntry): boolean {
|
||||
if (!entry.isIntersecting) return false;
|
||||
if (entry.intersectionRatio >= PLAYBACK_VISIBLE_RATIO) return true;
|
||||
return fillsPlaybackRoot(entry);
|
||||
}
|
||||
|
||||
function isNearLeadingEdge(entry: IntersectionObserverEntry): boolean {
|
||||
const rootBounds = entry.rootBounds;
|
||||
if (rootBounds == null) return false;
|
||||
const bottomGap = Math.abs(entry.intersectionRect.bottom - rootBounds.bottom);
|
||||
const topGap = Math.abs(entry.intersectionRect.top - rootBounds.top);
|
||||
return bottomGap < topGap;
|
||||
}
|
||||
|
||||
function stopTrailingPlayback(entered: Element): void {
|
||||
const ordered = Array.from(onScreenNodes);
|
||||
for (let index = ordered.length - 1; index >= 0; index--) {
|
||||
const node = ordered[index];
|
||||
if (node === entered) continue;
|
||||
if (!playingNodes.has(node)) continue;
|
||||
playingNodes.delete(node);
|
||||
trackedNodes.get(node)?.(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function startNextWaitingPlayback(): void {
|
||||
if (playingNodes.size >= MAX_CONCURRENT_PLAYBACK) return;
|
||||
if (onScreenNodes.size <= playingNodes.size) return;
|
||||
for (const node of onScreenNodes) {
|
||||
if (playingNodes.has(node)) continue;
|
||||
playingNodes.add(node);
|
||||
trackedNodes.get(node)?.(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEntered(entry: IntersectionObserverEntry, toggle: PlaybackToggle): void {
|
||||
const node = entry.target;
|
||||
if (onScreenNodes.has(node)) return;
|
||||
const leading = isNearLeadingEdge(entry);
|
||||
if (leading) {
|
||||
onScreenNodes = new Set<Element>([node, ...onScreenNodes]);
|
||||
} else {
|
||||
onScreenNodes.add(node);
|
||||
}
|
||||
const shouldPlay = leading || playingNodes.size < MAX_CONCURRENT_PLAYBACK;
|
||||
if (shouldPlay) {
|
||||
playingNodes.add(node);
|
||||
} else {
|
||||
playingNodes.delete(node);
|
||||
}
|
||||
toggle(shouldPlay);
|
||||
if (shouldPlay && playingNodes.size > MAX_CONCURRENT_PLAYBACK) {
|
||||
stopTrailingPlayback(node);
|
||||
}
|
||||
}
|
||||
|
||||
function handleExited(node: Element, toggle: PlaybackToggle): void {
|
||||
if (!onScreenNodes.has(node)) return;
|
||||
onScreenNodes.delete(node);
|
||||
playingNodes.delete(node);
|
||||
toggle(false);
|
||||
startNextWaitingPlayback();
|
||||
}
|
||||
|
||||
export function trackViewportPlayback(
|
||||
element: Element,
|
||||
toggle: PlaybackToggle,
|
||||
clipRoot: Element | null = null,
|
||||
): () => void {
|
||||
if (trackedNodes.has(element)) {
|
||||
throw new Error('trackViewportPlayback: element is already tracked, untrack it before tracking it again');
|
||||
}
|
||||
trackedNodes.set(element, toggle);
|
||||
const release = observeIntersection(
|
||||
element,
|
||||
(entry) => {
|
||||
const current = trackedNodes.get(entry.target);
|
||||
if (current == null) return;
|
||||
if (isPlaybackVisible(entry)) {
|
||||
handleEntered(entry, current);
|
||||
} else {
|
||||
handleExited(entry.target, current);
|
||||
}
|
||||
},
|
||||
{root: clipRoot, threshold: PLAYBACK_RATIO_STEPS},
|
||||
);
|
||||
let untracked = false;
|
||||
return () => {
|
||||
if (untracked) return;
|
||||
untracked = true;
|
||||
release();
|
||||
trackedNodes.delete(element);
|
||||
onScreenNodes.delete(element);
|
||||
if (playingNodes.delete(element)) {
|
||||
startNextWaitingPlayback();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user