perf(studio): timeline thumbnails come back from disk in the next session (#6676)

* perf(studio): timeline thumbnails come back from disk in the next session

Reopening a project re-read every video's index and re-decoded every strip:
the frame cache and the source facts lived in memory, keyed by the session.

Now each source's facts (aspect, duration) and every decoded frame are kept
in Cache Storage under the file's ETag, the version the server says it is. A
new session checks each source once with a conditional one-byte read: a 304
means the stored frames are still that file's, and a strip they cover paints
without opening a reader. A new ETag drops what was stored for the old one.
Sources are dropped whole, least recently used first, past 256 MiB. A store
that is missing or fails (private window, quota) warns once and the strip
decodes as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): a reopened project checks its videos again, and the disk index counts a frame once

The stored version a session validated was kept per video for the whole page,
so reopening a project after one of its videos was replaced at the same path
painted the old file's frames until a full reload. It is now kept per video
and project open (its contentVersion): each open re-validates once.

The index now counts a frame stored again under its name once, and merges
with what other tabs saved before writing, keeping caches this tab dropped
out, so the 256 MiB budget holds across tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-10-28 16:39:02 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 67c7ec27d9
commit e38585ef5f
6 changed files with 556 additions and 6 deletions
+7 -2
View File
@@ -177,6 +177,9 @@ export function mediaUrlSourceOptions(reader: MediaReader) {
const failuresByStart = new Map<number | null, number>();
const inFlightReads = new AbortController();
let ranges: "unknown" | "readable" | "hidden" = reader === "thumbnail" ? "unknown" : "hidden";
let etag: string | undefined;
const noteEtag = (response: Response) =>
void (etag ??= response.headers.get("ETag") ?? undefined);
const fetchFn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const sent = ranges === "readable" ? boundedRange(init) : init;
const asked = Number(rangeStart(sent, true) ?? 0);
@@ -187,6 +190,7 @@ export function mediaUrlSourceOptions(reader: MediaReader) {
);
const response = await typedFetch(reader, input, sent ?? {}, asked, request);
if (ranges !== "hidden") ranges = rangesReadable(sent, response) ? "readable" : "hidden";
noteEtag(response);
return typedResponse(response, asked, request);
};
const getRetryDelay = (_previousAttempts: number, error: unknown): number | null => {
@@ -201,8 +205,9 @@ export function mediaUrlSourceOptions(reader: MediaReader) {
};
const cancelReads = () =>
inFlightReads.abort(new DOMException("Media reads cancelled", "AbortError"));
if (reader === "probe") return { fetchFn, getRetryDelay, cancelReads };
return { fetchFn, getRetryDelay, cancelReads, maxCacheSize: POOLED_READER_CACHE_BYTES };
const etagOf = () => etag;
if (reader === "probe") return { fetchFn, getRetryDelay, cancelReads, etagOf };
return { fetchFn, getRetryDelay, cancelReads, etagOf, maxCacheSize: POOLED_READER_CACHE_BYTES };
}
export function reportMediaFetchFailure(error: unknown): void {
@@ -0,0 +1,158 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
readStoredFrame,
readStoredInfo,
rememberVersion,
resetStoredThumbnailsForTests,
STORED_THUMBNAIL_BYTES,
storedVersion,
storeFrame,
storeInfo,
} from "./thumbnailDiskCache";
/** Cache Storage as a Map of Maps: what a browser keeps across sessions. */
function fakeCacheStorage() {
const stores = new Map<string, Map<string, Response>>();
const storage = {
open: async (name: string) => {
const store = stores.get(name) ?? new Map<string, Response>();
stores.set(name, store);
return {
match: async (url: string) => store.get(String(url))?.clone(),
put: async (url: string, response: Response) => void store.set(String(url), response),
};
},
has: async (name: string) => stores.has(name),
keys: async () => [...stores.keys()],
delete: async (name: string) => stores.delete(name),
};
return { storage, stores };
}
const v1 = { source: "/media/take.mp4", etag: '"one"' };
const v2 = { source: "/media/take.mp4", etag: '"two"' };
// a picture that reports `bytes` without holding them, so the budget test needs no real 128 MB
function jpeg(bytes = 5): Blob {
const blob = new Blob([new Uint8Array(Math.min(bytes, 5))], { type: "image/jpeg" });
return Object.defineProperty(blob, "size", { value: bytes });
}
let stores: Map<string, Map<string, Response>>;
beforeEach(() => {
resetStoredThumbnailsForTests();
localStorage.clear();
const fake = fakeCacheStorage();
stores = fake.stores;
vi.stubGlobal("caches", fake.storage);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("stored thumbnails", () => {
it("keep a source's facts and each frame with the time it was decoded at", async () => {
await storeInfo(v1, { aspect: 16 / 9, metadataDuration: 30 });
await storeFrame(v1, "cover 320x180 2.5", 2.4, jpeg());
expect(await readStoredInfo(v1)).toEqual({ aspect: 16 / 9, metadataDuration: 30 });
const frame = await readStoredFrame(v1, "cover 320x180 2.5");
expect(frame?.decodedAt).toBe(2.4);
expect(frame?.blob.size).toBe(5);
expect(await readStoredFrame(v1, "cover 320x180 5")).toBeUndefined();
expect(await readStoredFrame(v2, "cover 320x180 2.5")).toBeUndefined();
});
it("are trusted in a new session only when the server answers the stored ETag with a 304", async () => {
const fetch = vi.fn(async () => new Response(null, { status: 304 }));
vi.stubGlobal("fetch", fetch);
expect(await storedVersion(v1.source)).toBeUndefined();
expect(fetch).not.toHaveBeenCalled();
await rememberVersion(v1);
expect(await storedVersion(v1.source)).toEqual(v1);
expect(fetch).toHaveBeenCalledWith(v1.source, {
headers: { Range: "bytes=0-0", "If-None-Match": '"one"' },
});
fetch.mockResolvedValueOnce(new Response("x", { status: 206 }));
expect(await storedVersion(v1.source)).toBeUndefined();
});
it("drop what was stored for a file's earlier version once a new one is read", async () => {
await rememberVersion(v1);
await storeFrame(v1, "cover 320x180 1", 1, jpeg());
await rememberVersion(v2);
expect(await readStoredFrame(v1, "cover 320x180 1")).toBeUndefined();
expect(stores.size).toBe(0);
});
it("drop the least recently used sources once they pass the budget", async () => {
const big = Math.floor(STORED_THUMBNAIL_BYTES / 2) + 1;
const others = ["/a.mp4", "/b.mp4", "/c.mp4"].map((source) => ({ source, etag: '"1"' }));
for (const version of others) {
await storeFrame(version, "cover 1x1 0", 0, jpeg(big));
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 1000);
}
expect(await readStoredFrame(others[0]!, "cover 1x1 0")).toBeUndefined();
expect(await readStoredFrame(others[2]!, "cover 1x1 0")).toBeDefined();
});
it("never fail a thumbnail: a broken store warns once and reads as empty", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.stubGlobal("caches", {
open: async () => {
throw new DOMException("quota", "QuotaExceededError");
},
});
expect(await readStoredInfo(v1)).toBeUndefined();
expect(await storeFrame(v1, "cover 1x1 0", 0, jpeg())).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
});
it("are skipped where the browser offers no Cache Storage", async () => {
vi.stubGlobal("caches", undefined);
expect(await readStoredInfo(v1)).toBeUndefined();
expect(await storeInfo(v1, { aspect: 1, metadataDuration: 1 })).toBeUndefined();
});
it("count a frame stored again under its name once, so the budget holds", async () => {
await storeFrame(v1, "cover 1x1 0", 0, jpeg(100));
await storeFrame(v1, "cover 1x1 0", 0, jpeg(100));
await vi.waitFor(() => expect(localStorage.getItem("hf-thumbnails-index")).toContain("bytes"), {
timeout: 3000,
});
const saved = JSON.parse(localStorage.getItem("hf-thumbnails-index")!);
expect(Object.values(saved.caches)).toMatchObject([{ bytes: 100 }]);
});
it("keep what another tab stored when saving, but not what this tab dropped", async () => {
await rememberVersion(v1);
await storeFrame(v1, "cover 1x1 0", 0, jpeg());
await vi.waitFor(() => expect(localStorage.getItem("hf-thumbnails-index")).toContain("bytes"), {
timeout: 3000,
});
// another tab saves its snapshot: a source of its own, and v1 as it was before this tab moved on
const other = JSON.parse(localStorage.getItem("hf-thumbnails-index")!);
other.caches["hf-thumbnails:other:tab"] = { usedAt: Date.now(), bytes: 7 };
localStorage.setItem("hf-thumbnails-index", JSON.stringify(other));
const v1Cache = Object.keys(other.caches).find((name) => name !== "hf-thumbnails:other:tab")!;
await rememberVersion(v2);
await storeFrame(v2, "cover 1x1 0", 0, jpeg());
await vi.waitFor(
() => expect(localStorage.getItem("hf-thumbnails-index")).not.toContain(v1Cache),
{ timeout: 3000 },
);
const saved = JSON.parse(localStorage.getItem("hf-thumbnails-index")!);
expect(Object.keys(saved.caches)).toContain("hf-thumbnails:other:tab");
expect(Object.keys(saved.caches)).toHaveLength(2);
});
});
@@ -0,0 +1,212 @@
import type { SourceInfo } from "./thumbnailSourcePool";
/** A video as the server serves it now: its URL and the ETag of this version of its file. */
export interface StoredSource {
source: string;
etag: string;
}
interface StoredFrame {
blob: Blob;
decodedAt: number;
}
const CACHE_PREFIX = "hf-thumbnails:";
const INDEX_KEY = "hf-thumbnails-index";
const ENTRY_ORIGIN = "https://hf-thumbnail.invalid/";
export const STORED_THUMBNAIL_BYTES = 256 * 1024 * 1024;
interface Index {
etags: Record<string, string>;
caches: Record<string, { usedAt: number; bytes: number }>;
}
let index: Index | undefined;
// caches this tab dropped since its last save, which another tab's older snapshot must not bring back
const dropped = new Set<string>();
let saveTimer: ReturnType<typeof setTimeout> | undefined;
let warned = false;
const available = () => typeof caches !== "undefined" && typeof crypto?.subtle !== "undefined";
/** Disk is a shortcut, never a reason a thumbnail fails: a broken store says so once and is skipped. */
async function quietly<T>(work: () => Promise<T>): Promise<T | undefined> {
if (!available()) return undefined;
try {
return await work();
} catch (error) {
if (!warned) console.warn("[Studio] timeline thumbnails stay in memory:", error);
warned = true;
return undefined;
}
}
async function digest(text: string): Promise<string> {
const bytes = new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)),
);
return Array.from(bytes.subarray(0, 12), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
const cacheNameOf = async ({ source, etag }: StoredSource) =>
`${CACHE_PREFIX}${await digest(source)}:${await digest(etag)}`;
function savedIndex(): Index | undefined {
try {
const saved = JSON.parse(localStorage.getItem(INDEX_KEY) ?? "null") as Index | null;
return saved?.etags && saved.caches ? saved : undefined;
} catch {
return undefined; // expected: no storage (a private window) or a value this version can't read
}
}
function loadIndex(): Index {
return (index ??= savedIndex() ?? { etags: {}, caches: {} });
}
type CacheEntry = Index["caches"][string];
/** Another tab's entry when it was used later than this tab's, with the larger count of bytes. */
function laterOf(own: CacheEntry | undefined, theirs: CacheEntry): CacheEntry | undefined {
if (own && own.usedAt >= theirs.usedAt) return undefined;
return { usedAt: theirs.usedAt, bytes: Math.max(theirs.bytes, own?.bytes ?? 0) };
}
/** This tab's index with what other tabs saved since: each cache at its latest use, none this tab dropped. */
function mergedIndex(): Index {
const mine = loadIndex();
const theirs = savedIndex() ?? { etags: {}, caches: {} };
for (const [name, entry] of Object.entries(theirs.caches)) {
const later = dropped.has(name) ? undefined : laterOf(mine.caches[name], entry);
if (later) mine.caches[name] = later;
}
mine.etags = { ...theirs.etags, ...mine.etags };
dropped.clear();
return mine;
}
function saveIndexSoon(): void {
saveTimer ??= setTimeout(() => {
saveTimer = undefined;
try {
localStorage.setItem(INDEX_KEY, JSON.stringify(mergedIndex()));
} catch {
// expected: storage refused; the caches still hold the frames, only their order of use is lost
}
}, 1000);
}
function used(name: string, addedBytes = 0): void {
const caches = loadIndex().caches;
caches[name] = { usedAt: Date.now(), bytes: (caches[name]?.bytes ?? 0) + addedBytes };
saveIndexSoon();
}
/** The version stored for `source`, when one conditional one-byte read says the server still has it (304). */
export const storedVersion = (source: string) =>
quietly(async (): Promise<StoredSource | undefined> => {
const etag = loadIndex().etags[await digest(source)];
if (!etag) return undefined;
// oxlint-disable-next-line no-restricted-globals -- one validation read, on the media's own route like mediabunny's
const response = await fetch(source, {
headers: { Range: "bytes=0-0", "If-None-Match": etag },
});
await response.body?.cancel();
return response.status === 304 ? { source, etag } : undefined;
});
/** Records the version the server now has, dropping what was stored for the source's earlier versions. */
export const rememberVersion = (stored: StoredSource) =>
quietly(async () => {
const key = await digest(stored.source);
const saved = loadIndex();
if (saved.etags[key] === stored.etag) return;
saved.etags[key] = stored.etag;
saveIndexSoon();
const keep = await cacheNameOf(stored);
for (const name of await caches.keys()) {
if (!name.startsWith(`${CACHE_PREFIX}${key}:`) || name === keep) continue;
await caches.delete(name);
delete saved.caches[name];
dropped.add(name);
}
});
async function entry(stored: StoredSource, path: string) {
const name = await cacheNameOf(stored);
return { name, cache: await caches.open(name), url: `${ENTRY_ORIGIN}${path}` };
}
/** A read opens nothing: opening a cache creates it, and an empty one for a gone version would never be dropped. */
async function storedEntry(stored: StoredSource, path: string) {
const name = await cacheNameOf(stored);
return (await caches.has(name)) ? entry(stored, path) : undefined;
}
export const readStoredInfo = (stored: StoredSource) =>
quietly(async (): Promise<SourceInfo | undefined> => {
const found = await storedEntry(stored, "info");
if (!found) return undefined;
const { name, cache, url } = found;
const info = (await (await cache.match(url))?.json()) as SourceInfo | undefined;
if (!(info && info.aspect > 0)) return undefined;
used(name);
return info;
});
export const storeInfo = (stored: StoredSource, info: SourceInfo) =>
quietly(async () => {
const { name, cache, url } = await entry(stored, "info");
await cache.put(url, new Response(JSON.stringify(info)));
used(name);
});
/** `frame` names one picture of the source: its fit, size and the time it was asked for. */
export const readStoredFrame = (stored: StoredSource, frame: string) =>
quietly(async (): Promise<StoredFrame | undefined> => {
const found = await storedEntry(stored, `frame?${encodeURIComponent(frame)}`);
if (!found) return undefined;
const { name, cache, url } = found;
const response = await cache.match(url);
const decodedAt = Number(response?.headers.get("x-decoded-at"));
if (!response || !Number.isFinite(decodedAt)) return undefined;
used(name);
return { blob: await response.blob(), decodedAt };
});
export const storeFrame = (stored: StoredSource, frame: string, decodedAt: number, blob: Blob) =>
quietly(async () => {
const { name, cache, url } = await entry(stored, `frame?${encodeURIComponent(frame)}`);
const replaced = Number((await cache.match(url))?.headers.get("x-bytes")) || 0;
const headers = {
"content-type": blob.type,
"x-decoded-at": String(decodedAt),
"x-bytes": String(blob.size),
};
await cache.put(url, new Response(blob, { headers }));
used(name, blob.size - replaced);
await dropLeastUsed(STORED_THUMBNAIL_BYTES);
});
/** Whole sources go, least recently used first, until what is stored fits `budgetBytes`. */
async function dropLeastUsed(budgetBytes: number): Promise<void> {
const saved = loadIndex();
let total = Object.values(saved.caches).reduce((sum, { bytes }) => sum + bytes, 0);
const oldestFirst = Object.entries(saved.caches).sort(([, a], [, b]) => a.usedAt - b.usedAt);
for (const [name, { bytes }] of oldestFirst) {
if (total <= budgetBytes) break;
await caches.delete(name);
delete saved.caches[name];
dropped.add(name);
total -= bytes;
}
saveIndexSoon();
}
/** Tests only: forget the in-memory index and any pending save. */
export function resetStoredThumbnailsForTests(): void {
clearTimeout(saveTimer);
saveTimer = undefined;
index = undefined;
warned = false;
}
@@ -12,6 +12,7 @@ interface OpenedSource {
mediabunny: Mediabunny;
track: InputVideoTrack;
info: SourceInfo;
etag: string | undefined;
}
export interface ThumbnailSource extends OpenedSource {
@@ -88,7 +89,8 @@ async function open(source: string, pooled: () => PooledSource): Promise<OpenedS
}
const metadataDuration = await track.getDurationFromMetadata({ skipLiveWait: true });
live();
return { mediabunny, track, info: { aspect: displayWidth / displayHeight, metadataDuration } };
const info = { aspect: displayWidth / displayHeight, metadataDuration };
return { mediabunny, track, info, etag: options.etagOf() };
}
function pooledSource(source: string, version: string | undefined): PooledSource {
@@ -16,6 +16,8 @@ const input = {
getPrimaryVideoTrack: vi.fn(),
dispose,
};
// The ETag the mocked server answers the reader's reads with; none unless a test sets one.
let servedEtag: string | undefined;
vi.mock("mediabunny", () => ({
ALL_FORMATS: {},
@@ -25,6 +27,7 @@ vi.mock("mediabunny", () => ({
readonly options?: { getRetryDelay?: unknown; cancelReads?: () => void },
) {
sourceRetryRules.push(typeof options?.getRetryDelay);
if (servedEtag && options) Object.assign(options, { etagOf: () => servedEtag });
// Spied where the pool reads it, after constructing the source.
if (options?.cancelReads)
cancelledReads.push((options.cancelReads = vi.fn(options.cancelReads)));
@@ -69,6 +72,8 @@ let sources = 0;
let source = "";
beforeEach(() => {
vi.unstubAllGlobals();
servedEtag = undefined;
source = `/clip-${++sources}.mp4`;
// Released frames outlive their test; evict them so each test counts only its own revokes.
releaseSharedFrames([], 0);
@@ -109,6 +114,26 @@ describe("videoThumbnailTimestamps", () => {
});
});
/** Cache Storage as a Map of Maps, empty, with an empty index: what a browser keeps across sessions. */
function stubCacheStorage(): Map<string, Map<string, Response>> {
const stores = new Map<string, Map<string, Response>>();
vi.stubGlobal("caches", {
open: async (name: string) => {
const store = stores.get(name) ?? new Map<string, Response>();
stores.set(name, store);
return {
match: async (url: string) => store.get(url)?.clone(),
put: async (url: string, response: Response) => void store.set(url, response),
};
},
has: async (name: string) => stores.has(name),
keys: async () => [...stores.keys()],
delete: async (name: string) => stores.delete(name),
});
localStorage.clear();
return stores;
}
describe("decodeVideoThumbnail", () => {
it("extracts sparse frames, returns object URLs, and disposes once", async () => {
const decoded: number[][] = [];
@@ -927,4 +952,58 @@ describe("decodeVideoThumbnail's shared source", () => {
// A UrlSource counts its own retries (mediaUrlSourceOptions), so a fresh one retries afresh.
expect(sourceRetryRules).toEqual(["function", "function"]);
});
it("paints a strip from a past session's frames on disk, without opening the file", async () => {
const stores = stubCacheStorage();
const signal = new AbortController().signal;
servedEtag = '"take-v1"';
recordDecodes([]);
const request = { source, sourceStart: 0, sourceRangeDuration: 4, frameCount: 2 };
await decodeVideoThumbnail({ ...request, contentVersion: "session-1" }, signal);
// the source's facts and its three frames, then the index the next session reads
await vi.waitFor(() => expect([...stores.values()][0]?.size).toBe(4));
await vi.waitFor(
() => expect(localStorage.getItem("hf-thumbnails-index")).toContain("take-v1"),
{
timeout: 3000,
},
);
vi.resetModules();
const nextSession = await import("./thumbnailVideoDecoder");
const validate = vi.fn(async () => new Response(null, { status: 304 }));
vi.stubGlobal("fetch", validate);
input.getPrimaryVideoTrack.mockClear();
canvasesAtTimestamps.mockClear();
const result = await nextSession.decodeVideoThumbnail(
{ ...request, contentVersion: "session-2" },
signal,
);
expect(validate).toHaveBeenCalledTimes(1);
expect(input.getPrimaryVideoTrack).not.toHaveBeenCalled();
expect(canvasesAtTimestamps).not.toHaveBeenCalled();
expect(result.value).toMatchObject({ kind: "filmstrip" });
expect((result.value as { urls: string[] }).urls).toHaveLength(3);
});
it("checks the file again when a project opens anew, so a replaced video never paints its old frames", async () => {
const stores = stubCacheStorage();
const signal = new AbortController().signal;
servedEtag = '"v1"';
recordDecodes([]);
const request = { source, sourceStart: 0, sourceRangeDuration: 4, frameCount: 2 };
await decodeVideoThumbnail({ ...request, contentVersion: "open-1" }, signal);
await vi.waitFor(() => expect([...stores.values()][0]?.size).toBe(4));
// the video is replaced at the same path, then its project opened again in this page
servedEtag = '"v2"';
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("x", { status: 206, headers: { ETag: '"v2"' } })),
);
await decodeVideoThumbnail({ ...request, contentVersion: "open-2" }, signal);
expect(input.getPrimaryVideoTrack).toHaveBeenCalledTimes(2);
});
});
@@ -17,6 +17,15 @@ import {
takeSharedFrame,
type HeldFrame,
} from "./thumbnailFrameCache";
import {
readStoredFrame,
readStoredInfo,
rememberVersion,
storedVersion,
storeFrame,
storeInfo,
type StoredSource,
} from "./thumbnailDiskCache";
export interface VideoThumbnailDecodeRequest {
source: string;
@@ -52,6 +61,17 @@ interface FrameWindow {
}
const sourceInfos = new Map<string, { version: string | undefined; info: SourceInfo }>();
const storedVersions = new Map<string, Promise<StoredSource | undefined>>();
const storedInfos = new Set<string>();
const openOf = (request: VideoThumbnailDecodeRequest) =>
`${request.source}\u0000${request.contentVersion ?? ""}`;
function storedVersionOf(request: VideoThumbnailDecodeRequest): Promise<StoredSource | undefined> {
let pending = storedVersions.get(openOf(request));
if (!pending) storedVersions.set(openOf(request), (pending = storedVersion(request.source)));
return pending;
}
const accepts = (window: FrameWindow, time: number, decodedAt: number) =>
decodedAt >= window.sourceStart && time - decodedAt <= (time >= window.end ? 0 : window.lead);
@@ -79,6 +99,7 @@ interface DecodedResources {
held: HeldFrame[];
canvases: Set<HTMLCanvasElement | OffscreenCanvas>;
releasedFrameBytes: number;
store?: (slot: number, decodedAt: number, blob: Blob) => void;
}
interface ThumbnailCanvasSink {
@@ -186,6 +207,7 @@ async function decodeFrames(
throwIfAborted(signal);
const frame = shareFrame(target.key, target.decodedAt, URL.createObjectURL(blob), blob.size);
progress.framesFromAnyJob += 1;
resources.store?.(target.slot, target.decodedAt, blob);
resources.urls[target.slot] = frame.url;
resources.held.push({ key: target.key, frame });
}
@@ -218,6 +240,64 @@ function loadedResult(
};
}
const storedFrameName =
(fit: string, info: SourceInfo, budgets: Readonly<TimelineViewportBudgets>) => (time: number) => {
const { width, height } = targetDimensions(info.aspect, budgets);
return `${fit} ${width}x${height} ${time}`;
};
async function takeStoredFrames(
{ timestamps, window }: ReturnType<typeof stripTimes>,
slots: number[],
keyOf: (time: number) => string,
nameOf: (time: number) => string,
resources: DecodedResources,
stored: StoredSource,
): Promise<number[]> {
const found = await Promise.all(
slots.map((slot) => readStoredFrame(stored, nameOf(timestamps[slot]!))),
);
return slots.filter((slot, i) => {
const time = timestamps[slot]!;
const hit = found[i];
if (!hit || !accepts(window, time, hit.decodedAt)) return true;
const key = keyOf(time);
const frame = shareFrame(key, hit.decodedAt, URL.createObjectURL(hit.blob), hit.blob.size);
resources.urls[slot] = frame.url;
resources.held.push({ key, frame });
return false;
});
}
function versionRead(
request: VideoThumbnailDecodeRequest,
source: ThumbnailSource,
): StoredSource | undefined {
if (!source.etag) return undefined;
const version = { source: request.source, etag: source.etag };
storedVersions.set(openOf(request), Promise.resolve(version));
return version;
}
function storeForNextSession(
request: VideoThumbnailDecodeRequest,
info: SourceInfo,
budgets: Readonly<TimelineViewportBudgets>,
resources: DecodedResources,
version: StoredSource | undefined,
): void {
if (!version) return;
const id = `${version.source}\u0000${version.etag}`;
if (!storedInfos.has(id)) {
storedInfos.add(id);
void rememberVersion(version).then(() => storeInfo(version, info));
}
const nameOf = storedFrameName(request.fit ?? "cover", info, budgets);
const { timestamps } = stripTimes(request, info, budgets);
resources.store = (slot, decodedAt, blob) =>
void storeFrame(version, nameOf(timestamps[slot]!), decodedAt, blob);
}
/** Sparse Mediabunny extraction with one pooled canvas and one cleanup owner. */
export async function decodeVideoThumbnail(
request: VideoThumbnailDecodeRequest,
@@ -234,16 +314,28 @@ export async function decodeVideoThumbnail(
releasedFrameBytes: budgets.sharedFrameCacheBytes,
};
const entry = sourceInfos.get(request.source);
const known = entry?.version === request.contentVersion ? entry?.info : undefined;
try {
const stored = await storedVersionOf(request);
const known =
entry && entry.version === request.contentVersion
? entry.info
: stored && (await readStoredInfo(stored));
if (known) {
if (takeDecodedFrames(stripTimes(request, known, budgets), keyOf, resources).length === 0) {
sourceInfos.set(request.source, { version: request.contentVersion, info: known });
const strip = stripTimes(request, known, budgets);
const missing = takeDecodedFrames(strip, keyOf, resources);
const nameOf = storedFrameName(fit, known, budgets);
const left =
stored && missing.length > 0
? await takeStoredFrames(strip, missing, keyOf, nameOf, resources, stored)
: missing;
if (left.length === 0) {
throwIfAborted(signal);
return loadedResult(resources, known.aspect, budgets);
}
releaseDecodedResources(resources);
}
return await decodeMissingFrames(request, signal, budgets, keyOf, resources);
return await decodeMissingFrames(request, signal, budgets, keyOf, resources, stored);
} catch (error) {
releaseDecodedResources(resources);
reportMediaFetchFailure(error);
@@ -257,6 +349,7 @@ async function decodeMissingFrames(
budgets: Readonly<TimelineViewportBudgets>,
keyOf: (time: number) => string,
resources: DecodedResources,
stored: StoredSource | undefined,
): Promise<ThumbnailLoadedResult> {
let source: ThumbnailSource;
try {
@@ -284,6 +377,7 @@ async function decodeMissingFrames(
sourceInfos.set(request.source, { version: request.contentVersion, info });
const strip = stripTimes(request, info, budgets);
const { timestamps, window } = strip;
storeForNextSession(request, info, budgets, resources, stored ?? versionRead(request, source));
const slots: { slot: number; key: string; decodedAt?: number }[] = takeDecodedFrames(
strip,
keyOf,