mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user