perf(studio-server): coordinate cancelable thumbnail generation (#2720)

* perf(studio): schedule adaptive timeline thumbnails

* perf(studio): bound thumbnail decoding resources

* perf(studio): virtualize timeline thumbnail media

* perf(studio): prioritize timeline thumbnail work

* perf(studio-server): coordinate cancelable thumbnail generation

---------

Co-authored-by: Codex <codex@local>
This commit is contained in:
Miguel Ángel
2026-08-05 20:41:35 -07:00
committed by GitHub
co-authored by Codex
parent 27d113e56c
commit 96861cbafc
59 changed files with 3537 additions and 879 deletions
@@ -0,0 +1,81 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getMediaProbeDiagnostics, probeMediaUrl, resetMediaProbeRegistry } from "./mediaProbe";
const dispose = vi.fn();
const getDurationFromMetadata = vi.fn(async () => 5);
vi.mock("mediabunny", () => ({
ALL_FORMATS: {},
UrlSource: class {
constructor(readonly url: string) {}
},
Input: class {
getDurationFromMetadata = getDurationFromMetadata;
getPrimaryVideoTrack = vi.fn(async () => ({ displayWidth: 640, displayHeight: 360 }));
getAudioTracks = vi.fn(async () => []);
dispose = dispose;
},
}));
beforeEach(() => {
resetMediaProbeRegistry();
vi.clearAllMocks();
getDurationFromMetadata.mockResolvedValue(5);
});
afterEach(() => {
vi.useRealTimers();
});
describe("media probe registry", () => {
it("deduplicates and caches successful probes", async () => {
const [first, second] = await Promise.all([
probeMediaUrl("/video.mp4"),
probeMediaUrl("/video.mp4"),
]);
expect(first).toEqual(second);
expect(getDurationFromMetadata).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect(getMediaProbeDiagnostics()).toEqual({ cached: 1, failed: 0, inflight: 0 });
});
it("bounds retained successes to the configured registry count", async () => {
for (let index = 0; index < 513; index++) {
await probeMediaUrl(`/video-${index}.mp4`);
}
expect(getMediaProbeDiagnostics().cached).toBe(512);
});
it("limits concurrent metadata probes and drains the queue", async () => {
const resolvers: Array<(duration: number) => void> = [];
getDurationFromMetadata.mockImplementation(
() => new Promise<number>((resolve) => resolvers.push(resolve)),
);
const probes = Array.from({ length: 5 }, (_, index) => probeMediaUrl(`/queued-${index}.mp4`));
await Promise.resolve();
await Promise.resolve();
expect(getDurationFromMetadata).toHaveBeenCalledTimes(4);
resolvers[0]?.(5);
await vi.waitFor(() => expect(getDurationFromMetadata).toHaveBeenCalledTimes(5));
for (const resolve of resolvers.slice(1)) resolve(5);
await expect(Promise.all(probes)).resolves.toHaveLength(5);
expect(getMediaProbeDiagnostics()).toEqual({ cached: 5, failed: 0, inflight: 0 });
});
it("retries failures only after the failure TTL", async () => {
vi.useFakeTimers();
getDurationFromMetadata.mockRejectedValue(new Error("bad source"));
await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull();
await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull();
expect(getDurationFromMetadata).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(30_001);
await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull();
expect(getDurationFromMetadata).toHaveBeenCalledTimes(2);
});
});
+94 -14
View File
@@ -1,4 +1,6 @@
interface MediaProbeResult {
import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";
export interface MediaProbeResult {
duration: number;
width?: number;
height?: number;
@@ -6,11 +8,24 @@ interface MediaProbeResult {
hasAudio: boolean;
}
const cache = new Map<string, MediaProbeResult>();
interface CachedProbe {
result: MediaProbeResult;
lastAccess: number;
}
const cache = new Map<string, CachedProbe>();
const inflight = new Map<string, Promise<MediaProbeResult | null>>();
// URLs whose probe failed (CORS, 404, non-media). Remembered so the rAF-driven
// timeline re-derive doesn't re-fetch them every frame and flood the console.
const failed = new Set<string>();
const failed = new Map<string, { failedAt: number; lastAccess: number }>();
let accessSequence = 0;
let activeProbes = 0;
let registryEpoch = 0;
const probeQueue: Array<{
key: string;
epoch: number;
resolve: (result: MediaProbeResult | null) => void;
}> = [];
let mediabunnyModule: typeof import("mediabunny") | null | false = null;
@@ -65,7 +80,22 @@ async function probeOne(url: string): Promise<MediaProbeResult | null> {
}
function getCachedProbe(url: string): MediaProbeResult | undefined {
return cache.get(normalizeUrl(url));
const cached = cache.get(normalizeUrl(url));
if (cached) cached.lastAccess = ++accessSequence;
return cached?.result;
}
function evictMetadataOverflow(): void {
const overflow = cache.size + failed.size - TIMELINE_VIEWPORT_BUDGETS.metadataRegistryEntries;
if (overflow <= 0) return;
const entries = [
...Array.from(cache, ([key, value]) => ({ key, at: value.lastAccess, failed: false })),
...Array.from(failed, ([key, value]) => ({ key, at: value.lastAccess, failed: true })),
].sort((left, right) => left.at - right.at);
for (const entry of entries.slice(0, overflow)) {
if (entry.failed) failed.delete(entry.key);
else cache.delete(entry.key);
}
}
/**
@@ -101,32 +131,82 @@ export async function probeMissingSourceDurations<
el.sourceDuration == null &&
["video", "audio"].includes(el.tag.toLowerCase()) &&
!getCachedProbe(el.src) &&
!failed.has(normalizeUrl(el.src)),
!hasFreshFailure(normalizeUrl(el.src)),
);
if (needs.length === 0) return;
await Promise.allSettled(
needs.map(async (el) => {
const result = await probeMediaUrl(el.src!);
const source = el.src;
if (!source) return;
const result = await probeMediaUrl(source);
if (result) apply(el.key ?? el.id, result.duration);
}),
);
}
async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
function hasFreshFailure(key: string): boolean {
const failedAt = failed.get(key);
if (failedAt === undefined) return false;
if (Date.now() - failedAt.failedAt < TIMELINE_VIEWPORT_BUDGETS.metadataFailureTtlMs) {
failedAt.lastAccess = ++accessSequence;
return true;
}
failed.delete(key);
return false;
}
export async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
const key = normalizeUrl(url);
const cached = cache.get(key);
const cached = getCachedProbe(key);
if (cached) return cached;
if (failed.has(key)) return null;
if (hasFreshFailure(key)) return null;
let pending = inflight.get(key);
if (pending) return pending;
pending = probeOne(key).then((result) => {
inflight.delete(key);
if (result) cache.set(key, result);
else failed.add(key);
return result;
pending = new Promise<MediaProbeResult | null>((resolve) => {
probeQueue.push({ key, epoch: registryEpoch, resolve });
pumpProbeQueue();
});
inflight.set(key, pending);
return pending;
}
function pumpProbeQueue(): void {
while (activeProbes < TIMELINE_VIEWPORT_BUDGETS.concurrentMetadataJobs) {
const queued = probeQueue.shift();
if (!queued) return;
if (queued.epoch !== registryEpoch) {
queued.resolve(null);
continue;
}
activeProbes++;
void probeOne(queued.key)
.then((result) => {
if (queued.epoch !== registryEpoch) return null;
inflight.delete(queued.key);
if (result) cache.set(queued.key, { result, lastAccess: ++accessSequence });
else failed.set(queued.key, { failedAt: Date.now(), lastAccess: ++accessSequence });
evictMetadataOverflow();
return result;
})
.then(queued.resolve)
.finally(() => {
activeProbes--;
pumpProbeQueue();
});
}
}
export function getMediaProbeDiagnostics() {
return { cached: cache.size, failed: failed.size, inflight: inflight.size };
}
export function resetMediaProbeRegistry(): void {
registryEpoch++;
for (const queued of probeQueue.splice(0)) queued.resolve(null);
cache.clear();
failed.clear();
inflight.clear();
accessSequence = 0;
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { defaultThumbnailMode, effectiveThumbnailMode } from "./thumbnailPolicy";
describe("thumbnail runtime policy", () => {
it("defaults missing preferences adaptively after activation", () => {
expect(defaultThumbnailMode(undefined, "follow-preference")).toBe("adaptive");
expect(defaultThumbnailMode(undefined, "legacy-default")).toBe("hidden");
});
it("forces the safe renderer without overwriting user intent", () => {
expect(effectiveThumbnailMode("adaptive", "force-hidden")).toBe("hidden");
expect(effectiveThumbnailMode("adaptive", "follow-preference")).toBe("adaptive");
});
});
@@ -0,0 +1,24 @@
export type ThumbnailMode = "adaptive" | "hidden";
export type ThumbnailRuntimePolicy = "follow-preference" | "force-hidden" | "legacy-default";
// "Adaptive" currently means the scheduler pauses rich work while scrolling.
// The mode name leaves room for finer-grained runtime heuristics later.
const rawPolicy = import.meta.env.VITE_STUDIO_TIMELINE_THUMBNAIL_POLICY;
const studioThumbnailRuntimePolicy: ThumbnailRuntimePolicy =
rawPolicy === "force-hidden" || rawPolicy === "legacy-default" ? rawPolicy : "follow-preference";
export function defaultThumbnailMode(
storedMode: ThumbnailMode | undefined,
policy: ThumbnailRuntimePolicy = studioThumbnailRuntimePolicy,
): ThumbnailMode {
return storedMode ?? (policy === "legacy-default" ? "hidden" : "adaptive");
}
export function effectiveThumbnailMode(
preferredMode: ThumbnailMode,
policy: ThumbnailRuntimePolicy = studioThumbnailRuntimePolicy,
): ThumbnailMode {
return policy === "force-hidden" ? "hidden" : preferredMode;
}
@@ -0,0 +1,388 @@
import { describe, expect, it, vi } from "vitest";
import { resolveTimelineViewportBudgets } from "./timelineViewportBudgets";
import {
createThumbnailKey,
createThumbnailRequestIdentity,
ThumbnailScheduler,
type ThumbnailLoadedResult,
type ThumbnailPriority,
type ThumbnailRequest,
} from "./thumbnailScheduler";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((accept, fail) => {
resolve = accept;
reject = fail;
});
return { promise, resolve, reject };
}
function result(name: string, weight = 1, dispose = vi.fn()): ThumbnailLoadedResult {
return { value: { kind: "image", url: name, aspect: 1 }, weight, dispose };
}
function request(
key: string,
load: ThumbnailRequest["load"],
priority: ThumbnailPriority = "visible",
overrides: Partial<ThumbnailRequest> = {},
): ThumbnailRequest {
return {
key,
projectId: "project-a",
sessionEpoch: 1,
kind: "image",
priority,
load,
...overrides,
};
}
async function flush(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("ThumbnailScheduler", () => {
it("deduplicates identical requests and releases subscribers independently", async () => {
const scheduler = new ThumbnailScheduler();
const pending = deferred<ThumbnailLoadedResult>();
const load = vi.fn(() => pending.promise);
const listenerA = vi.fn();
const listenerB = vi.fn();
const leaseA = scheduler.acquire(request("same", load), listenerA);
const leaseB = scheduler.acquire(request("same", load), listenerB);
expect(load).toHaveBeenCalledTimes(1);
leaseA.release();
listenerA.mockClear();
pending.resolve(result("poster"));
await flush();
expect(scheduler.getSnapshot(request("same", load))).toMatchObject({
status: "ready",
value: { url: "poster" },
});
expect(listenerA).not.toHaveBeenCalled();
expect(listenerB).toHaveBeenCalled();
leaseB.release();
expect(scheduler.getDiagnostics().leases).toBe(0);
});
it("keeps snapshots referentially stable and supports independent leases sharing a callback", async () => {
const scheduler = new ThumbnailScheduler();
const listener = vi.fn();
const shared = request("stable", async () => result("stable"));
const first = scheduler.acquire(shared, listener);
const second = scheduler.acquire(shared, listener);
await flush();
expect(scheduler.getSnapshot(shared)).toBe(scheduler.getSnapshot(shared));
listener.mockClear();
scheduler.invalidateProject("project-a");
expect(listener).not.toHaveBeenCalled();
expect(scheduler.getDiagnostics().leases).toBe(2);
first.release();
second.release();
scheduler.invalidateProject("project-a");
expect(scheduler.getSnapshot(shared).status).toBe("idle");
});
it("scopes the same caller key by work shape", async () => {
const scheduler = new ThumbnailScheduler();
const poster = request("shared", async () => result("poster"));
const strip = request("shared", async () => result("strip"), "visible", {
kind: "video",
rich: true,
});
scheduler.acquire(poster, vi.fn());
scheduler.acquire(strip, vi.fn());
await flush();
expect(scheduler.getSnapshot(poster)).toMatchObject({
status: "ready",
value: { url: "poster" },
});
expect(scheduler.getSnapshot(strip)).toMatchObject({
status: "ready",
value: { url: "strip" },
});
});
it("orders interaction before visible before overscan when a slot opens", async () => {
const scheduler = new ThumbnailScheduler(
resolveTimelineViewportBudgets({ concurrentMetadataJobs: 1 }),
);
const first = deferred<ThumbnailLoadedResult>();
const starts: string[] = [];
const load = (name: string, pending?: ReturnType<typeof deferred<ThumbnailLoadedResult>>) =>
vi.fn(() => {
starts.push(name);
return pending?.promise ?? Promise.resolve(result(name));
});
scheduler.acquire(request("first", load("first", first)), vi.fn());
scheduler.acquire(request("overscan", load("overscan"), "overscan"), vi.fn());
scheduler.acquire(request("visible", load("visible"), "visible"), vi.fn());
scheduler.acquire(request("interaction", load("interaction"), "interaction"), vi.fn());
expect(starts).toEqual(["first"]);
first.resolve(result("first"));
await vi.waitFor(() => {
expect(starts).toEqual(["first", "interaction", "visible", "overscan"]);
});
});
it("suspends new rich work while scrolling but lets posters proceed", async () => {
const scheduler = new ThumbnailScheduler();
const richLoad = vi.fn(async () => result("rich"));
const posterLoad = vi.fn(async () => result("poster"));
scheduler.setScrolling(true);
scheduler.acquire(request("rich", richLoad, "interaction", { rich: true }), vi.fn());
scheduler.acquire(request("poster", posterLoad), vi.fn());
await flush();
expect(richLoad).not.toHaveBeenCalled();
expect(posterLoad).toHaveBeenCalledTimes(1);
scheduler.setScrolling(false);
await flush();
expect(richLoad).toHaveBeenCalledTimes(1);
});
it("aborts queued and active jobs after the final release", async () => {
const scheduler = new ThumbnailScheduler(
resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }),
);
const active = deferred<ThumbnailLoadedResult>();
let activeSignal: AbortSignal | undefined;
const activeLease = scheduler.acquire(
request(
"active",
(signal) => {
activeSignal = signal;
return active.promise;
},
"visible",
{ kind: "video" },
),
vi.fn(),
);
const queuedLoad = vi.fn(async () => result("queued"));
const queuedLease = scheduler.acquire(
request("queued", queuedLoad, "visible", { kind: "video" }),
vi.fn(),
);
queuedLease.release();
activeLease.release();
expect(activeSignal?.aborted).toBe(true);
expect(queuedLoad).not.toHaveBeenCalled();
active.reject(new DOMException("aborted", "AbortError"));
await flush();
expect(scheduler.getDiagnostics()).toMatchObject({ queued: 0, active: 0, leases: 0 });
});
it("disposes a late result exactly once after its final lease releases", async () => {
const scheduler = new ThumbnailScheduler();
const pending = deferred<ThumbnailLoadedResult>();
const dispose = vi.fn();
const lease = scheduler.acquire(
request("late", () => pending.promise),
vi.fn(),
);
lease.release();
pending.resolve(result("late", 1, dispose));
await flush();
expect(dispose).toHaveBeenCalledTimes(1);
expect(scheduler.getSnapshot(request("late", () => pending.promise))).toEqual({
status: "idle",
});
});
it("preserves a synchronous re-acquire when an expired failure is replaced", async () => {
vi.useFakeTimers();
const scheduler = new ThumbnailScheduler(
resolveTimelineViewportBudgets({ metadataFailureTtlMs: 10 }),
);
const load = vi
.fn<ThumbnailRequest["load"]>()
.mockRejectedValueOnce(new Error("temporary"))
.mockResolvedValue(result("recovered"));
const failed = request("retry", load);
let reacquireOnNotify = false;
let nestedLease: ReturnType<ThumbnailScheduler["acquire"]> | undefined;
const firstLease = scheduler.acquire(failed, () => {
if (!reacquireOnNotify) return;
reacquireOnNotify = false;
nestedLease = scheduler.acquire(failed, vi.fn());
});
await flush();
vi.advanceTimersByTime(11);
reacquireOnNotify = true;
const outerLease = scheduler.acquire(failed, vi.fn());
await flush();
expect(load).toHaveBeenCalledTimes(2);
expect(scheduler.getSnapshot(failed)).toMatchObject({
status: "ready",
value: { url: "recovered" },
});
expect(scheduler.getDiagnostics().leases).toBe(2);
firstLease.release();
nestedLease?.release();
outerLease.release();
vi.useRealTimers();
});
it("times out a hung loader, frees its bucket, and disposes a late result", async () => {
vi.useFakeTimers();
const scheduler = new ThumbnailScheduler(
resolveTimelineViewportBudgets({
concurrentVideoDecodes: 1,
thumbnailLoadTimeoutMs: 10,
}),
);
const hung = deferred<ThumbnailLoadedResult>();
const dispose = vi.fn();
let signal: AbortSignal | undefined;
const hungRequest = request(
"hung",
(activeSignal) => {
signal = activeSignal;
return hung.promise;
},
"visible",
{ kind: "video" },
);
const nextLoad = vi.fn(async () => result("next"));
const nextRequest = request("next", nextLoad, "visible", { kind: "video" });
const hungLease = scheduler.acquire(hungRequest, vi.fn());
const nextLease = scheduler.acquire(nextRequest, vi.fn());
await vi.advanceTimersByTimeAsync(11);
await flush();
expect(signal?.aborted).toBe(true);
expect(nextLoad).toHaveBeenCalledTimes(1);
expect(scheduler.getSnapshot(hungRequest).status).toBe("error");
hung.resolve(result("late", 1, dispose));
await flush();
expect(dispose).toHaveBeenCalledTimes(1);
hungLease.release();
nextLease.release();
vi.useRealTimers();
});
it("isolates late results by session epoch", async () => {
const scheduler = new ThumbnailScheduler();
const old = deferred<ThumbnailLoadedResult>();
const oldRequest = request("poster", () => old.promise, "visible", { sessionEpoch: 1 });
const nextRequest = request("poster", async () => result("next"), "visible", {
sessionEpoch: 2,
});
scheduler.acquire(oldRequest, vi.fn());
scheduler.acquire(nextRequest, vi.fn());
await flush();
old.resolve(result("old"));
await flush();
expect(scheduler.getSnapshot(nextRequest)).toMatchObject({
status: "ready",
value: { url: "next" },
});
});
it("turns synchronous loader errors into bounded failure snapshots", async () => {
const scheduler = new ThumbnailScheduler();
const bad = request("throws", () => {
throw new Error("sync failure");
});
const lease = scheduler.acquire(bad, vi.fn());
await flush();
expect(scheduler.getSnapshot(bad)).toMatchObject({ status: "error" });
expect(scheduler.getDiagnostics().active).toBe(0);
lease.release();
});
it("disposes uncached results after the final lease and tolerates broken disposers", async () => {
const scheduler = new ThumbnailScheduler(
resolveTimelineViewportBudgets({ thumbnailCacheBytes: 1 }),
);
const dispose = vi.fn(() => {
throw new Error("cleanup failure");
});
const oversized = request("oversized", async () => result("large", 2, dispose));
const lease = scheduler.acquire(oversized, vi.fn());
await flush();
expect(scheduler.getSnapshot(oversized).status).toBe("ready");
expect(() => lease.release()).not.toThrow();
expect(dispose).toHaveBeenCalledTimes(1);
expect(scheduler.getSnapshot(oversized).status).toBe("idle");
});
it("evicts least-recently-used unleased values by bytes, count, and project count", async () => {
const scheduler = new ThumbnailScheduler(
resolveTimelineViewportBudgets({
thumbnailCacheBytes: 6,
thumbnailCacheEntries: 2,
thumbnailCacheEntriesPerProject: 2,
}),
);
const disposals = [vi.fn(), vi.fn(), vi.fn()];
for (let index = 0; index < 3; index++) {
const lease = scheduler.acquire(
request(`key-${index}`, async () => result(`url-${index}`, 3, disposals[index])),
vi.fn(),
);
await flush();
lease.release();
}
expect(scheduler.getDiagnostics()).toMatchObject({ cacheEntries: 2, cacheBytes: 6 });
expect(disposals[0]).toHaveBeenCalledTimes(1);
expect(disposals[1]).not.toHaveBeenCalled();
expect(disposals[2]).not.toHaveBeenCalled();
});
it("keeps failed requests quiet until the TTL expires", async () => {
vi.useFakeTimers();
const scheduler = new ThumbnailScheduler(
resolveTimelineViewportBudgets({ metadataFailureTtlMs: 100 }),
);
const load = vi.fn(async () => {
throw new Error("unsupported");
});
const first = scheduler.acquire(request("failed", load), vi.fn());
await flush();
first.release();
const second = scheduler.acquire(request("failed", load), vi.fn());
expect(load).toHaveBeenCalledTimes(1);
second.release();
vi.advanceTimersByTime(101);
scheduler.acquire(request("failed", load), vi.fn());
await flush();
expect(load).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
});
describe("createThumbnailKey", () => {
it("normalizes field order and preserves sentinel zero values", () => {
expect(createThumbnailKey({ source: "clip a", time: 0, missing: undefined })).toBe(
createThumbnailKey({ time: 0, source: "clip a" }),
);
});
it("includes the work shape in request identity", () => {
const poster = request("shared", vi.fn(), "visible", { kind: "video" });
const strip = { ...poster, rich: true };
expect(createThumbnailRequestIdentity(poster)).not.toBe(createThumbnailRequestIdentity(strip));
});
});
@@ -0,0 +1,483 @@
import { TIMELINE_VIEWPORT_BUDGETS, type TimelineViewportBudgets } from "./timelineViewportBudgets";
export type ThumbnailPriority = "overscan" | "visible" | "interaction";
export type ThumbnailJobKind = "video" | "image" | "composition" | "waveform";
export type ThumbnailValue =
| { kind: "image"; url: string; aspect: number }
| { kind: "filmstrip"; urls: readonly string[]; aspect: number }
| { kind: "waveform"; peaks: readonly number[] };
export interface ThumbnailLoadedResult {
value: ThumbnailValue;
/** Estimated decoded/object-URL bytes retained by this result. */
weight: number;
dispose?: () => void;
}
export interface ThumbnailRequest {
key: string;
projectId: string;
sessionEpoch: number;
kind: ThumbnailJobKind;
priority: ThumbnailPriority;
/** Rich work is paused while the timeline is fast-scrolling. */
rich?: boolean;
load: (signal: AbortSignal) => Promise<ThumbnailLoadedResult>;
}
export type ThumbnailSnapshot =
| { status: "idle" | "queued" | "loading" }
| { status: "ready"; value: ThumbnailValue }
| { status: "error"; error: Error };
export interface ThumbnailLease {
updatePriority(priority: ThumbnailPriority): void;
release(): void;
}
export interface ThumbnailSchedulerDiagnostics {
queued: number;
active: number;
leases: number;
cacheEntries: number;
cacheBytes: number;
waveformCacheEntries: number;
waveformCacheBytes: number;
activeByKind: Readonly<Record<ThumbnailJobKind, number>>;
}
type EntryState = "queued" | "loading" | "ready" | "error";
interface ThumbnailEntry {
request: ThumbnailRequest;
scopedKey: string;
state: EntryState;
leases: Map<number, ThumbnailPriority>;
listeners: Map<number, () => void>;
controller: AbortController | null;
value?: ThumbnailValue;
error?: Error;
failedAt?: number;
weight: number;
dispose?: () => void;
disposed: boolean;
cached: boolean;
lastAccess: number;
snapshot: ThumbnailSnapshot;
}
const PRIORITY_SCORE: Readonly<Record<ThumbnailPriority, number>> = {
overscan: 0,
visible: 1,
interaction: 2,
};
const EMPTY_SNAPSHOT: ThumbnailSnapshot = Object.freeze({ status: "idle" });
function concurrencyBucket(kind: ThumbnailJobKind): "video" | "composition" | "general" {
if (kind === "video") return "video";
if (kind === "composition") return "composition";
return "general";
}
function errorFrom(reason: unknown): Error {
return reason instanceof Error ? reason : new Error(String(reason));
}
export function createThumbnailKey(parts: Readonly<Record<string, string | number | undefined>>) {
return Object.entries(parts)
.filter((entry): entry is [string, string | number] => entry[1] !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`)
.join("&");
}
export function createThumbnailRequestIdentity(
request: Pick<ThumbnailRequest, "key" | "projectId" | "sessionEpoch" | "kind" | "rich">,
) {
return createThumbnailKey({
project: request.projectId,
session: request.sessionEpoch,
request: request.key,
kind: request.kind,
rich: request.rich ? 1 : 0,
});
}
/** Sole client owner for thumbnail work, cached resources, and cleanup. */
export class ThumbnailScheduler {
private readonly entries = new Map<string, ThumbnailEntry>();
private readonly budgets: Readonly<TimelineViewportBudgets>;
private nextLeaseId = 1;
private nextSequence = 1;
private scrolling = false;
private cacheBytes = 0;
private waveformCacheBytes = 0;
private readonly activeByBucket = { video: 0, composition: 0, general: 0 };
private readonly activeByKind: Record<ThumbnailJobKind, number> = {
video: 0,
image: 0,
composition: 0,
waveform: 0,
};
private readonly now: () => number;
constructor(
budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
now: () => number = Date.now,
) {
this.budgets = budgets;
this.now = now;
}
acquire(request: ThumbnailRequest, listener: () => void): ThumbnailLease {
const scopedKey = createThumbnailRequestIdentity(request);
let entry = this.entries.get(scopedKey);
if (
entry?.state === "error" &&
entry.failedAt !== undefined &&
this.now() - entry.failedAt >= this.budgets.metadataFailureTtlMs
) {
this.deleteEntry(scopedKey, entry);
entry = this.entries.get(scopedKey);
}
if (!entry) {
entry = {
request,
scopedKey,
state: "queued",
leases: new Map(),
listeners: new Map(),
controller: null,
weight: 0,
disposed: false,
cached: false,
lastAccess: this.nextSequence++,
snapshot: Object.freeze({ status: "queued" }),
};
this.entries.set(scopedKey, entry);
}
const leaseId = this.nextLeaseId++;
entry.leases.set(leaseId, request.priority);
entry.listeners.set(leaseId, listener);
entry.lastAccess = this.nextSequence++;
this.pump();
let released = false;
return {
updatePriority: (priority) => {
const current = this.entries.get(scopedKey);
if (!current || !current.leases.has(leaseId)) return;
current.leases.set(leaseId, priority);
current.lastAccess = this.nextSequence++;
this.pump();
},
release: () => {
if (released) return;
released = true;
const current = this.entries.get(scopedKey);
if (!current) return;
current.leases.delete(leaseId);
current.listeners.delete(leaseId);
if (current.leases.size === 0) {
if (current.state === "queued") {
this.deleteEntry(scopedKey, current);
} else if (current.state === "loading") {
current.controller?.abort();
this.deleteEntry(scopedKey, current);
} else if (current.state === "ready" && !current.cached) {
this.deleteEntry(scopedKey, current);
}
}
this.evict();
},
};
}
getSnapshot(
request: Pick<ThumbnailRequest, "key" | "projectId" | "sessionEpoch" | "kind" | "rich">,
): ThumbnailSnapshot {
const entry = this.entries.get(createThumbnailRequestIdentity(request));
if (!entry) return EMPTY_SNAPSHOT;
return entry.snapshot;
}
setScrolling(scrolling: boolean): void {
if (this.scrolling === scrolling) return;
this.scrolling = scrolling;
if (!scrolling) this.pump();
}
/** Evict project cache entries that are no longer owned by mounted consumers. */
invalidateProject(projectId: string): void {
for (const [key, entry] of this.entries) {
if (entry.request.projectId !== projectId) continue;
if (entry.leases.size > 0) continue;
entry.controller?.abort();
this.deleteEntry(key, entry);
}
}
getDiagnostics(): ThumbnailSchedulerDiagnostics {
let queued = 0;
let active = 0;
let leases = 0;
let cacheEntries = 0;
let waveformCacheEntries = 0;
for (const entry of this.entries.values()) {
if (entry.state === "queued") queued++;
if (entry.state === "loading") active++;
if (entry.cached) {
if (entry.request.kind === "waveform") waveformCacheEntries++;
else cacheEntries++;
}
leases += entry.leases.size;
}
return {
queued,
active,
leases,
cacheEntries,
cacheBytes: this.cacheBytes,
waveformCacheEntries,
waveformCacheBytes: this.waveformCacheBytes,
activeByKind: { ...this.activeByKind },
};
}
private pump(): void {
const queued = Array.from(this.entries.values())
.filter((entry) => entry.state === "queued" && entry.leases.size > 0)
.sort((left, right) => {
const priorityDelta = this.entryPriority(right) - this.entryPriority(left);
return priorityDelta || left.lastAccess - right.lastAccess;
});
for (const entry of queued) {
if (this.scrolling && entry.request.rich) continue;
const bucket = concurrencyBucket(entry.request.kind);
if (this.activeByBucket[bucket] >= this.bucketLimit(bucket)) continue;
this.start(entry, bucket);
}
}
private start(entry: ThumbnailEntry, bucket: "video" | "composition" | "general"): void {
entry.state = "loading";
entry.snapshot = Object.freeze({ status: "loading" });
const controller = new AbortController();
entry.controller = controller;
this.activeByBucket[bucket]++;
this.activeByKind[entry.request.kind]++;
this.notify(entry);
const pending = this.loadWithTimeout(entry, controller);
void pending
.then((result) => this.acceptResult(entry, controller, result))
.catch((reason: unknown) => {
if (this.entries.get(entry.scopedKey) !== entry) return;
if (controller.signal.aborted && entry.leases.size === 0) {
this.deleteEntry(entry.scopedKey, entry);
return;
}
entry.state = "error";
entry.error = errorFrom(reason);
entry.failedAt = this.now();
entry.snapshot = Object.freeze({ status: "error", error: entry.error });
this.notify(entry);
this.evictFailures();
})
.finally(() => {
if (entry.controller === controller) entry.controller = null;
this.activeByBucket[bucket]--;
this.activeByKind[entry.request.kind]--;
this.pump();
});
}
private acceptResult(
entry: ThumbnailEntry,
controller: AbortController,
result: ThumbnailLoadedResult,
): void {
if (controller.signal.aborted || this.entries.get(entry.scopedKey) !== entry) {
this.safeDispose(result.dispose);
return;
}
this.validateResult(result);
entry.state = "ready";
entry.value = result.value;
entry.weight = result.weight;
entry.dispose = result.dispose;
this.cacheResult(entry);
entry.snapshot = Object.freeze({ status: "ready", value: result.value });
this.notify(entry);
this.evict();
if (!entry.cached && entry.leases.size === 0) this.deleteEntry(entry.scopedKey, entry);
}
private validateResult(result: ThumbnailLoadedResult): void {
if (Number.isFinite(result.weight) && result.weight >= 0) return;
this.safeDispose(result.dispose);
throw new RangeError("Thumbnail result weight must be finite and non-negative");
}
private cacheResult(entry: ThumbnailEntry): void {
const isWaveform = entry.request.kind === "waveform";
const byteBudget = isWaveform
? this.budgets.waveformCacheBytes
: this.budgets.thumbnailCacheBytes;
entry.cached = entry.weight <= byteBudget;
if (!entry.cached) return;
if (isWaveform) this.waveformCacheBytes += entry.weight;
else this.cacheBytes += entry.weight;
}
private entryPriority(entry: ThumbnailEntry): number {
let score = -1;
for (const priority of entry.leases.values()) {
score = Math.max(score, PRIORITY_SCORE[priority]);
}
return score;
}
private bucketLimit(bucket: "video" | "composition" | "general"): number {
if (bucket === "video") return this.budgets.concurrentVideoDecodes;
if (bucket === "composition") return this.budgets.concurrentCompositionFetches;
return this.budgets.concurrentMetadataJobs;
}
private evict(): void {
this.evictPartition(false);
this.evictPartition(true);
}
private evictPartition(waveform: boolean): void {
const cached = Array.from(this.entries.values()).filter(
(entry) => entry.cached && (entry.request.kind === "waveform") === waveform,
);
const perProject = new Map<string, number>();
for (const entry of cached) {
perProject.set(entry.request.projectId, (perProject.get(entry.request.projectId) ?? 0) + 1);
}
cached.sort((left, right) => left.lastAccess - right.lastAccess);
let cacheEntries = cached.length;
for (const entry of cached) {
const projectEntries = perProject.get(entry.request.projectId) ?? 0;
if (!this.isCacheOverBudget(waveform, cacheEntries, projectEntries)) continue;
this.uncache(entry);
cacheEntries--;
perProject.set(entry.request.projectId, projectEntries - 1);
if (entry.leases.size === 0) this.deleteEntry(entry.scopedKey, entry);
}
}
private isCacheOverBudget(
waveform: boolean,
cacheEntries: number,
projectEntries: number,
): boolean {
const entryBudget = waveform
? this.budgets.waveformCacheEntries
: this.budgets.thumbnailCacheEntries;
const byteBudget = waveform
? this.budgets.waveformCacheBytes
: this.budgets.thumbnailCacheBytes;
const cacheBytes = waveform ? this.waveformCacheBytes : this.cacheBytes;
const projectOverBudget =
!waveform && projectEntries > this.budgets.thumbnailCacheEntriesPerProject;
return cacheEntries > entryBudget || cacheBytes > byteBudget || projectOverBudget;
}
private uncache(entry: ThumbnailEntry): void {
entry.cached = false;
if (entry.request.kind === "waveform") this.waveformCacheBytes -= entry.weight;
else this.cacheBytes -= entry.weight;
}
private notify(entry: ThumbnailEntry): void {
for (const listener of new Set(entry.listeners.values())) listener();
}
private loadWithTimeout(
entry: ThumbnailEntry,
controller: AbortController,
): Promise<ThumbnailLoadedResult> {
let load: Promise<ThumbnailLoadedResult>;
try {
load = entry.request.load(controller.signal);
} catch (reason) {
load = Promise.reject(reason);
}
return new Promise((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
controller.abort();
reject(
new Error(`Thumbnail load timed out after ${this.budgets.thumbnailLoadTimeoutMs}ms`),
);
}, this.budgets.thumbnailLoadTimeoutMs);
load.then(
(result) => {
if (settled) {
this.safeDispose(result.dispose);
return;
}
settled = true;
clearTimeout(timeout);
resolve(result);
},
(reason: unknown) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(reason);
},
);
});
}
private deleteEntry(key: string, entry: ThumbnailEntry): void {
if (this.entries.get(key) !== entry) return;
this.entries.delete(key);
entry.snapshot = EMPTY_SNAPSHOT;
this.notify(entry);
if (entry.cached) {
entry.cached = false;
if (entry.request.kind === "waveform") this.waveformCacheBytes -= entry.weight;
else this.cacheBytes -= entry.weight;
}
if (!entry.disposed) {
entry.disposed = true;
this.safeDispose(entry.dispose);
}
entry.listeners.clear();
entry.leases.clear();
}
private evictFailures(): void {
const failed = Array.from(this.entries.values())
.filter((entry) => entry.state === "error" && entry.leases.size === 0)
.sort((left, right) => left.lastAccess - right.lastAccess);
const overflow = failed.length - this.budgets.thumbnailCacheEntries;
for (const entry of failed.slice(0, Math.max(0, overflow))) {
this.deleteEntry(entry.scopedKey, entry);
}
}
private safeDispose(dispose: (() => void) | undefined): void {
try {
dispose?.();
} catch {
// Cleanup is best-effort; one broken resource must not block the remaining cleanup.
}
}
}
export const thumbnailScheduler = new ThumbnailScheduler();
@@ -0,0 +1,127 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { decodeVideoThumbnail, videoThumbnailTimestamps } from "./thumbnailVideoDecoder";
const dispose = vi.fn();
const canvasesAtTimestamps = vi.fn();
const input = {
getPrimaryVideoTrack: vi.fn(),
dispose,
};
vi.mock("mediabunny", () => ({
ALL_FORMATS: {},
UrlSource: class {
constructor(readonly url: string) {}
},
Input: class {
getPrimaryVideoTrack = input.getPrimaryVideoTrack;
dispose = input.dispose;
},
CanvasSink: class {
canvasesAtTimestamps = canvasesAtTimestamps;
},
}));
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(URL, "createObjectURL").mockReturnValueOnce("blob:one").mockReturnValueOnce("blob:two");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
HTMLCanvasElement.prototype.toBlob = function toBlob(callback) {
callback(new Blob(["frame"], { type: "image/jpeg" }));
};
input.getPrimaryVideoTrack.mockResolvedValue({
getDisplayWidth: vi.fn(async () => 1080),
getDisplayHeight: vi.fn(async () => 1920),
getDurationFromMetadata: vi.fn(async () => 10),
});
});
describe("videoThumbnailTimestamps", () => {
it("uses the midpoint for a poster and sorted sparse points for a strip", () => {
expect(videoThumbnailTimestamps(2, 6, 1)).toEqual([5]);
expect(videoThumbnailTimestamps(2, 6, 4)).toEqual([2, 4, 6, 8]);
});
it("clamps invalid source ranges", () => {
expect(videoThumbnailTimestamps(-2, Number.NaN, 0)).toEqual([0]);
expect(videoThumbnailTimestamps(2, 8, Number.NaN)).toEqual([6]);
});
});
describe("decodeVideoThumbnail", () => {
it("extracts sparse frames, returns object URLs, and disposes once", async () => {
const canvas = document.createElement("canvas");
canvasesAtTimestamps.mockImplementation(async function* (timestamps: number[]) {
expect(timestamps).toEqual([2, 8]);
yield { canvas, timestamp: 2, duration: 1 };
yield { canvas, timestamp: 8, duration: 1 };
});
const result = await decodeVideoThumbnail(
{ source: "/clip.mp4", sourceStart: 2, sourceRangeDuration: 6, frameCount: 2 },
new AbortController().signal,
);
expect(result.value).toEqual({
kind: "filmstrip",
urls: ["blob:one", "blob:two"],
aspect: 9 / 16,
});
result.dispose?.();
result.dispose?.();
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);
expect(dispose).toHaveBeenCalledTimes(1);
});
it("releases input and degrades when the source has no video track", async () => {
input.getPrimaryVideoTrack.mockResolvedValue(null);
await expect(
decodeVideoThumbnail({ source: "/audio.mp3", frameCount: 1 }, new AbortController().signal),
).rejects.toThrow("no decodable video track");
expect(dispose).toHaveBeenCalledTimes(1);
});
it("revokes partial results when cancellation lands during extraction", async () => {
const controller = new AbortController();
const canvas = document.createElement("canvas");
canvasesAtTimestamps.mockImplementation(async function* () {
yield { canvas, timestamp: 1, duration: 1 };
controller.abort();
yield { canvas, timestamp: 2, duration: 1 };
});
await expect(
decodeVideoThumbnail({ source: "/clip.mp4", frameCount: 2 }, controller.signal),
).rejects.toMatchObject({ name: "AbortError" });
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
});
it("stops after metadata cancellation before occupying the decoder", async () => {
const controller = new AbortController();
let resolveWidth!: (width: number) => void;
const width = new Promise<number>((resolve) => {
resolveWidth = resolve;
});
const getDisplayWidth = vi.fn(() => width);
const getDurationFromMetadata = vi.fn(async () => 10);
input.getPrimaryVideoTrack.mockResolvedValue({
getDisplayWidth,
getDisplayHeight: vi.fn(async () => 1920),
getDurationFromMetadata,
});
const decoding = decodeVideoThumbnail(
{ source: "/clip.mp4", frameCount: 1 },
controller.signal,
);
await vi.waitFor(() => expect(getDisplayWidth).toHaveBeenCalledOnce());
controller.abort();
resolveWidth(1080);
await expect(decoding).rejects.toMatchObject({ name: "AbortError" });
expect(getDurationFromMetadata).not.toHaveBeenCalled();
expect(canvasesAtTimestamps).not.toHaveBeenCalled();
expect(dispose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,170 @@
import { TIMELINE_VIEWPORT_BUDGETS, type TimelineViewportBudgets } from "./timelineViewportBudgets";
import type { ThumbnailLoadedResult, ThumbnailValue } from "./thumbnailScheduler";
export interface VideoThumbnailDecodeRequest {
source: string;
sourceStart?: number;
sourceRangeDuration?: number;
frameCount: number;
fit?: "contain" | "cover";
}
export function videoThumbnailTimestamps(
start: number,
duration: number,
frameCount: number,
): number[] {
const safeStart = Math.max(0, Number.isFinite(start) ? start : 0);
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
const count = Math.max(1, Number.isFinite(frameCount) ? Math.floor(frameCount) : 1);
if (count === 1) return [safeStart + safeDuration / 2];
return Array.from(
{ length: count },
(_, index) => safeStart + (safeDuration * index) / (count - 1),
);
}
async function canvasToBlob(canvas: HTMLCanvasElement | OffscreenCanvas): Promise<Blob> {
if (canvas instanceof HTMLCanvasElement) {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Video thumbnail encode failed"))),
"image/jpeg",
0.72,
);
});
}
return canvas.convertToBlob({ type: "image/jpeg", quality: 0.72 });
}
interface DecodedResources {
urls: string[];
canvases: Set<HTMLCanvasElement | OffscreenCanvas>;
}
interface ThumbnailCanvasSink {
canvasesAtTimestamps(
timestamps: number[],
): AsyncIterable<{ canvas: HTMLCanvasElement | OffscreenCanvas } | null>;
}
function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
}
function releaseDecodedResources(resources: DecodedResources): void {
for (const url of resources.urls.splice(0)) URL.revokeObjectURL(url);
for (const canvas of resources.canvases) {
canvas.width = 0;
canvas.height = 0;
}
resources.canvases.clear();
}
function targetDimensions(
aspect: number,
budgets: Readonly<TimelineViewportBudgets>,
): { width: number; height: number } {
const width = Math.max(
1,
Math.min(budgets.posterMaxPhysicalWidth, Math.round(budgets.posterMaxPhysicalHeight * aspect)),
);
return {
width,
height: Math.max(1, Math.min(budgets.posterMaxPhysicalHeight, Math.round(width / aspect))),
};
}
async function decodeFrames(
sink: ThumbnailCanvasSink,
timestamps: number[],
signal: AbortSignal,
resources: DecodedResources,
): Promise<void> {
for await (const wrapped of sink.canvasesAtTimestamps(timestamps)) {
throwIfAborted(signal);
if (!wrapped) continue;
resources.canvases.add(wrapped.canvas);
const blob = await canvasToBlob(wrapped.canvas);
throwIfAborted(signal);
resources.urls.push(URL.createObjectURL(blob));
}
}
function loadedResult(
resources: DecodedResources,
aspect: number,
width: number,
height: number,
): ThumbnailLoadedResult {
const firstUrl = resources.urls[0];
if (!firstUrl) throw new Error("Video source returned no thumbnail frames");
const value: ThumbnailValue =
resources.urls.length === 1
? { kind: "image", url: firstUrl, aspect }
: { kind: "filmstrip", urls: [...resources.urls], aspect };
return {
value,
weight: width * height * 4 * resources.urls.length,
dispose: () => releaseDecodedResources(resources),
};
}
/** Sparse Mediabunny extraction with one pooled canvas and one cleanup owner. */
export async function decodeVideoThumbnail(
request: VideoThumbnailDecodeRequest,
signal: AbortSignal,
budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
): Promise<ThumbnailLoadedResult> {
const mediabunny = await import("mediabunny");
throwIfAborted(signal);
const input = new mediabunny.Input({
source: new mediabunny.UrlSource(request.source),
formats: mediabunny.ALL_FORMATS,
});
const resources: DecodedResources = { urls: [], canvases: new Set() };
try {
const track = await input.getPrimaryVideoTrack();
throwIfAborted(signal);
if (!track) throw new Error("Video source has no decodable video track");
const [displayWidth, displayHeight] = await Promise.all([
track.getDisplayWidth(),
track.getDisplayHeight(),
]);
throwIfAborted(signal);
if (!(displayWidth > 0 && displayHeight > 0)) {
throw new Error("Video source has invalid dimensions");
}
const metadataDuration = await track.getDurationFromMetadata({ skipLiveWait: true });
throwIfAborted(signal);
const sourceDuration = Math.max(0, metadataDuration ?? request.sourceRangeDuration ?? 0);
const sourceStart = Math.min(Math.max(0, request.sourceStart ?? 0), sourceDuration);
const requestedDuration =
request.sourceRangeDuration ?? Math.max(0, sourceDuration - sourceStart);
const duration = Math.min(
Math.max(0, requestedDuration),
Math.max(0, sourceDuration - sourceStart),
);
const timestamps = videoThumbnailTimestamps(
sourceStart,
duration,
Math.min(request.frameCount, budgets.richPreviewFrameCount),
);
const aspect = displayWidth / displayHeight;
const target = targetDimensions(aspect, budgets);
const sink = new mediabunny.CanvasSink(track, {
width: target.width,
height: target.height,
fit: request.fit ?? "cover",
poolSize: 1,
});
await decodeFrames(sink, timestamps, signal, resources);
return loadedResult(resources, aspect, target.width, target.height);
} catch (error) {
releaseDecodedResources(resources);
throw error;
} finally {
input.dispose();
}
}
@@ -14,6 +14,7 @@ export interface TimelineViewportBudgets {
concurrentMetadataJobs: number;
concurrentCompositionFetches: number;
concurrentServerPages: number;
thumbnailLoadTimeoutMs: number;
thumbnailCacheBytes: number;
thumbnailCacheEntries: number;
thumbnailCacheEntriesPerProject: number;
@@ -70,6 +71,7 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Obje
concurrentMetadataJobs: 4,
concurrentCompositionFetches: 2,
concurrentServerPages: 1,
thumbnailLoadTimeoutMs: 30_000,
thumbnailCacheBytes: 64 * MEBIBYTE,
thumbnailCacheEntries: 256,
thumbnailCacheEntriesPerProject: 96,