Skip to content

Commit f35dc0c

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 faf6d36 commit f35dc0c

8 files changed

Lines changed: 260 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: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { useState } from "react";
2+
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
3+
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
4+
import { FRAME_STATUS_META } from "./frameStatus";
5+
6+
export interface StoryboardFrameTileProps {
7+
projectId: string;
8+
frame: StoryboardFrameView;
9+
}
10+
11+
const TILE_WIDTH = 360;
12+
13+
/** Time (seconds) to show a tile at — past the intro so the key moment is visible. */
14+
function posterTime(frame: StoryboardFrameView): number {
15+
if (frame.poster != null) return frame.poster;
16+
if (frame.durationSeconds != null) return frame.durationSeconds * 0.66;
17+
return 1.5;
18+
}
19+
20+
function firstLine(text: string): string {
21+
return (
22+
text
23+
.split("\n")
24+
.find((line) => line.trim().length > 0)
25+
?.trim() ?? ""
26+
);
27+
}
28+
29+
function placeholderMessage(frame: StoryboardFrameView): string {
30+
if (frame.status === "outline") return "Not built yet";
31+
if (frame.src && !frame.srcExists) return "Frame file not found";
32+
return "No preview";
33+
}
34+
35+
/** A single contact-sheet tile: poster preview + its metadata. */
36+
// fallow-ignore-next-line complexity
37+
export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTileProps) {
38+
const meta = FRAME_STATUS_META[frame.status];
39+
const renderable = frame.srcExists && frame.status !== "outline";
40+
const title = frame.title ?? `Frame ${frame.index}`;
41+
const sceneLine = frame.scene ?? firstLine(frame.narrative);
42+
43+
return (
44+
<article style={{ width: TILE_WIDTH }}>
45+
<div className="relative aspect-video overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
46+
<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">
47+
{frame.number ?? frame.index}
48+
</div>
49+
{renderable && frame.src ? (
50+
<FramePoster
51+
projectId={projectId}
52+
src={frame.src}
53+
seconds={posterTime(frame)}
54+
title={title}
55+
/>
56+
) : (
57+
<FrameTilePlaceholder frame={frame} />
58+
)}
59+
</div>
60+
61+
<div className="mt-2 flex items-start justify-between gap-2">
62+
<h3 className="truncate text-sm font-medium text-neutral-200">{title}</h3>
63+
<span
64+
title={meta.tooltip}
65+
className={`shrink-0 cursor-default rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${meta.chipClass}`}
66+
>
67+
{meta.label}
68+
</span>
69+
</div>
70+
{sceneLine && <p className="mt-0.5 line-clamp-2 text-xs text-neutral-400">{sceneLine}</p>}
71+
{frame.voiceover && (
72+
<p className="mt-1 line-clamp-2 text-xs italic text-neutral-500">
73+
<span aria-hidden="true">🎙 </span>{frame.voiceover}
74+
</p>
75+
)}
76+
<div className="mt-1 flex gap-3 text-[11px] text-neutral-600">
77+
{frame.duration && <span>{frame.duration}</span>}
78+
{frame.transitionIn && <span>{frame.transitionIn}</span>}
79+
</div>
80+
</article>
81+
);
82+
}
83+
84+
/**
85+
* Server-rendered poster for a frame. The thumbnail route seeks the composition
86+
* by time (at its real fps) and caches the result, so there's no live iframe,
87+
* no postMessage seek, and no client-side fps assumption.
88+
*/
89+
function FramePoster({
90+
projectId,
91+
src,
92+
seconds,
93+
title,
94+
}: {
95+
projectId: string;
96+
src: string;
97+
seconds: number;
98+
title: string;
99+
}) {
100+
const [failed, setFailed] = useState(false);
101+
if (failed) {
102+
return (
103+
<div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
104+
Preview unavailable
105+
</div>
106+
);
107+
}
108+
const url = buildCompositionThumbnailUrl({
109+
previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
110+
seekTime: seconds,
111+
duration: 0,
112+
origin: window.location.origin,
113+
});
114+
return (
115+
<img
116+
src={url}
117+
alt={title}
118+
draggable={false}
119+
loading="lazy"
120+
onError={() => setFailed(true)}
121+
className="h-full w-full object-cover"
122+
/>
123+
);
124+
}
125+
126+
function FrameTilePlaceholder({ frame }: { frame: StoryboardFrameView }) {
127+
return (
128+
<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">
129+
<span className="text-xs font-medium text-neutral-400">{frame.title ?? "Outline"}</span>
130+
<span className="text-[11px] text-neutral-600">{placeholderMessage(frame)}</span>
131+
</div>
132+
);
133+
}
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
2+
3+
/**
4+
* Explains the frame lifecycle: a frame advances outline → built → animated.
5+
* Mirrors the status chips on each tile (shares FRAME_STATUS_META).
6+
*/
7+
export function StoryboardStatusLegend() {
8+
return (
9+
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-neutral-500">
10+
<span className="uppercase tracking-wider text-neutral-600">Status</span>
11+
{FRAME_STATUS_ORDER.map((status, i) => (
12+
<span key={status} className="flex items-center gap-1.5">
13+
{i > 0 && <span className="text-neutral-700"></span>}
14+
<span className={`h-2 w-2 rounded-full ${FRAME_STATUS_META[status].dotClass}`} />
15+
<span className="font-medium text-neutral-300">{FRAME_STATUS_META[status].label}</span>
16+
<span className="text-neutral-500">{FRAME_STATUS_META[status].description}</span>
17+
</span>
18+
))}
19+
</div>
20+
);
21+
}

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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { FrameStatus } from "@hyperframes/core/storyboard";
2+
3+
/**
4+
* Single source of truth for how each frame lifecycle status is presented —
5+
* label, tooltip, description, and the chip/dot color classes — so the tile
6+
* chip and the legend dot can't drift apart.
7+
*/
8+
export const FRAME_STATUS_META: Record<
9+
FrameStatus,
10+
{ label: string; tooltip: string; description: string; chipClass: string; dotClass: string }
11+
> = {
12+
outline: {
13+
label: "Outline",
14+
tooltip: "Planned in text — no HTML frame built yet.",
15+
description: "Planned in text. Story and intent exist; the visual isn’t built.",
16+
chipClass: "bg-neutral-800 text-neutral-300",
17+
dotClass: "bg-neutral-500",
18+
},
19+
built: {
20+
label: "Built",
21+
tooltip: "Static HTML frame built — not animated yet.",
22+
description: "The HTML frame exists as a static key moment. Look is locked; motion isn’t.",
23+
chipClass: "bg-sky-500/20 text-sky-300",
24+
dotClass: "bg-sky-400",
25+
},
26+
animated: {
27+
label: "Animated",
28+
tooltip: "Keyframed and animated — plays in the final cut.",
29+
description: "Keyframed into motion. This is the file stitched into the final video.",
30+
chipClass: "bg-emerald-500/20 text-emerald-300",
31+
dotClass: "bg-emerald-400",
32+
},
33+
};
34+
35+
/** The lifecycle order an agent advances each frame through. */
36+
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)