Skip to content
Draft
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
104 changes: 104 additions & 0 deletions packages/studio/src/hooks/useThumbnailLease.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThumbnailScheduler, type ThumbnailRequest } from "../player/lib/thumbnailScheduler";
import { useThumbnailLease } from "./useThumbnailLease";

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

afterEach(() => {
document.body.innerHTML = "";
});

describe("useThumbnailLease", () => {
it("subscribes once, publishes the result, and releases on unmount", async () => {
const scheduler = new ThumbnailScheduler();
const load = vi.fn(async () => ({
value: { kind: "image" as const, url: "blob:poster", aspect: 16 / 9 },
weight: 10,
}));
const request: ThumbnailRequest = {
key: "poster",
projectId: "demo",
sessionEpoch: 1,
kind: "image",
priority: "visible",
load,
};
let status = "missing";

function Probe() {
status = useThumbnailLease(request, scheduler).status;
return null;
}

const root = createRoot(document.createElement("div"));
await act(async () => {
root.render(React.createElement(Probe));
await Promise.resolve();
});
expect(load).toHaveBeenCalledTimes(1);
expect(status).toBe("ready");
expect(scheduler.getDiagnostics().leases).toBe(1);

act(() => root.unmount());
expect(scheduler.getDiagnostics().leases).toBe(0);
});

it("does not acquire work for a null request", () => {
const scheduler = new ThumbnailScheduler();
let status = "missing";
function Probe() {
status = useThumbnailLease(null, scheduler).status;
return null;
}
const root = createRoot(document.createElement("div"));
act(() => root.render(React.createElement(Probe)));
expect(status).toBe("idle");
expect(scheduler.getDiagnostics().leases).toBe(0);
act(() => root.unmount());
});

it("updates priority without restarting the active request", async () => {
const scheduler = new ThumbnailScheduler();
let resolve!: (value: {
value: { kind: "image"; url: string; aspect: number };
weight: number;
}) => void;
const pending = new Promise<{
value: { kind: "image"; url: string; aspect: number };
weight: number;
}>((accept) => {
resolve = accept;
});
const load = vi.fn(() => pending);
let priority: ThumbnailRequest["priority"] = "overscan";
function Probe() {
useThumbnailLease(
{
key: "same-content",
projectId: "demo",
sessionEpoch: 1,
kind: "image",
priority,
load,
},
scheduler,
);
return null;
}
const root = createRoot(document.createElement("div"));
act(() => root.render(React.createElement(Probe)));
priority = "interaction";
act(() => root.render(React.createElement(Probe)));
expect(load).toHaveBeenCalledTimes(1);

await act(async () => {
resolve({ value: { kind: "image", url: "blob:done", aspect: 1 }, weight: 1 });
await pending;
});
act(() => root.unmount());
});
});
45 changes: 45 additions & 0 deletions packages/studio/src/hooks/useThumbnailLease.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
import {
thumbnailScheduler,
type ThumbnailRequest,
type ThumbnailScheduler,
type ThumbnailSnapshot,
} from "../player/lib/thumbnailScheduler";

const IDLE: ThumbnailSnapshot = Object.freeze({ status: "idle" });

export function useThumbnailLease(
request: ThumbnailRequest | null,
scheduler: ThumbnailScheduler = thumbnailScheduler,
): ThumbnailSnapshot {
const requestRef = useRef(request);
requestRef.current = request;
const leaseRef = useRef<ReturnType<ThumbnailScheduler["acquire"]> | null>(null);
const identity = request
? `${request.projectId}\u0000${request.sessionEpoch}\u0000${request.key}`
: null;
const priority = request?.priority;
const subscribe = useCallback(
(listener: () => void) => {
const current = requestRef.current;
if (!current || identity === null) return () => {};
const lease = scheduler.acquire(current, listener);
leaseRef.current = lease;
return () => {
if (leaseRef.current === lease) leaseRef.current = null;
lease.release();
};
},
[identity, scheduler],
);
const getSnapshot = useCallback(() => {
const current = requestRef.current;
return current && identity !== null ? scheduler.getSnapshot(current) : IDLE;
}, [identity, scheduler]);

useLayoutEffect(() => {
if (priority) leaseRef.current?.updatePriority(priority);
}, [priority]);

return useSyncExternalStore(subscribe, getSnapshot, () => IDLE);
}
81 changes: 81 additions & 0 deletions packages/studio/src/player/lib/mediaProbe.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
108 changes: 94 additions & 14 deletions packages/studio/src/player/lib/mediaProbe.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
interface MediaProbeResult {
import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";

export interface MediaProbeResult {
duration: number;
width?: number;
height?: number;
hasVideo: boolean;
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;

Expand Down Expand Up @@ -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);
}
}

/**
Expand Down Expand Up @@ -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;
}
Loading
Loading