@hyperframes/studio 0.6.109 → 0.6.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/assets/hyperframes-player-BRjQ5a5_.js +418 -0
  2. package/dist/assets/index-B7S86vK1.js +370 -0
  3. package/dist/assets/index-DP8pPIk2.css +1 -0
  4. package/dist/assets/{index-SlMJloLR.js → index-_IV-vm9l.js} +1 -1
  5. package/dist/assets/{index-CFCe0Xvn.js → index-x0c2-zQN.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/package.json +8 -5
  8. package/src/App.tsx +133 -141
  9. package/src/components/StudioHeader.tsx +44 -0
  10. package/src/components/StudioOverlays.tsx +70 -0
  11. package/src/components/editor/SourceEditor.tsx +19 -16
  12. package/src/components/editor/gsapAnimationConstants.ts +24 -0
  13. package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
  14. package/src/components/editor/manualEditingAvailability.ts +25 -7
  15. package/src/components/storyboard/FramePoster.tsx +56 -0
  16. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  17. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  18. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  19. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  20. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  21. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  22. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  23. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  24. package/src/components/storyboard/StoryboardView.tsx +78 -0
  25. package/src/components/storyboard/frameStatus.ts +36 -0
  26. package/src/contexts/ViewModeContext.tsx +98 -0
  27. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  28. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  29. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  30. package/src/hooks/timelineEditingHelpers.ts +2 -0
  31. package/src/hooks/useAppHotkeys.ts +20 -0
  32. package/src/hooks/useDomEditCommits.ts +43 -14
  33. package/src/hooks/useDomEditSession.test.ts +41 -0
  34. package/src/hooks/useDomEditSession.ts +38 -6
  35. package/src/hooks/useDomGeometryCommits.ts +5 -9
  36. package/src/hooks/useElementLifecycleOps.ts +30 -0
  37. package/src/hooks/useGsapAnimationOps.ts +78 -51
  38. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  39. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  40. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  41. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  42. package/src/hooks/useGsapScriptCommits.ts +95 -40
  43. package/src/hooks/useGsapTweenCache.ts +0 -27
  44. package/src/hooks/useRazorSplit.ts +4 -10
  45. package/src/hooks/useSdkSession.test.ts +12 -0
  46. package/src/hooks/useSdkSession.ts +91 -25
  47. package/src/hooks/useStoryboard.ts +80 -0
  48. package/src/hooks/useTimelineEditing.ts +163 -68
  49. package/src/utils/gsapSoftReload.ts +14 -0
  50. package/src/utils/sdkCutover.gate.test.ts +61 -0
  51. package/src/utils/sdkCutover.test.ts +839 -0
  52. package/src/utils/sdkCutover.ts +465 -0
  53. package/src/utils/sdkOpMapping.ts +43 -0
  54. package/src/utils/sdkResolverShadow.test.ts +366 -0
  55. package/src/utils/sdkResolverShadow.ts +313 -0
  56. package/src/utils/timelineElementSplit.test.ts +61 -1
  57. package/src/utils/timelineElementSplit.ts +18 -0
  58. package/dist/assets/hyperframes-player-67pq7USK.js +0 -418
  59. package/dist/assets/index-BVqybwMG.css +0 -1
  60. package/dist/assets/index-ho_f4axK.js +0 -296
  61. package/src/utils/sdkShadow.test.ts +0 -606
  62. package/src/utils/sdkShadow.ts +0 -517
  63. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  64. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  65. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  66. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -0,0 +1,262 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframes/core/storyboard";
3
+ import type { StoryboardFrameView } from "../../hooks/useStoryboard";
4
+ import { useFileManagerContext } from "../../contexts/FileManagerContext";
5
+ import { useViewMode } from "../../contexts/ViewModeContext";
6
+ import { FramePoster, posterTime } from "./FramePoster";
7
+ import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
8
+
9
+ export interface StoryboardFrameFocusProps {
10
+ projectId: string;
11
+ /** Path to STORYBOARD.md (edits are written here). */
12
+ storyboardPath: string;
13
+ frame: StoryboardFrameView;
14
+ frameCount: number;
15
+ onBack: () => void;
16
+ onNavigate: (delta: number) => void;
17
+ /** Re-parse the manifest after an edit is saved. */
18
+ onSaved: () => void;
19
+ /** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */
20
+ onSelectComposition: (path: string) => void;
21
+ }
22
+
23
+ /**
24
+ * Full-area focus on a single frame: large poster, editable voiceover guide,
25
+ * status advancement, full narrative, and a jump into the live preview. Edits
26
+ * are written back to STORYBOARD.md in place (markdown stays canonical).
27
+ *
28
+ * Mounted with a `key` per frame, so `draft` initializes from the frame and a
29
+ * save-triggered reload never clobbers in-progress typing.
30
+ */
31
+ // fallow-ignore-next-line complexity
32
+ export function StoryboardFrameFocus({
33
+ projectId,
34
+ storyboardPath,
35
+ frame,
36
+ frameCount,
37
+ onBack,
38
+ onNavigate,
39
+ onSaved,
40
+ onSelectComposition,
41
+ }: StoryboardFrameFocusProps) {
42
+ const { readProjectFile, writeProjectFile } = useFileManagerContext();
43
+ const { setViewMode } = useViewMode();
44
+ const [draft, setDraft] = useState(frame.voiceover ?? "");
45
+ const [busy, setBusy] = useState(false);
46
+ const [error, setError] = useState<string | null>(null);
47
+
48
+ const applyEdit = useCallback(
49
+ async (edit: (source: string) => string) => {
50
+ if (busy) return; // one read-modify-write at a time; avoids a lost update
51
+ setBusy(true);
52
+ setError(null);
53
+ try {
54
+ const source = await readProjectFile(storyboardPath);
55
+ await writeProjectFile(storyboardPath, edit(source));
56
+ onSaved();
57
+ } catch (err: unknown) {
58
+ setError(err instanceof Error ? err.message : "failed to save");
59
+ } finally {
60
+ setBusy(false);
61
+ }
62
+ },
63
+ [readProjectFile, writeProjectFile, storyboardPath, onSaved, busy],
64
+ );
65
+
66
+ const title = frame.title ?? `Frame ${frame.index}`;
67
+ const dirty = draft !== (frame.voiceover ?? "");
68
+ const canOpenPreview = frame.srcExists && Boolean(frame.src);
69
+
70
+ // Leaving the frame drops the in-memory voiceover draft; confirm when it's dirty.
71
+ const confirmLeave = () => !dirty || window.confirm("Discard unsaved voiceover changes?");
72
+ const handleBack = () => {
73
+ if (confirmLeave()) onBack();
74
+ };
75
+ const handleNavigate = (delta: number) => {
76
+ if (confirmLeave()) onNavigate(delta);
77
+ };
78
+
79
+ // ←/→ navigate frames, Esc returns to the Board — but never while typing in a field.
80
+ useEffect(() => {
81
+ // fallow-ignore-next-line complexity
82
+ const onKey = (e: KeyboardEvent) => {
83
+ const el = document.activeElement;
84
+ if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) return;
85
+ if (e.key === "Escape") handleBack();
86
+ else if (e.key === "ArrowLeft" && frame.index > 1) handleNavigate(-1);
87
+ else if (e.key === "ArrowRight" && frame.index < frameCount) handleNavigate(1);
88
+ };
89
+ window.addEventListener("keydown", onKey);
90
+ return () => window.removeEventListener("keydown", onKey);
91
+ });
92
+
93
+ const openInPreview = () => {
94
+ if (frame.src) onSelectComposition(frame.src);
95
+ setViewMode("timeline");
96
+ };
97
+
98
+ return (
99
+ <div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
100
+ <div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
101
+ <button
102
+ type="button"
103
+ onClick={handleBack}
104
+ className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800"
105
+ >
106
+ ← Board
107
+ </button>
108
+ <span className="text-sm font-medium text-neutral-200">
109
+ Frame {frame.number ?? frame.index} — {title}
110
+ </span>
111
+ <div className="ml-auto flex items-center gap-1">
112
+ <NavButton
113
+ label="‹ Prev"
114
+ disabled={frame.index <= 1}
115
+ onClick={() => handleNavigate(-1)}
116
+ />
117
+ <NavButton
118
+ label="Next ›"
119
+ disabled={frame.index >= frameCount}
120
+ onClick={() => handleNavigate(1)}
121
+ />
122
+ </div>
123
+ </div>
124
+
125
+ <div className="flex flex-1 min-h-0">
126
+ <div className="flex w-3/5 min-w-0 items-center justify-center bg-neutral-900/40 p-8">
127
+ <div className="aspect-video w-full max-w-[900px] overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
128
+ {canOpenPreview && frame.src ? (
129
+ <FramePoster
130
+ projectId={projectId}
131
+ src={frame.src}
132
+ seconds={posterTime(frame)}
133
+ title={title}
134
+ fit="contain"
135
+ />
136
+ ) : (
137
+ <div className="flex h-full w-full items-center justify-center text-sm text-neutral-600">
138
+ {frame.status === "outline" ? "Not built yet" : "No preview"}
139
+ </div>
140
+ )}
141
+ </div>
142
+ </div>
143
+
144
+ <div className="w-2/5 min-w-0 space-y-6 overflow-auto border-l border-neutral-800 px-6 py-5">
145
+ <StatusRow
146
+ status={frame.status}
147
+ busy={busy}
148
+ onSet={(s) => applyEdit((src) => setFrameStatus(src, frame.index, s))}
149
+ />
150
+
151
+ <div className="flex flex-wrap gap-x-6 gap-y-1 text-[11px] text-neutral-500">
152
+ {frame.duration && <span>Duration {frame.duration}</span>}
153
+ {frame.transitionIn && <span>Transition {frame.transitionIn}</span>}
154
+ </div>
155
+
156
+ <section>
157
+ <div className="mb-1 flex items-center justify-between">
158
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
159
+ 🎙 Voiceover <span className="font-normal normal-case text-neutral-600">guide</span>
160
+ </h3>
161
+ <button
162
+ type="button"
163
+ onClick={() => applyEdit((src) => setFrameVoiceover(src, frame.index, draft))}
164
+ disabled={!dirty || busy}
165
+ className="rounded bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
166
+ >
167
+ Save
168
+ </button>
169
+ </div>
170
+ <textarea
171
+ value={draft}
172
+ onChange={(e) => setDraft(e.target.value)}
173
+ rows={3}
174
+ placeholder="What the narrator says over this frame…"
175
+ 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"
176
+ />
177
+ <p className="mt-1 text-[11px] text-neutral-600">
178
+ A draft guide. SCRIPT.md locks the final narration that drives TTS.
179
+ </p>
180
+ {error && <p className="mt-1 text-[11px] text-red-400">{error}</p>}
181
+ </section>
182
+
183
+ {frame.narrative && (
184
+ <section>
185
+ <h3 className="mb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
186
+ Narrative
187
+ </h3>
188
+ <p className="whitespace-pre-wrap text-sm text-neutral-300">{frame.narrative}</p>
189
+ </section>
190
+ )}
191
+
192
+ <button
193
+ type="button"
194
+ onClick={openInPreview}
195
+ disabled={!canOpenPreview}
196
+ 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"
197
+ >
198
+ Open in Preview →
199
+ </button>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ );
204
+ }
205
+
206
+ function NavButton({
207
+ label,
208
+ disabled,
209
+ onClick,
210
+ }: {
211
+ label: string;
212
+ disabled: boolean;
213
+ onClick: () => void;
214
+ }) {
215
+ return (
216
+ <button
217
+ type="button"
218
+ onClick={onClick}
219
+ disabled={disabled}
220
+ className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800 disabled:opacity-30"
221
+ >
222
+ {label}
223
+ </button>
224
+ );
225
+ }
226
+
227
+ function StatusRow({
228
+ status,
229
+ busy,
230
+ onSet,
231
+ }: {
232
+ status: FrameStatus;
233
+ busy: boolean;
234
+ onSet: (next: FrameStatus) => void;
235
+ }) {
236
+ return (
237
+ <div className="flex items-center gap-2">
238
+ <span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
239
+ Status
240
+ </span>
241
+ <div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5">
242
+ {FRAME_STATUS_ORDER.map((option) => (
243
+ <button
244
+ key={option}
245
+ type="button"
246
+ disabled={busy}
247
+ aria-pressed={status === option}
248
+ title={FRAME_STATUS_META[option].tooltip}
249
+ onClick={() => onSet(option)}
250
+ className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
251
+ status === option
252
+ ? "bg-neutral-700 text-neutral-100"
253
+ : "text-neutral-400 hover:text-neutral-200"
254
+ }`}
255
+ >
256
+ {FRAME_STATUS_META[option].label}
257
+ </button>
258
+ ))}
259
+ </div>
260
+ </div>
261
+ );
262
+ }
@@ -0,0 +1,88 @@
1
+ import type { StoryboardFrameView } from "../../hooks/useStoryboard";
2
+ import { FramePoster, posterTime } from "./FramePoster";
3
+ import { FRAME_STATUS_META } from "./frameStatus";
4
+
5
+ export interface StoryboardFrameTileProps {
6
+ projectId: string;
7
+ frame: StoryboardFrameView;
8
+ /** Open this frame in the full-area focus view. */
9
+ onOpen: (index: number) => void;
10
+ }
11
+
12
+ function firstLine(text: string): string {
13
+ return (
14
+ text
15
+ .split("\n")
16
+ .find((line) => line.trim().length > 0)
17
+ ?.trim() ?? ""
18
+ );
19
+ }
20
+
21
+ function placeholderMessage(frame: StoryboardFrameView): string {
22
+ if (frame.status === "outline") return "Not built yet";
23
+ if (frame.src && !frame.srcExists) return "Frame file not found";
24
+ return "No preview";
25
+ }
26
+
27
+ /** A single contact-sheet tile: poster preview + its metadata. Click to focus. */
28
+ // fallow-ignore-next-line complexity
29
+ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFrameTileProps) {
30
+ const meta = FRAME_STATUS_META[frame.status];
31
+ const renderable = frame.srcExists && frame.status !== "outline";
32
+ const title = frame.title ?? `Frame ${frame.index}`;
33
+ const sceneLine = frame.scene ?? firstLine(frame.narrative);
34
+
35
+ return (
36
+ <article className="min-w-0">
37
+ <button
38
+ type="button"
39
+ onClick={() => onOpen(frame.index)}
40
+ 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"
41
+ >
42
+ <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">
43
+ {frame.number ?? frame.index}
44
+ </div>
45
+ {renderable && frame.src ? (
46
+ <FramePoster
47
+ projectId={projectId}
48
+ src={frame.src}
49
+ seconds={posterTime(frame)}
50
+ title={title}
51
+ />
52
+ ) : (
53
+ <FrameTilePlaceholder frame={frame} />
54
+ )}
55
+ </button>
56
+
57
+ <div className="mt-2 flex items-start justify-between gap-2">
58
+ <h3 className="truncate text-sm font-medium text-neutral-200">{title}</h3>
59
+ <span
60
+ title={meta.tooltip}
61
+ aria-label={`Status: ${meta.label} — ${meta.tooltip}`}
62
+ className={`shrink-0 cursor-default rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${meta.chipClass}`}
63
+ >
64
+ {meta.label}
65
+ </span>
66
+ </div>
67
+ {sceneLine && <p className="mt-0.5 line-clamp-2 text-xs text-neutral-400">{sceneLine}</p>}
68
+ {frame.voiceover && (
69
+ <p className="mt-1 line-clamp-2 text-xs italic text-neutral-500">
70
+ <span aria-hidden="true">🎙 </span>“{frame.voiceover}”
71
+ </p>
72
+ )}
73
+ <div className="mt-1 flex gap-3 text-[11px] text-neutral-600">
74
+ {frame.duration && <span>{frame.duration}</span>}
75
+ {frame.transitionIn && <span>↘ {frame.transitionIn}</span>}
76
+ </div>
77
+ </article>
78
+ );
79
+ }
80
+
81
+ function FrameTilePlaceholder({ frame }: { frame: StoryboardFrameView }) {
82
+ return (
83
+ <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">
84
+ <span className="text-xs font-medium text-neutral-400">{frame.title ?? "Outline"}</span>
85
+ <span className="text-[11px] text-neutral-600">{placeholderMessage(frame)}</span>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,33 @@
1
+ import type { StoryboardFrameView } from "../../hooks/useStoryboard";
2
+ import { StoryboardFrameTile } from "./StoryboardFrameTile";
3
+
4
+ export interface StoryboardGridProps {
5
+ projectId: string;
6
+ frames: StoryboardFrameView[];
7
+ /** Open a frame in the full-area focus view. */
8
+ onOpenFrame: (index: number) => void;
9
+ }
10
+
11
+ /** The contact sheet: ordered frame tiles in a responsive grid. */
12
+ export function StoryboardGrid({ projectId, frames, onOpenFrame }: StoryboardGridProps) {
13
+ if (frames.length === 0) {
14
+ return (
15
+ <div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
16
+ This storyboard has no frames yet.
17
+ </div>
18
+ );
19
+ }
20
+
21
+ return (
22
+ <div className="mt-8 grid gap-x-6 gap-y-8 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
23
+ {frames.map((frame) => (
24
+ <StoryboardFrameTile
25
+ key={frame.index}
26
+ projectId={projectId}
27
+ frame={frame}
28
+ onOpen={onOpenFrame}
29
+ />
30
+ ))}
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,136 @@
1
+ import { useMemo, useState } from "react";
2
+ import type { StoryboardResponse } from "../../hooks/useStoryboard";
3
+ import { StoryboardDirection } from "./StoryboardDirection";
4
+ import { StoryboardGrid } from "./StoryboardGrid";
5
+ import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
6
+ import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
7
+ import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
8
+ import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
9
+
10
+ type SubView = "board" | "source";
11
+
12
+ export interface StoryboardLoadedProps {
13
+ projectId: string;
14
+ data: StoryboardResponse;
15
+ /** Re-fetch the manifest after a source edit is saved. */
16
+ reload: () => void;
17
+ /** Select a composition in the timeline (used by "Open in Preview"). */
18
+ onSelectComposition: (path: string) => void;
19
+ }
20
+
21
+ function clampIndex(index: number, count: number): number {
22
+ return Math.max(1, Math.min(count, index));
23
+ }
24
+
25
+ /** A storyboard that exists on disk: Board (contact sheet) ↔ Source ↔ frame focus. */
26
+ // fallow-ignore-next-line complexity
27
+ export function StoryboardLoaded({
28
+ projectId,
29
+ data,
30
+ reload,
31
+ onSelectComposition,
32
+ }: StoryboardLoadedProps) {
33
+ const [subView, setSubView] = useState<SubView>("board");
34
+ const [sourceDirty, setSourceDirty] = useState(false);
35
+ const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
36
+ const sourceFiles = useMemo<SourceFile[]>(() => {
37
+ const files: SourceFile[] = [{ path: data.path, label: data.path }];
38
+ if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
39
+ return files;
40
+ // Depend on the stable fields, not the `data.script` object — every reload()
41
+ // produces a fresh object and would needlessly re-create this array.
42
+ }, [data.path, data.script?.path, data.script?.exists]);
43
+
44
+ // Leaving the source editor drops its in-memory buffer; confirm when it's dirty.
45
+ // fallow-ignore-next-line complexity
46
+ const changeSubView = (next: SubView) => {
47
+ if (next === subView) return;
48
+ if (
49
+ subView === "source" &&
50
+ sourceDirty &&
51
+ !window.confirm("Discard unsaved markdown changes?")
52
+ ) {
53
+ return;
54
+ }
55
+ setSubView(next);
56
+ };
57
+
58
+ const focusedFrame =
59
+ focusedIndex != null ? (data.frames.find((f) => f.index === focusedIndex) ?? null) : null;
60
+
61
+ if (focusedFrame) {
62
+ return (
63
+ <StoryboardFrameFocus
64
+ key={focusedFrame.index}
65
+ projectId={projectId}
66
+ storyboardPath={data.path}
67
+ frame={focusedFrame}
68
+ frameCount={data.frames.length}
69
+ onBack={() => setFocusedIndex(null)}
70
+ onNavigate={(delta) =>
71
+ setFocusedIndex(clampIndex(focusedFrame.index + delta, data.frames.length))
72
+ }
73
+ onSaved={reload}
74
+ onSelectComposition={onSelectComposition}
75
+ />
76
+ );
77
+ }
78
+
79
+ return (
80
+ <div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
81
+ <div className="flex items-center border-b border-neutral-800 px-4 py-2">
82
+ <SubViewToggle value={subView} onChange={changeSubView} />
83
+ </div>
84
+ {subView === "board" ? (
85
+ <div className="flex-1 min-h-0 overflow-auto">
86
+ <div className="mx-auto max-w-[1400px] px-8 py-8">
87
+ <StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
88
+ <div className="mt-5">
89
+ <StoryboardStatusLegend />
90
+ </div>
91
+ <StoryboardGrid
92
+ projectId={projectId}
93
+ frames={data.frames}
94
+ onOpenFrame={setFocusedIndex}
95
+ />
96
+ {data.script && <StoryboardScriptPanel script={data.script} />}
97
+ </div>
98
+ </div>
99
+ ) : (
100
+ <StoryboardSourceEditor
101
+ files={sourceFiles}
102
+ onSaved={reload}
103
+ onDirtyChange={setSourceDirty}
104
+ />
105
+ )}
106
+ </div>
107
+ );
108
+ }
109
+
110
+ const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
111
+ { value: "board", label: "Board" },
112
+ { value: "source", label: "Source" },
113
+ ];
114
+
115
+ function SubViewToggle({ value, onChange }: { value: SubView; onChange: (next: SubView) => void }) {
116
+ return (
117
+ <div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5" role="tablist">
118
+ {SUB_VIEWS.map((option) => (
119
+ <button
120
+ key={option.value}
121
+ type="button"
122
+ role="tab"
123
+ aria-selected={value === option.value}
124
+ onClick={() => onChange(option.value)}
125
+ className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
126
+ value === option.value
127
+ ? "bg-neutral-700 text-neutral-100"
128
+ : "text-neutral-400 hover:text-neutral-200"
129
+ }`}
130
+ >
131
+ {option.label}
132
+ </button>
133
+ ))}
134
+ </div>
135
+ );
136
+ }
@@ -0,0 +1,26 @@
1
+ import type { StoryboardScript } from "../../hooks/useStoryboard";
2
+
3
+ export interface StoryboardScriptPanelProps {
4
+ script: StoryboardScript;
5
+ }
6
+
7
+ /**
8
+ * Collapsible view of the companion narration script (SCRIPT.md). The mature
9
+ * pipeline keeps the full voiceover script — voice settings, per-line delivery
10
+ * and timing — in this file; here it's surfaced read-only alongside the frames.
11
+ * (Per-frame VO iteration will live in the frame focus view.)
12
+ */
13
+ export function StoryboardScriptPanel({ script }: StoryboardScriptPanelProps) {
14
+ if (!script.exists) return null;
15
+ return (
16
+ <details className="mt-10 rounded-lg border border-neutral-800 bg-neutral-900/50">
17
+ <summary className="cursor-pointer select-none px-4 py-3 text-sm font-medium text-neutral-300">
18
+ Narration script
19
+ <span className="ml-2 font-normal text-neutral-500">{script.path}</span>
20
+ </summary>
21
+ <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">
22
+ {script.content}
23
+ </pre>
24
+ </details>
25
+ );
26
+ }