From b4d5abd7b2cdc419906153ca75d99bd813adf614 Mon Sep 17 00:00:00 2001 From: James Russo Date: Thu, 20 Aug 2026 15:46:12 -0700 Subject: [PATCH] fix(studio): capture storyboard tiles at review density (#3371) * fix(studio): capture storyboard tiles at source resolution * fix(studio): bound storyboard tile captures --- .../src/routes/thumbnail.test.ts | 70 +++++++++++++++++++ .../studio-server/src/routes/thumbnail.ts | 17 ++++- .../storyboard/FramePoster.test.tsx | 23 +++--- .../src/components/storyboard/FramePoster.tsx | 15 ++-- .../components/CompositionThumbnail.test.ts | 3 + .../components/CompositionThumbnail.tsx | 6 +- 6 files changed, 106 insertions(+), 28 deletions(-) diff --git a/packages/studio-server/src/routes/thumbnail.test.ts b/packages/studio-server/src/routes/thumbnail.test.ts index 5543a9f5a..e5101cfcd 100644 --- a/packages/studio-server/src/routes/thumbnail.test.ts +++ b/packages/studio-server/src/routes/thumbnail.test.ts @@ -44,6 +44,19 @@ function createAdapter(): StudioApiAdapter { }; } +async function writeComposition( + adapter: StudioApiAdapter, + width: number, + height: number, +): Promise { + const project = await adapter.resolveProject("demo"); + if (!project) throw new Error("missing project"); + writeFileSync( + join(project.dir, "index.html"), + `
`, + ); +} + describe("registerThumbnailRoutes", () => { it("forwards selector queries to thumbnail generation", async () => { const adapter = createAdapter(); @@ -68,6 +81,63 @@ describe("registerThumbnailRoutes", () => { ); }); + it("maps square authored dimensions across jpeg output modes", async () => { + const adapter = createAdapter(); + const app = new Hono(); + registerThumbnailRoutes(app, adapter); + await writeComposition(adapter, 1080, 1080); + + const sourceResponse = await app.request( + "http://localhost/projects/demo/thumbnail/index.html?t=1.2&output=source", + ); + const previewResponse = await app.request( + "http://localhost/projects/demo/thumbnail/index.html?t=1.2&output=preview", + ); + const storyboardResponse = await app.request( + "http://localhost/projects/demo/thumbnail/index.html?t=1.2&output=storyboard", + ); + + expect(sourceResponse.status).toBe(200); + expect(previewResponse.status).toBe(200); + expect(storyboardResponse.status).toBe(200); + expect(adapter.generateThumbnail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + format: "jpeg", + outputWidth: 1080, + outputHeight: 1080, + }), + ); + expect(adapter.generateThumbnail).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ outputWidth: 135, outputHeight: 135 }), + ); + expect(adapter.generateThumbnail).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ outputWidth: 1080, outputHeight: 1080 }), + ); + }); + + it("caps storyboard output at a 1080px longest side", async () => { + const adapter = createAdapter(); + const app = new Hono(); + registerThumbnailRoutes(app, adapter); + await writeComposition(adapter, 7680, 4320); + + const response = await app.request( + "http://localhost/projects/demo/thumbnail/index.html?t=1.2&output=storyboard", + ); + + expect(response.status).toBe(200); + expect(adapter.generateThumbnail).toHaveBeenCalledWith( + expect.objectContaining({ + format: "jpeg", + outputWidth: 1080, + outputHeight: 608, + }), + ); + }); + it("deduplicates concurrent generation and writes one complete cache entry", async () => { const adapter = createAdapter(); const project = await adapter.resolveProject("demo"); diff --git a/packages/studio-server/src/routes/thumbnail.ts b/packages/studio-server/src/routes/thumbnail.ts index 0f2fba697..81ac04774 100644 --- a/packages/studio-server/src/routes/thumbnail.ts +++ b/packages/studio-server/src/routes/thumbnail.ts @@ -20,6 +20,7 @@ import { thumbnailGenerationCoordinator } from "./thumbnailGenerationCoordinator const THUMBNAIL_CACHE_VERSION = "v4"; const THUMBNAIL_MAX_OUTPUT_WIDTH = 240; const THUMBNAIL_MAX_OUTPUT_HEIGHT = 135; +const STORYBOARD_MAX_OUTPUT_DIMENSION = 1080; const THUMBNAIL_CACHE_MAX_BYTES = 512 * 1024 * 1024; const THUMBNAIL_CACHE_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; const prunedCacheDirs = new Set(); @@ -98,9 +99,13 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v // 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") + requestedOutput === "source" ? "source" - : "preview"; + : requestedOutput === "storyboard" + ? "storyboard" + : 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; @@ -160,7 +165,13 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v const outputScale = outputMode === "source" ? 1 - : Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH); + : outputMode === "storyboard" + ? Math.min( + 1, + STORYBOARD_MAX_OUTPUT_DIMENSION / compW, + STORYBOARD_MAX_OUTPUT_DIMENSION / compH, + ) + : 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"}`; diff --git a/packages/studio/src/components/storyboard/FramePoster.test.tsx b/packages/studio/src/components/storyboard/FramePoster.test.tsx index fb64a4494..c1f187958 100644 --- a/packages/studio/src/components/storyboard/FramePoster.test.tsx +++ b/packages/studio/src/components/storyboard/FramePoster.test.tsx @@ -37,24 +37,21 @@ function renderPoster(surface?: "tile" | "hero"): HTMLImageElement { } describe("FramePoster", () => { - // Regression: the hero and the tile shared one bounded poster, so the frame - // detail view — the sketch pass's only picture — showed a 240x135 capture - // upscaled past 7x on a retina display, and body copy was unreadable. - it("captures the focus hero at the composition's own dimensions", () => { - const url = new URL(renderPoster("hero").src); + // Regression: the contact sheet stretched a 240x135 capture across a wide, + // high-density card, making body copy, thin lines, and sprite details blurry. + it.each([ + [undefined, "storyboard"], + ["tile", "storyboard"], + ["hero", "source"], + ] as const)("captures the %s surface at %s density", (surface, output) => { + const url = new URL(renderPoster(surface).src); - expect(url.searchParams.get("output")).toBe("source"); + expect(url.searchParams.get("output")).toBe(output); expect(url.pathname).toBe("/api/projects/demo/thumbnail/frames/01-hero.html"); }); - it("leaves the contact-sheet tile on the route's bounded preview capture", () => { - const url = new URL(renderPoster("tile").src); - - expect(url.searchParams.has("output")).toBe(false); - }); - it("defaults to the tile surface", () => { - expect(new URL(renderPoster().src).search).toBe(new URL(renderPoster("tile").src).search); + expect(renderPoster().className).toBe(renderPoster("tile").className); }); it("letterboxes only the hero, so a tile still fills its cell", () => { diff --git a/packages/studio/src/components/storyboard/FramePoster.tsx b/packages/studio/src/components/storyboard/FramePoster.tsx index e58de6e9d..d0baaa595 100644 --- a/packages/studio/src/components/storyboard/FramePoster.tsx +++ b/packages/studio/src/components/storyboard/FramePoster.tsx @@ -11,10 +11,8 @@ export interface FramePosterProps { /** * Where this poster is rendered. A contact-sheet tile is ~300px wide and there * are many of them; the focus hero is up to 900px wide and there is exactly - * one. That single difference decides both the crop and how much resolution - * the server has to capture, so it is one prop rather than two that can - * disagree: `tile` fills+crops at the route's bounded preview density, `hero` - * letterboxes at the composition's own dimensions. + * one. Tiles use a bounded high-density capture; the hero uses source density. + * The surface also decides whether the result fills or letterboxes its cell. */ surface?: "tile" | "hero"; /** @@ -57,11 +55,10 @@ export function FramePoster({ seekTime: seconds, duration: 0, origin: window.location.origin, - // The hero is the sketch pass's only picture (references/review-loop.md), and - // it is shown large. Bounded to the preview cap it arrives at 240x135 and - // upscales past 7x on a retina display, which is unreadable for exactly the - // body copy and labels this pass exists to confirm. - ...(surface === "hero" ? { output: "source" as const } : {}), + // The normal 240x135 preview is unreadable in the contact sheet, while source + // density is unbounded across all tiles. Give tiles a capped review density + // and reserve true source output for the single focus hero. + output: surface === "hero" ? "source" : "storyboard", }); if (posterVersion) { const withVersion = new URL(url, window.location.origin); diff --git a/packages/studio/src/player/components/CompositionThumbnail.test.ts b/packages/studio/src/player/components/CompositionThumbnail.test.ts index dd9ef04d3..f9a3e986c 100644 --- a/packages/studio/src/player/components/CompositionThumbnail.test.ts +++ b/packages/studio/src/player/components/CompositionThumbnail.test.ts @@ -87,6 +87,9 @@ describe("buildCompositionThumbnailUrl", () => { expect(buildCompositionThumbnailUrl(base)).not.toContain("output="); expect(buildCompositionThumbnailUrl({ ...base, output: "source" })).toContain("output=source"); + expect(buildCompositionThumbnailUrl({ ...base, output: "storyboard" })).toContain( + "output=storyboard", + ); }); }); diff --git a/packages/studio/src/player/components/CompositionThumbnail.tsx b/packages/studio/src/player/components/CompositionThumbnail.tsx index a30e4b485..301206ec3 100644 --- a/packages/studio/src/player/components/CompositionThumbnail.tsx +++ b/packages/studio/src/player/components/CompositionThumbnail.tsx @@ -42,10 +42,10 @@ export function buildCompositionThumbnailUrl({ /** * Capture density. Omitted, the route bounds the image to its preview cap — * right for the timeline, where thumbnails are small and numerous and their - * decoded bytes are budgeted. `"source"` captures at the composition's own - * dimensions, for the rare surface that shows one poster large enough to read. + * decoded bytes are budgeted. `"storyboard"` caps the longest side at a + * high-density review size; `"source"` uses the composition's own dimensions. */ - output?: "source"; + output?: "source" | "storyboard"; }): string { const thumbnailBase = previewUrl .replace("/preview/comp/", "/thumbnail/")