create-aura3d 1.3.2 → 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 (110) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +9 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +10 -2
  6. package/templates/animation-channel/package.json +1 -1
  7. package/templates/animation-channel/tests/screenshot.spec.ts +16 -0
  8. package/templates/animation-studio/dist/episodes/prompt-to-scene-proof/two-robots-fixing-a-car.document.json +680 -0
  9. package/templates/animation-studio/dist/episodes/scene/episode-3d.webm +0 -0
  10. package/templates/animation-studio/dist/episodes/scene/frames/action.png +0 -0
  11. package/templates/animation-studio/dist/episodes/scene/frames/dialogue.png +0 -0
  12. package/templates/animation-studio/dist/episodes/scene/frames/final.png +0 -0
  13. package/templates/animation-studio/dist/episodes/scene/frames/first.png +0 -0
  14. package/templates/animation-studio/dist/episodes/scene/frames/mouth-closed.png +0 -0
  15. package/templates/animation-studio/dist/episodes/scene/frames/mouth-open.png +0 -0
  16. package/templates/animation-studio/dist/episodes/scene/render-live-summary.json +4829 -682
  17. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/friend-1.png +0 -0
  18. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/friend-2.png +0 -0
  19. package/templates/animation-studio/dist/scene/working.document.json +95 -16
  20. package/templates/animation-studio/index.html +12 -0
  21. package/templates/animation-studio/node_modules.publish-aside/.vite/deps/_metadata.json +8 -0
  22. package/templates/animation-studio/node_modules.publish-aside/.vite/deps/package.json +3 -0
  23. package/templates/animation-studio/package.json +1 -1
  24. package/templates/animation-studio/scripts/asset-motion-probe.ts +7 -1
  25. package/templates/animation-studio/scripts/render-live.ts +24 -0
  26. package/templates/animation-studio/src/director/prompt-to-scene.ts +73 -5
  27. package/templates/animation-studio/studio/dist/assets/index-C7f8tHfi.css +1 -0
  28. package/templates/animation-studio/studio/dist/assets/index-CLo3GXyN.js +43 -0
  29. package/templates/animation-studio/studio/dist/index.html +2 -2
  30. package/templates/animation-studio/studio/src/App.tsx +146 -78
  31. package/templates/animation-studio/studio/src/components/Console.tsx +8 -2
  32. package/templates/animation-studio/studio/src/components/Inspector.tsx +57 -1
  33. package/templates/animation-studio/studio/src/components/Stage.tsx +10 -5
  34. package/templates/animation-studio/studio/src/components/Timeline.tsx +49 -2
  35. package/templates/animation-studio/studio/src/state/mapDocument.ts +13 -2
  36. package/templates/animation-studio/studio/src/state/sceneTool.ts +20 -9
  37. package/templates/animation-studio/studio/src/state/types.ts +8 -0
  38. package/templates/animation-studio/tests/route-health.spec.ts +33 -0
  39. package/templates/animation-studio/tests/screenshot.spec.ts +30 -0
  40. package/templates/character-controller/package.json +1 -1
  41. package/templates/character-controller/src/main.ts +18 -1
  42. package/templates/cinematic-scene/package.json +1 -1
  43. package/templates/cinematic-scene/tests/screenshot.spec.ts +3 -1
  44. package/templates/episode-builder/package.json +1 -1
  45. package/templates/episode-builder/tests/screenshot.spec.ts +15 -0
  46. package/templates/fighting-game/package.json +1 -1
  47. package/templates/fighting-game/tests/screenshot.spec.ts +15 -0
  48. package/templates/mini-game/package.json +1 -1
  49. package/templates/product-viewer/package.json +1 -1
  50. package/templates/product-viewer/tests/screenshot.spec.ts +3 -1
  51. package/templates/prompt-animation-channel/package.json +1 -1
  52. package/templates/prompt-animation-channel/tests/screenshot.spec.ts +15 -0
  53. package/templates/three-compat-architecture-interior/index.html +16 -0
  54. package/templates/three-compat-architecture-interior/package.json +21 -0
  55. package/templates/three-compat-architecture-interior/playwright.config.ts +14 -0
  56. package/templates/three-compat-architecture-interior/src/main.ts +27 -0
  57. package/templates/three-compat-architecture-interior/tests/route-health.spec.ts +16 -0
  58. package/templates/three-compat-architecture-interior/tests/screenshot.spec.ts +15 -0
  59. package/templates/three-compat-architecture-interior/tsconfig.json +19 -0
  60. package/templates/three-compat-asset-inspector/index.html +16 -0
  61. package/templates/three-compat-asset-inspector/package.json +21 -0
  62. package/templates/three-compat-asset-inspector/playwright.config.ts +14 -0
  63. package/templates/three-compat-asset-inspector/src/main.ts +17 -0
  64. package/templates/three-compat-asset-inspector/tests/route-health.spec.ts +16 -0
  65. package/templates/three-compat-asset-inspector/tests/screenshot.spec.ts +15 -0
  66. package/templates/three-compat-asset-inspector/tsconfig.json +19 -0
  67. package/templates/three-compat-character-viewer/index.html +16 -0
  68. package/templates/three-compat-character-viewer/package.json +21 -0
  69. package/templates/three-compat-character-viewer/playwright.config.ts +14 -0
  70. package/templates/three-compat-character-viewer/src/main.ts +17 -0
  71. package/templates/three-compat-character-viewer/tests/route-health.spec.ts +16 -0
  72. package/templates/three-compat-character-viewer/tests/screenshot.spec.ts +15 -0
  73. package/templates/three-compat-character-viewer/tsconfig.json +19 -0
  74. package/templates/three-compat-custom-threejs-migration/index.html +16 -0
  75. package/templates/three-compat-custom-threejs-migration/package.json +21 -0
  76. package/templates/three-compat-custom-threejs-migration/playwright.config.ts +14 -0
  77. package/templates/three-compat-custom-threejs-migration/src/main.ts +16 -0
  78. package/templates/three-compat-custom-threejs-migration/tests/route-health.spec.ts +16 -0
  79. package/templates/three-compat-custom-threejs-migration/tests/screenshot.spec.ts +15 -0
  80. package/templates/three-compat-custom-threejs-migration/tsconfig.json +19 -0
  81. package/templates/three-compat-large-scene/index.html +16 -0
  82. package/templates/three-compat-large-scene/package.json +21 -0
  83. package/templates/three-compat-large-scene/playwright.config.ts +14 -0
  84. package/templates/three-compat-large-scene/src/main.ts +31 -0
  85. package/templates/three-compat-large-scene/tests/route-health.spec.ts +16 -0
  86. package/templates/three-compat-large-scene/tests/screenshot.spec.ts +15 -0
  87. package/templates/three-compat-large-scene/tsconfig.json +19 -0
  88. package/templates/three-compat-material-authoring/index.html +16 -0
  89. package/templates/three-compat-material-authoring/package.json +21 -0
  90. package/templates/three-compat-material-authoring/playwright.config.ts +14 -0
  91. package/templates/three-compat-material-authoring/src/main.ts +33 -0
  92. package/templates/three-compat-material-authoring/tests/route-health.spec.ts +16 -0
  93. package/templates/three-compat-material-authoring/tests/screenshot.spec.ts +15 -0
  94. package/templates/three-compat-material-authoring/tsconfig.json +19 -0
  95. package/templates/three-compat-postprocess-scene/index.html +16 -0
  96. package/templates/three-compat-postprocess-scene/package.json +21 -0
  97. package/templates/three-compat-postprocess-scene/playwright.config.ts +14 -0
  98. package/templates/three-compat-postprocess-scene/src/main.ts +17 -0
  99. package/templates/three-compat-postprocess-scene/tests/route-health.spec.ts +16 -0
  100. package/templates/three-compat-postprocess-scene/tests/screenshot.spec.ts +15 -0
  101. package/templates/three-compat-postprocess-scene/tsconfig.json +19 -0
  102. package/templates/three-compat-premium-product-viewer/index.html +16 -0
  103. package/templates/three-compat-premium-product-viewer/package.json +21 -0
  104. package/templates/three-compat-premium-product-viewer/playwright.config.ts +14 -0
  105. package/templates/three-compat-premium-product-viewer/src/main.ts +18 -0
  106. package/templates/three-compat-premium-product-viewer/tests/route-health.spec.ts +16 -0
  107. package/templates/three-compat-premium-product-viewer/tests/screenshot.spec.ts +15 -0
  108. package/templates/three-compat-premium-product-viewer/tsconfig.json +19 -0
  109. package/templates/animation-studio/studio/dist/assets/index-i8URxOOt.css +0 -1
  110. package/templates/animation-studio/studio/dist/assets/index-pC9hdZ-B.js +0 -11
