From 3146b27b7aaa3a9eaffe88080243a67bd3f1e8f6 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 20 Jul 2026 21:32:44 +0200 Subject: [PATCH] perf(studio-server): coordinate cancelable thumbnail generation --- packages/cli/src/server/studioServer.ts | 23 +- .../src/helpers/thumbnailOutput.test.ts | 37 +++ .../src/helpers/thumbnailOutput.ts | 20 ++ packages/studio-server/src/index.ts | 4 + .../src/routes/thumbnail.test.ts | 79 +++++- .../studio-server/src/routes/thumbnail.ts | 131 +++++++-- .../thumbnailGenerationCoordinator.test.ts | 123 +++++++++ .../routes/thumbnailGenerationCoordinator.ts | 129 +++++++++ packages/studio-server/src/types.ts | 5 +- .../studio/src/utils/frameCapture.test.ts | 2 +- packages/studio/src/utils/frameCapture.ts | 1 + .../studio/src/utils/projectRouting.test.ts | 2 +- .../src/utils/studioSelectionSnapshot.test.ts | 2 +- .../src/utils/studioSelectionSnapshot.ts | 1 + packages/studio/vite.browser.test.ts | 89 ++++++ packages/studio/vite.browser.ts | 261 ++++++++++-------- packages/studio/vite.thumbnail.ts | 7 +- 17 files changed, 771 insertions(+), 145 deletions(-) create mode 100644 packages/studio-server/src/helpers/thumbnailOutput.test.ts create mode 100644 packages/studio-server/src/helpers/thumbnailOutput.ts create mode 100644 packages/studio-server/src/routes/thumbnailGenerationCoordinator.test.ts create mode 100644 packages/studio-server/src/routes/thumbnailGenerationCoordinator.ts create mode 100644 packages/studio/vite.browser.test.ts diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 358189ad63..28349c7f32 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -27,6 +27,7 @@ import { consumeFileWriteReceipt, getMimeType, type PreviewApiAdapter, + thumbnailDeviceScaleFactor, type ResolvedProject, type RenderJobState, type BackgroundRemovalRender, @@ -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( @@ -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(() => {}); } }, diff --git a/packages/studio-server/src/helpers/thumbnailOutput.test.ts b/packages/studio-server/src/helpers/thumbnailOutput.test.ts new file mode 100644 index 0000000000..67a265e830 --- /dev/null +++ b/packages/studio-server/src/helpers/thumbnailOutput.test.ts @@ -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); + }); +}); diff --git a/packages/studio-server/src/helpers/thumbnailOutput.ts b/packages/studio-server/src/helpers/thumbnailOutput.ts new file mode 100644 index 0000000000..6138eff42b --- /dev/null +++ b/packages/studio-server/src/helpers/thumbnailOutput.ts @@ -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); +} diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index 88e8f236a6..834b3459c8 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -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, diff --git a/packages/studio-server/src/routes/thumbnail.test.ts b/packages/studio-server/src/routes/thumbnail.test.ts index 7946e846ff..5543a9f5ab 100644 --- a/packages/studio-server/src/routes/thumbnail.test.ts +++ b/packages/studio-server/src/routes/thumbnail.test.ts @@ -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((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); + }); }); diff --git a/packages/studio-server/src/routes/thumbnail.ts b/packages/studio-server/src/routes/thumbnail.ts index ab18df5e91..0f2fba697e 100644 --- a/packages/studio-server/src/routes/thumbnail.ts +++ b/packages/studio-server/src/routes/thumbnail.ts @@ -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(); + +export function pruneThumbnailCache( + cacheDir: string, + protectedPaths: ReadonlySet, + 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); } diff --git a/packages/studio-server/src/routes/thumbnailGenerationCoordinator.test.ts b/packages/studio-server/src/routes/thumbnailGenerationCoordinator.test.ts new file mode 100644 index 0000000000..3398ef669d --- /dev/null +++ b/packages/studio-server/src/routes/thumbnailGenerationCoordinator.test.ts @@ -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((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); + }); +}); diff --git a/packages/studio-server/src/routes/thumbnailGenerationCoordinator.ts b/packages/studio-server/src/routes/thumbnailGenerationCoordinator.ts new file mode 100644 index 0000000000..6804df3b0e --- /dev/null +++ b/packages/studio-server/src/routes/thumbnailGenerationCoordinator.ts @@ -0,0 +1,129 @@ +export type ThumbnailGenerationValue = Buffer | null; +export type ThumbnailGenerationWork = (signal: AbortSignal) => Promise; + +interface GenerationEntry { + key: string; + controller: AbortController; + leases: number; + state: "queued" | "active"; + work: ThumbnailGenerationWork; + promise: Promise; + 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(); + private readonly queue: GenerationEntry[] = []; + private readonly activeEntries = new Set(); + 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 { + 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((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 { + return new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]); + } + + private lease(entry: GenerationEntry, signal: AbortSignal): Promise { + 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 { + 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); diff --git a/packages/studio-server/src/types.ts b/packages/studio-server/src/types.ts index f9223ece09..c5387b4e18 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -179,17 +179,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; /** Optional: resolve session ID to project (multi-project mode). */ diff --git a/packages/studio/src/utils/frameCapture.test.ts b/packages/studio/src/utils/frameCapture.test.ts index b4dc4193cd..e48ac79b0c 100644 --- a/packages/studio/src/utils/frameCapture.test.ts +++ b/packages/studio/src/utils/frameCapture.test.ts @@ -14,7 +14,7 @@ describe("frame capture utilities", () => { origin: "http://localhost:5194", }), ).toBe( - "http://localhost:5194/api/projects/demo%20project/thumbnail/index.html?t=1.235&format=png&v=1777464000000", + "http://localhost:5194/api/projects/demo%20project/thumbnail/index.html?t=1.235&format=png&output=source&v=1777464000000", ); vi.useRealTimers(); diff --git a/packages/studio/src/utils/frameCapture.ts b/packages/studio/src/utils/frameCapture.ts index bd513b4676..bba31ad3fa 100644 --- a/packages/studio/src/utils/frameCapture.ts +++ b/packages/studio/src/utils/frameCapture.ts @@ -24,6 +24,7 @@ export function buildFrameCaptureUrl({ ); url.searchParams.set("t", Math.max(0, currentTime).toFixed(3)); url.searchParams.set("format", "png"); + url.searchParams.set("output", "source"); url.searchParams.set("v", String(Date.now())); return url.toString(); } diff --git a/packages/studio/src/utils/projectRouting.test.ts b/packages/studio/src/utils/projectRouting.test.ts index 95b1580e51..1f1e0badc3 100644 --- a/packages/studio/src/utils/projectRouting.test.ts +++ b/packages/studio/src/utils/projectRouting.test.ts @@ -24,7 +24,7 @@ describe("project routing utilities", () => { origin: "http://localhost:3002", }), ).toBe( - "http://localhost:3002/api/projects/Notion%20Showcase/thumbnail/index.html?t=1.809&format=png&v=1777636800000", + "http://localhost:3002/api/projects/Notion%20Showcase/thumbnail/index.html?t=1.809&format=png&output=source&v=1777636800000", ); vi.useRealTimers(); diff --git a/packages/studio/src/utils/studioSelectionSnapshot.test.ts b/packages/studio/src/utils/studioSelectionSnapshot.test.ts index 0175d3096f..fb645ba83a 100644 --- a/packages/studio/src/utils/studioSelectionSnapshot.test.ts +++ b/packages/studio/src/utils/studioSelectionSnapshot.test.ts @@ -39,7 +39,7 @@ describe("buildStudioSelectionSnapshot", () => { currentTime: 1.25, target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 }, thumbnailUrl: - "/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0", + "/api/projects/demo/thumbnail/index.html?t=1.25&format=png&output=source&selector=.title&selectorIndex=0", }); expect(JSON.stringify(snapshot)).not.toContain("HTMLElement"); expect(snapshot).not.toHaveProperty("element"); diff --git a/packages/studio/src/utils/studioSelectionSnapshot.ts b/packages/studio/src/utils/studioSelectionSnapshot.ts index 594dd47e17..b5499de57c 100644 --- a/packages/studio/src/utils/studioSelectionSnapshot.ts +++ b/packages/studio/src/utils/studioSelectionSnapshot.ts @@ -20,6 +20,7 @@ function thumbnailUrl({ const params = new URLSearchParams({ t: String(round3(currentTime)), format: "png", + output: "source", }); if (selection.selector) params.set("selector", selection.selector); if (selection.selectorIndex != null) params.set("selectorIndex", String(selection.selectorIndex)); diff --git a/packages/studio/vite.browser.test.ts b/packages/studio/vite.browser.test.ts new file mode 100644 index 0000000000..a4e8eb4631 --- /dev/null +++ b/packages/studio/vite.browser.test.ts @@ -0,0 +1,89 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const browserMocks = vi.hoisted(() => { + const newPage = vi.fn(async () => ({ + setViewport: vi.fn(async () => {}), + goto: vi.fn(async () => {}), + evaluate: vi.fn(async () => undefined), + waitForFunction: vi.fn(async () => undefined), + addScriptTag: vi.fn(async () => undefined), + screenshot: vi.fn(async () => Buffer.from("thumbnail")), + close: vi.fn(async () => {}), + })); + const launch = vi.fn(async () => ({ connected: true, newPage })); + return { launch, newPage }; +}); + +vi.mock("puppeteer-core", () => ({ default: { launch: browserMocks.launch } })); + +import { generateThumbnail, type GenerateThumbnailOptions } from "./vite.browser"; + +const originalExecutable = process.env.PUPPETEER_EXECUTABLE_PATH; + +function options(signal = new AbortController().signal): GenerateThumbnailOptions { + return { + project: { dir: process.cwd() }, + compPath: "index.html", + seekTime: 1, + previewUrl: "http://localhost/preview", + width: 1920, + height: 1080, + outputWidth: 240, + outputHeight: 135, + format: "jpeg", + signal, + }; +} + +describe("generateThumbnail", () => { + beforeAll(() => { + process.env.PUPPETEER_EXECUTABLE_PATH = process.execPath; + }); + + beforeEach(() => { + browserMocks.newPage.mockClear(); + }); + + afterAll(() => { + if (originalExecutable == null) delete process.env.PUPPETEER_EXECUTABLE_PATH; + else process.env.PUPPETEER_EXECUTABLE_PATH = originalExecutable; + }); + + it("retries browser launch after a transient failure", async () => { + browserMocks.launch.mockClear(); + browserMocks.launch.mockRejectedValueOnce(new Error("launch failed")); + + await expect(generateThumbnail(options())).rejects.toThrow("launch failed"); + await expect(generateThumbnail(options())).resolves.toEqual(Buffer.from("thumbnail")); + expect(browserMocks.launch).toHaveBeenCalledTimes(2); + }); + + it("captures at the route-provided physical output size", async () => { + await expect(generateThumbnail(options())).resolves.toEqual(Buffer.from("thumbnail")); + const result = browserMocks.newPage.mock.results[0]; + if (!result) throw new Error("page was not created"); + const page = await result.value; + + expect(page.setViewport).toHaveBeenCalledWith({ + width: 1920, + height: 1080, + deviceScaleFactor: 0.125, + }); + expect(page.close).toHaveBeenCalledTimes(1); + }); + + it("lets the route own dedupe and closes browser work on cancellation", async () => { + const controller = new AbortController(); + const cancelled = generateThumbnail(options(controller.signal)); + await vi.waitFor(() => expect(browserMocks.newPage).toHaveBeenCalledTimes(1)); + controller.abort(); + await expect(cancelled).resolves.toBeNull(); + const cancelledResult = browserMocks.newPage.mock.results[0]; + if (!cancelledResult) throw new Error("cancelled page was not created"); + expect((await cancelledResult.value).close).toHaveBeenCalled(); + + browserMocks.newPage.mockClear(); + await Promise.all([generateThumbnail(options()), generateThumbnail(options())]); + expect(browserMocks.newPage).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/studio/vite.browser.ts b/packages/studio/vite.browser.ts index 9d1482cdfb..a3102045ff 100644 --- a/packages/studio/vite.browser.ts +++ b/packages/studio/vite.browser.ts @@ -1,31 +1,77 @@ // Shared Puppeteer browser management and thumbnail generation for Studio dev server. -import { existsSync } from "node:fs"; -import { createHash } from "node:crypto"; -import { - createStudioDevRenderBodyScripts, - readStudioDevManualEditManifestContent, - readStudioDevMotionManifestContent, -} from "./vite.studioMotion"; +import { existsSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { thumbnailDeviceScaleFactor } from "@hyperframes/studio-server"; +import { createStudioDevRenderBodyScripts } from "./vite.studioMotion"; import { seekThumbnailPreview } from "./vite.thumbnail"; // ── Shared Puppeteer browser ───────────────────────────────────────────────── let _browser: import("puppeteer-core").Browser | null = null; -let _browserLaunchPromise: Promise | null = null; +let _browserLaunchPromise: Promise | null = null; -const CHROME_PATHS = [ +// Installed system browsers (Chrome/Chromium-family), macOS + Linux. +const CHROME_APP_PATHS = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", "/usr/bin/chromium-browser", ]; +// Chrome-for-Testing downloaded by Puppeteer (or `npx puppeteer browsers install`). +// Layout: /chrome/-/chrome-/. +// Prefer the highest version so a stale early build doesn't win. +// fallow-ignore-next-line complexity +function findPuppeteerCacheChrome(): string | undefined { + const cacheRoot = process.env.PUPPETEER_CACHE_DIR || join(homedir(), ".cache", "puppeteer"); + const chromeRoot = join(cacheRoot, "chrome"); + let versionDirs: string[]; + try { + versionDirs = readdirSync(chromeRoot); + } catch { + return undefined; // no cache dir + } + const buildOrder = (dir: string): number => { + const parts = (dir.split("-").pop() ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0); + return parts.reduce((acc, n) => acc * 100000 + n, 0); + }; + const relCandidates = [ + "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + "chrome-linux64/chrome", + ]; + for (const dir of versionDirs.sort((a, b) => buildOrder(b) - buildOrder(a))) { + for (const rel of relCandidates) { + const exe = join(chromeRoot, dir, rel); + if (existsSync(exe)) return exe; + } + } + return undefined; +} + +/** Resolve a Chrome/Chromium executable: env override → system install → Puppeteer cache. */ +function resolveChromeExecutable(): string | undefined { + const envOverride = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + process.env.CHROME_PATH, + process.env.CHROME_BIN, + ].find((p): p is string => !!p && existsSync(p)); + return envOverride ?? CHROME_APP_PATHS.find((p) => existsSync(p)) ?? findPuppeteerCacheChrome(); +} + async function getSharedBrowser(): Promise { if (_browser?.connected) return _browser; if (_browserLaunchPromise) return _browserLaunchPromise; - _browserLaunchPromise = (async () => { + const launchPromise = (async () => { const puppeteer = await import("puppeteer-core"); - const executablePath = CHROME_PATHS.find((p) => existsSync(p)); + const executablePath = resolveChromeExecutable(); if (!executablePath) return null; _browser = await puppeteer.default.launch({ headless: true, @@ -40,21 +86,21 @@ async function getSharedBrowser(): Promise existsSync(p)); + return resolveChromeExecutable(); } -// In-flight thumbnail dedup -const _thumbnailInflight = new Map>(); -const THUMBNAIL_CACHE_VERSION = "v4"; - interface ScreenshotClip { x: number; y: number; @@ -99,104 +145,95 @@ export interface GenerateThumbnailOptions { previewUrl: string; width: number; height: number; - format: "jpeg" | "png"; + outputWidth: number; + outputHeight: number; + format?: "jpeg" | "png"; selector?: string; selectorIndex?: number; + signal: AbortSignal; } export async function generateThumbnail(opts: GenerateThumbnailOptions): Promise { - const selectorKey = opts.selector - ? `_${opts.selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${opts.selectorIndex ?? 0}` - : ""; - const manualManifestContent = readStudioDevManualEditManifestContent(opts.project.dir); - const manualManifestKey = manualManifestContent.trim() - ? `_${createHash("sha1").update(manualManifestContent).digest("hex").slice(0, 16)}` - : ""; - const motionManifestContent = readStudioDevMotionManifestContent(opts.project.dir); - const motionManifestKey = motionManifestContent.trim() - ? `_${createHash("sha1").update(motionManifestContent).digest("hex").slice(0, 16)}` - : ""; - const cacheKey = `${THUMBNAIL_CACHE_VERSION}${manualManifestKey}${motionManifestKey}_${opts.compPath.replace(/\//g, "_")}_${opts.seekTime.toFixed(2)}${selectorKey}.${opts.format === "png" ? "png" : "jpg"}`; - - let bufferPromise = _thumbnailInflight.get(cacheKey); - if (!bufferPromise) { - bufferPromise = (async () => { - const browser = await getSharedBrowser(); - if (!browser) return null; - let page: Awaited> | null = null; - try { - page = await browser.newPage(); - await page.setViewport({ - width: opts.width, - height: opts.height, - deviceScaleFactor: opts.format === "png" ? 1 : 0.5, - }); - await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 }); - await page.evaluate(() => { - document.documentElement.style.background = "#1c2028"; - document.body.style.background = "#1c2028"; - document.body.style.margin = "0"; - document.body.style.overflow = "hidden"; - }); - await page - .waitForFunction(`!!(window.__timelines && Object.keys(window.__timelines).length > 0)`, { - timeout: 5000, - }) - .catch(() => {}); - await seekThumbnailPreview(page, opts.seekTime); - await applyStudioRenderBodyScriptsToThumbnailPage(page, opts.project.dir, opts.compPath); - await page.evaluate("document.fonts?.ready"); - await new Promise((r) => setTimeout(r, 200)); - await reapplyStudioRenderBodyScriptsToThumbnailPage(page); - let clip: ScreenshotClip | undefined; - if (opts.selector) { - clip = await page.evaluate( - (selector: string, selectorIndex: number | undefined) => { - const matches = Array.from(document.querySelectorAll(selector)).filter( - (el): el is HTMLElement => el instanceof HTMLElement, - ); - const safeIndex = Math.max( - 0, - Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)), - ); - const el = matches[safeIndex] ?? null; - if (!(el instanceof HTMLElement)) return undefined; - const rect = el.getBoundingClientRect(); - if (rect.width < 4 || rect.height < 4) return undefined; - const pad = 8; - const x = Math.max(0, rect.left - pad); - const y = Math.max(0, rect.top - pad); - const maxWidth = window.innerWidth - x; - const maxHeight = window.innerHeight - y; - return { - x, - y, - width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)), - height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)), - }; - }, - opts.selector, - opts.selectorIndex, + if (opts.signal.aborted) return null; + const format = opts.format ?? "jpeg"; + const browser = await getSharedBrowser(); + if (!browser || opts.signal.aborted) return null; + let page: Awaited> | null = null; + const closePage = () => void page?.close().catch(() => {}); + opts.signal.addEventListener("abort", closePage, { once: true }); + try { + page = await browser.newPage(); + if (opts.signal.aborted) return null; + await page.setViewport({ + width: opts.width, + height: opts.height, + deviceScaleFactor: thumbnailDeviceScaleFactor(opts), + }); + await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 }); + await page.evaluate(() => { + document.documentElement.style.background = "#1c2028"; + document.body.style.background = "#1c2028"; + document.body.style.margin = "0"; + document.body.style.overflow = "hidden"; + }); + await page + .waitForFunction(`!!(window.__timelines && Object.keys(window.__timelines).length > 0)`, { + timeout: 5000, + }) + .catch(() => {}); + await seekThumbnailPreview(page, opts.seekTime); + await applyStudioRenderBodyScriptsToThumbnailPage(page, opts.project.dir, opts.compPath); + await page.evaluate("document.fonts?.ready"); + await new Promise((r) => setTimeout(r, 200)); + await reapplyStudioRenderBodyScriptsToThumbnailPage(page); + let clip: ScreenshotClip | undefined; + if (opts.selector) { + clip = await page.evaluate( + (selector: string, selectorIndex: number | undefined) => { + const matches = Array.from(document.querySelectorAll(selector)).filter( + (el): el is HTMLElement => el instanceof HTMLElement, ); - } - const buf = await page.screenshot( - opts.format === "png" - ? { type: "png", ...(clip ? { clip } : {}) } - : { type: "jpeg", quality: 75, ...(clip ? { clip } : {}) }, - ); - await page.close(); - return buf as Buffer; - } catch (err) { - if (page) await page.close().catch(() => {}); - console.warn( - "[Studio] Thumbnail generation failed:", - err instanceof Error ? err.message : err, - ); - return null; - } - })(); - _thumbnailInflight.set(cacheKey, bufferPromise); - bufferPromise.finally(() => _thumbnailInflight.delete(cacheKey)); + const safeIndex = Math.max( + 0, + Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)), + ); + const el = matches[safeIndex] ?? null; + if (!(el instanceof HTMLElement)) return undefined; + const rect = el.getBoundingClientRect(); + if (rect.width < 4 || rect.height < 4) return undefined; + const pad = 8; + const x = Math.max(0, rect.left - pad); + const y = Math.max(0, rect.top - pad); + const maxWidth = window.innerWidth - x; + const maxHeight = window.innerHeight - y; + return { + x, + y, + width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)), + height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)), + }; + }, + opts.selector, + opts.selectorIndex, + ); + } + if (opts.signal.aborted) return null; + const buf = await page.screenshot( + format === "png" + ? { type: "png", ...(clip ? { clip } : {}) } + : { type: "jpeg", quality: 75, ...(clip ? { clip } : {}) }, + ); + return Buffer.from(buf); + } catch (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(() => {}); } - return bufferPromise; } diff --git a/packages/studio/vite.thumbnail.ts b/packages/studio/vite.thumbnail.ts index 3fc7227aa6..3c9eb9a6e9 100644 --- a/packages/studio/vite.thumbnail.ts +++ b/packages/studio/vite.thumbnail.ts @@ -1,9 +1,4 @@ -interface ThumbnailPreviewPage { - evaluate( - fn: (arg: TArg) => TResult | Promise, - arg: TArg, - ): Promise; -} +type ThumbnailPreviewPage = Pick; type SeekResult = "player" | "timelines" | "none";