fix(studio): capture storyboard tiles at review density (#3371)

* fix(studio): capture storyboard tiles at source resolution

* fix(studio): bound storyboard tile captures
This commit is contained in:
James Russo
2026-08-20 15:46:12 -07:00
committed by GitHub
parent 2be5a03b80
commit b4d5abd7b2
6 changed files with 106 additions and 28 deletions
@@ -44,6 +44,19 @@ function createAdapter(): StudioApiAdapter {
};
}
async function writeComposition(
adapter: StudioApiAdapter,
width: number,
height: number,
): Promise<void> {
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
writeFileSync(
join(project.dir, "index.html"),
`<div data-width="${width}" data-height="${height}"></div>`,
);
}
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");
+14 -3
View File
@@ -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<string>();
@@ -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"}`;
@@ -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", () => {
@@ -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);
@@ -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",
);
});
});
@@ -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/")