mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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/")
|
||||
|
||||
Reference in New Issue
Block a user