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
23 changes: 18 additions & 5 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
consumeFileWriteReceipt,
getMimeType,
type PreviewApiAdapter,
thumbnailDeviceScaleFactor,
type ResolvedProject,
type RenderJobState,
type BackgroundRemovalRender,
Expand Down Expand Up @@ -500,9 +501,18 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
return null;
}
let page: import("puppeteer-core").Page | null = null;
const closePage = () => void page?.close().catch(() => {});
opts.signal.addEventListener("abort", closePage, { once: true });
try {
page = await browser.newPage();
await page.setViewport({ width: opts.width || 1920, height: opts.height || 1080 });
if (opts.signal.aborted) return null;
const width = opts.width || 1920;
const height = opts.height || 1080;
await page.setViewport({
width,
height,
deviceScaleFactor: thumbnailDeviceScaleFactor(opts),
});
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await page
.waitForFunction(
Expand Down Expand Up @@ -560,12 +570,15 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
)) as Buffer;
return screenshot;
} catch (err) {
console.warn(
"[Studio] Thumbnail generation failed:",
err instanceof Error ? err.message : err,
);
if (!opts.signal.aborted) {
console.warn(
"[Studio] Thumbnail generation failed:",
err instanceof Error ? err.message : err,
);
}
return null;
} finally {
opts.signal.removeEventListener("abort", closePage);
await page?.close().catch(() => {});
}
},
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 @@ -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,
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);
});
});
Loading
Loading