feat(studio): render geometry-sized video filmstrips (#4234)

* feat(studio): render geometry-sized video filmstrips

* fix(studio): keep poster tier during timeline scroll

* fix(studio): cap rendered thumbnail tiles

* chore(studio): refresh filmstrip checks

* chore(studio): refresh filmstrip captures

* chore(studio): refresh four-times captures

* fix(studio): stop capping filmstrip render tiles at 33

computeThumbnailStrip no longer clamps frameCount; the decode-side cap already lives in quantizeThumbnailFrameCount. VideoThumbnail only acquires the rich filmstrip lease when requestFrameCount > 1, so a narrow clip issues one decode job instead of two identical ones. Deletes the unreachable duplicate defaults in createVideoThumbnailRequest.

* style(studio): format filmstrip changes
This commit is contained in:
Miguel Ángel
2026-09-22 06:45:11 -04:00
committed by GitHub
parent 658d9afa30
commit 5ca2e78383
7 changed files with 134 additions and 44 deletions
@@ -208,7 +208,7 @@ describe("useRenderClipContent", () => {
}
});
it("forwards the viewport priority and interaction detail to media work", () => {
it("forwards the viewport priority to video media work", () => {
usePlayerStore.setState({ thumbnailMode: "adaptive", timelineSessionEpoch: 7 });
const content = renderClipContent(
@@ -229,7 +229,6 @@ describe("useRenderClipContent", () => {
projectId: string;
sessionEpoch: number;
priority: string;
rich: boolean;
}>(content),
).toBe(true);
if (isValidElement(content)) {
@@ -237,8 +236,8 @@ describe("useRenderClipContent", () => {
projectId: "my-project",
sessionEpoch: 7,
priority: "interaction",
rich: true,
});
expect(content.props).not.toHaveProperty("rich");
}
});
@@ -220,7 +220,6 @@ export function useRenderClipContent({
projectId: pid,
sessionEpoch,
priority: context.priority,
rich: context.rich,
});
}
@@ -36,7 +36,8 @@ afterEach(() => {
document.body.innerHTML = "";
});
async function render(rich = false) {
async function render(width = 0) {
Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
root = createRoot(host);
await act(async () => {
root!.render(
@@ -47,7 +48,6 @@ async function render(rich = false) {
projectId="p"
sessionEpoch={1}
priority="visible"
rich={rich}
/>,
);
await Promise.resolve();
@@ -55,7 +55,7 @@ async function render(rich = false) {
}
describe("VideoThumbnail", () => {
it("renders a scheduler-provided sparse poster", async () => {
it("does not acquire a thumbnail lease before the clip is measured", async () => {
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
value: { kind: "image", url: "blob:poster", aspect: 16 / 9 },
weight: 128,
@@ -63,29 +63,40 @@ describe("VideoThumbnail", () => {
await render();
expect(decodeVideoThumbnail).toHaveBeenCalledWith(
expect.objectContaining({ frameCount: 1 }),
expect.any(AbortSignal),
);
expect(host.querySelector('img[src="blob:poster"]')).not.toBeNull();
expect(host.querySelector(".animate-pulse")).toBeNull();
expect(decodeVideoThumbnail).not.toHaveBeenCalled();
});
it("requests a rich filmstrip only for interaction actors", async () => {
it("requests a geometry-sized filmstrip by default", async () => {
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
weight: 256,
});
await render(true);
await render(500);
expect(decodeVideoThumbnail).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ frameCount: 1 }),
expect.any(AbortSignal),
);
expect(decodeVideoThumbnail).toHaveBeenCalledWith(
expect.objectContaining({ frameCount: 6 }),
expect.objectContaining({ frameCount: 8 }),
expect.any(AbortSignal),
);
expect(host.querySelectorAll("img").length).toBeGreaterThan(0);
});
it("issues a single decode job for a narrow clip", async () => {
vi.mocked(decodeVideoThumbnail).mockResolvedValue({
value: { kind: "image", url: "blob:poster", aspect: 16 / 9 },
weight: 128,
});
await render(100);
expect(decodeVideoThumbnail).toHaveBeenCalledTimes(1);
});
it("clears the loading shimmer when the scheduled decode fails", async () => {
vi.mocked(decodeVideoThumbnail).mockRejectedValue(new Error("decode failed"));
@@ -1,9 +1,17 @@
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
import {
createThumbnailKey,
type ThumbnailPriority,
type ThumbnailSnapshot,
} from "../lib/thumbnailScheduler";
import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
import { computeThumbnailStrip, THUMBNAIL_CLIP_HEIGHT } from "./thumbnailUtils";
import {
computeThumbnailStrip,
quantizeThumbnailFrameCount,
THUMBNAIL_CLIP_HEIGHT,
} from "./thumbnailUtils";
interface VideoThumbnailProps {
videoSrc: string;
@@ -15,7 +23,58 @@ interface VideoThumbnailProps {
projectId?: string;
sessionEpoch?: number;
priority?: ThumbnailPriority;
rich?: boolean;
}
function createVideoThumbnailRequest(
props: Pick<VideoThumbnailProps, "videoSrc" | "sourceStart" | "sourceRangeDuration"> &
Required<Pick<VideoThumbnailProps, "duration" | "projectId" | "sessionEpoch" | "priority">>,
frameCount: number,
rich: boolean,
) {
const {
videoSrc,
sourceStart,
sourceRangeDuration,
duration,
projectId,
sessionEpoch,
priority,
} = props;
return {
key: createThumbnailKey({
kind: "video",
source: videoSrc,
start: sourceStart,
duration: sourceRangeDuration ?? duration,
frames: frameCount,
}),
projectId,
sessionEpoch,
kind: "video" as const,
priority,
rich,
load: (signal: AbortSignal) =>
decodeVideoThumbnail(
{
source: videoSrc,
sourceStart,
sourceRangeDuration: sourceRangeDuration ?? duration,
frameCount,
fit: "cover",
},
signal,
),
};
}
function selectThumbnailSnapshot(
poster: ThumbnailSnapshot,
rich: ThumbnailSnapshot,
): ThumbnailSnapshot {
if (rich.status === "ready") return rich;
if (poster.status === "ready") return poster;
if (rich.status === "loading" || poster.status === "loading") return { status: "loading" };
return poster;
}
/** Sparse, bounded video frames supplied by the shared thumbnail scheduler. */
@@ -29,39 +88,36 @@ export const VideoThumbnail = memo(function VideoThumbnail({
projectId = videoSrc,
sessionEpoch = 0,
priority = "visible",
rich = false,
}: VideoThumbnailProps) {
const [containerWidth, setContainerWidth] = useState(0);
const observerRef = useRef<ResizeObserver | null>(null);
const request = useMemo(
const requestFrameCount = quantizeThumbnailFrameCount(
computeThumbnailStrip(containerWidth, 16 / 9).frameCount,
);
const requestProps = useMemo(
() => ({
key: createThumbnailKey({
kind: "video",
source: videoSrc,
start: sourceStart,
duration: sourceRangeDuration ?? duration,
frames: rich ? 6 : 1,
}),
videoSrc,
sourceStart,
sourceRangeDuration,
duration,
projectId,
sessionEpoch,
kind: "video" as const,
priority,
rich,
load: (signal: AbortSignal) =>
decodeVideoThumbnail(
{
source: videoSrc,
sourceStart,
sourceRangeDuration: sourceRangeDuration ?? duration,
frameCount: rich ? 6 : 1,
fit: "cover",
},
signal,
),
}),
[duration, priority, projectId, rich, sessionEpoch, sourceRangeDuration, sourceStart, videoSrc],
[duration, priority, projectId, sessionEpoch, sourceRangeDuration, sourceStart, videoSrc],
);
const snapshot = useThumbnailLease(request);
const posterRequest = useMemo(
() => createVideoThumbnailRequest(requestProps, 1, false),
[requestProps],
);
const richRequest = useMemo(
() => createVideoThumbnailRequest(requestProps, requestFrameCount, true),
[requestFrameCount, requestProps],
);
const measured = containerWidth > 0;
const posterSnapshot = useThumbnailLease(measured ? posterRequest : null);
const richSnapshot = useThumbnailLease(measured && requestFrameCount > 1 ? richRequest : null);
const snapshot = selectThumbnailSnapshot(posterSnapshot, richSnapshot);
const value = snapshot.status === "ready" ? snapshot.value : null;
const urls =
value?.kind === "filmstrip" ? value.urls : value?.kind === "image" ? [value.url] : [];
@@ -3,6 +3,7 @@ import {
computeThumbnailStrip,
encodePreviewPath,
resolveMediaPreviewUrl,
quantizeThumbnailFrameCount,
THUMBNAIL_CLIP_HEIGHT,
} from "./thumbnailUtils";
@@ -19,6 +20,10 @@ describe("computeThumbnailStrip", () => {
expect(frameCount * frameW).toBeGreaterThanOrEqual(500);
});
it("paints tiles across the full clip past the shared visible-frame budget", () => {
expect(computeThumbnailStrip(14_400, 16 / 9).frameCount).toBeGreaterThan(33);
});
it("returns one tile when the container width is unknown", () => {
expect(computeThumbnailStrip(0, 16 / 9).frameCount).toBe(1);
expect(computeThumbnailStrip(-10, 16 / 9).frameCount).toBe(1);
@@ -50,6 +55,18 @@ describe("computeThumbnailStrip", () => {
});
});
describe("quantizeThumbnailFrameCount", () => {
it("uses doubling buckets and never exceeds the 4K geometry ceiling", () => {
expect(quantizeThumbnailFrameCount(5)).toBe(8);
expect(quantizeThumbnailFrameCount(32)).toBe(32);
expect(quantizeThumbnailFrameCount(34)).toBe(33);
});
it("caps decode requests at the shared visible-frame budget", () => {
expect(quantizeThumbnailFrameCount(124)).toBe(33);
});
});
describe("resolveMediaPreviewUrl", () => {
it("reroutes same-origin root media resolved by the preview iframe", () => {
expect(
@@ -1,4 +1,5 @@
import { buildProjectApiPath } from "../../utils/projectRouting";
import { MAX_VISIBLE_THUMBNAIL_FRAMES } from "../lib/timelineViewportBudgets";
/** Rendered height of a timeline-clip thumbnail strip, in CSS px. */
export const THUMBNAIL_CLIP_HEIGHT = 66;
@@ -9,6 +10,12 @@ export interface ThumbnailStripLayout {
frameCount: number;
}
/** Quantize request identities so a pixel-by-pixel resize does not thrash the cache. */
export function quantizeThumbnailFrameCount(frameCount: number): number {
const safeCount = Math.max(1, Number.isFinite(frameCount) ? Math.ceil(frameCount) : 1);
return Math.min(MAX_VISIBLE_THUMBNAIL_FRAMES, 2 ** Math.ceil(Math.log2(safeCount)));
}
/**
* Measure an image without mounting it in React's DOM. The scheduler owns the
* abort signal, so an unmounted clip cannot leave Blink retaining a pending
@@ -49,6 +49,7 @@ export interface TimelineViewportBudgets {
const MEBIBYTE = 1024 * 1024;
const DAY_MS = 24 * 60 * 60 * 1000;
export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); // 4K timeline width / tallest 16:9 tile.
/**
* The sole default budget owner for timeline viewport and media virtualization.
@@ -66,7 +67,7 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Obje
posterMaxPhysicalWidth: 240,
posterMaxPhysicalHeight: 135,
posterDprCap: 1.5,
richPreviewFrameCount: 6,
richPreviewFrameCount: MAX_VISIBLE_THUMBNAIL_FRAMES,
concurrentVideoDecodes: 2,
concurrentMetadataJobs: 4,
concurrentCompositionFetches: 2,