Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/studio/src/hooks/useRenderClipContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -229,16 +229,15 @@ describe("useRenderClipContent", () => {
projectId: string;
sessionEpoch: number;
priority: string;
rich: boolean;
}>(content),
).toBe(true);
if (isValidElement(content)) {
expect(content.props).toMatchObject({
projectId: "my-project",
sessionEpoch: 7,
priority: "interaction",
rich: true,
});
expect(content.props).not.toHaveProperty("rich");
}
});

Expand Down
1 change: 0 additions & 1 deletion packages/studio/src/hooks/useRenderClipContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,6 @@ export function useRenderClipContent({
projectId: pid,
sessionEpoch,
priority: context.priority,
rich: context.rich,
});
}

Expand Down
24 changes: 12 additions & 12 deletions packages/studio/src/player/components/VideoThumbnail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -47,40 +48,39 @@ async function render(rich = false) {
projectId="p"
sessionEpoch={1}
priority="visible"
rich={rich}
/>,
);
await Promise.resolve();
});
}

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,
});

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);
Expand Down
118 changes: 91 additions & 27 deletions packages/studio/src/player/components/VideoThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,7 +23,66 @@ interface VideoThumbnailProps {
projectId?: string;
sessionEpoch?: number;
priority?: ThumbnailPriority;
rich?: boolean;
}

function createVideoThumbnailRequest(
props: Pick<
VideoThumbnailProps,
| "videoSrc"
| "sourceStart"
| "sourceRangeDuration"
| "duration"
| "projectId"
| "sessionEpoch"
| "priority"
>,
frameCount: number,
rich: boolean,
) {
const {
videoSrc,
sourceStart,
sourceRangeDuration,
duration = 5,
projectId = videoSrc,
sessionEpoch = 0,
priority = "visible",
} = 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. */
Expand All @@ -29,39 +96,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 posterRequest = useMemo(
() => createVideoThumbnailRequest(requestProps, 1, false),
[requestProps],
);
const richRequest = useMemo(
() => createVideoThumbnailRequest(requestProps, requestFrameCount, true),
[requestFrameCount, requestProps],
);
const snapshot = useThumbnailLease(request);
const measured = containerWidth > 0;
const posterSnapshot = useThumbnailLease(measured ? posterRequest : null);
const richSnapshot = useThumbnailLease(measured ? 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] : [];
Expand Down
13 changes: 13 additions & 0 deletions packages/studio/src/player/components/thumbnailUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
computeThumbnailStrip,
encodePreviewPath,
resolveMediaPreviewUrl,
quantizeThumbnailFrameCount,
THUMBNAIL_CLIP_HEIGHT,
} from "./thumbnailUtils";

Expand All @@ -19,6 +20,10 @@ describe("computeThumbnailStrip", () => {
expect(frameCount * frameW).toBeGreaterThanOrEqual(500);
});

it("caps rendered tiles at the shared visible-frame budget", () => {
expect(computeThumbnailStrip(10_000, 1).frameCount).toBe(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);
Expand Down Expand Up @@ -50,6 +55,14 @@ 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);
});
});

describe("resolveMediaPreviewUrl", () => {
it("reroutes same-origin root media resolved by the preview iframe", () => {
expect(
Expand Down
12 changes: 11 additions & 1 deletion packages/studio/src/player/components/thumbnailUtils.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -68,7 +75,10 @@ export function computeThumbnailStrip(
): ThumbnailStripLayout {
const safeAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 16 / 9;
const frameW = Math.max(minFrameWidth, Math.round(clipHeight * safeAspect));
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
const frameCount =
containerWidth > 0
? Math.min(MAX_VISIBLE_THUMBNAIL_FRAMES, Math.max(1, Math.ceil(containerWidth / frameW)))
: 1;
return { frameW, frameCount };
}

Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/player/lib/timelineViewportBudgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
Loading