Skip to content

Commit 4fbece4

Browse files
jrusso1020claude
andcommitted
feat(studio): storyboard frame contact-sheet grid
Third PR in the Studio storyboarding stack. Renders the frames as a live contact sheet inside the storyboard view. - StoryboardGrid: ordered, responsive grid of frame tiles. - StoryboardFrameTile: number badge, scaled non-interactive live preview iframe (via /api/projects/:id/preview/comp/<src>), title, duration, transition, and a status chip (outline / built / animated). - Frames that are outline-only or whose src is missing render an explicit placeholder instead of an iframe. - StoryboardView swaps its placeholder for the real grid. With PR1-PR3 the storyboard view is end-to-end viewable against the storyboard-sample fixture for UI/UX feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7a7af93 commit 4fbece4

8 files changed

Lines changed: 289 additions & 6 deletions

File tree

packages/studio/src/components/storyboard/StoryboardDirection.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function StoryboardDirection({ globals, frameCount }: StoryboardDirection
1313
const meta = [
1414
{ label: "Arc", value: globals.arc },
1515
{ label: "Audience", value: globals.audience },
16+
{ label: "Voice", value: globals.extra.voice },
1617
{ label: "Format", value: globals.format },
1718
{ label: "Frames", value: String(frameCount) },
1819
].filter((item): item is { label: string; value: string } => Boolean(item.value));
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { useEffect, useRef } from "react";
2+
import type { FrameStatus } from "@hyperframes/core/storyboard";
3+
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
4+
import { FRAME_STATUS_META } from "./frameStatus";
5+
6+
export interface StoryboardFrameTileProps {
7+
projectId: string;
8+
frame: StoryboardFrameView;
9+
}
10+
11+
// Frames author at 1920×1080; tiles render a scaled, non-interactive preview.
12+
const TILE_WIDTH = 360;
13+
const FRAME_WIDTH = 1920;
14+
const FRAME_HEIGHT = 1080;
15+
const SCALE = TILE_WIDTH / FRAME_WIDTH;
16+
// Compositions are seek-driven at the canonical 30fps; tiles seek by frame index.
17+
const PREVIEW_FPS = 30;
18+
19+
const STATUS_CHIP: Record<FrameStatus, string> = {
20+
outline: "bg-neutral-800 text-neutral-300",
21+
built: "bg-sky-500/20 text-sky-300",
22+
animated: "bg-emerald-500/20 text-emerald-300",
23+
};
24+
25+
/** Time (seconds) to show a tile at — past the intro so the key moment is visible. */
26+
function posterTime(frame: StoryboardFrameView): number {
27+
if (frame.poster != null) return frame.poster;
28+
if (frame.durationSeconds != null) return frame.durationSeconds * 0.66;
29+
return 1.5;
30+
}
31+
32+
function firstLine(text: string): string {
33+
return (
34+
text
35+
.split("\n")
36+
.find((line) => line.trim().length > 0)
37+
?.trim() ?? ""
38+
);
39+
}
40+
41+
/** True for the runtime's `{ source: "hf-preview", type: "ready" }` announcement. */
42+
function isReadyMessage(data: unknown): boolean {
43+
const msg = data as { source?: string; type?: string } | null;
44+
return msg !== null && msg.source === "hf-preview" && msg.type === "ready";
45+
}
46+
47+
function placeholderMessage(frame: StoryboardFrameView): string {
48+
if (frame.status === "outline") return "Not built yet";
49+
if (frame.src && !frame.srcExists) return "Frame file not found";
50+
return "No preview";
51+
}
52+
53+
/** A single contact-sheet tile: live frame preview + its metadata. */
54+
// fallow-ignore-next-line complexity
55+
export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTileProps) {
56+
const meta = FRAME_STATUS_META[frame.status];
57+
const renderable = frame.srcExists && frame.status !== "outline";
58+
const title = frame.title ?? `Frame ${frame.index}`;
59+
const sceneLine = frame.scene ?? firstLine(frame.narrative);
60+
const iframeRef = useRef<HTMLIFrameElement>(null);
61+
useTilePosterSeek(iframeRef, renderable, posterTime(frame));
62+
63+
return (
64+
<article className="w-[360px]">
65+
<div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
66+
<div className="absolute left-2 top-2 z-10 flex h-6 min-w-6 items-center justify-center rounded-full bg-black/70 px-1.5 text-xs font-semibold text-neutral-100">
67+
{frame.number ?? frame.index}
68+
</div>
69+
<div
70+
style={{ width: TILE_WIDTH, height: TILE_WIDTH * (FRAME_HEIGHT / FRAME_WIDTH) }}
71+
className="relative"
72+
>
73+
{renderable && frame.src ? (
74+
<iframe
75+
ref={iframeRef}
76+
title={title}
77+
src={`/api/projects/${projectId}/preview/comp/${frame.src}`}
78+
width={FRAME_WIDTH}
79+
height={FRAME_HEIGHT}
80+
tabIndex={-1}
81+
style={{
82+
border: 0,
83+
transformOrigin: "top left",
84+
transform: `scale(${SCALE})`,
85+
pointerEvents: "none",
86+
}}
87+
/>
88+
) : (
89+
<FrameTilePlaceholder frame={frame} />
90+
)}
91+
</div>
92+
</div>
93+
94+
<div className="mt-2 flex items-start justify-between gap-2">
95+
<h3 className="truncate text-sm font-medium text-neutral-200">{title}</h3>
96+
<span
97+
title={meta.tooltip}
98+
className={`shrink-0 cursor-default rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${STATUS_CHIP[frame.status]}`}
99+
>
100+
{meta.label}
101+
</span>
102+
</div>
103+
{sceneLine && <p className="mt-0.5 line-clamp-2 text-xs text-neutral-400">{sceneLine}</p>}
104+
{frame.voiceover && (
105+
<p className="mt-1 line-clamp-2 text-xs italic text-neutral-500">
106+
<span aria-hidden="true">🎙 </span>{frame.voiceover}
107+
</p>
108+
)}
109+
<div className="mt-1 flex gap-3 text-[11px] text-neutral-600">
110+
{frame.duration && <span>{frame.duration}</span>}
111+
{frame.transitionIn && <span>{frame.transitionIn}</span>}
112+
</div>
113+
</article>
114+
);
115+
}
116+
117+
/**
118+
* Seek the tile's preview iframe to its poster time once the runtime is ready,
119+
* so frames whose content animates in from t=0 aren't shown blank. The runtime
120+
* announces readiness via a `hf-preview` `ready` message; we reply with a seek
121+
* control command (and also retry on load as a race-safe fallback).
122+
*/
123+
function useTilePosterSeek(
124+
iframeRef: React.RefObject<HTMLIFrameElement | null>,
125+
renderable: boolean,
126+
seconds: number,
127+
): void {
128+
useEffect(() => {
129+
if (!renderable) return;
130+
const iframe = iframeRef.current;
131+
if (!iframe) return;
132+
const targetFrame = Math.max(0, Math.round(seconds * PREVIEW_FPS));
133+
const sendSeek = () => {
134+
iframe.contentWindow?.postMessage(
135+
{
136+
source: "hf-parent",
137+
type: "control",
138+
action: "seek",
139+
frame: targetFrame,
140+
seekMode: "commit",
141+
},
142+
"*",
143+
);
144+
};
145+
const onMessage = (event: MessageEvent) => {
146+
if (event.source === iframe.contentWindow && isReadyMessage(event.data)) sendSeek();
147+
};
148+
const onLoad = () => window.setTimeout(sendSeek, 150);
149+
window.addEventListener("message", onMessage);
150+
iframe.addEventListener("load", onLoad);
151+
return () => {
152+
window.removeEventListener("message", onMessage);
153+
iframe.removeEventListener("load", onLoad);
154+
};
155+
}, [iframeRef, renderable, seconds]);
156+
}
157+
158+
function FrameTilePlaceholder({ frame }: { frame: StoryboardFrameView }) {
159+
const message = placeholderMessage(frame);
160+
return (
161+
<div className="flex h-full w-full flex-col items-center justify-center gap-1 border border-dashed border-neutral-700 bg-neutral-950 text-center">
162+
<span className="text-xs font-medium text-neutral-400">{frame.title ?? "Outline"}</span>
163+
<span className="text-[11px] text-neutral-600">{message}</span>
164+
</div>
165+
);
166+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
2+
import { StoryboardFrameTile } from "./StoryboardFrameTile";
3+
4+
export interface StoryboardGridProps {
5+
projectId: string;
6+
frames: StoryboardFrameView[];
7+
}
8+
9+
/** The contact sheet: ordered frame tiles in a responsive grid. */
10+
export function StoryboardGrid({ projectId, frames }: StoryboardGridProps) {
11+
if (frames.length === 0) {
12+
return (
13+
<div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
14+
This storyboard has no frames yet.
15+
</div>
16+
);
17+
}
18+
19+
return (
20+
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-8">
21+
{frames.map((frame) => (
22+
<StoryboardFrameTile key={frame.index} projectId={projectId} frame={frame} />
23+
))}
24+
</div>
25+
);
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { StoryboardScript } from "../../hooks/useStoryboard";
2+
3+
export interface StoryboardScriptPanelProps {
4+
script: StoryboardScript;
5+
}
6+
7+
/**
8+
* Collapsible view of the companion narration script (SCRIPT.md). The mature
9+
* pipeline keeps the full voiceover script — voice settings, per-line delivery
10+
* and timing — in this file; here it's surfaced read-only alongside the frames.
11+
* (Per-frame VO iteration will live in the frame focus view.)
12+
*/
13+
export function StoryboardScriptPanel({ script }: StoryboardScriptPanelProps) {
14+
if (!script.exists) return null;
15+
return (
16+
<details className="mt-10 rounded-lg border border-neutral-800 bg-neutral-900/50">
17+
<summary className="cursor-pointer select-none px-4 py-3 text-sm font-medium text-neutral-300">
18+
Narration script
19+
<span className="ml-2 font-normal text-neutral-500">{script.path}</span>
20+
</summary>
21+
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap border-t border-neutral-800 px-4 py-3 text-xs leading-relaxed text-neutral-400">
22+
{script.content}
23+
</pre>
24+
</details>
25+
);
26+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
2+
3+
const DOT: Record<string, string> = {
4+
outline: "bg-neutral-500",
5+
built: "bg-sky-400",
6+
animated: "bg-emerald-400",
7+
};
8+
9+
/**
10+
* Explains the frame lifecycle: a frame advances outline → built → animated.
11+
* Mirrors the status chips on each tile.
12+
*/
13+
export function StoryboardStatusLegend() {
14+
return (
15+
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-neutral-500">
16+
<span className="uppercase tracking-wider text-neutral-600">Status</span>
17+
{FRAME_STATUS_ORDER.map((status, i) => (
18+
<span key={status} className="flex items-center gap-1.5">
19+
{i > 0 && <span className="text-neutral-700"></span>}
20+
<span className={`h-2 w-2 rounded-full ${DOT[status]}`} />
21+
<span className="font-medium text-neutral-300">{FRAME_STATUS_META[status].label}</span>
22+
<span className="text-neutral-500">{FRAME_STATUS_META[status].description}</span>
23+
</span>
24+
))}
25+
</div>
26+
);
27+
}

packages/studio/src/components/storyboard/StoryboardView.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import type { ReactNode } from "react";
22
import { useStoryboard } from "../../hooks/useStoryboard";
33
import { StoryboardDirection } from "./StoryboardDirection";
4+
import { StoryboardGrid } from "./StoryboardGrid";
5+
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
6+
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
47

58
export interface StoryboardViewProps {
69
projectId: string;
710
}
811

912
/**
1013
* Top-level storyboard stage. Replaces the timeline/preview when the view mode
11-
* is `storyboard`. PR2 lands the shell (global direction + states); the frame
12-
* contact-sheet grid arrives in PR3.
14+
* is `storyboard`: renders the global direction plus the frame contact sheet,
15+
* with loading / error / empty states.
1316
*/
1417
// fallow-ignore-next-line complexity
1518
export function StoryboardView({ projectId }: StoryboardViewProps) {
@@ -35,11 +38,11 @@ export function StoryboardView({ projectId }: StoryboardViewProps) {
3538
return (
3639
<StoryboardFrame>
3740
<StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
38-
{/* PR3: frame contact-sheet grid renders here. */}
39-
<div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
40-
{data.frames.length} frame{data.frames.length === 1 ? "" : "s"} parsed — the contact sheet
41-
renders here next.
41+
<div className="mt-5">
42+
<StoryboardStatusLegend />
4243
</div>
44+
<StoryboardGrid projectId={projectId} frames={data.frames} />
45+
{data.script && <StoryboardScriptPanel script={data.script} />}
4346
</StoryboardFrame>
4447
);
4548
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { FrameStatus } from "@hyperframes/core/storyboard";
2+
3+
/** Display metadata for each frame lifecycle status, shared by tiles and the legend. */
4+
export const FRAME_STATUS_META: Record<
5+
FrameStatus,
6+
{ label: string; tooltip: string; description: string }
7+
> = {
8+
outline: {
9+
label: "Outline",
10+
tooltip: "Planned in text — no HTML frame built yet.",
11+
description: "Planned in text. Story and intent exist; the visual isn’t built.",
12+
},
13+
built: {
14+
label: "Built",
15+
tooltip: "Static HTML frame built — not animated yet.",
16+
description: "The HTML frame exists as a static key moment. Look is locked; motion isn’t.",
17+
},
18+
animated: {
19+
label: "Animated",
20+
tooltip: "Keyframed and animated — plays in the final cut.",
21+
description: "Keyframed into motion. This is the file stitched into the final video.",
22+
},
23+
};
24+
25+
/** The lifecycle order an agent advances each frame through. */
26+
export const FRAME_STATUS_ORDER: FrameStatus[] = ["outline", "built", "animated"];

packages/studio/src/hooks/useStoryboard.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,21 @@ export interface StoryboardFrameView extends StoryboardFrame {
1111
srcExists: boolean;
1212
}
1313

14+
/** The companion narration script (SCRIPT.md), when present alongside the storyboard. */
15+
export interface StoryboardScript {
16+
exists: boolean;
17+
path: string;
18+
content: string;
19+
}
20+
1421
/** Shape of `GET /api/projects/:id/storyboard`. */
1522
export interface StoryboardResponse {
1623
exists: boolean;
1724
path: string;
1825
globals: StoryboardGlobals;
1926
frames: StoryboardFrameView[];
2027
warnings: StoryboardWarning[];
28+
script?: StoryboardScript;
2129
}
2230

2331
export interface UseStoryboardResult {

0 commit comments

Comments
 (0)