create-aura3d 1.3.1 → 1.3.3

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 (126) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +9 -2
  5. package/dist/index.js.map +1 -1
  6. package/package.json +11 -3
  7. package/templates/animation-channel/package.json +1 -1
  8. package/templates/animation-channel/tests/screenshot.spec.ts +16 -0
  9. package/templates/animation-studio/dist/episodes/prompt-to-scene-proof/two-robots-fixing-a-car.document.json +680 -0
  10. package/templates/animation-studio/dist/episodes/scene/episode-3d.webm +0 -0
  11. package/templates/animation-studio/dist/episodes/scene/frames/action.png +0 -0
  12. package/templates/animation-studio/dist/episodes/scene/frames/dialogue.png +0 -0
  13. package/templates/animation-studio/dist/episodes/scene/frames/final.png +0 -0
  14. package/templates/animation-studio/dist/episodes/scene/frames/first.png +0 -0
  15. package/templates/animation-studio/dist/episodes/scene/frames/mouth-closed.png +0 -0
  16. package/templates/animation-studio/dist/episodes/scene/frames/mouth-open.png +0 -0
  17. package/templates/animation-studio/dist/episodes/scene/render-live-summary.json +2021 -2542
  18. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/broken.png +0 -0
  19. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/chefs.png +0 -0
  20. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/customer.png +0 -0
  21. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/friend-1.png +0 -0
  22. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/friend-2.png +0 -0
  23. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-1.png +0 -0
  24. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-2.png +0 -0
  25. package/templates/animation-studio/dist/scene/working.document.json +219 -269
  26. package/templates/animation-studio/index.html +12 -0
  27. package/templates/animation-studio/node_modules.publish-aside/.vite/deps/_metadata.json +8 -0
  28. package/templates/animation-studio/node_modules.publish-aside/.vite/deps/package.json +3 -0
  29. package/templates/animation-studio/package-lock.json +1307 -55
  30. package/templates/animation-studio/package.json +11 -5
  31. package/templates/animation-studio/scripts/asset-motion-probe.ts +7 -1
  32. package/templates/animation-studio/scripts/render-live.ts +24 -0
  33. package/templates/animation-studio/src/director/prompt-to-scene.ts +73 -5
  34. package/templates/animation-studio/studio/dist/assets/index-C7f8tHfi.css +1 -0
  35. package/templates/animation-studio/studio/dist/assets/index-CLo3GXyN.js +43 -0
  36. package/templates/animation-studio/studio/dist/index.html +19 -0
  37. package/templates/animation-studio/studio/index.html +18 -0
  38. package/templates/animation-studio/studio/src/App.tsx +498 -0
  39. package/templates/animation-studio/studio/src/components/Console.tsx +372 -0
  40. package/templates/animation-studio/studio/src/components/Icon.tsx +90 -0
  41. package/templates/animation-studio/studio/src/components/Inspector.tsx +265 -0
  42. package/templates/animation-studio/studio/src/components/Outliner.tsx +210 -0
  43. package/templates/animation-studio/studio/src/components/Palette.tsx +204 -0
  44. package/templates/animation-studio/studio/src/components/Stage.tsx +344 -0
  45. package/templates/animation-studio/studio/src/components/Timeline.tsx +230 -0
  46. package/templates/animation-studio/studio/src/components/Topbar.tsx +66 -0
  47. package/templates/animation-studio/studio/src/main.tsx +10 -0
  48. package/templates/animation-studio/studio/src/state/backend.ts +108 -0
  49. package/templates/animation-studio/studio/src/state/fidelity.ts +110 -0
  50. package/templates/animation-studio/studio/src/state/mapDocument.ts +430 -0
  51. package/templates/animation-studio/studio/src/state/sceneTool.ts +66 -0
  52. package/templates/animation-studio/studio/src/state/types.ts +173 -0
  53. package/templates/animation-studio/studio/src/state/util.ts +46 -0
  54. package/templates/animation-studio/studio/src/styles.css +528 -0
  55. package/templates/animation-studio/studio/vite.config.ts +175 -0
  56. package/templates/animation-studio/tests/route-health.spec.ts +33 -0
  57. package/templates/animation-studio/tests/screenshot.spec.ts +30 -0
  58. package/templates/character-controller/package.json +3 -3
  59. package/templates/character-controller/src/main.ts +18 -1
  60. package/templates/cinematic-scene/package.json +1 -1
  61. package/templates/cinematic-scene/tests/screenshot.spec.ts +3 -1
  62. package/templates/episode-builder/package.json +1 -1
  63. package/templates/episode-builder/tests/screenshot.spec.ts +15 -0
  64. package/templates/fighting-game/package.json +1 -1
  65. package/templates/fighting-game/tests/screenshot.spec.ts +15 -0
  66. package/templates/mini-game/package.json +1 -1
  67. package/templates/product-viewer/package.json +1 -1
  68. package/templates/product-viewer/tests/screenshot.spec.ts +3 -1
  69. package/templates/prompt-animation-channel/package.json +1 -1
  70. package/templates/prompt-animation-channel/tests/screenshot.spec.ts +15 -0
  71. package/templates/three-compat-architecture-interior/index.html +16 -0
  72. package/templates/three-compat-architecture-interior/package.json +21 -0
  73. package/templates/three-compat-architecture-interior/playwright.config.ts +14 -0
  74. package/templates/three-compat-architecture-interior/src/main.ts +27 -0
  75. package/templates/three-compat-architecture-interior/tests/route-health.spec.ts +16 -0
  76. package/templates/three-compat-architecture-interior/tests/screenshot.spec.ts +15 -0
  77. package/templates/three-compat-architecture-interior/tsconfig.json +19 -0
  78. package/templates/three-compat-asset-inspector/index.html +16 -0
  79. package/templates/three-compat-asset-inspector/package.json +21 -0
  80. package/templates/three-compat-asset-inspector/playwright.config.ts +14 -0
  81. package/templates/three-compat-asset-inspector/src/main.ts +17 -0
  82. package/templates/three-compat-asset-inspector/tests/route-health.spec.ts +16 -0
  83. package/templates/three-compat-asset-inspector/tests/screenshot.spec.ts +15 -0
  84. package/templates/three-compat-asset-inspector/tsconfig.json +19 -0
  85. package/templates/three-compat-character-viewer/index.html +16 -0
  86. package/templates/three-compat-character-viewer/package.json +21 -0
  87. package/templates/three-compat-character-viewer/playwright.config.ts +14 -0
  88. package/templates/three-compat-character-viewer/src/main.ts +17 -0
  89. package/templates/three-compat-character-viewer/tests/route-health.spec.ts +16 -0
  90. package/templates/three-compat-character-viewer/tests/screenshot.spec.ts +15 -0
  91. package/templates/three-compat-character-viewer/tsconfig.json +19 -0
  92. package/templates/three-compat-custom-threejs-migration/index.html +16 -0
  93. package/templates/three-compat-custom-threejs-migration/package.json +21 -0
  94. package/templates/three-compat-custom-threejs-migration/playwright.config.ts +14 -0
  95. package/templates/three-compat-custom-threejs-migration/src/main.ts +16 -0
  96. package/templates/three-compat-custom-threejs-migration/tests/route-health.spec.ts +16 -0
  97. package/templates/three-compat-custom-threejs-migration/tests/screenshot.spec.ts +15 -0
  98. package/templates/three-compat-custom-threejs-migration/tsconfig.json +19 -0
  99. package/templates/three-compat-large-scene/index.html +16 -0
  100. package/templates/three-compat-large-scene/package.json +21 -0
  101. package/templates/three-compat-large-scene/playwright.config.ts +14 -0
  102. package/templates/three-compat-large-scene/src/main.ts +31 -0
  103. package/templates/three-compat-large-scene/tests/route-health.spec.ts +16 -0
  104. package/templates/three-compat-large-scene/tests/screenshot.spec.ts +15 -0
  105. package/templates/three-compat-large-scene/tsconfig.json +19 -0
  106. package/templates/three-compat-material-authoring/index.html +16 -0
  107. package/templates/three-compat-material-authoring/package.json +21 -0
  108. package/templates/three-compat-material-authoring/playwright.config.ts +14 -0
  109. package/templates/three-compat-material-authoring/src/main.ts +33 -0
  110. package/templates/three-compat-material-authoring/tests/route-health.spec.ts +16 -0
  111. package/templates/three-compat-material-authoring/tests/screenshot.spec.ts +15 -0
  112. package/templates/three-compat-material-authoring/tsconfig.json +19 -0
  113. package/templates/three-compat-postprocess-scene/index.html +16 -0
  114. package/templates/three-compat-postprocess-scene/package.json +21 -0
  115. package/templates/three-compat-postprocess-scene/playwright.config.ts +14 -0
  116. package/templates/three-compat-postprocess-scene/src/main.ts +17 -0
  117. package/templates/three-compat-postprocess-scene/tests/route-health.spec.ts +16 -0
  118. package/templates/three-compat-postprocess-scene/tests/screenshot.spec.ts +15 -0
  119. package/templates/three-compat-postprocess-scene/tsconfig.json +19 -0
  120. package/templates/three-compat-premium-product-viewer/index.html +16 -0
  121. package/templates/three-compat-premium-product-viewer/package.json +21 -0
  122. package/templates/three-compat-premium-product-viewer/playwright.config.ts +14 -0
  123. package/templates/three-compat-premium-product-viewer/src/main.ts +18 -0
  124. package/templates/three-compat-premium-product-viewer/tests/route-health.spec.ts +16 -0
  125. package/templates/three-compat-premium-product-viewer/tests/screenshot.spec.ts +15 -0
  126. package/templates/three-compat-premium-product-viewer/tsconfig.json +19 -0
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Aura3D Animation Studio</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ rel="stylesheet"
11
+ href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
12
+ />
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ <script type="module" src="/src/main.tsx"></script>
17
+ </body>
18
+ </html>
@@ -0,0 +1,498 @@
1
+ /**
2
+ * App shell — global state, playback clock, render queue, composition (PRD §7.2.1).
3
+ *
4
+ * Global state = the working EpisodeDocument (local model). Selection model is
5
+ * `{type,id}`; the playback clock persists the scrub time to localStorage.
6
+ * Keyboard: Space = play/pause, ⌘K = command palette.
7
+ *
8
+ * Render + Console actions are wired to the REAL backend via the dev-server
9
+ * middleware (see `vite.config.ts`): `doRender` -> POST /api/render (the render
10
+ * pipeline), Console Command mode -> POST /api/scene (the agent-native Scene-Tool
11
+ * CLI: validated mutation commits / rejections). This is the dev-only local studio
12
+ * control surface; the user's coding agent remains the director.
13
+ */
14
+ import { useCallback, useEffect, useRef, useState } from "react";
15
+ import { fetchDocument, fetchHistory, fetchExistingRender, runRender, runSceneCommand } from "./state/backend";
16
+ import { documentExists, mapDocument, mapHistory, type RuntimeDocument } from "./state/mapDocument";
17
+ import { Topbar } from "./components/Topbar";
18
+ import { Outliner } from "./components/Outliner";
19
+ import { Inspector } from "./components/Inspector";
20
+ import { Stage } from "./components/Stage";
21
+ import { Timeline } from "./components/Timeline";
22
+ import { Console, type ConsoleApi } from "./components/Console";
23
+ import { Palette } from "./components/Palette";
24
+ import { Icon } from "./components/Icon";
25
+ import { fmt } from "./state/util";
26
+ import type { EpisodeDocument, Selection, SelectionType, Toast, Turn, ViewMode } from "./state/types";
27
+
28
+ /** Empty view model — used until the real document is fetched, and when none exists. */
29
+ const EMPTY_DOC: EpisodeDocument = {
30
+ title: "Untitled scene",
31
+ cast: [],
32
+ sets: [],
33
+ props: [],
34
+ shots: [],
35
+ beats: [],
36
+ camera: [],
37
+ gestures: [],
38
+ fx: [],
39
+ DUR: 0,
40
+ // M7 — an empty scene has no graded cast → previz floor.
41
+ fidelity: { grade: "C", previz: true, characters: [], reason: "previz: no characters in the scene" }
42
+ };
43
+
44
+ /** The view-model collection a selection type indexes into. */
45
+ function selKey(t: SelectionType): "shots" | "cast" | "sets" | "props" {
46
+ return t === "shot" ? "shots" : t === "cast" ? "cast" : t === "set" ? "sets" : "props";
47
+ }
48
+
49
+ export function App() {
50
+ // The single source of truth is the REAL working document, fetched on mount and
51
+ // re-fetched after every validated mutation / render so the UI stays in sync.
52
+ const [data, setData] = useState<EpisodeDocument>(EMPTY_DOC);
53
+ const [docExists, setDocExists] = useState(false);
54
+ const [sel, setSel] = useState<Selection | null>(null);
55
+ const [hidden, setHidden] = useState<Set<string>>(() => new Set());
56
+ const [time, setTime] = useState<number>(() => {
57
+ const s = parseFloat(localStorage.getItem("aura.time") || "");
58
+ return isNaN(s) ? 0 : s;
59
+ });
60
+ const [playing, setPlaying] = useState(false);
61
+ const [viewMode, setViewMode] = useState<ViewMode>("Render");
62
+ const [transcript, setTranscript] = useState<Turn[]>([]);
63
+ const [rendering, setRendering] = useState(false);
64
+ const [renderPct, setRenderPct] = useState(0);
65
+ // The most recent REAL render output (served under /preview/*) loaded into the Stage.
66
+ const [renderVideo, setRenderVideo] = useState<string | null>(null);
67
+ const [renderPoster, setRenderPoster] = useState<string | null>(null);
68
+ const [toast, setToast] = useState<Toast | null>(null);
69
+ const [paletteOpen, setPaletteOpen] = useState(false);
70
+ // "Generate new scene from a prompt" — runs the real `new --prompt` Scene-Tool command
71
+ // (the same thing the CLI `scene new` does) and re-hydrates the whole UI. No commands,
72
+ // no AI key: the deterministic Director builds the cast/set/shots from the sentence.
73
+ const [newScenePrompt, setNewScenePrompt] = useState("");
74
+ const [generating, setGenerating] = useState(false);
75
+ const [continuing, setContinuing] = useState(false);
76
+ const consoleApi = useRef<ConsoleApi>({});
77
+ const raf = useRef(0);
78
+ // The live render-progress EventSource — kept in a ref so unmount can close it
79
+ // (otherwise an in-flight render leaves a perpetually reconnecting connection).
80
+ const progressEs = useRef<EventSource | null>(null);
81
+ // The active toast-dismiss timer — cleared before each new toast so an earlier
82
+ // timer can't dismiss a later toast early.
83
+ const toastTimer = useRef(0);
84
+
85
+ const DUR = data.DUR;
86
+ const hasShots = data.shots.length > 0;
87
+ const currentShot = hasShots
88
+ ? data.shots.find((s) => time >= s.start && time < s.start + s.dur) || data.shots[data.shots.length - 1]
89
+ : null;
90
+
91
+ // Hydrate the UI from the REAL working document (and command history). Called on mount and
92
+ // after each /api/scene or /api/render so the panels reflect the live document. Returns the
93
+ // freshly mapped document (or null when none exists) so callers can read post-hydrate values
94
+ // (e.g. the NEW scene's duration) without waiting for a React state round-trip.
95
+ const hydrate = useCallback(async (): Promise<EpisodeDocument | null> => {
96
+ const [doc, hist, render] = await Promise.all([fetchDocument(), fetchHistory(), fetchExistingRender()]);
97
+ const exists = documentExists(doc as RuntimeDocument & { exists?: boolean });
98
+ setDocExists(exists);
99
+ setTranscript(mapHistory(hist));
100
+ // Show an already-rendered video on the Stage immediately (no need to hit Render first).
101
+ if (render.video) {
102
+ const bust = `?t=${Date.now()}`;
103
+ setRenderVideo(render.video + bust);
104
+ if (render.poster) setRenderPoster(render.poster + bust);
105
+ }
106
+ if (!exists) {
107
+ setData(EMPTY_DOC);
108
+ setSel(null);
109
+ return null;
110
+ }
111
+ const mapped = mapDocument(doc as RuntimeDocument);
112
+ setData(mapped);
113
+ setSel((prev) => {
114
+ if (prev && mapped[selKey(prev.type)].some((e: { id: string }) => e.id === prev.id)) return prev;
115
+ return mapped.shots[0] ? { type: "shot", id: mapped.shots[0].id } : null;
116
+ });
117
+ return mapped;
118
+ }, []);
119
+
120
+ useEffect(() => {
121
+ void hydrate();
122
+ }, [hydrate]);
123
+
124
+ // Close the render-progress EventSource on unmount (it is opened per-render in doRender).
125
+ useEffect(
126
+ () => () => {
127
+ progressEs.current?.close();
128
+ progressEs.current = null;
129
+ },
130
+ []
131
+ );
132
+
133
+ useEffect(() => {
134
+ localStorage.setItem("aura.time", time.toFixed(2));
135
+ }, [time]);
136
+
137
+ // playback loop. When a rendered VIDEO is on the Stage, the <video> element is the clock
138
+ // (its onTimeUpdate drives `time`), so this rAF clock is disabled to avoid two clocks fighting
139
+ // (which caused the sporadic start/stop). It still runs in frame/no-video mode.
140
+ useEffect(() => {
141
+ if (!playing || DUR <= 0 || renderVideo) return;
142
+ let last = performance.now();
143
+ const tick = (now: number) => {
144
+ const dt = (now - last) / 1000;
145
+ last = now;
146
+ setTime((t) => {
147
+ const nt = t + dt;
148
+ if (nt >= DUR) {
149
+ setPlaying(false);
150
+ return DUR;
151
+ }
152
+ return nt;
153
+ });
154
+ raf.current = requestAnimationFrame(tick);
155
+ };
156
+ raf.current = requestAnimationFrame(tick);
157
+ return () => cancelAnimationFrame(raf.current);
158
+ }, [playing, DUR, renderVideo]);
159
+
160
+ // keyboard: Space toggles play, ⌘K toggles palette
161
+ useEffect(() => {
162
+ const h = (e: KeyboardEvent) => {
163
+ if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) {
164
+ e.preventDefault();
165
+ setPaletteOpen((o) => !o);
166
+ return;
167
+ }
168
+ const tag = (document.activeElement && document.activeElement.tagName) || "";
169
+ if (e.code === "Space" && tag !== "TEXTAREA" && tag !== "INPUT") {
170
+ e.preventDefault();
171
+ setPlaying((p) => !p);
172
+ }
173
+ };
174
+ window.addEventListener("keydown", h);
175
+ return () => window.removeEventListener("keydown", h);
176
+ }, []);
177
+
178
+ const showToast = (msg: string, kind: Toast["kind"]) => {
179
+ setToast({ msg, kind });
180
+ window.clearTimeout(toastTimer.current);
181
+ toastTimer.current = window.setTimeout(() => setToast(null), 2600);
182
+ };
183
+
184
+ const selectShot = (id: string) => {
185
+ const s = data.shots.find((x) => x.id === id);
186
+ if (s) {
187
+ setTime(s.start + 0.2);
188
+ setSel({ type: "shot", id });
189
+ }
190
+ };
191
+ const onSelect = (type: SelectionType, id: string) => {
192
+ if (type === "shot") selectShot(id);
193
+ else setSel({ type, id });
194
+ };
195
+ const stepShot = (dir: number) => {
196
+ if (!currentShot) return;
197
+ const i = data.shots.indexOf(currentShot);
198
+ const ni = Math.max(0, Math.min(data.shots.length - 1, i + dir));
199
+ const next = data.shots[ni];
200
+ if (next) selectShot(next.id);
201
+ };
202
+ const toggleVis = (id: string) =>
203
+ setHidden((h) => {
204
+ const n = new Set(h);
205
+ if (n.has(id)) n.delete(id);
206
+ else n.add(id);
207
+ return n;
208
+ });
209
+
210
+ // Outliner Cast "+" — run a REAL `cast add` command through the same path the Console
211
+ // uses (forceCommand bypasses Prompt mode), so it actually mutates the working document,
212
+ // appends a committed/rejected card, and re-hydrates. The CLI grammar is
213
+ // `cast add --id <id> --query "…"`; a unique default id avoids colliding with existing
214
+ // cast (the user swaps the look with a follow-up `cast add --id <id> --query "…"`).
215
+ const addCast = () => {
216
+ const run = consoleApi.current.run;
217
+ if (!run) {
218
+ showToast("Console not ready — try again", "tip");
219
+ return;
220
+ }
221
+ const taken = new Set(data.cast.map((c) => c.id.toLowerCase()));
222
+ let n = data.cast.length + 1;
223
+ let id = `cast-${n}`;
224
+ while (taken.has(id)) id = `cast-${++n}`;
225
+ run(`cast add --id ${id} --query "friendly cartoon character"`, true);
226
+ };
227
+
228
+ // Generate a brand-new scene from a plain-English sentence. This is the headline action:
229
+ // it runs `new --prompt "…" --full` (the deterministic Director — parses cast, keyword-routes
230
+ // the set, lays out shots/dialogue), replacing the working document, then re-hydrates the UI
231
+ // and auto-renders a preview so the Stage shows the new scene — not stale captions.
232
+ const generateScene = async () => {
233
+ const p = newScenePrompt.trim();
234
+ // No generate while a render is in flight: the in-flight render is of the OLD document
235
+ // and would put the old scene's video on the Stage (the buttons are disabled too).
236
+ if (!p || generating || rendering) return;
237
+ setGenerating(true);
238
+ setPlaying(false);
239
+ try {
240
+ // Strip quotes/backslashes so the prompt can't break the command tokenizer.
241
+ const safe = p.replace(/["\\]/g, "");
242
+ const res = await runSceneCommand(`new --prompt "${safe}" --full`);
243
+ if (!res.ok) {
244
+ showToast("Couldn't generate that scene — try rewording it", "tip");
245
+ return;
246
+ }
247
+ await hydrate(); // panels (cast/set/props/shots/dialogue) now reflect the NEW scene
248
+ setNewScenePrompt("");
249
+ setTime(0);
250
+ // CRITICAL: the render on disk is the OLD scene. Drop it so the stale video can't masquerade
251
+ // as the new one, then auto-render the new document.
252
+ setRenderVideo(null);
253
+ setRenderPoster(null);
254
+ const rendered = await doRender("sequence"); // ← awaited so the spinner stays until the video is ready
255
+ // doRender already toasts on failure — only celebrate when the render actually succeeded.
256
+ if (rendered) showToast("Scene ready — click Play on the transport to watch", "ok");
257
+ } catch (e) {
258
+ showToast(`Scene generation failed — ${e instanceof Error ? e.message : "try again"}`, "tip");
259
+ } finally {
260
+ setGenerating(false);
261
+ }
262
+ };
263
+
264
+ // "Continue scene" — add another shot to the CURRENT scene (same cast & set) so you can
265
+ // build a longer video without regenerating. Appends a medium shot; the user directs it next.
266
+ const continueScene = async () => {
267
+ if (continuing || generating || rendering || !docExists) return;
268
+ setContinuing(true);
269
+ setPlaying(false);
270
+ const existing = new Set(data.shots.map((s) => s.id));
271
+ let n = data.shots.length + 1;
272
+ let id = `shot-${n}`;
273
+ while (existing.has(id)) id = `shot-${++n}`;
274
+ const res = await runSceneCommand(`shot add --id ${id} --preset medium --duration 8`);
275
+ setContinuing(false);
276
+ if (!res.ok) {
277
+ showToast("Couldn't add a shot — try again", "tip");
278
+ return;
279
+ }
280
+ try {
281
+ await hydrate();
282
+ } catch {
283
+ // The shot committed but the re-fetch failed — recover instead of an unhandled rejection.
284
+ showToast("Shot added, but refreshing the scene failed — reload to see it", "tip");
285
+ return;
286
+ }
287
+ setSel({ type: "shot", id });
288
+ showToast("Shot added — same cast & set. Direct it, or tell the AI what happens next.", "ok");
289
+ };
290
+
291
+ // Drag-retime from the Timeline's Shots track: issues the real `shot retime` CLI
292
+ // command and re-hydrates so every consumer (stage, inspector, totals) updates.
293
+ const retimeShot = async (shotId: string, newDuration: number) => {
294
+ if (generating || rendering) return;
295
+ const duration = Math.max(1, Math.round(newDuration));
296
+ const res = await runSceneCommand(`shot retime --id ${shotId} --duration ${duration}`);
297
+ if (!res.ok) {
298
+ showToast("Couldn't retime the shot — try again", "tip");
299
+ return;
300
+ }
301
+ try {
302
+ await hydrate();
303
+ } catch {
304
+ showToast("Shot retimed, but refreshing the scene failed — reload to see it", "tip");
305
+ }
306
+ };
307
+
308
+ // Render hits the REAL pipeline via POST /api/render (dev middleware -> render-live /
309
+ // warm render server). Progress easing runs while the request is in flight (renders are
310
+ // not streamed); on success the rendered webm/poster is loaded into the Stage and a
311
+ // render card is appended. Low-fi is the default for the fast studio iteration loop.
312
+ // Resolves true only when the render actually succeeded, so callers (e.g. generateScene)
313
+ // can branch their own toasts on the real outcome.
314
+ const doRender = async (scope: "shot" | "sequence"): Promise<boolean> => {
315
+ if (rendering) {
316
+ showToast("Render already in progress", "tip");
317
+ return false;
318
+ }
319
+ const isShot = scope === "shot";
320
+ if (isShot && !currentShot) return false;
321
+ setRendering(true);
322
+ setRenderPct(-1);
323
+ setPlaying(false);
324
+ // Subscribe to real render-progress SSE from the render pipeline. The EventSource lives
325
+ // in a ref so the unmount effect can close it if the user leaves mid-render.
326
+ progressEs.current?.close();
327
+ const es = new EventSource("/api/render-progress");
328
+ progressEs.current = es;
329
+ es.onmessage = (e) => {
330
+ try {
331
+ const d = JSON.parse(e.data) as { current: number; total: number; label: string; pct: number };
332
+ // "waiting" = the render process hasn't written progress yet — keep the intentional
333
+ // indeterminate state (-1) instead of snapping the bar to a determinate 0%.
334
+ if (d.label === "waiting") return;
335
+ setRenderPct(d.pct);
336
+ } catch {}
337
+ };
338
+ es.onerror = () => { /* normal on close */ };
339
+
340
+ // Render the current shot's time-range when scope is "shot", else the whole sequence.
341
+ const range = isShot && currentShot ? `${Math.floor(currentShot.start)}-${Math.ceil(currentShot.start + currentShot.dur)}` : undefined;
342
+ const res = await runRender({ lowFi: true, range });
343
+ es.close();
344
+ if (progressEs.current === es) progressEs.current = null;
345
+ setRendering(false);
346
+ if (!res.ok) {
347
+ showToast("Render failed — see console", "tip");
348
+ return false;
349
+ }
350
+ // Re-sync the panels with the real document FIRST so the render card's duration reflects
351
+ // the document that was actually rendered (not a closure-captured pre-hydrate DUR).
352
+ let freshDur = DUR;
353
+ try {
354
+ const fresh = await hydrate();
355
+ if (fresh) freshDur = fresh.DUR;
356
+ } catch {
357
+ // Panels stay stale but the render itself succeeded — don't fail the whole render.
358
+ }
359
+ // Cache-bust so the Stage reloads the freshly rendered media.
360
+ const bust = "?t=" + Date.now();
361
+ if (res.video) setRenderVideo(res.video + bust);
362
+ if (res.poster) setRenderPoster(res.poster + bust);
363
+ setTranscript((tr) => [
364
+ ...tr,
365
+ {
366
+ type: "render",
367
+ id: "r" + Date.now(),
368
+ frame: res.poster ? res.poster + bust : "",
369
+ label: "low-fi preview · " + (isShot && currentShot ? fmt(currentShot.dur) : fmt(freshDur)),
370
+ shot: isShot && currentShot ? currentShot.name : "Full sequence",
371
+ meta: res.ms ? "rendered in " + (res.ms / 1000).toFixed(1) + "s" : "just now"
372
+ }
373
+ ]);
374
+ showToast(
375
+ isShot && currentShot ? "Shot render complete · " + currentShot.name : "Render complete · full sequence",
376
+ "ok"
377
+ );
378
+ return true;
379
+ };
380
+
381
+ return (
382
+ <div className="app">
383
+ <Topbar
384
+ scene={data.title}
385
+ onRender={() => doRender("sequence")}
386
+ rendering={rendering}
387
+ viewMode={viewMode}
388
+ setViewMode={setViewMode}
389
+ // Honest save status: while a render is in flight the working document is being
390
+ // committed/processed (not idle); it's "saved" only when no render is running.
391
+ saved={!rendering}
392
+ />
393
+ <div className={"newscene-bar" + (generating ? " busy" : "")}>
394
+ <span className="newscene-spark">✨</span>
395
+ <span className="newscene-label">New scene</span>
396
+ {generating ? (
397
+ <span className="newscene-status">
398
+ <span className="newscene-spinner" />
399
+ Building your scene — this takes ~25 seconds (generating cast, set, shots, then rendering a preview)
400
+ </span>
401
+ ) : (
402
+ <input
403
+ className="newscene-input"
404
+ type="text"
405
+ placeholder="Describe a scene in plain English — e.g. “a mechanic and a customer arguing about a broken car”"
406
+ value={newScenePrompt}
407
+ onChange={(e) => setNewScenePrompt(e.target.value)}
408
+ onKeyDown={(e) => {
409
+ if (e.key === "Enter") void generateScene();
410
+ }}
411
+ />
412
+ )}
413
+ <button
414
+ className={"newscene-btn" + (generating ? " busy" : "")}
415
+ // Also disabled while a render is in flight: generating then would race the
416
+ // in-flight render of the OLD document (stale video lands on the Stage).
417
+ disabled={generating || rendering || !newScenePrompt.trim()}
418
+ onClick={() => void generateScene()}
419
+ >
420
+ {generating ? "Generating…" : "Generate scene"}
421
+ </button>
422
+ <span className="newscene-div" />
423
+ <button
424
+ className="newscene-btn newscene-continue"
425
+ disabled={generating || continuing || rendering || !docExists}
426
+ onClick={() => void continueScene()}
427
+ title="Add a shot to this scene — keeps the same characters & set"
428
+ >
429
+ {continuing ? "Adding…" : "+ Continue scene"}
430
+ </button>
431
+ </div>
432
+ <div className="body">
433
+ <div className="col split">
434
+ <Outliner
435
+ data={data}
436
+ sel={sel}
437
+ onSelect={onSelect}
438
+ hidden={hidden}
439
+ onToggle={toggleVis}
440
+ onOpenPalette={() => setPaletteOpen(true)}
441
+ onAddCast={addCast}
442
+ />
443
+ <Inspector data={data} sel={sel} />
444
+ </div>
445
+ <div className="col center">
446
+ <Stage
447
+ data={data}
448
+ shot={currentShot}
449
+ time={time}
450
+ playing={playing}
451
+ onPlay={() => setPlaying((p) => !p)}
452
+ onScrub={setTime}
453
+ onStep={stepShot}
454
+ viewMode={viewMode}
455
+ rendering={rendering}
456
+ renderPct={renderPct}
457
+ renderVideo={renderVideo}
458
+ renderPoster={renderPoster}
459
+ empty={!docExists}
460
+ />
461
+ <Timeline data={data} time={time} onScrub={setTime} selShot={currentShot?.id ?? null} onSelShot={selectShot} onRetime={(shotId, newDuration) => void retimeShot(shotId, newDuration)} />
462
+ </div>
463
+ <div className="col">
464
+ <Console
465
+ transcript={transcript}
466
+ setTranscript={setTranscript}
467
+ selShot={currentShot?.id ?? null}
468
+ onRender={doRender}
469
+ onSceneCommit={hydrate}
470
+ api={consoleApi}
471
+ />
472
+ </div>
473
+ </div>
474
+
475
+ <Palette
476
+ open={paletteOpen}
477
+ setOpen={setPaletteOpen}
478
+ data={data}
479
+ api={consoleApi}
480
+ onJumpShot={selectShot}
481
+ onSelectEntity={onSelect}
482
+ onRender={doRender}
483
+ onView={setViewMode}
484
+ />
485
+
486
+ {toast && (
487
+ <div className="toast-wrap">
488
+ <div className="toast">
489
+ <span className="i" style={{ color: toast.kind === "ok" ? "var(--ok)" : "var(--acc)" }}>
490
+ <Icon name={toast.kind === "ok" ? "check" : "sparkles"} size={16} />
491
+ </span>
492
+ {toast.msg}
493
+ </div>
494
+ </div>
495
+ )}
496
+ </div>
497
+ );
498
+ }