feat(studio): storyboard frame contact-sheet grid (#1530)

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>
This commit is contained in:
James Russo
2026-06-17 14:23:47 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 195d7aa7bd
commit 015529e663
8 changed files with 260 additions and 6 deletions
@@ -13,6 +13,7 @@ export function StoryboardDirection({ globals, frameCount }: StoryboardDirection
const meta = [ const meta = [
{ label: "Arc", value: globals.arc }, { label: "Arc", value: globals.arc },
{ label: "Audience", value: globals.audience }, { label: "Audience", value: globals.audience },
{ label: "Voice", value: globals.extra.voice },
{ label: "Format", value: globals.format }, { label: "Format", value: globals.format },
{ label: "Frames", value: String(frameCount) }, { label: "Frames", value: String(frameCount) },
].filter((item): item is { label: string; value: string } => Boolean(item.value)); ].filter((item): item is { label: string; value: string } => Boolean(item.value));
@@ -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 (
<article style={{ width: TILE_WIDTH }}>
<div className="relative aspect-video overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
<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">
{frame.number ?? frame.index}
</div>
{renderable && frame.src ? (
<FramePoster
projectId={projectId}
src={frame.src}
seconds={posterTime(frame)}
title={title}
/>
) : (
<FrameTilePlaceholder frame={frame} />
)}
</div>
<div className="mt-2 flex items-start justify-between gap-2">
<h3 className="truncate text-sm font-medium text-neutral-200">{title}</h3>
<span
title={meta.tooltip}
className={`shrink-0 cursor-default rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${meta.chipClass}`}
>
{meta.label}
</span>
</div>
{sceneLine && <p className="mt-0.5 line-clamp-2 text-xs text-neutral-400">{sceneLine}</p>}
{frame.voiceover && (
<p className="mt-1 line-clamp-2 text-xs italic text-neutral-500">
<span aria-hidden="true">🎙 </span>{frame.voiceover}
</p>
)}
<div className="mt-1 flex gap-3 text-[11px] text-neutral-600">
{frame.duration && <span>{frame.duration}</span>}
{frame.transitionIn && <span> {frame.transitionIn}</span>}
</div>
</article>
);
}
/**
* 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 (
<div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
Preview unavailable
</div>
);
}
const url = buildCompositionThumbnailUrl({
previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
seekTime: seconds,
duration: 0,
origin: window.location.origin,
});
return (
<img
src={url}
alt={title}
draggable={false}
loading="lazy"
onError={() => setFailed(true)}
className="h-full w-full object-cover"
/>
);
}
function FrameTilePlaceholder({ frame }: { frame: StoryboardFrameView }) {
return (
<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">
<span className="text-xs font-medium text-neutral-400">{frame.title ?? "Outline"}</span>
<span className="text-[11px] text-neutral-600">{placeholderMessage(frame)}</span>
</div>
);
}
@@ -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 (
<div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
This storyboard has no frames yet.
</div>
);
}
return (
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-8">
{frames.map((frame) => (
<StoryboardFrameTile key={frame.index} projectId={projectId} frame={frame} />
))}
</div>
);
}
@@ -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 (
<details className="mt-10 rounded-lg border border-neutral-800 bg-neutral-900/50">
<summary className="cursor-pointer select-none px-4 py-3 text-sm font-medium text-neutral-300">
Narration script
<span className="ml-2 font-normal text-neutral-500">{script.path}</span>
</summary>
<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">
{script.content}
</pre>
</details>
);
}
@@ -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 (
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-neutral-500">
<span className="uppercase tracking-wider text-neutral-600">Status</span>
{FRAME_STATUS_ORDER.map((status, i) => (
<span key={status} className="flex items-center gap-1.5">
{i > 0 && <span className="text-neutral-700"></span>}
<span className={`h-2 w-2 rounded-full ${FRAME_STATUS_META[status].dotClass}`} />
<span className="font-medium text-neutral-300">{FRAME_STATUS_META[status].label}</span>
<span className="text-neutral-500"> {FRAME_STATUS_META[status].description}</span>
</span>
))}
</div>
);
}
@@ -1,6 +1,9 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useStoryboard } from "../../hooks/useStoryboard"; import { useStoryboard } from "../../hooks/useStoryboard";
import { StoryboardDirection } from "./StoryboardDirection"; import { StoryboardDirection } from "./StoryboardDirection";
import { StoryboardGrid } from "./StoryboardGrid";
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
export interface StoryboardViewProps { export interface StoryboardViewProps {
projectId: string; projectId: string;
@@ -8,8 +11,8 @@ export interface StoryboardViewProps {
/** /**
* Top-level storyboard stage. Replaces the timeline/preview when the view mode * Top-level storyboard stage. Replaces the timeline/preview when the view mode
* is `storyboard`. PR2 lands the shell (global direction + states); the frame * is `storyboard`: renders the global direction plus the frame contact sheet,
* contact-sheet grid arrives in PR3. * with loading / error / empty states.
*/ */
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
export function StoryboardView({ projectId }: StoryboardViewProps) { export function StoryboardView({ projectId }: StoryboardViewProps) {
@@ -35,11 +38,11 @@ export function StoryboardView({ projectId }: StoryboardViewProps) {
return ( return (
<StoryboardFrame> <StoryboardFrame>
<StoryboardDirection globals={data.globals} frameCount={data.frames.length} /> <StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
{/* PR3: frame contact-sheet grid renders here. */} <div className="mt-5">
<div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500"> <StoryboardStatusLegend />
{data.frames.length} frame{data.frames.length === 1 ? "" : "s"} parsed the contact sheet
renders here next.
</div> </div>
<StoryboardGrid projectId={projectId} frames={data.frames} />
{data.script && <StoryboardScriptPanel script={data.script} />}
</StoryboardFrame> </StoryboardFrame>
); );
} }
@@ -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 isnt 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 isnt.",
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"];
@@ -12,6 +12,13 @@ export interface StoryboardFrameView extends StoryboardFrame {
srcExists: boolean; 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`. */ /** Shape of `GET /api/projects/:id/storyboard`. */
export interface StoryboardResponse { export interface StoryboardResponse {
exists: boolean; exists: boolean;
@@ -19,6 +26,7 @@ export interface StoryboardResponse {
globals: StoryboardGlobals; globals: StoryboardGlobals;
frames: StoryboardFrameView[]; frames: StoryboardFrameView[];
warnings: StoryboardWarning[]; warnings: StoryboardWarning[];
script?: StoryboardScript;
} }
export interface UseStoryboardResult { export interface UseStoryboardResult {