diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 8d6ebf1e7..613a3ddb7 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -32,6 +32,7 @@ import { consumeFileWriteReceipt, getMimeType, type PreviewApiAdapter, + thumbnailDeviceScaleFactor, type ResolvedProject, type RenderJobState, type BackgroundRemovalRender, @@ -571,9 +572,18 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { ); } let page: import("puppeteer-core").Page | null = null; + const closePage = () => void page?.close().catch(() => {}); + opts.signal.addEventListener("abort", closePage, { once: true }); try { page = await session.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( @@ -621,12 +631,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 000000000..67a265e83 --- /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 000000000..6138eff42 --- /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 88e8f236a..834b3459c 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 7946e846f..5543a9f5a 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 ab18df5e9..0f2fba697 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 000000000..3398ef669 --- /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 000000000..6804df3b0 --- /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 07bb5cffc..b569f45ac 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -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; /** Optional: resolve session ID to project (multi-project mode). */ diff --git a/packages/studio/src/components/EditorShell.selectionSync.test.tsx b/packages/studio/src/components/EditorShell.selectionSync.test.tsx new file mode 100644 index 000000000..8565f5dd4 --- /dev/null +++ b/packages/studio/src/components/EditorShell.selectionSync.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorShell } from "./EditorShell"; + +const hookMocks = vi.hoisted(() => ({ + useTimelineSelectionPreviewSync: vi.fn(), +})); + +vi.mock("../hooks/useTimelineSelectionPreviewSync", () => hookMocks); +vi.mock("../contexts/StudioContext", () => ({ + useStudioPlaybackContext: () => ({ + captionEditMode: false, + refreshKey: 0, + refreshPreviewDocumentVersion: vi.fn(), + timelineElements: [], + }), + useStudioShellContext: () => ({ + projectId: "project-1", + activeCompPath: "index.html", + setActiveCompPath: vi.fn(), + handlePreviewIframeRef: vi.fn(), + showToast: vi.fn(), + }), +})); +vi.mock("../contexts/DomEditContext", () => ({ + useDomEditActionsContext: () => ({ + handleTimelineElementSelect: vi.fn(), + buildDomSelectionForTimelineElement: vi.fn(), + applyDomSelection: vi.fn(), + applyMarqueeSelection: vi.fn(), + }), + useDomEditSelectionContext: () => ({ + domEditSelection: null, + domEditGroupSelections: [], + }), +})); +vi.mock("./nle/NLEContext", () => ({ + NLEProvider: ({ children }: { children: React.ReactNode }) => children, + useNLEContext: () => ({ + compositionStack: [], + updateCompositionStack: vi.fn(), + containerRef: { current: null }, + }), +})); +vi.mock("./nle/useTimelineEditCallbacks", () => ({ + useTimelineEditCallbacks: () => ({}), +})); +vi.mock("./nle/PreviewPane", () => ({ PreviewPane: () => null })); +vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null })); +vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null })); +vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null })); +vi.mock("./StudioFeedbackBar", () => ({ StudioFeedbackBar: () => null })); + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +afterEach(() => { + document.body.innerHTML = ""; + hookMocks.useTimelineSelectionPreviewSync.mockClear(); +}); + +describe("EditorShell timeline selection sync", () => { + it("keeps the timeline store mirrored into the preview selection", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + act(() => { + root.render( + null} + handleTimelineElementDelete={vi.fn()} + handleTimelineAssetDrop={vi.fn()} + handleTimelineFileDrop={vi.fn()} + handleTimelineElementMove={vi.fn()} + handleTimelineElementsMove={vi.fn()} + handleTimelineElementResize={vi.fn()} + handleTimelineGroupResize={vi.fn()} + handleToggleTrackHidden={vi.fn()} + handleBlockedTimelineEdit={vi.fn()} + handleTimelineElementSplit={vi.fn()} + handleRazorSplit={vi.fn()} + handleRazorSplitAll={vi.fn()} + setCompIdToSrc={vi.fn()} + setCompositionLoading={vi.fn()} + shouldShowMotionPath={false} + shouldShowSelectedDomBounds={false} + />, + ); + }); + + expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledOnce(); + expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledWith( + expect.objectContaining({ + activeCompPath: "index.html", + timelineElements: [], + domEditSelection: null, + domEditGroupSelections: [], + }), + ); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index fbf068871..3e0079a13 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -10,11 +10,12 @@ import { NLEProvider, useNLEContext } from "./nle/NLEContext"; import { CaptionTimeline } from "../captions/components/CaptionTimeline"; import { StudioFeedbackBar } from "./StudioFeedbackBar"; import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; -import { useDomEditActionsContext } from "../contexts/DomEditContext"; +import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext"; import { TimelineEditProvider } from "../contexts/TimelineEditContext"; -import type { TimelineElement } from "../player"; +import { usePlayerStore, type TimelineElement } from "../player"; import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; import type { GestureRecordingState } from "./editor/GestureRecordControl"; +import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync"; type RenderClipContent = ( element: TimelineElement, @@ -99,10 +100,35 @@ export function EditorShell({ blockPreview, gestureOverlay, }: EditorShellProps) { - const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef } = + const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef, showToast } = useStudioShellContext(); - const { refreshKey, captionEditMode, refreshPreviewDocumentVersion } = useStudioPlaybackContext(); - const { handleTimelineElementSelect } = useDomEditActionsContext(); + const { refreshKey, captionEditMode, refreshPreviewDocumentVersion, timelineElements } = + useStudioPlaybackContext(); + const { + handleTimelineElementSelect, + buildDomSelectionForTimelineElement, + applyDomSelection, + applyMarqueeSelection, + } = useDomEditActionsContext(); + const { domEditSelection, domEditGroupSelections } = useDomEditSelectionContext(); + const selectedElementId = usePlayerStore((state) => state.selectedElementId); + const selectedElementIds = usePlayerStore((state) => state.selectedElementIds); + const reportTimelineSelectionNotFound = useCallback(() => { + showToast("The selected clip is not available in the preview yet.", "info"); + }, [showToast]); + + useTimelineSelectionPreviewSync({ + selectedElementId, + selectedElementIds, + timelineElements, + domEditSelection, + domEditGroupSelections, + activeCompPath, + buildDomSelectionForTimelineElement, + applyDomSelection, + applyMarqueeSelection, + onSelectionNotFound: reportTimelineSelectionNotFound, + }); const timelineEditCallbacks = useTimelineEditCallbacks({ handleTimelineElementMove, diff --git a/packages/studio/src/components/TimelineToolbar.test.tsx b/packages/studio/src/components/TimelineToolbar.test.tsx index 0050dcfc9..2e7900e82 100644 --- a/packages/studio/src/components/TimelineToolbar.test.tsx +++ b/packages/studio/src/components/TimelineToolbar.test.tsx @@ -12,7 +12,7 @@ import { TimelineToolbar } from "./TimelineToolbar"; afterEach(() => { document.body.innerHTML = ""; - usePlayerStore.setState({ autoKeyframeEnabled: true }); + usePlayerStore.setState({ autoKeyframeEnabled: true, thumbnailMode: "adaptive" }); }); function renderToolbar( @@ -58,6 +58,25 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => { act(() => root.unmount()); }); }); + +describe("TimelineToolbar — adaptive thumbnails", () => { + it("keeps a user-controlled hidden mode as the rollback path", () => { + const { host, root } = renderToolbar(); + const button = host.querySelector( + 'button[aria-label="Hide thumbnails — labels only"]', + ); + if (!button) throw new Error("thumbnail toggle not rendered"); + + act(() => button.click()); + + expect(usePlayerStore.getState().thumbnailMode).toBe("hidden"); + expect(button.getAttribute("aria-label")).toBe( + "Show thumbnails — posters stay visible; richer previews appear on interaction", + ); + act(() => root.unmount()); + }); +}); + describe("TimelineToolbar — motion path endpoints", () => { it("does not advertise a destructive keyframe toggle for a required endpoint", () => { usePlayerStore.setState({ currentTime: 10 }); diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 539148800..141766507 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react"; +import { Image, Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react"; import { useEnableKeyframes, isPlayheadWithinTween, @@ -111,6 +111,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled); const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled); const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled); + const thumbnailMode = usePlayerStore((s) => s.thumbnailMode); + const setThumbnailMode = usePlayerStore((s) => s.setThumbnailMode); + const thumbnailsVisible = thumbnailMode === "adaptive"; // Subscribe so the add-beat button reacts to playhead movement and analysis load. const currentTime = usePlayerStore((s) => s.currentTime); const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null); @@ -403,6 +406,31 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool })()}
+ + +