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,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 }) {