diff --git a/packages/studio/src/components/storyboard/FramePoster.test.tsx b/packages/studio/src/components/storyboard/FramePoster.test.tsx
new file mode 100644
index 000000000..fb64a4494
--- /dev/null
+++ b/packages/studio/src/components/storyboard/FramePoster.test.tsx
@@ -0,0 +1,64 @@
+// @vitest-environment happy-dom
+
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vitest";
+import { FramePoster } from "./FramePoster";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const roots: Root[] = [];
+
+afterEach(() => {
+ act(() => roots.splice(0).forEach((root) => root.unmount()));
+ document.body.replaceChildren();
+});
+
+// A fresh host per render: several cases compare two surfaces side by side.
+function renderPoster(surface?: "tile" | "hero"): HTMLImageElement {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ roots.push(root);
+ act(() => {
+ root.render(
+ ");
+ return img;
+}
+
+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");
+ 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);
+ });
+
+ it("letterboxes only the hero, so a tile still fills its cell", () => {
+ expect(renderPoster("hero").className).toContain("object-contain");
+ expect(renderPoster("tile").className).toContain("object-cover");
+ });
+});
diff --git a/packages/studio/src/components/storyboard/FramePoster.tsx b/packages/studio/src/components/storyboard/FramePoster.tsx
index 60d41e044..e58de6e9d 100644
--- a/packages/studio/src/components/storyboard/FramePoster.tsx
+++ b/packages/studio/src/components/storyboard/FramePoster.tsx
@@ -8,8 +8,15 @@ export interface FramePosterProps {
/** Time (seconds) to seek to for the poster. */
seconds: number;
title: string;
- /** `cover` fills+crops (contact-sheet tile); `contain` letterboxes (focus hero). */
- fit?: "cover" | "contain";
+ /**
+ * 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.
+ */
+ surface?: "tile" | "hero";
/**
* Project content signature to key the poster URL on. The thumbnail route
* regenerates when the frame's source changes, but the browser only refetches
@@ -30,14 +37,14 @@ export function FramePoster({
src,
seconds,
title,
- fit = "cover",
+ surface = "tile",
posterVersion,
}: FramePosterProps) {
const [failed, setFailed] = useState(false);
// The
is reused (no key) when a tile/hero swaps to a different frame, so a
// prior load error would stick. Reset when the poster target changes — including
// a new posterVersion, so a frame that failed mid-write retries once it settles.
- useEffect(() => setFailed(false), [src, seconds, posterVersion]);
+ useEffect(() => setFailed(false), [src, seconds, posterVersion, surface]);
if (failed) {
return (