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,37 @@
import { describe, expect, it } from "vitest";
import { thumbnailDeviceScaleFactor } from "./thumbnailOutput";
describe("thumbnailDeviceScaleFactor", () => {
it("preserves source-density captures and bounds landscape and portrait previews", () => {
expect(
thumbnailDeviceScaleFactor({
width: 1920,
height: 1080,
outputWidth: 1920,
outputHeight: 1080,
}),
).toBe(1);
expect(
thumbnailDeviceScaleFactor({
width: 1920,
height: 1080,
outputWidth: 240,
outputHeight: 135,
}),
).toBe(0.125);
expect(
thumbnailDeviceScaleFactor({
width: 1080,
height: 1920,
outputWidth: 76,
outputHeight: 135,
}),
).toBeCloseTo(76 / 1080);
});
it("rejects invalid dimensions instead of silently changing layout", () => {
expect(() =>
thumbnailDeviceScaleFactor({ width: 0, height: 1080, outputWidth: 240, outputHeight: 135 }),
).toThrow(RangeError);
});
});
@@ -0,0 +1,20 @@
export interface ThumbnailOutputDimensions {
width: number;
height: number;
outputWidth: number;
outputHeight: number;
}
/** Sole adapter rule for capturing authored layout at bounded physical dimensions. */
export function thumbnailDeviceScaleFactor({
width,
height,
outputWidth,
outputHeight,
}: ThumbnailOutputDimensions): number {
const dimensions = [width, height, outputWidth, outputHeight];
if (dimensions.some((value) => !Number.isFinite(value) || value <= 0)) {
throw new RangeError("Thumbnail dimensions must be positive finite numbers");
}
return Math.min(1, outputWidth / width, outputHeight / height);
}
+4
View File
@@ -20,6 +20,10 @@ export {
} from "./helpers/fileVersion.js";
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js";
export {
thumbnailDeviceScaleFactor,
type ThumbnailOutputDimensions,
} from "./helpers/thumbnailOutput.js";
export {
createBackgroundRemovalJob,
type BackgroundRemovalRender,
@@ -1,9 +1,18 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
truncateSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerThumbnailRoutes } from "./thumbnail";
import { pruneThumbnailCache, registerThumbnailRoutes } from "./thumbnail";
import type { StudioApiAdapter } from "../types";
const tempProjectDirs: string[] = [];
@@ -52,10 +61,37 @@ describe("registerThumbnailRoutes", () => {
seekTime: 1.2,
selector: "#title-card",
format: "jpeg",
outputWidth: 240,
outputHeight: 135,
signal: expect.any(AbortSignal),
}),
);
});
it("deduplicates concurrent generation and writes one complete cache entry", async () => {
const adapter = createAdapter();
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
let resolve!: (buffer: Buffer) => void;
const generated = new Promise<Buffer>((done) => (resolve = done));
adapter.generateThumbnail = vi.fn(async () => generated);
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const url = "http://localhost/projects/demo/thumbnail/index.html?t=3";
const first = app.request(url);
const second = app.request(url);
await vi.waitFor(() => expect(adapter.generateThumbnail).toHaveBeenCalledTimes(1));
resolve(Buffer.from("shared"));
expect(await (await first).text()).toBe("shared");
expect(await (await second).text()).toBe("shared");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(1);
const cached = readdirSync(join(project.dir, ".thumbnails"));
expect(cached).toHaveLength(1);
expect(cached[0]).not.toContain(".tmp");
});
it("forwards png capture requests and returns a png content type", async () => {
const adapter = createAdapter();
const app = new Hono();
@@ -72,10 +108,27 @@ describe("registerThumbnailRoutes", () => {
compPath: "compositions/intro.html",
seekTime: 2,
format: "png",
outputWidth: 1920,
outputHeight: 1080,
}),
);
});
it("allows png callers to opt into bounded preview output", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const response = await app.request(
"http://localhost/projects/demo/thumbnail/index.html?format=png&output=preview",
);
expect(response.status).toBe(200);
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
expect.objectContaining({ outputWidth: 240, outputHeight: 135 }),
);
});
it("preserves an explicit zero seek time", async () => {
const adapter = createAdapter();
const app = new Hono();
@@ -220,4 +273,26 @@ describe("registerThumbnailRoutes", () => {
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
it("prunes expired and over-budget files without touching protected work", () => {
const cacheDir = mkdtempSync(join(tmpdir(), "hf-thumbnail-cache-test-"));
tempProjectDirs.push(cacheDir);
const expiredPath = join(cacheDir, "expired.jpg");
const protectedPath = join(cacheDir, "protected.jpg");
const overflowPath = join(cacheDir, "overflow.jpg");
writeFileSync(expiredPath, "expired");
writeFileSync(protectedPath, "protected");
writeFileSync(overflowPath, "overflow");
const now = Date.now();
const expiredSeconds = (now - 15 * 24 * 60 * 60 * 1000) / 1000;
utimesSync(expiredPath, expiredSeconds, expiredSeconds);
truncateSync(protectedPath, 400 * 1024 * 1024);
truncateSync(overflowPath, 200 * 1024 * 1024);
pruneThumbnailCache(cacheDir, new Set([protectedPath]), now);
expect(existsSync(expiredPath)).toBe(false);
expect(existsSync(protectedPath)).toBe(true);
expect(existsSync(overflowPath)).toBe(false);
});
});
+115 -16
View File
@@ -1,12 +1,76 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import type { StudioApiAdapter } from "../types.js";
import { STUDIO_MANUAL_EDITS_PATH } from "../helpers/manualEditsRenderScript.js";
import { STUDIO_MOTION_PATH } from "../helpers/studioMotionRenderScript.js";
import { thumbnailGenerationCoordinator } from "./thumbnailGenerationCoordinator.js";
const THUMBNAIL_CACHE_VERSION = "v4";
const THUMBNAIL_MAX_OUTPUT_WIDTH = 240;
const THUMBNAIL_MAX_OUTPUT_HEIGHT = 135;
const THUMBNAIL_CACHE_MAX_BYTES = 512 * 1024 * 1024;
const THUMBNAIL_CACHE_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
const prunedCacheDirs = new Set<string>();
export function pruneThumbnailCache(
cacheDir: string,
protectedPaths: ReadonlySet<string>,
now = Date.now(),
): void {
if (!existsSync(cacheDir)) return;
const files = readdirSync(cacheDir, { withFileTypes: true }).flatMap((entry) => {
if (!entry.isFile()) return [];
const path = join(cacheDir, entry.name);
try {
const stats = statSync(path);
return [{ path, bytes: stats.size, mtimeMs: stats.mtimeMs }];
} catch {
return [];
}
});
const retained = [];
for (const file of files) {
if (!protectedPaths.has(file.path) && now - file.mtimeMs > THUMBNAIL_CACHE_MAX_AGE_MS) {
rmSync(file.path, { force: true });
} else {
retained.push(file);
}
}
let bytes = retained.reduce((total, file) => total + file.bytes, 0);
for (const file of retained.sort((left, right) => left.mtimeMs - right.mtimeMs)) {
if (bytes <= THUMBNAIL_CACHE_MAX_BYTES) break;
if (protectedPaths.has(file.path)) continue;
try {
unlinkSync(file.path);
bytes -= file.bytes;
} catch {
// Another request may have pruned the same file.
}
}
}
function writeThumbnailAtomically(path: string, buffer: Buffer): void {
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
try {
writeFileSync(temporaryPath, buffer, { flag: "wx" });
renameSync(temporaryPath, path);
} finally {
rmSync(temporaryPath, { force: true });
}
}
export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/thumbnail/*", async (c) => {
@@ -30,6 +94,13 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
const selector = url.searchParams.get("selector") || undefined;
const format = url.searchParams.get("format") === "png" ? "png" : "jpeg";
const contentType = format === "png" ? "image/png" : "image/jpeg";
const requestedOutput = url.searchParams.get("output");
// PNG is the legacy source-density capture contract. Callers can opt either
// format into the bounded preview contract explicitly.
const outputMode =
requestedOutput === "source" || (requestedOutput !== "preview" && format === "png")
? "source"
: "preview";
const rawSelectorIndex = Number.parseInt(url.searchParams.get("selectorIndex") || "0", 10);
const selectorIndex =
Number.isFinite(rawSelectorIndex) && rawSelectorIndex > 0 ? rawSelectorIndex : undefined;
@@ -86,8 +157,21 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
const urlVersionKey = urlVersion
? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}`
: "";
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}${motionKey}${sourceKey}_${format}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
const outputScale =
outputMode === "source"
? 1
: Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH);
const outputWidth = Math.max(1, Math.round(compW * outputScale));
const outputHeight = Math.max(1, Math.round(compH * outputScale));
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}${motionKey}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
const cachePath = join(cacheDir, cacheKey);
if (!prunedCacheDirs.has(cacheDir)) {
prunedCacheDirs.add(cacheDir);
pruneThumbnailCache(
cacheDir,
new Set([...thumbnailGenerationCoordinator.protectedKeys(), cachePath]),
);
}
if (existsSync(cachePath)) {
return new Response(new Uint8Array(readFileSync(cachePath)), {
headers: { "Content-Type": contentType, "Cache-Control": "no-cache" },
@@ -95,29 +179,44 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
}
try {
const buffer = await adapter.generateThumbnail({
project,
compPath,
seekTime,
width: compW,
height: compH,
previewUrl,
selector,
format,
selectorIndex,
});
const buffer = await thumbnailGenerationCoordinator.acquire(
cachePath,
c.req.raw.signal,
async (signal) => {
const generated = await adapter.generateThumbnail!({
project,
compPath,
seekTime,
width: compW,
height: compH,
outputWidth,
outputHeight,
previewUrl,
selector,
format,
selectorIndex,
signal,
});
if (!generated) return null;
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
writeThumbnailAtomically(cachePath, generated);
return generated;
},
);
if (!buffer) {
return c.json(
{ error: "Thumbnail generation failed — Chrome browser may not be available" },
500,
);
}
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, buffer);
pruneThumbnailCache(cacheDir, thumbnailGenerationCoordinator.protectedKeys());
return new Response(new Uint8Array(buffer), {
headers: { "Content-Type": contentType, "Cache-Control": "no-cache" },
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return new Response(null, { status: 499 });
}
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
}
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from "vitest";
import { ThumbnailGenerationCoordinator } from "./thumbnailGenerationCoordinator";
function deferred() {
let resolve!: (value: Buffer | null) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<Buffer | null>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
describe("ThumbnailGenerationCoordinator", () => {
it("deduplicates same-key leases and bounds different-key concurrency", async () => {
const coordinator = new ThumbnailGenerationCoordinator(2);
const first = deferred();
const second = deferred();
const starts: string[] = [];
const signal = new AbortController().signal;
const a = coordinator.acquire("a", signal, async () => {
starts.push("a");
return first.promise;
});
const duplicateWork = vi.fn(async () => Buffer.from("wrong"));
const duplicate = coordinator.acquire("a", signal, duplicateWork);
const b = coordinator.acquire("b", signal, async () => {
starts.push("b");
return second.promise;
});
const c = coordinator.acquire("c", signal, async () => {
starts.push("c");
return Buffer.from("c");
});
expect(starts).toEqual(["a", "b"]);
first.resolve(Buffer.from("a"));
second.resolve(Buffer.from("b"));
await expect(Promise.all([a, duplicate, b, c])).resolves.toEqual([
Buffer.from("a"),
Buffer.from("a"),
Buffer.from("b"),
Buffer.from("c"),
]);
expect(duplicateWork).not.toHaveBeenCalled();
expect(starts).toEqual(["a", "b", "c"]);
});
it("keeps shared work alive until its final lease leaves", async () => {
const coordinator = new ThumbnailGenerationCoordinator();
const firstController = new AbortController();
const secondController = new AbortController();
let workSignal: AbortSignal | undefined;
const work = deferred();
const first = coordinator.acquire("shared", firstController.signal, async (signal) => {
workSignal = signal;
return work.promise;
});
const second = coordinator.acquire("shared", secondController.signal, vi.fn());
firstController.abort();
await expect(first).rejects.toMatchObject({ name: "AbortError" });
expect(workSignal?.aborted).toBe(false);
secondController.abort();
await expect(second).rejects.toMatchObject({ name: "AbortError" });
expect(workSignal?.aborted).toBe(true);
work.reject(new DOMException("Aborted", "AbortError"));
await vi.waitFor(() => expect(coordinator.protectedKeys().size).toBe(0));
});
it("removes an unleased queued job without starting it", async () => {
const coordinator = new ThumbnailGenerationCoordinator(1);
const activeWork = deferred();
const active = coordinator.acquire(
"active",
new AbortController().signal,
async () => activeWork.promise,
);
const queuedController = new AbortController();
const queuedWork = vi.fn(async () => Buffer.from("queued"));
const queued = coordinator.acquire("queued", queuedController.signal, queuedWork);
queuedController.abort();
await expect(queued).rejects.toMatchObject({ name: "AbortError" });
activeWork.resolve(Buffer.from("active"));
await expect(active).resolves.toEqual(Buffer.from("active"));
expect(queuedWork).not.toHaveBeenCalled();
});
it("does not attach a new lease to work already aborted by its final lease", async () => {
const coordinator = new ThumbnailGenerationCoordinator();
const firstController = new AbortController();
const firstWork = deferred();
const first = coordinator.acquire(
"same",
firstController.signal,
async () => firstWork.promise,
);
firstController.abort();
await expect(first).rejects.toMatchObject({ name: "AbortError" });
const replacementWork = vi.fn(async () => Buffer.from("replacement"));
const replacement = coordinator.acquire("same", new AbortController().signal, replacementWork);
expect(replacementWork).not.toHaveBeenCalled();
firstWork.reject(new DOMException("Aborted", "AbortError"));
await expect(replacement).resolves.toEqual(Buffer.from("replacement"));
expect(replacementWork).toHaveBeenCalledTimes(1);
});
it("does not enqueue work for an already-aborted lease", async () => {
const coordinator = new ThumbnailGenerationCoordinator();
const controller = new AbortController();
const work = vi.fn(async () => Buffer.from("unexpected"));
controller.abort();
await expect(coordinator.acquire("aborted", controller.signal, work)).rejects.toMatchObject({
name: "AbortError",
});
expect(work).not.toHaveBeenCalled();
expect(coordinator.protectedKeys().size).toBe(0);
});
});
@@ -0,0 +1,129 @@
export type ThumbnailGenerationValue = Buffer | null;
export type ThumbnailGenerationWork = (signal: AbortSignal) => Promise<ThumbnailGenerationValue>;
interface GenerationEntry {
key: string;
controller: AbortController;
leases: number;
state: "queued" | "active";
work: ThumbnailGenerationWork;
promise: Promise<ThumbnailGenerationValue>;
resolve: (value: ThumbnailGenerationValue) => void;
reject: (reason: unknown) => void;
}
/** Sole server owner for same-key dedupe, concurrency, cancellation, and queue order. */
export class ThumbnailGenerationCoordinator {
private readonly entries = new Map<string, GenerationEntry>();
private readonly queue: GenerationEntry[] = [];
private readonly activeEntries = new Set<GenerationEntry>();
private active = 0;
constructor(private readonly concurrency = 1) {
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new RangeError("Thumbnail concurrency must be a positive integer");
}
}
acquire(
key: string,
signal: AbortSignal,
work: ThumbnailGenerationWork,
): Promise<ThumbnailGenerationValue> {
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
let entry = this.entries.get(key);
if (!entry) {
let resolve!: (value: ThumbnailGenerationValue) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<ThumbnailGenerationValue>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
entry = {
key,
controller: new AbortController(),
leases: 0,
state: "queued",
work,
promise,
resolve,
reject,
};
this.entries.set(key, entry);
this.queue.push(entry);
}
entry.leases++;
this.pump();
return this.lease(entry, signal);
}
protectedKeys(): ReadonlySet<string> {
return new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
}
private lease(entry: GenerationEntry, signal: AbortSignal): Promise<ThumbnailGenerationValue> {
return new Promise((resolve, reject) => {
let released = false;
const release = () => {
if (released) return;
released = true;
signal.removeEventListener("abort", onAbort);
entry.leases--;
if (entry.leases > 0 || !this.entries.has(entry.key)) return;
entry.controller.abort();
if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key);
if (entry.state === "queued") {
const index = this.queue.indexOf(entry);
if (index >= 0) this.queue.splice(index, 1);
entry.reject(new DOMException("Aborted", "AbortError"));
}
};
const onAbort = () => {
release();
reject(new DOMException("Aborted", "AbortError"));
};
signal.addEventListener("abort", onAbort, { once: true });
entry.promise.then(
(value) => {
release();
resolve(value);
},
(reason) => {
release();
reject(reason);
},
);
});
}
private pump(): void {
while (this.active < this.concurrency) {
const entry = this.queue.shift();
if (!entry) return;
if (entry.leases === 0) continue;
entry.state = "active";
this.activeEntries.add(entry);
this.active++;
void this.run(entry);
}
}
private async run(entry: GenerationEntry): Promise<void> {
try {
entry.resolve(await entry.work(entry.controller.signal));
} catch (error) {
entry.reject(error);
} finally {
this.active--;
this.activeEntries.delete(entry);
if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key);
this.pump();
}
}
}
export const thumbnailGenerationCoordinator = new ThumbnailGenerationCoordinator(1);
+4 -1
View File
@@ -186,17 +186,20 @@ export interface StudioApiAdapter {
jobId: string;
}) => MediaProcessingJobState;
/** Optional: generate a JPEG thumbnail via Puppeteer or similar. */
/** Optional: generate a thumbnail at the route's explicit output dimensions. */
generateThumbnail?: (opts: {
project: ResolvedProject;
compPath: string;
seekTime: number;
width: number;
height: number;
outputWidth: number;
outputHeight: number;
previewUrl: string;
selector?: string;
format?: "jpeg" | "png";
selectorIndex?: number;
signal: AbortSignal;
}) => Promise<Buffer | null>;
/** Optional: resolve session ID to project (multi-project mode). */