Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions packages/studio-server/src/routes/thumbnail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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");
Expand Down
17 changes: 14 additions & 3 deletions packages/studio-server/src/routes/thumbnail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"}`;
Expand Down
25 changes: 11 additions & 14 deletions packages/studio/src/components/storyboard/FramePoster.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

expect(url.searchParams.get("output")).toBe("source");
// 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(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", () => {
Expand Down
15 changes: 6 additions & 9 deletions packages/studio/src/components/storyboard/FramePoster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
/**
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/")
Expand Down
Loading