diff --git a/packages/studio/src/components/storyboard/StoryboardDirection.tsx b/packages/studio/src/components/storyboard/StoryboardDirection.tsx
index 8f32319fce..dfe64e67ff 100644
--- a/packages/studio/src/components/storyboard/StoryboardDirection.tsx
+++ b/packages/studio/src/components/storyboard/StoryboardDirection.tsx
@@ -13,6 +13,7 @@ export function StoryboardDirection({ globals, frameCount }: StoryboardDirection
const meta = [
{ label: "Arc", value: globals.arc },
{ label: "Audience", value: globals.audience },
+ { label: "Voice", value: globals.extra.voice },
{ label: "Format", value: globals.format },
{ label: "Frames", value: String(frameCount) },
].filter((item): item is { label: string; value: string } => Boolean(item.value));
diff --git a/packages/studio/src/components/storyboard/StoryboardFrameTile.tsx b/packages/studio/src/components/storyboard/StoryboardFrameTile.tsx
new file mode 100644
index 0000000000..fb8bbce90f
--- /dev/null
+++ b/packages/studio/src/components/storyboard/StoryboardFrameTile.tsx
@@ -0,0 +1,133 @@
+import { useState } from "react";
+import type { StoryboardFrameView } from "../../hooks/useStoryboard";
+import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
+import { FRAME_STATUS_META } from "./frameStatus";
+
+export interface StoryboardFrameTileProps {
+ projectId: string;
+ frame: StoryboardFrameView;
+}
+
+const TILE_WIDTH = 360;
+
+/** Time (seconds) to show a tile at β past the intro so the key moment is visible. */
+function posterTime(frame: StoryboardFrameView): number {
+ if (frame.poster != null) return frame.poster;
+ if (frame.durationSeconds != null) return frame.durationSeconds * 0.66;
+ return 1.5;
+}
+
+function firstLine(text: string): string {
+ return (
+ text
+ .split("\n")
+ .find((line) => line.trim().length > 0)
+ ?.trim() ?? ""
+ );
+}
+
+function placeholderMessage(frame: StoryboardFrameView): string {
+ if (frame.status === "outline") return "Not built yet";
+ if (frame.src && !frame.srcExists) return "Frame file not found";
+ return "No preview";
+}
+
+/** A single contact-sheet tile: poster preview + its metadata. */
+// fallow-ignore-next-line complexity
+export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTileProps) {
+ const meta = FRAME_STATUS_META[frame.status];
+ const renderable = frame.srcExists && frame.status !== "outline";
+ const title = frame.title ?? `Frame ${frame.index}`;
+ const sceneLine = frame.scene ?? firstLine(frame.narrative);
+
+ return (
+
+
+
+ {frame.number ?? frame.index}
+
+ {renderable && frame.src ? (
+
+ ) : (
+
+ )}
+
+
+
+
{title}
+
+ {meta.label}
+
+
+ {sceneLine && {sceneLine}
}
+ {frame.voiceover && (
+
+ π β{frame.voiceover}β
+
+ )}
+
+ {frame.duration && {frame.duration}}
+ {frame.transitionIn && β {frame.transitionIn}}
+
+
+ );
+}
+
+/**
+ * Server-rendered poster for a frame. The thumbnail route seeks the composition
+ * by time (at its real fps) and caches the result, so there's no live iframe,
+ * no postMessage seek, and no client-side fps assumption.
+ */
+function FramePoster({
+ projectId,
+ src,
+ seconds,
+ title,
+}: {
+ projectId: string;
+ src: string;
+ seconds: number;
+ title: string;
+}) {
+ const [failed, setFailed] = useState(false);
+ if (failed) {
+ return (
+
+ Preview unavailable
+
+ );
+ }
+ const url = buildCompositionThumbnailUrl({
+ previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
+ seekTime: seconds,
+ duration: 0,
+ origin: window.location.origin,
+ });
+ return (
+
setFailed(true)}
+ className="h-full w-full object-cover"
+ />
+ );
+}
+
+function FrameTilePlaceholder({ frame }: { frame: StoryboardFrameView }) {
+ return (
+
+ {frame.title ?? "Outline"}
+ {placeholderMessage(frame)}
+
+ );
+}
diff --git a/packages/studio/src/components/storyboard/StoryboardGrid.tsx b/packages/studio/src/components/storyboard/StoryboardGrid.tsx
new file mode 100644
index 0000000000..aebaf24111
--- /dev/null
+++ b/packages/studio/src/components/storyboard/StoryboardGrid.tsx
@@ -0,0 +1,26 @@
+import type { StoryboardFrameView } from "../../hooks/useStoryboard";
+import { StoryboardFrameTile } from "./StoryboardFrameTile";
+
+export interface StoryboardGridProps {
+ projectId: string;
+ frames: StoryboardFrameView[];
+}
+
+/** The contact sheet: ordered frame tiles in a responsive grid. */
+export function StoryboardGrid({ projectId, frames }: StoryboardGridProps) {
+ if (frames.length === 0) {
+ return (
+
+ This storyboard has no frames yet.
+
+ );
+ }
+
+ return (
+
+ {frames.map((frame) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/studio/src/components/storyboard/StoryboardScriptPanel.tsx b/packages/studio/src/components/storyboard/StoryboardScriptPanel.tsx
new file mode 100644
index 0000000000..607f3d165b
--- /dev/null
+++ b/packages/studio/src/components/storyboard/StoryboardScriptPanel.tsx
@@ -0,0 +1,26 @@
+import type { StoryboardScript } from "../../hooks/useStoryboard";
+
+export interface StoryboardScriptPanelProps {
+ script: StoryboardScript;
+}
+
+/**
+ * Collapsible view of the companion narration script (SCRIPT.md). The mature
+ * pipeline keeps the full voiceover script β voice settings, per-line delivery
+ * and timing β in this file; here it's surfaced read-only alongside the frames.
+ * (Per-frame VO iteration will live in the frame focus view.)
+ */
+export function StoryboardScriptPanel({ script }: StoryboardScriptPanelProps) {
+ if (!script.exists) return null;
+ return (
+
+
+ Narration script
+ {script.path}
+
+
+ {script.content}
+
+
+ );
+}
diff --git a/packages/studio/src/components/storyboard/StoryboardStatusLegend.tsx b/packages/studio/src/components/storyboard/StoryboardStatusLegend.tsx
new file mode 100644
index 0000000000..5050e4de34
--- /dev/null
+++ b/packages/studio/src/components/storyboard/StoryboardStatusLegend.tsx
@@ -0,0 +1,21 @@
+import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
+
+/**
+ * Explains the frame lifecycle: a frame advances outline β built β animated.
+ * Mirrors the status chips on each tile (shares FRAME_STATUS_META).
+ */
+export function StoryboardStatusLegend() {
+ return (
+
+ Status
+ {FRAME_STATUS_ORDER.map((status, i) => (
+
+ {i > 0 && β}
+
+ {FRAME_STATUS_META[status].label}
+ β {FRAME_STATUS_META[status].description}
+
+ ))}
+
+ );
+}
diff --git a/packages/studio/src/components/storyboard/StoryboardView.tsx b/packages/studio/src/components/storyboard/StoryboardView.tsx
index c082333528..3d8a922647 100644
--- a/packages/studio/src/components/storyboard/StoryboardView.tsx
+++ b/packages/studio/src/components/storyboard/StoryboardView.tsx
@@ -1,6 +1,9 @@
import type { ReactNode } from "react";
import { useStoryboard } from "../../hooks/useStoryboard";
import { StoryboardDirection } from "./StoryboardDirection";
+import { StoryboardGrid } from "./StoryboardGrid";
+import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
+import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
export interface StoryboardViewProps {
projectId: string;
@@ -8,8 +11,8 @@ export interface StoryboardViewProps {
/**
* Top-level storyboard stage. Replaces the timeline/preview when the view mode
- * is `storyboard`. PR2 lands the shell (global direction + states); the frame
- * contact-sheet grid arrives in PR3.
+ * is `storyboard`: renders the global direction plus the frame contact sheet,
+ * with loading / error / empty states.
*/
// fallow-ignore-next-line complexity
export function StoryboardView({ projectId }: StoryboardViewProps) {
@@ -35,11 +38,11 @@ export function StoryboardView({ projectId }: StoryboardViewProps) {
return (
- {/* PR3: frame contact-sheet grid renders here. */}
-
- {data.frames.length} frame{data.frames.length === 1 ? "" : "s"} parsed β the contact sheet
- renders here next.
+
+
+
+ {data.script &&
}
);
}
diff --git a/packages/studio/src/components/storyboard/frameStatus.ts b/packages/studio/src/components/storyboard/frameStatus.ts
new file mode 100644
index 0000000000..fff35bcc98
--- /dev/null
+++ b/packages/studio/src/components/storyboard/frameStatus.ts
@@ -0,0 +1,36 @@
+import type { FrameStatus } from "@hyperframes/core/storyboard";
+
+/**
+ * Single source of truth for how each frame lifecycle status is presented β
+ * label, tooltip, description, and the chip/dot color classes β so the tile
+ * chip and the legend dot can't drift apart.
+ */
+export const FRAME_STATUS_META: Record<
+ FrameStatus,
+ { label: string; tooltip: string; description: string; chipClass: string; dotClass: string }
+> = {
+ outline: {
+ label: "Outline",
+ tooltip: "Planned in text β no HTML frame built yet.",
+ description: "Planned in text. Story and intent exist; the visual isnβt built.",
+ chipClass: "bg-neutral-800 text-neutral-300",
+ dotClass: "bg-neutral-500",
+ },
+ built: {
+ label: "Built",
+ tooltip: "Static HTML frame built β not animated yet.",
+ description: "The HTML frame exists as a static key moment. Look is locked; motion isnβt.",
+ chipClass: "bg-sky-500/20 text-sky-300",
+ dotClass: "bg-sky-400",
+ },
+ animated: {
+ label: "Animated",
+ tooltip: "Keyframed and animated β plays in the final cut.",
+ description: "Keyframed into motion. This is the file stitched into the final video.",
+ chipClass: "bg-emerald-500/20 text-emerald-300",
+ dotClass: "bg-emerald-400",
+ },
+};
+
+/** The lifecycle order an agent advances each frame through. */
+export const FRAME_STATUS_ORDER: FrameStatus[] = ["outline", "built", "animated"];
diff --git a/packages/studio/src/hooks/useStoryboard.ts b/packages/studio/src/hooks/useStoryboard.ts
index a7f025fbcc..0ba2001614 100644
--- a/packages/studio/src/hooks/useStoryboard.ts
+++ b/packages/studio/src/hooks/useStoryboard.ts
@@ -12,6 +12,13 @@ export interface StoryboardFrameView extends StoryboardFrame {
srcExists: boolean;
}
+/** The companion narration script (SCRIPT.md), when present alongside the storyboard. */
+export interface StoryboardScript {
+ exists: boolean;
+ path: string;
+ content: string;
+}
+
/** Shape of `GET /api/projects/:id/storyboard`. */
export interface StoryboardResponse {
exists: boolean;
@@ -19,6 +26,7 @@ export interface StoryboardResponse {
globals: StoryboardGlobals;
frames: StoryboardFrameView[];
warnings: StoryboardWarning[];
+ script?: StoryboardScript;
}
export interface UseStoryboardResult {