Skip to content

Commit 7458567

Browse files
jrusso1020claude
andcommitted
feat(studio): storyboard frame focus + voiceover iteration
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>
1 parent cf26e05 commit 7458567

10 files changed

Lines changed: 561 additions & 63 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect } from "vitest";
2+
import { parseStoryboard } from "./parseStoryboard.js";
3+
import { setFrameStatus, setFrameVoiceover } from "./editStoryboard.js";
4+
5+
const DOC = `---
6+
message: Hi
7+
---
8+
9+
## Frame 1 — Hook
10+
- status: outline
11+
- voiceover: "old line"
12+
13+
Hook narrative.
14+
15+
## Frame 2 — Close
16+
- duration: 3s
17+
18+
Close narrative.
19+
`;
20+
21+
describe("setFrameVoiceover / setFrameStatus", () => {
22+
it("replaces an existing voiceover line in place, leaving other frames untouched", () => {
23+
const next = setFrameVoiceover(DOC, 1, "new line");
24+
const parsed = parseStoryboard(next);
25+
expect(parsed.frames[0].voiceover).toBe("new line");
26+
expect(parsed.frames[1].duration).toBe("3s");
27+
expect(next).toContain('- voiceover: "new line"');
28+
});
29+
30+
it("matches voiceover aliases (vo)", () => {
31+
const doc = "## Frame 1\n- vo: original\n\nBody.";
32+
const next = setFrameVoiceover(doc, 1, "updated");
33+
// The aliased key is preserved, only the value changes.
34+
expect(next).toContain("- vo: ");
35+
expect(parseStoryboard(next).frames[0].voiceover).toBe("updated");
36+
});
37+
38+
it("inserts the field after the heading when absent", () => {
39+
const next = setFrameVoiceover("## Frame 1 — Hook\n\nBody.", 1, "hi");
40+
const lines = next.split("\n");
41+
expect(lines[0]).toBe("## Frame 1 — Hook");
42+
expect(lines[1]).toBe('- voiceover: "hi"');
43+
expect(parseStoryboard(next).frames[0].voiceover).toBe("hi");
44+
});
45+
46+
it("advances status in place", () => {
47+
const next = setFrameStatus(DOC, 1, "built");
48+
expect(parseStoryboard(next).frames[0].status).toBe("built");
49+
expect(parseStoryboard(next).frames[1].status).toBe("outline");
50+
});
51+
52+
it("round-trips a voiceover containing double quotes (always wraps)", () => {
53+
const next = setFrameVoiceover("## Frame 1\n- voiceover: x\n", 1, 'she said "hi"');
54+
expect(parseStoryboard(next).frames[0].voiceover).toBe('she said "hi"');
55+
});
56+
57+
it("round-trips an empty voiceover and a fully-quoted phrase", () => {
58+
const cleared = setFrameVoiceover("## Frame 1\n- voiceover: x\n", 1, "");
59+
expect(parseStoryboard(cleared).frames[0].voiceover).toBe("");
60+
const quoted = setFrameVoiceover("## Frame 1\n- voiceover: x\n", 1, '"hello"');
61+
expect(parseStoryboard(quoted).frames[0].voiceover).toBe('"hello"');
62+
});
63+
64+
it("collapses newlines in a multi-line voiceover to a single line", () => {
65+
const next = setFrameVoiceover(
66+
"## Frame 1\n- voiceover: x\n\nNarrative.",
67+
1,
68+
"line one\nline two",
69+
);
70+
expect(parseStoryboard(next).frames[0].voiceover).toBe("line one line two");
71+
expect(parseStoryboard(next).frames[0].narrative).toBe("Narrative.");
72+
});
73+
74+
it("throws for an out-of-range frame", () => {
75+
expect(() => setFrameStatus(DOC, 9, "built")).toThrow(/not found/);
76+
});
77+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { FrameStatus } from "./types.js";
2+
3+
/**
4+
* Surgical writers for `STORYBOARD.md`.
5+
*
6+
* These update a single frame's metadata in place — preserving all other
7+
* content, formatting, comments, and non-frame sections — rather than
8+
* re-serializing the parsed manifest (which would be lossy). Used by the
9+
* storyboard frame-focus editor to persist `voiceover` / `status` edits.
10+
*
11+
* The patterns below mirror the read-side detection in `parseStoryboard.ts`.
12+
*/
13+
14+
// Linear (single bounded whitespace run before a literal) to avoid the polynomial
15+
// backtracking CodeQL flags (js/polynomial-redos); mirrors parseStoryboard.ts.
16+
const FRAME_HEADING_RE = /^(#{2,3})[ \t]+(?:frame|beat|scene)\b/i;
17+
const HEADING_LEVEL_RE = /^(#{1,6})[ \t]+/;
18+
/**
19+
* `- key: value` capturing bullet, key, separator, and value for in-place replacement.
20+
* Value is greedy-to-EOL (not `(.+?)\s*$`) so it can't backtrack on trailing whitespace.
21+
*/
22+
const META_LINE_RE = /^([ \t]*[-*][ \t]+)([A-Za-z_][\w-]*)([ \t]*:[ \t]*)(.*)$/;
23+
24+
/** Aliases the parser accepts for the voiceover field. */
25+
export const VOICEOVER_ALIASES = ["voiceover", "vo", "voice_over", "narration"];
26+
27+
interface FrameBounds {
28+
/** 0-based line index of the frame heading. */
29+
start: number;
30+
/** 0-based line index just past the frame's content (exclusive). */
31+
end: number;
32+
/** Heading depth (`#` count) that opened the frame. */
33+
level: number;
34+
}
35+
36+
/** Locate every frame's line range, using the same boundary rules as the parser. */
37+
// fallow-ignore-next-line complexity
38+
function frameBounds(lines: string[]): FrameBounds[] {
39+
const bounds: FrameBounds[] = [];
40+
let current: FrameBounds | null = null;
41+
for (let i = 0; i < lines.length; i++) {
42+
const line = lines[i] ?? "";
43+
const frameMatch = FRAME_HEADING_RE.exec(line);
44+
if (frameMatch) {
45+
if (current) current.end = i;
46+
current = { start: i, end: lines.length, level: (frameMatch[1] ?? "##").length };
47+
bounds.push(current);
48+
continue;
49+
}
50+
const heading = HEADING_LEVEL_RE.exec(line);
51+
if (current && heading && (heading[1] ?? "").length <= current.level) {
52+
current.end = i;
53+
current = null;
54+
}
55+
}
56+
return bounds;
57+
}
58+
59+
function formatValue(value: string, quote: boolean): string {
60+
// Metadata is a single line: collapse any newlines (from a multi-line textarea)
61+
// to spaces so the value can never split the `- key:` line and corrupt the file.
62+
const clean = value.replace(/\s*\r?\n\s*/g, " ").trim();
63+
// Always wrap when quoting. The parser's stripQuotes removes exactly one outer
64+
// pair, so wrapping round-trips losslessly even for empty values or values that
65+
// themselves contain quotes (`"foo"` → `""foo""` → parses back to `"foo"`).
66+
return quote ? `"${clean}"` : clean;
67+
}
68+
69+
/**
70+
* Set (or insert) a metadata field on the frame at `frameIndex` (1-based).
71+
* Replaces an existing `- key: …` line (matching any alias) in place; otherwise
72+
* inserts a new line right after the frame heading.
73+
*
74+
* Throws when the frame doesn't exist, so a stale/raced index (e.g. the frame
75+
* was deleted on disk after render) surfaces as an error instead of a silent
76+
* no-op the UI would report as a successful save.
77+
*/
78+
export function setFrameField(
79+
source: string,
80+
frameIndex: number,
81+
key: string,
82+
value: string,
83+
opts: { aliases?: string[]; quote?: boolean } = {},
84+
): string {
85+
const lines = source.split(/\r?\n/);
86+
const target = frameBounds(lines)[frameIndex - 1];
87+
if (!target) throw new Error(`storyboard frame ${frameIndex} not found`);
88+
89+
const aliases = new Set([key, ...(opts.aliases ?? [])].map((k) => k.toLowerCase()));
90+
const formatted = formatValue(value, opts.quote ?? false);
91+
92+
for (let i = target.start + 1; i < target.end; i++) {
93+
const match = META_LINE_RE.exec(lines[i] ?? "");
94+
if (match && aliases.has((match[2] ?? "").toLowerCase())) {
95+
lines[i] = `${match[1]}${match[2]}${match[3]}${formatted}`;
96+
return lines.join("\n");
97+
}
98+
}
99+
100+
lines.splice(target.start + 1, 0, `- ${key}: ${formatted}`);
101+
return lines.join("\n");
102+
}
103+
104+
/** Set the voiceover (guide) line for a frame, matching any voiceover alias. */
105+
export function setFrameVoiceover(source: string, frameIndex: number, value: string): string {
106+
return setFrameField(source, frameIndex, "voiceover", value, {
107+
aliases: VOICEOVER_ALIASES,
108+
quote: true,
109+
});
110+
}
111+
112+
/** Set the lifecycle status for a frame. */
113+
export function setFrameStatus(source: string, frameIndex: number, status: FrameStatus): string {
114+
return setFrameField(source, frameIndex, "status", status);
115+
}

packages/core/src/storyboard/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ export {
1010
type StoryboardManifest,
1111
} from "./types.js";
1212
export { parseStoryboard } from "./parseStoryboard.js";
13+
export {
14+
setFrameField,
15+
setFrameVoiceover,
16+
setFrameStatus,
17+
VOICEOVER_ALIASES,
18+
} from "./editStoryboard.js";

packages/studio/src/App.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,10 @@ export function StudioApp() {
498498
/>
499499
)}
500500
{viewModeValue.viewMode === "storyboard" && (
501-
<StoryboardView projectId={projectId} />
501+
<StoryboardView
502+
projectId={projectId}
503+
onSelectComposition={handleSelectComposition}
504+
/>
502505
)}
503506
{/* Timeline stage stays mounted (just hidden) in storyboard mode,
504507
so preview/player/gesture/render state survives the toggle. */}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useState } from "react";
2+
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
3+
4+
export interface FramePosterProps {
5+
projectId: string;
6+
/** Project-relative path to the frame's HTML sub-composition. */
7+
src: string;
8+
/** Time (seconds) to seek to for the poster. */
9+
seconds: number;
10+
title: string;
11+
/** `cover` fills+crops (contact-sheet tile); `contain` letterboxes (focus hero). */
12+
fit?: "cover" | "contain";
13+
}
14+
15+
/**
16+
* Server-rendered poster for a frame. The thumbnail route seeks the composition
17+
* by time (at its real fps) and caches the result, so there's no live iframe,
18+
* no postMessage seek, and no client-side fps assumption. Shared by the
19+
* contact-sheet tile and the frame-focus view.
20+
*/
21+
export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: FramePosterProps) {
22+
const [failed, setFailed] = useState(false);
23+
if (failed) {
24+
return (
25+
<div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
26+
Preview unavailable
27+
</div>
28+
);
29+
}
30+
const url = buildCompositionThumbnailUrl({
31+
previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
32+
seekTime: seconds,
33+
duration: 0,
34+
origin: window.location.origin,
35+
});
36+
return (
37+
<img
38+
src={url}
39+
alt={title}
40+
draggable={false}
41+
loading="lazy"
42+
onError={() => setFailed(true)}
43+
className={`h-full w-full ${fit === "contain" ? "object-contain" : "object-cover"}`}
44+
/>
45+
);
46+
}
47+
48+
/** Time (seconds) to show a frame at — past the intro so the key moment is visible. */
49+
export function posterTime(frame: { poster?: number; durationSeconds?: number }): number {
50+
if (frame.poster != null) return frame.poster;
51+
if (frame.durationSeconds != null) return frame.durationSeconds * 0.66;
52+
return 1.5;
53+
}

0 commit comments

Comments
 (0)