feat(studio): storyboard frame focus + voiceover iteration (#1532)

Fifth PR in the Studio storyboarding stack. Click a contact-sheet tile to
open a full-area focus on that frame.

- StoryboardFrameFocus: large poster, prev/next nav, full narrative, and an
  editable voiceover *guide* (textarea) saved back to STORYBOARD.md. Status
  can be advanced outline → built → animated inline.
- "Open in Preview" jumps to the timeline focused on the frame's
  sub-composition (setActiveCompPath + view-mode timeline).
- core/storyboard: setFrameField / setFrameVoiceover / setFrameStatus —
  surgical in-place writers that update one frame's metadata without
  re-serializing (markdown stays canonical). Tested.
- Extract shared FramePoster (used by tile + focus); tiles are now buttons
  that open focus.

Voiceover here is the editable guide; SCRIPT.md remains the locked narration
that drives TTS.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-06-17 15:34:44 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 29809069c8
commit c8fd16f2d3
11 changed files with 586 additions and 66 deletions
@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import { parseStoryboard } from "./parseStoryboard.js";
import { setFrameStatus, setFrameVoiceover } from "./editStoryboard.js";
const DOC = `---
message: Hi
---
## Frame 1 — Hook
- status: outline
- voiceover: "old line"
Hook narrative.
## Frame 2 — Close
- duration: 3s
Close narrative.
`;
describe("setFrameVoiceover / setFrameStatus", () => {
it("replaces an existing voiceover line in place, leaving other frames untouched", () => {
const next = setFrameVoiceover(DOC, 1, "new line");
const parsed = parseStoryboard(next);
expect(parsed.frames[0].voiceover).toBe("new line");
expect(parsed.frames[1].duration).toBe("3s");
expect(next).toContain('- voiceover: "new line"');
});
it("matches voiceover aliases (vo)", () => {
const doc = "## Frame 1\n- vo: original\n\nBody.";
const next = setFrameVoiceover(doc, 1, "updated");
// The aliased key is preserved, only the value changes.
expect(next).toContain("- vo: ");
expect(parseStoryboard(next).frames[0].voiceover).toBe("updated");
});
it("inserts the field after the heading when absent", () => {
const next = setFrameVoiceover("## Frame 1 — Hook\n\nBody.", 1, "hi");
const lines = next.split("\n");
expect(lines[0]).toBe("## Frame 1 — Hook");
expect(lines[1]).toBe('- voiceover: "hi"');
expect(parseStoryboard(next).frames[0].voiceover).toBe("hi");
});
it("advances status in place", () => {
const next = setFrameStatus(DOC, 1, "built");
expect(parseStoryboard(next).frames[0].status).toBe("built");
expect(parseStoryboard(next).frames[1].status).toBe("outline");
});
it("round-trips a voiceover containing double quotes (always wraps)", () => {
const next = setFrameVoiceover("## Frame 1\n- voiceover: x\n", 1, 'she said "hi"');
expect(parseStoryboard(next).frames[0].voiceover).toBe('she said "hi"');
});
it("round-trips an empty voiceover and a fully-quoted phrase", () => {
const cleared = setFrameVoiceover("## Frame 1\n- voiceover: x\n", 1, "");
expect(parseStoryboard(cleared).frames[0].voiceover).toBe("");
const quoted = setFrameVoiceover("## Frame 1\n- voiceover: x\n", 1, '"hello"');
expect(parseStoryboard(quoted).frames[0].voiceover).toBe('"hello"');
});
it("collapses newlines in a multi-line voiceover to a single line", () => {
const next = setFrameVoiceover(
"## Frame 1\n- voiceover: x\n\nNarrative.",
1,
"line one\nline two",
);
expect(parseStoryboard(next).frames[0].voiceover).toBe("line one line two");
expect(parseStoryboard(next).frames[0].narrative).toBe("Narrative.");
});
it("throws for an out-of-range frame", () => {
expect(() => setFrameStatus(DOC, 9, "built")).toThrow(/not found/);
});
});
@@ -0,0 +1,119 @@
import { FRAME_HEADING_RE, VOICEOVER_ALIASES } from "./parseStoryboard.js";
import type { FrameStatus } from "./types.js";
// Re-exported for back-compat: the canonical list now lives in parseStoryboard.ts.
export { VOICEOVER_ALIASES };
/**
* Surgical writers for `STORYBOARD.md`.
*
* These update a single frame's metadata in place — preserving all other
* content, formatting, comments, and non-frame sections — rather than
* re-serializing the parsed manifest (which would be lossy). Used by the
* storyboard frame-focus editor to persist `voiceover` / `status` edits.
*
* Frame detection and the voiceover aliases are imported from `parseStoryboard.ts`
* so the read and write sides share one definition and can't drift.
*/
const HEADING_LEVEL_RE = /^(#{1,6})[ \t]+/;
/**
* `- key:` prefix — captures the bullet, key, and `:`-separator (incl. surrounding
* spaces) so the line can be rewritten as `<prefix><new value>`. Deliberately stops
* at the separator and captures no value/EOL: the old value is overwritten wholesale,
* so there's nothing to capture, and dropping the trailing `[ \t]*…(.*)$` removes the
* overlapping-quantifier polynomial backtracking CodeQL flags (js/polynomial-redos).
*/
const META_LINE_RE = /^([ \t]*[-*][ \t]+)([A-Za-z_][\w-]*)([ \t]*:[ \t]*)/;
interface FrameBounds {
/** 0-based line index of the frame heading. */
start: number;
/** 0-based line index just past the frame's content (exclusive). */
end: number;
/** Heading depth (`#` count) that opened the frame. */
level: number;
}
/** Locate every frame's line range, using the same boundary rules as the parser. */
// fallow-ignore-next-line complexity
function frameBounds(lines: string[]): FrameBounds[] {
const bounds: FrameBounds[] = [];
let current: FrameBounds | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const frameMatch = FRAME_HEADING_RE.exec(line);
if (frameMatch) {
if (current) current.end = i;
current = { start: i, end: lines.length, level: (frameMatch[1] ?? "##").length };
bounds.push(current);
continue;
}
const heading = HEADING_LEVEL_RE.exec(line);
if (current && heading && (heading[1] ?? "").length <= current.level) {
current.end = i;
current = null;
}
}
return bounds;
}
function formatValue(value: string, quote: boolean): string {
// Metadata is a single line: collapse every whitespace run (incl. newlines from a
// multi-line textarea) to one space so the value can't split the `- key:` line and
// corrupt the file. A single linear `\s+` avoids the `\s*\r?\n\s*` polynomial
// backtracking CodeQL flags (js/polynomial-redos) on long all-space input.
const clean = value.replace(/\s+/g, " ").trim();
// Always wrap when quoting. The parser's stripQuotes removes exactly one outer
// pair, so wrapping round-trips losslessly even for empty values or values that
// themselves contain quotes (`"foo"` → `""foo""` → parses back to `"foo"`).
return quote ? `"${clean}"` : clean;
}
/**
* Set (or insert) a metadata field on the frame at `frameIndex` (1-based).
* Replaces an existing `- key: …` line (matching any alias) in place; otherwise
* inserts a new line right after the frame heading.
*
* Throws when the frame doesn't exist, so a stale/raced index (e.g. the frame
* was deleted on disk after render) surfaces as an error instead of a silent
* no-op the UI would report as a successful save.
*/
export function setFrameField(
source: string,
frameIndex: number,
key: string,
value: string,
opts: { aliases?: readonly string[]; quote?: boolean } = {},
): string {
const lines = source.split(/\r?\n/);
const target = frameBounds(lines)[frameIndex - 1];
if (!target) throw new Error(`storyboard frame ${frameIndex} not found`);
const aliases = new Set([key, ...(opts.aliases ?? [])].map((k) => k.toLowerCase()));
const formatted = formatValue(value, opts.quote ?? false);
for (let i = target.start + 1; i < target.end; i++) {
const match = META_LINE_RE.exec(lines[i] ?? "");
if (match && aliases.has((match[2] ?? "").toLowerCase())) {
lines[i] = `${match[1]}${match[2]}${match[3]}${formatted}`;
return lines.join("\n");
}
}
lines.splice(target.start + 1, 0, `- ${key}: ${formatted}`);
return lines.join("\n");
}
/** Set the voiceover (guide) line for a frame, matching any voiceover alias. */
export function setFrameVoiceover(source: string, frameIndex: number, value: string): string {
return setFrameField(source, frameIndex, "voiceover", value, {
aliases: VOICEOVER_ALIASES,
quote: true,
});
}
/** Set the lifecycle status for a frame. */
export function setFrameStatus(source: string, frameIndex: number, status: FrameStatus): string {
return setFrameField(source, frameIndex, "status", status);
}
+6
View File
@@ -10,3 +10,9 @@ export {
type StoryboardManifest,
} from "./types.js";
export { parseStoryboard } from "./parseStoryboard.js";
export {
setFrameField,
setFrameVoiceover,
setFrameStatus,
VOICEOVER_ALIASES,
} from "./editStoryboard.js";
@@ -46,7 +46,7 @@ export function parseStoryboard(source: string): StoryboardManifest {
// Detection-only (ends at the keyword) — the title is sliced off in code. A single
// `[ \t]+` before the required keyword stays linear; avoids the polynomial backtracking
// a trailing `[\s…]*(.*)$` would add on tab-heavy input (CodeQL js/polynomial-redos).
const FRAME_HEADING_RE = /^(#{2,3})[ \t]+(?:frame|beat|scene)\b/i;
export const FRAME_HEADING_RE = /^(#{2,3})[ \t]+(?:frame|beat|scene)\b/i;
/** Leading separators between the frame keyword and its title text. */
const FRAME_TITLE_SEP_RE = /^[\s.:—-]+/;
/** Any markdown heading; captures the `#` run so section depth can be compared. */
@@ -61,8 +61,13 @@ const DURATION_NUM_RE = /(\d+(?:\.\d+)?)/;
const TRANSITION_KEYS = new Set(["transition_in", "transitionin", "transition"]);
/** Metadata keys that all map to the one-line scene description. */
const SCENE_KEYS = new Set(["scene", "description", "summary", "caption"]);
/** Metadata keys that all map to the voiceover/narration line. */
const VOICEOVER_KEYS = new Set(["voiceover", "vo", "voice_over", "narration"]);
/**
* Aliases that all map to the voiceover/narration line. The single source of
* truth — `editStoryboard.ts` imports this so the read and write sides can't
* drift (one would silently fail to match the other's field name).
*/
export const VOICEOVER_ALIASES = ["voiceover", "vo", "voice_over", "narration"] as const;
const VOICEOVER_KEYS = new Set<string>(VOICEOVER_ALIASES);
interface FrontmatterResult {
globals: StoryboardGlobals;
+4 -1
View File
@@ -498,7 +498,10 @@ export function StudioApp() {
/>
)}
{viewModeValue.viewMode === "storyboard" && (
<StoryboardView projectId={projectId} />
<StoryboardView
projectId={projectId}
onSelectComposition={handleSelectComposition}
/>
)}
{/* Timeline stage stays mounted (just hidden) in storyboard mode,
so preview/player/gesture/render state survives the toggle. */}
@@ -0,0 +1,53 @@
import { useState } from "react";
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
export interface FramePosterProps {
projectId: string;
/** Project-relative path to the frame's HTML sub-composition. */
src: string;
/** Time (seconds) to seek to for the poster. */
seconds: number;
title: string;
/** `cover` fills+crops (contact-sheet tile); `contain` letterboxes (focus hero). */
fit?: "cover" | "contain";
}
/**
* 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. Shared by the
* contact-sheet tile and the frame-focus view.
*/
export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: FramePosterProps) {
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 ${fit === "contain" ? "object-contain" : "object-cover"}`}
/>
);
}
/** Time (seconds) to show a frame at — past the intro so the key moment is visible. */
export function posterTime(frame: { poster?: number; durationSeconds?: number }): number {
if (frame.poster != null) return frame.poster;
if (frame.durationSeconds != null) return frame.durationSeconds * 0.66;
return 1.5;
}
@@ -0,0 +1,246 @@
import { useCallback, useState } from "react";
import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframes/core/storyboard";
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
import { useViewMode } from "../../contexts/ViewModeContext";
import { FramePoster, posterTime } from "./FramePoster";
import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
export interface StoryboardFrameFocusProps {
projectId: string;
/** Path to STORYBOARD.md (edits are written here). */
storyboardPath: string;
frame: StoryboardFrameView;
frameCount: number;
onBack: () => void;
onNavigate: (delta: number) => void;
/** Re-parse the manifest after an edit is saved. */
onSaved: () => void;
/** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */
onSelectComposition: (path: string) => void;
}
/**
* Full-area focus on a single frame: large poster, editable voiceover guide,
* status advancement, full narrative, and a jump into the live preview. Edits
* are written back to STORYBOARD.md in place (markdown stays canonical).
*
* Mounted with a `key` per frame, so `draft` initializes from the frame and a
* save-triggered reload never clobbers in-progress typing.
*/
// fallow-ignore-next-line complexity
export function StoryboardFrameFocus({
projectId,
storyboardPath,
frame,
frameCount,
onBack,
onNavigate,
onSaved,
onSelectComposition,
}: StoryboardFrameFocusProps) {
const { readProjectFile, writeProjectFile } = useFileManagerContext();
const { setViewMode } = useViewMode();
const [draft, setDraft] = useState(frame.voiceover ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const applyEdit = useCallback(
async (edit: (source: string) => string) => {
setBusy(true);
setError(null);
try {
const source = await readProjectFile(storyboardPath);
await writeProjectFile(storyboardPath, edit(source));
onSaved();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "failed to save");
} finally {
setBusy(false);
}
},
[readProjectFile, writeProjectFile, storyboardPath, onSaved],
);
const title = frame.title ?? `Frame ${frame.index}`;
const dirty = draft !== (frame.voiceover ?? "");
const canOpenPreview = frame.srcExists && Boolean(frame.src);
// Leaving the frame drops the in-memory voiceover draft; confirm when it's dirty.
const confirmLeave = () => !dirty || window.confirm("Discard unsaved voiceover changes?");
const handleBack = () => {
if (confirmLeave()) onBack();
};
const handleNavigate = (delta: number) => {
if (confirmLeave()) onNavigate(delta);
};
const openInPreview = () => {
if (frame.src) onSelectComposition(frame.src);
setViewMode("timeline");
};
return (
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
<div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
<button
type="button"
onClick={handleBack}
className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800"
>
Board
</button>
<span className="text-sm font-medium text-neutral-200">
Frame {frame.number ?? frame.index} {title}
</span>
<div className="ml-auto flex items-center gap-1">
<NavButton
label=" Prev"
disabled={frame.index <= 1}
onClick={() => handleNavigate(-1)}
/>
<NavButton
label="Next "
disabled={frame.index >= frameCount}
onClick={() => handleNavigate(1)}
/>
</div>
</div>
<div className="flex flex-1 min-h-0">
<div className="flex w-3/5 min-w-0 items-center justify-center bg-neutral-900/40 p-8">
<div className="aspect-video w-full max-w-[900px] overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
{canOpenPreview && frame.src ? (
<FramePoster
projectId={projectId}
src={frame.src}
seconds={posterTime(frame)}
title={title}
fit="contain"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-neutral-600">
{frame.status === "outline" ? "Not built yet" : "No preview"}
</div>
)}
</div>
</div>
<div className="w-2/5 min-w-0 space-y-6 overflow-auto border-l border-neutral-800 px-6 py-5">
<StatusRow
status={frame.status}
busy={busy}
onSet={(s) => applyEdit((src) => setFrameStatus(src, frame.index, s))}
/>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[11px] text-neutral-500">
{frame.duration && <span>Duration {frame.duration}</span>}
{frame.transitionIn && <span>Transition {frame.transitionIn}</span>}
</div>
<section>
<div className="mb-1 flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
🎙 Voiceover <span className="font-normal normal-case text-neutral-600">guide</span>
</h3>
<button
type="button"
onClick={() => applyEdit((src) => setFrameVoiceover(src, frame.index, draft))}
disabled={!dirty || busy}
className="rounded bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
>
Save
</button>
</div>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={3}
placeholder="What the narrator says over this frame…"
className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 outline-none focus:border-neutral-600"
/>
<p className="mt-1 text-[11px] text-neutral-600">
A draft guide. SCRIPT.md locks the final narration that drives TTS.
</p>
{error && <p className="mt-1 text-[11px] text-red-400">{error}</p>}
</section>
{frame.narrative && (
<section>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
Narrative
</h3>
<p className="whitespace-pre-wrap text-sm text-neutral-300">{frame.narrative}</p>
</section>
)}
<button
type="button"
onClick={openInPreview}
disabled={!canOpenPreview}
className="rounded border border-neutral-700 px-3 py-1.5 text-xs font-medium text-neutral-200 hover:bg-neutral-800 disabled:opacity-40"
>
Open in Preview
</button>
</div>
</div>
</div>
);
}
function NavButton({
label,
disabled,
onClick,
}: {
label: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800 disabled:opacity-30"
>
{label}
</button>
);
}
function StatusRow({
status,
busy,
onSet,
}: {
status: FrameStatus;
busy: boolean;
onSet: (next: FrameStatus) => void;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
Status
</span>
<div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5">
{FRAME_STATUS_ORDER.map((option) => (
<button
key={option}
type="button"
disabled={busy}
title={FRAME_STATUS_META[option].tooltip}
onClick={() => onSet(option)}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
status === option
? "bg-neutral-700 text-neutral-100"
: "text-neutral-400 hover:text-neutral-200"
}`}
>
{FRAME_STATUS_META[option].label}
</button>
))}
</div>
</div>
);
}
@@ -1,22 +1,16 @@
import { useState } from "react";
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
import { FramePoster, posterTime } from "./FramePoster";
import { FRAME_STATUS_META } from "./frameStatus";
export interface StoryboardFrameTileProps {
projectId: string;
frame: StoryboardFrameView;
/** Open this frame in the full-area focus view. */
onOpen: (index: number) => void;
}
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
@@ -32,9 +26,9 @@ function placeholderMessage(frame: StoryboardFrameView): string {
return "No preview";
}
/** A single contact-sheet tile: poster preview + its metadata. */
/** A single contact-sheet tile: poster preview + its metadata. Click to focus. */
// fallow-ignore-next-line complexity
export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTileProps) {
export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFrameTileProps) {
const meta = FRAME_STATUS_META[frame.status];
const renderable = frame.srcExists && frame.status !== "outline";
const title = frame.title ?? `Frame ${frame.index}`;
@@ -42,7 +36,11 @@ export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTilePro
return (
<article style={{ width: TILE_WIDTH }}>
<div className="relative aspect-video overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
<button
type="button"
onClick={() => onOpen(frame.index)}
className="group relative block aspect-video w-full overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900 text-left transition-colors hover:border-neutral-600"
>
<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>
@@ -56,7 +54,7 @@ export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTilePro
) : (
<FrameTilePlaceholder frame={frame} />
)}
</div>
</button>
<div className="mt-2 flex items-start justify-between gap-2">
<h3 className="truncate text-sm font-medium text-neutral-200">{title}</h3>
@@ -81,48 +79,6 @@ export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTilePro
);
}
/**
* 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">
@@ -4,10 +4,12 @@ import { StoryboardFrameTile } from "./StoryboardFrameTile";
export interface StoryboardGridProps {
projectId: string;
frames: StoryboardFrameView[];
/** Open a frame in the full-area focus view. */
onOpenFrame: (index: number) => void;
}
/** The contact sheet: ordered frame tiles in a responsive grid. */
export function StoryboardGrid({ projectId, frames }: StoryboardGridProps) {
export function StoryboardGrid({ projectId, frames, onOpenFrame }: 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">
@@ -19,7 +21,12 @@ export function StoryboardGrid({ projectId, frames }: StoryboardGridProps) {
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} />
<StoryboardFrameTile
key={frame.index}
projectId={projectId}
frame={frame}
onOpen={onOpenFrame}
/>
))}
</div>
);
@@ -5,6 +5,7 @@ import { StoryboardGrid } from "./StoryboardGrid";
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
type SubView = "board" | "source";
@@ -13,12 +14,25 @@ export interface StoryboardLoadedProps {
data: StoryboardResponse;
/** Re-fetch the manifest after a source edit is saved. */
reload: () => void;
/** Select a composition in the timeline (used by "Open in Preview"). */
onSelectComposition: (path: string) => void;
}
/** A storyboard that exists on disk: Board (contact sheet) ↔ Source (markdown editor). */
export function StoryboardLoaded({ projectId, data, reload }: StoryboardLoadedProps) {
function clampIndex(index: number, count: number): number {
return Math.max(1, Math.min(count, index));
}
/** A storyboard that exists on disk: Board (contact sheet) ↔ Source ↔ frame focus. */
// fallow-ignore-next-line complexity
export function StoryboardLoaded({
projectId,
data,
reload,
onSelectComposition,
}: StoryboardLoadedProps) {
const [subView, setSubView] = useState<SubView>("board");
const [sourceDirty, setSourceDirty] = useState(false);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const sourceFiles = useMemo<SourceFile[]>(() => {
const files: SourceFile[] = [{ path: data.path, label: data.path }];
if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
@@ -39,6 +53,27 @@ export function StoryboardLoaded({ projectId, data, reload }: StoryboardLoadedPr
setSubView(next);
};
const focusedFrame =
focusedIndex != null ? (data.frames.find((f) => f.index === focusedIndex) ?? null) : null;
if (focusedFrame) {
return (
<StoryboardFrameFocus
key={focusedFrame.index}
projectId={projectId}
storyboardPath={data.path}
frame={focusedFrame}
frameCount={data.frames.length}
onBack={() => setFocusedIndex(null)}
onNavigate={(delta) =>
setFocusedIndex(clampIndex(focusedFrame.index + delta, data.frames.length))
}
onSaved={reload}
onSelectComposition={onSelectComposition}
/>
);
}
return (
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
<div className="flex items-center border-b border-neutral-800 px-4 py-2">
@@ -51,7 +86,11 @@ export function StoryboardLoaded({ projectId, data, reload }: StoryboardLoadedPr
<div className="mt-5">
<StoryboardStatusLegend />
</div>
<StoryboardGrid projectId={projectId} frames={data.frames} />
<StoryboardGrid
projectId={projectId}
frames={data.frames}
onOpenFrame={setFocusedIndex}
/>
{data.script && <StoryboardScriptPanel script={data.script} />}
</div>
</div>
@@ -4,6 +4,8 @@ import { StoryboardLoaded } from "./StoryboardLoaded";
export interface StoryboardViewProps {
projectId: string;
/** Select a composition in the timeline (used by the frame focus "Open in Preview"). */
onSelectComposition: (path: string) => void;
}
/**
@@ -12,7 +14,7 @@ export interface StoryboardViewProps {
* {@link StoryboardLoaded} owns the Board Source experience.
*/
// fallow-ignore-next-line complexity
export function StoryboardView({ projectId }: StoryboardViewProps) {
export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
const { data, loading, error, reload } = useStoryboard(projectId);
if (loading) return <StoryboardFrame>{<Message>Loading storyboard</Message>}</StoryboardFrame>;
@@ -32,7 +34,14 @@ export function StoryboardView({ projectId }: StoryboardViewProps) {
);
}
return <StoryboardLoaded projectId={projectId} data={data} reload={reload} />;
return (
<StoryboardLoaded
projectId={projectId}
data={data}
reload={reload}
onSelectComposition={onSelectComposition}
/>
);
}
function StoryboardFrame({ children }: { children: ReactNode }) {