@@ -75,6 +75,12 @@ export function App() {
75
75
  const [continuing, setContinuing] = useState(false);
76
76
  const consoleApi = useRef<ConsoleApi>({});
77
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);
78
84
 
79
85
  const DUR = data.DUR;
80
86
  const hasShots = data.shots.length > 0;
@@ -83,8 +89,10 @@ export function App() {
83
89
  : null;
84
90
 
85
91
  // Hydrate the UI from the REAL working document (and command history). Called on mount and
86
- // after each /api/scene or /api/render so the panels reflect the live document.
87
- const hydrate = useCallback(async () => {
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> => {
88
96
  const [doc, hist, render] = await Promise.all([fetchDocument(), fetchHistory(), fetchExistingRender()]);
89
97
  const exists = documentExists(doc as RuntimeDocument & { exists?: boolean });
90
98
  setDocExists(exists);
@@ -98,7 +106,7 @@ export function App() {
98
106
  if (!exists) {
99
107
  setData(EMPTY_DOC);
100
108
  setSel(null);
101
- return;
109
+ return null;
102
110
  }
103
111
  const mapped = mapDocument(doc as RuntimeDocument);
104
112
  setData(mapped);
@@ -106,12 +114,22 @@ export function App() {
106
114
  if (prev && mapped[selKey(prev.type)].some((e: { id: string }) => e.id === prev.id)) return prev;
107
115
  return mapped.shots[0] ? { type: "shot", id: mapped.shots[0].id } : null;
108
116
  });
117
+ return mapped;
109
118
  }, []);
110
119
 
111
120
  useEffect(() => {
112
121
  void hydrate();
113
122
  }, [hydrate]);
114
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
+
115
133
  useEffect(() => {
116
134
  localStorage.setItem("aura.time", time.toFixed(2));
117
135
  }, [time]);
@@ -159,7 +177,8 @@ export function App() {
159
177
 
160
178
  const showToast = (msg: string, kind: Toast["kind"]) => {
161
179
  setToast({ msg, kind });
162
- window.setTimeout(() => setToast(null), 2600);
180
+ window.clearTimeout(toastTimer.current);
181
+ toastTimer.current = window.setTimeout(() => setToast(null), 2600);
163
182
  };
164
183
 
165
184
  const selectShot = (id: string) => {
@@ -190,54 +209,62 @@ export function App() {
190
209
 
191
210
  // Outliner Cast "+" — run a REAL `cast add` command through the same path the Console
192
211
  // uses (forceCommand bypasses Prompt mode), so it actually mutates the working document,
193
- // appends a committed/rejected card, and re-hydrates. A unique default name avoids
194
- // colliding with existing cast (the user renames via the Inspector / a follow-up command).
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 "…"`).
195
215
  const addCast = () => {
196
216
  const run = consoleApi.current.run;
197
217
  if (!run) {
198
218
  showToast("Console not ready — try again", "tip");
199
219
  return;
200
220
  }
201
- const taken = new Set(data.cast.map((c) => c.name.toLowerCase()));
221
+ const taken = new Set(data.cast.map((c) => c.id.toLowerCase()));
202
222
  let n = data.cast.length + 1;
203
- let name = `Cast ${n}`;
204
- while (taken.has(name.toLowerCase())) name = `Cast ${++n}`;
205
- run(`cast add ${name}`, true);
223
+ let id = `cast-${n}`;
224
+ while (taken.has(id)) id = `cast-${++n}`;
225
+ run(`cast add --id ${id} --query "friendly cartoon character"`, true);
206
226
  };
207
227
 
208
228
  // Generate a brand-new scene from a plain-English sentence. This is the headline action:
209
229
  // it runs `new --prompt "…" --full` (the deterministic Director — parses cast, keyword-routes
210
- // the set, lays out shots/dialogue), replacing the working document, then re-hydrates the UI.
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.
211
232
  const generateScene = async () => {
212
233
  const p = newScenePrompt.trim();
213
- if (!p || generating) return;
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;
214
237
  setGenerating(true);
215
238
  setPlaying(false);
216
- // Strip quotes/backslashes so the prompt can't break the command tokenizer.
217
- const safe = p.replace(/["\\]/g, "");
218
- const res = await runSceneCommand(`new --prompt "${safe}" --full`);
219
- if (!res.ok) {
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 {
220
260
  setGenerating(false);
221
- showToast("Couldn't generate that scene — try rewording it", "tip");
222
- return;
223
261
  }
224
- await hydrate(); // panels (cast/set/props/shots/dialogue) now reflect the NEW scene
225
- setNewScenePrompt("");
226
- setTime(0);
227
- setGenerating(false);
228
- // CRITICAL: the render on disk is the OLD scene. Drop it (so the stale video can't masquerade
229
- // as the new one) and auto-render the new document, so the big preview ACTUALLY changes — not
230
- // just the captions. Without this, the Stage keeps showing the previous render.
231
- setRenderVideo(null);
232
- setRenderPoster(null);
233
- showToast("New scene built — rendering a preview so you can see it…", "ok");
234
- doRender("sequence");
235
262
  };
236
263
 
237
264
  // "Continue scene" — add another shot to the CURRENT scene (same cast & set) so you can
238
265
  // build a longer video without regenerating. Appends a medium shot; the user directs it next.
239
266
  const continueScene = async () => {
240
- if (continuing || generating || !docExists) return;
267
+ if (continuing || generating || rendering || !docExists) return;
241
268
  setContinuing(true);
242
269
  setPlaying(false);
243
270
  const existing = new Set(data.shots.map((s) => s.id));
@@ -250,66 +277,105 @@ export function App() {
250
277
  showToast("Couldn't add a shot — try again", "tip");
251
278
  return;
252
279
  }
253
- await hydrate();
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
+ }
254
287
  setSel({ type: "shot", id });
255
288
  showToast("Shot added — same cast & set. Direct it, or tell the AI what happens next.", "ok");
256
289
  };
257
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
+
258
308
  // Render hits the REAL pipeline via POST /api/render (dev middleware -> render-live /
259
309
  // warm render server). Progress easing runs while the request is in flight (renders are
260
310
  // not streamed); on success the rendered webm/poster is loaded into the Stage and a
261
311
  // render card is appended. Low-fi is the default for the fast studio iteration loop.
262
- const doRender = (scope: "shot" | "sequence") => {
263
- if (rendering) return;
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
+ }
264
319
  const isShot = scope === "shot";
265
- if (isShot && !currentShot) return;
320
+ if (isShot && !currentShot) return false;
266
321
  setRendering(true);
267
- setRenderPct(0);
322
+ setRenderPct(-1);
268
323
  setPlaying(false);
269
- // Ease the progress ring toward ~92% while we await the real render; snap to 100% on done.
270
- const started = performance.now();
271
- let live = true;
272
- const ease = () => {
273
- if (!live) return;
274
- const t = (performance.now() - started) / 1000;
275
- // Asymptotic approach so the ring never claims completion before the server returns.
276
- setRenderPct(Math.min(92, 92 * (1 - Math.exp(-t / 9))));
277
- requestAnimationFrame(ease);
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 {}
278
337
  };
279
- requestAnimationFrame(ease);
338
+ es.onerror = () => { /* normal on close */ };
280
339
 
281
340
  // Render the current shot's time-range when scope is "shot", else the whole sequence.
282
341
  const range = isShot && currentShot ? `${Math.floor(currentShot.start)}-${Math.ceil(currentShot.start + currentShot.dur)}` : undefined;
283
- void runRender({ lowFi: true, range }).then((res) => {
284
- live = false;
285
- setRenderPct(100);
286
- setRendering(false);
287
- if (!res.ok) {
288
- showToast("Render failed — see console", "tip");
289
- return;
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"
290
372
  }
291
- // Cache-bust so the Stage reloads the freshly rendered media.
292
- const bust = "?t=" + Date.now();
293
- if (res.video) setRenderVideo(res.video + bust);
294
- if (res.poster) setRenderPoster(res.poster + bust);
295
- setTranscript((tr) => [
296
- ...tr,
297
- {
298
- type: "render",
299
- id: "r" + Date.now(),
300
- frame: res.poster ? res.poster + bust : "",
301
- label: "low-fi preview · " + (isShot && currentShot ? fmt(currentShot.dur) : fmt(DUR)),
302
- shot: isShot && currentShot ? currentShot.name : "Full sequence",
303
- meta: res.ms ? "rendered in " + (res.ms / 1000).toFixed(1) + "s" : "just now"
304
- }
305
- ]);
306
- showToast(
307
- isShot && currentShot ? "Shot render complete · " + currentShot.name : "Render complete · full sequence",
308
- "ok"
309
- );
310
- // Re-sync the panels with the real document (shot frames may now exist).
311
- void hydrate();
312
- });
373
+ ]);
374
+ showToast(
375
+ isShot && currentShot ? "Shot render complete · " + currentShot.name : "Render complete · full sequence",
376
+ "ok"
377
+ );
378
+ return true;
313
379
  };
314
380
 
315
381
  return (
@@ -330,7 +396,7 @@ export function App() {
330
396
  {generating ? (
331
397
  <span className="newscene-status">
332
398
  <span className="newscene-spinner" />
333
- Building your scene — cast, set, shots &amp; dialogue… (a few seconds)
399
+ Building your scene — this takes ~25 seconds (generating cast, set, shots, then rendering a preview)
334
400
  </span>
335
401
  ) : (
336
402
  <input
@@ -346,7 +412,9 @@ export function App() {
346
412
  )}
347
413
  <button
348
414
  className={"newscene-btn" + (generating ? " busy" : "")}
349
- disabled={generating || !newScenePrompt.trim()}
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()}
350
418
  onClick={() => void generateScene()}
351
419
  >
352
420
  {generating ? "Generating…" : "Generate scene"}
@@ -354,7 +422,7 @@ export function App() {
354
422
  <span className="newscene-div" />
355
423
  <button
356
424
  className="newscene-btn newscene-continue"
357
- disabled={generating || continuing || !docExists}
425
+ disabled={generating || continuing || rendering || !docExists}
358
426
  onClick={() => void continueScene()}
359
427
  title="Add a shot to this scene — keeps the same characters & set"
360
428
  >
@@ -390,7 +458,7 @@ export function App() {
390
458
  renderPoster={renderPoster}
391
459
  empty={!docExists}
392
460
  />
393
- <Timeline data={data} time={time} onScrub={setTime} selShot={currentShot?.id ?? null} onSelShot={selectShot} />
461
+ <Timeline data={data} time={time} onScrub={setTime} selShot={currentShot?.id ?? null} onSelShot={selectShot} onRetime={(shotId, newDuration) => void retimeShot(shotId, newDuration)} />
394
462
  </div>
395
463
  <div className="col">
396
464
  <Console
@@ -12,7 +12,13 @@ export interface ConsoleApi {
12
12
  run?: (text: string, forceCommand?: boolean) => void;
13
13
  }
14
14
 
15
- const CHIPS = ["cast add Pip", "shot retime --id shot-2 --duration 30", "cam orbit", "fx add rim light"];
15
+ // Quick-insert examples each one is the REAL CLI grammar (animation-scene.ts) and runs as-is.
16
+ const CHIPS = [
17
+ 'cast add --id pip --query "robot kid"',
18
+ "shot retime --id shot-2 --duration 30",
19
+ "camera --shot shot-1 --preset close-up",
20
+ "gesture --character pip --shot shot-1 --clip wave"
21
+ ];
16
22
 
17
23
  /** The portion of a command after its (possibly two-word) verb — the card's args line. */
18
24
  function argTail(raw: string, verb: string): string {
@@ -332,7 +338,7 @@ export function Console({ transcript, setTranscript, selShot, onRender, onSceneC
332
338
  placeholder={
333
339
  mode === "Prompt"
334
340
  ? "Tell your AI agent what to change — e.g. “make the second line angrier and cut to a close-up” (it writes this down; your agent does it)"
335
- : "Type an exact command — e.g. set space · cast add robot --name Pip · shot retime …"
341
+ : 'Type an exact command — e.g. set garage · cast add --id pip --query "robot kid" · shot retime …'
336
342
  }
337
343
  onChange={(e) => {
338
344
  setVal(e.target.value);
@@ -4,6 +4,12 @@ import { Icon } from "./Icon";
4
4
  import { cap, fmt, shade } from "../state/util";
5
5
  import type { EpisodeDocument, Selection } from "../state/types";
6
6
 
7
+ const STANDARD_CLIP_IDS = [
8
+ "idle", "talk", "gesture", "point", "nod", "wave",
9
+ "walk", "run", "react",
10
+ "sit", "shrug", "cross_arms", "salute", "shake_head"
11
+ ];
12
+
7
13
  function Field({
8
14
  label,
9
15
  icon,
@@ -160,8 +166,58 @@ export function Inspector({ data, sel }: InspectorProps) {
160
166
  }
161
167
  />
162
168
  </div>
169
+ <div className="prop-grid one">
170
+ <Field
171
+ label="Default clip"
172
+ icon="film"
173
+ value={
174
+ <select
175
+ disabled
176
+ value={c.defaultClip ?? "idle"}
177
+ style={{
178
+ width: "100%",
179
+ background: "transparent",
180
+ color: "inherit",
181
+ border: "none",
182
+ fontFamily: "var(--mono)",
183
+ fontSize: 12,
184
+ cursor: "not-allowed"
185
+ }}
186
+ >
187
+ {STANDARD_CLIP_IDS.map((id) => (
188
+ <option key={id} value={id}>{id}</option>
189
+ ))}
190
+ </select>
191
+ }
192
+ />
193
+ </div>
194
+ <div className="prop-grid">
195
+ <Field label="Mouth morph" icon="mic" value={c.mouthMorphIndex ?? -1} mono />
196
+ <Field
197
+ label="Foot IK"
198
+ icon="zap"
199
+ value={
200
+ <input
201
+ type="checkbox"
202
+ disabled
203
+ checked={!!c.footIk}
204
+ style={{ cursor: "not-allowed" }}
205
+ />
206
+ }
207
+ />
208
+ </div>
209
+ <div className="prop-grid one">
210
+ <Field
211
+ label="Grade intent"
212
+ value={
213
+ <span className="beat-chip cam" style={{ fontSize: 10 }}>
214
+ {c.gradeAwareIntent ?? "—"}
215
+ </span>
216
+ }
217
+ />
218
+ </div>
163
219
  <div className="insp-note">
164
- {c.name + " is rigged and assignable to any beat. Type cast commands in the console to re-pose or re-cast."}
220
+ {c.name + " is rigged and assignable to any beat. Type \"cast\" commands in the console to re-pose or re-cast."}
165
221
  </div>
166
222
  </Fragment>
167
223
  );
@@ -7,6 +7,7 @@ import type { EpisodeDocument, Shot, ViewMode } from "../state/types";
7
7
  function RenderOverlay({ pct }: { pct: number }) {
8
8
  const r = 32;
9
9
  const c = 2 * Math.PI * r;
10
+ const indeterminate = pct < 0;
10
11
  return (
11
12
  <div className="render-ov">
12
13
  <div className="render-ring">
@@ -20,17 +21,21 @@ function RenderOverlay({ pct }: { pct: number }) {
20
21
  stroke="var(--warm)"
21
22
  strokeWidth={5}
22
23
  strokeLinecap="round"
23
- strokeDasharray={c}
24
- strokeDashoffset={c * (1 - pct / 100)}
25
- style={{ transition: "stroke-dashoffset .25s" }}
24
+ strokeDasharray={indeterminate ? `${c * 0.25} ${c * 0.75}` : c}
25
+ strokeDashoffset={indeterminate ? undefined : c * (1 - pct / 100)}
26
+ style={
27
+ indeterminate
28
+ ? { animation: "sp 1.2s linear infinite", transformOrigin: "37px 37px" }
29
+ : { transition: "stroke-dashoffset .25s" }
30
+ }
26
31
  />
27
32
  </svg>
28
- <div className="render-pct">{Math.round(pct) + "%"}</div>
33
+ <div className="render-pct">{indeterminate ? "···" : Math.round(pct) + "%"}</div>
29
34
  </div>
30
35
  <div style={{ textAlign: "center" }}>
31
36
  <div className="ttl">Rendering preview</div>
32
37
  <div className="sub">
33
- {pct < 40 ? "aura · rigging cast…" : pct < 75 ? "aura · simulating lighting…" : "aura · compositing frames…"}
38
+ {indeterminate ? "aura · working…" : pct < 40 ? "aura · rigging cast…" : pct < 75 ? "aura · simulating lighting…" : "aura · compositing frames…"}
34
39
  </div>
35
40
  </div>
36
41
  </div>
@@ -23,15 +23,43 @@ function Clip({
23
23
  clip,
24
24
  DUR,
25
25
  sel,
26
- onClick
26
+ onClick,
27
+ trackRef,
28
+ zoom,
29
+ onRetime
27
30
  }: {
28
31
  clip: ClipModel;
29
32
  DUR: number;
30
33
  sel: boolean;
31
34
  onClick: (e: React.MouseEvent) => void;
35
+ trackRef: React.RefObject<HTMLDivElement>;
36
+ zoom: number;
37
+ onRetime?: (clipId: string, newDuration: number) => void;
32
38
  }) {
33
39
  const left = (clip.start / DUR) * 100;
34
40
  const width = (clip.dur / DUR) * 100;
41
+
42
+ const handleDown = (e: React.MouseEvent) => {
43
+ e.stopPropagation();
44
+ if (!onRetime || !trackRef.current) return;
45
+ const initialDur = clip.dur;
46
+ const startX = e.clientX;
47
+ const trackWidth = trackRef.current.getBoundingClientRect().width;
48
+ const pxPerSecond = (trackWidth * zoom) / DUR;
49
+
50
+ const onMove = (ev: MouseEvent) => {
51
+ const delta = ev.clientX - startX;
52
+ const newDur = Math.max(1, initialDur + delta / pxPerSecond);
53
+ onRetime(clip.id, newDur);
54
+ };
55
+ const onUp = () => {
56
+ window.removeEventListener("mousemove", onMove);
57
+ window.removeEventListener("mouseup", onUp);
58
+ };
59
+ window.addEventListener("mousemove", onMove);
60
+ window.addEventListener("mouseup", onUp);
61
+ };
62
+
35
63
  return (
36
64
  <div
37
65
  className={"clip" + (sel ? " sel" : "")}
@@ -43,6 +71,21 @@ function Clip({
43
71
  }}
44
72
  >
45
73
  <span className="clab">{clip.label}</span>
74
+ {onRetime && (
75
+ <div
76
+ onMouseDown={handleDown}
77
+ title="Drag to retime"
78
+ style={{
79
+ position: "absolute",
80
+ right: 0,
81
+ top: 0,
82
+ bottom: 0,
83
+ width: 6,
84
+ cursor: "e-resize",
85
+ zIndex: 2
86
+ }}
87
+ />
88
+ )}
46
89
  </div>
47
90
  );
48
91
  }
@@ -53,9 +96,10 @@ export interface TimelineProps {
53
96
  onScrub: (t: number) => void;
54
97
  selShot: string | null;
55
98
  onSelShot: (id: string) => void;
99
+ onRetime?: (clipId: string, newDuration: number) => void;
56
100
  }
57
101
 
58
- export function Timeline({ data, time, onScrub, selShot, onSelShot }: TimelineProps) {
102
+ export function Timeline({ data, time, onScrub, selShot, onSelShot, onRetime }: TimelineProps) {
59
103
  const DUR = data.DUR;
60
104
  const [zoom, setZoom] = useState(1);
61
105
  const ref = useRef<HTMLDivElement>(null);
@@ -170,6 +214,9 @@ export function Timeline({ data, time, onScrub, selShot, onSelShot }: TimelinePr
170
214
  onScrub(r.start);
171
215
  if (t.name === "Shots") onSelShot(r.id);
172
216
  }}
217
+ trackRef={ref}
218
+ zoom={zoom}
219
+ onRetime={t.name === "Shots" ? onRetime : undefined}
173
220
  />
174
221
  ))}
175
222
  </div>
@@ -61,6 +61,12 @@ export interface RuntimeCharacter {
61
61
  rigGrade?: "A" | "B" | "C" | "D";
62
62
  /** M7 — dominant motion source the render played (mocap/extracted/procedural…), when recorded. */
63
63
  motionSource?: string;
64
+ /** Default performance clip id. */
65
+ defaultClip?: string;
66
+ /** Mouth morph target index for lip-sync. */
67
+ mouthMorphIndex?: number;
68
+ /** Foot IK configuration (truthy when enabled). */
69
+ footIk?: unknown;
64
70
  }
65
71
 
66
72
  export interface RuntimeShotBlocking {
@@ -254,6 +260,8 @@ export function mapDocument(doc: RuntimeDocument): EpisodeDocument {
254
260
 
255
261
  const cast: CastMember[] = rChars.map((c) => {
256
262
  const { source, sourceLabel } = castProvenance(c);
263
+ const fidelity =
264
+ fidelityById.get(c.id) ?? { id: c.id, grade: "C", previz: true, reason: "previz: ungraded" };
257
265
  return {
258
266
  id: c.id,
259
267
  name: cap(c.id),
@@ -263,8 +271,11 @@ export function mapDocument(doc: RuntimeDocument): EpisodeDocument {
263
271
  lines: linesPerSpeaker(c.id),
264
272
  source,
265
273
  sourceLabel,
266
- fidelity:
267
- fidelityById.get(c.id) ?? { id: c.id, grade: "C", previz: true, reason: "previz: ungraded" }
274
+ fidelity,
275
+ defaultClip: c.defaultClip,
276
+ mouthMorphIndex: c.mouthMorphIndex,
277
+ footIk: !!c.footIk,
278
+ gradeAwareIntent: `${fidelity.grade}-grade`
268
279
  };
269
280
  });
270
281
 
@@ -16,16 +16,27 @@ export interface VerbSpec {
16
16
  desc: string;
17
17
  }
18
18
 
19
+ // The REAL CLI grammar (animation-scene.ts) — every entry here, inserted verbatim with its
20
+ // placeholders filled, is accepted by the CLI's switch/flag parser. These strings are also
21
+ // surfaced verbatim to LLMs, so they must never advertise a syntax the validator rejects.
19
22
  export const VERBS: VerbSpec[] = [
20
- { verb: "set", tail: "<name> --hdr <mood>", desc: "Define / re-light the active set" },
21
- { verb: "cast add", tail: "<name>", desc: "Cast a character into the scene" },
22
- { verb: "cast remove", tail: "<name>", desc: "Remove a character" },
23
- { verb: "shot add", tail: "--after <id>", desc: "Block a new shot" },
23
+ { verb: "new", tail: '--prompt "<scene>" [--full]', desc: "Start a new working scene from a prompt" },
24
+ { verb: "set", tail: '<studio|garage|office|kitchen|moon-garden|space-station|meadow> | --query "<setting>"', desc: "Choose the active set template" },
25
+ { verb: "cast add", tail: '--id <id> --query "<look>" [--scale <s>]', desc: "Cast a character (catalog resolve)" },
26
+ { verb: "prop add", tail: '--id <id> --query "<look>"', desc: "Register a prop from the catalog" },
27
+ { verb: "shot add", tail: "--id <id> --preset <preset> --duration <s>", desc: "Block a new shot" },
28
+ { verb: "shot remove", tail: "--id <id>", desc: "Remove a shot" },
24
29
  { verb: "shot retime", tail: "--id <id> --duration <s>", desc: "Change a shot's duration" },
25
- { verb: "cam", tail: "<wide|medium|close|orbit>", desc: "Set the active shot camera" },
26
- { verb: "light add", tail: "--type rim", desc: "Add a light to the set" },
27
- { verb: "fx add", tail: "<name>", desc: "Add an effect to the timeline" },
28
- { verb: "render", tail: "[--shot <id>]", desc: "Render a low-fi preview" }
30
+ { verb: "camera", tail: "--shot <id> --preset <establishing|medium|two-shot|close-up>", desc: "Set a shot's camera" },
31
+ { verb: "dialogue", tail: '--line <id> --speaker <id> --text "<line>" --start <s>', desc: "Write a dialogue line" },
32
+ { verb: "block", tail: "--character <id> --shot <id> --to <x,z> [--clip <clip>]", desc: "Move a character in a shot" },
33
+ { verb: "gesture", tail: "--character <id> --shot <id> --clip <clip>", desc: "Queue a gesture clip for a character" },
34
+ { verb: "dress", tail: "--prop <id> --at <x,z> [--scale <s>]", desc: "Place a registered prop on the set" },
35
+ { verb: "scale", tail: "--character <id> --to <s>", desc: "Rescale a character" },
36
+ { verb: "show", tail: "", desc: "Print the scene summary" },
37
+ { verb: "validate", tail: "", desc: "Check scene coherence" },
38
+ { verb: "undo", tail: "", desc: "Undo the last committed edit" },
39
+ { verb: "render", tail: "[--shot <id>]", desc: "Render a low-fi preview (shot or full sequence)" }
29
40
  ];
30
41
 
31
42
  export interface ParsedCommand {
@@ -47,7 +58,7 @@ export function parse(raw: string): ParsedCommand {
47
58
  const toks = head.split(/\s+/).filter(Boolean);
48
59
  let verb = toks[0] || "";
49
60
  let rest = toks.slice(1);
50
- if (["cast", "shot", "light", "fx"].includes(verb) && rest[0]) {
61
+ if (["cast", "shot", "prop"].includes(verb) && rest[0]) {
51
62
  verb += " " + rest[0];
52
63
  rest = rest.slice(1);
53
64
  }