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 ( + {title} 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 {