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

@@ -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;
}
@@ -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;
}