mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user