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
6 changes: 2 additions & 4 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
createBackgroundRemovalJob,
consumeFileWriteReceipt,
getMimeType,
thumbnailDeviceScaleFactor,
type StudioApiAdapter,
type ResolvedProject,
type RenderJobState,
Expand Down Expand Up @@ -493,10 +494,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
await page.setViewport({
width,
height,
deviceScaleFactor: Math.max(
0.1,
Math.min(1, opts.outputWidth / width, opts.outputHeight / height),
),
deviceScaleFactor: thumbnailDeviceScaleFactor(opts),
});
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await page
Expand Down
37 changes: 37 additions & 0 deletions packages/studio-server/src/helpers/thumbnailOutput.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 20 additions & 0 deletions packages/studio-server/src/helpers/thumbnailOutput.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions packages/studio-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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,
Expand Down
79 changes: 77 additions & 2 deletions packages/studio-server/src/routes/thumbnail.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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);
});
});
131 changes: 115 additions & 16 deletions packages/studio-server/src/routes/thumbnail.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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;
Expand Down Expand Up @@ -86,38 +157,66 @@ 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" },
});
}

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