create-aura3d 1.3.0 → 1.3.2

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 (50) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/index.js +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -3
  5. package/templates/animation-channel/package.json +1 -1
  6. package/templates/animation-studio/dist/episodes/scene/episode-3d.webm +0 -0
  7. package/templates/animation-studio/dist/episodes/scene/frames/action.png +0 -0
  8. package/templates/animation-studio/dist/episodes/scene/frames/dialogue.png +0 -0
  9. package/templates/animation-studio/dist/episodes/scene/frames/final.png +0 -0
  10. package/templates/animation-studio/dist/episodes/scene/frames/first.png +0 -0
  11. package/templates/animation-studio/dist/episodes/scene/frames/mouth-closed.png +0 -0
  12. package/templates/animation-studio/dist/episodes/scene/frames/mouth-open.png +0 -0
  13. package/templates/animation-studio/dist/episodes/scene/render-live-summary.json +2086 -6754
  14. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/broken.png +0 -0
  15. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/chefs.png +0 -0
  16. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/customer.png +0 -0
  17. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-1.png +0 -0
  18. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-2.png +0 -0
  19. package/templates/animation-studio/dist/scene/working.document.json +219 -348
  20. package/templates/animation-studio/package-lock.json +1307 -55
  21. package/templates/animation-studio/package.json +11 -5
  22. package/templates/animation-studio/studio/dist/assets/index-i8URxOOt.css +1 -0
  23. package/templates/animation-studio/studio/dist/assets/index-pC9hdZ-B.js +11 -0
  24. package/templates/animation-studio/studio/dist/index.html +19 -0
  25. package/templates/animation-studio/studio/index.html +18 -0
  26. package/templates/animation-studio/studio/src/App.tsx +430 -0
  27. package/templates/animation-studio/studio/src/components/Console.tsx +366 -0
  28. package/templates/animation-studio/studio/src/components/Icon.tsx +90 -0
  29. package/templates/animation-studio/studio/src/components/Inspector.tsx +209 -0
  30. package/templates/animation-studio/studio/src/components/Outliner.tsx +210 -0
  31. package/templates/animation-studio/studio/src/components/Palette.tsx +204 -0
  32. package/templates/animation-studio/studio/src/components/Stage.tsx +339 -0
  33. package/templates/animation-studio/studio/src/components/Timeline.tsx +183 -0
  34. package/templates/animation-studio/studio/src/components/Topbar.tsx +66 -0
  35. package/templates/animation-studio/studio/src/main.tsx +10 -0
  36. package/templates/animation-studio/studio/src/state/backend.ts +108 -0
  37. package/templates/animation-studio/studio/src/state/fidelity.ts +110 -0
  38. package/templates/animation-studio/studio/src/state/mapDocument.ts +419 -0
  39. package/templates/animation-studio/studio/src/state/sceneTool.ts +55 -0
  40. package/templates/animation-studio/studio/src/state/types.ts +165 -0
  41. package/templates/animation-studio/studio/src/state/util.ts +46 -0
  42. package/templates/animation-studio/studio/src/styles.css +528 -0
  43. package/templates/animation-studio/studio/vite.config.ts +175 -0
  44. package/templates/character-controller/package.json +3 -3
  45. package/templates/cinematic-scene/package.json +1 -1
  46. package/templates/episode-builder/package.json +1 -1
  47. package/templates/fighting-game/package.json +1 -1
  48. package/templates/mini-game/package.json +1 -1
  49. package/templates/product-viewer/package.json +1 -1
  50. package/templates/prompt-animation-channel/package.json +1 -1
@@ -0,0 +1,430 @@
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
+
79
+ const DUR = data.DUR;
80
+ const hasShots = data.shots.length > 0;
81
+ const currentShot = hasShots
82
+ ? data.shots.find((s) => time >= s.start && time < s.start + s.dur) || data.shots[data.shots.length - 1]
83
+ : null;
84
+
85
+ // 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 () => {
88
+ const [doc, hist, render] = await Promise.all([fetchDocument(), fetchHistory(), fetchExistingRender()]);
89
+ const exists = documentExists(doc as RuntimeDocument & { exists?: boolean });
90
+ setDocExists(exists);
91
+ setTranscript(mapHistory(hist));
92
+ // Show an already-rendered video on the Stage immediately (no need to hit Render first).
93
+ if (render.video) {
94
+ const bust = `?t=${Date.now()}`;
95
+ setRenderVideo(render.video + bust);
96
+ if (render.poster) setRenderPoster(render.poster + bust);
97
+ }
98
+ if (!exists) {
99
+ setData(EMPTY_DOC);
100
+ setSel(null);
101
+ return;
102
+ }
103
+ const mapped = mapDocument(doc as RuntimeDocument);
104
+ setData(mapped);
105
+ setSel((prev) => {
106
+ if (prev && mapped[selKey(prev.type)].some((e: { id: string }) => e.id === prev.id)) return prev;
107
+ return mapped.shots[0] ? { type: "shot", id: mapped.shots[0].id } : null;
108
+ });
109
+ }, []);
110
+
111
+ useEffect(() => {
112
+ void hydrate();
113
+ }, [hydrate]);
114
+
115
+ useEffect(() => {
116
+ localStorage.setItem("aura.time", time.toFixed(2));
117
+ }, [time]);
118
+
119
+ // playback loop. When a rendered VIDEO is on the Stage, the <video> element is the clock
120
+ // (its onTimeUpdate drives `time`), so this rAF clock is disabled to avoid two clocks fighting
121
+ // (which caused the sporadic start/stop). It still runs in frame/no-video mode.
122
+ useEffect(() => {
123
+ if (!playing || DUR <= 0 || renderVideo) return;
124
+ let last = performance.now();
125
+ const tick = (now: number) => {
126
+ const dt = (now - last) / 1000;
127
+ last = now;
128
+ setTime((t) => {
129
+ const nt = t + dt;
130
+ if (nt >= DUR) {
131
+ setPlaying(false);
132
+ return DUR;
133
+ }
134
+ return nt;
135
+ });
136
+ raf.current = requestAnimationFrame(tick);
137
+ };
138
+ raf.current = requestAnimationFrame(tick);
139
+ return () => cancelAnimationFrame(raf.current);
140
+ }, [playing, DUR, renderVideo]);
141
+
142
+ // keyboard: Space toggles play, ⌘K toggles palette
143
+ useEffect(() => {
144
+ const h = (e: KeyboardEvent) => {
145
+ if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) {
146
+ e.preventDefault();
147
+ setPaletteOpen((o) => !o);
148
+ return;
149
+ }
150
+ const tag = (document.activeElement && document.activeElement.tagName) || "";
151
+ if (e.code === "Space" && tag !== "TEXTAREA" && tag !== "INPUT") {
152
+ e.preventDefault();
153
+ setPlaying((p) => !p);
154
+ }
155
+ };
156
+ window.addEventListener("keydown", h);
157
+ return () => window.removeEventListener("keydown", h);
158
+ }, []);
159
+
160
+ const showToast = (msg: string, kind: Toast["kind"]) => {
161
+ setToast({ msg, kind });
162
+ window.setTimeout(() => setToast(null), 2600);
163
+ };
164
+
165
+ const selectShot = (id: string) => {
166
+ const s = data.shots.find((x) => x.id === id);
167
+ if (s) {
168
+ setTime(s.start + 0.2);
169
+ setSel({ type: "shot", id });
170
+ }
171
+ };
172
+ const onSelect = (type: SelectionType, id: string) => {
173
+ if (type === "shot") selectShot(id);
174
+ else setSel({ type, id });
175
+ };
176
+ const stepShot = (dir: number) => {
177
+ if (!currentShot) return;
178
+ const i = data.shots.indexOf(currentShot);
179
+ const ni = Math.max(0, Math.min(data.shots.length - 1, i + dir));
180
+ const next = data.shots[ni];
181
+ if (next) selectShot(next.id);
182
+ };
183
+ const toggleVis = (id: string) =>
184
+ setHidden((h) => {
185
+ const n = new Set(h);
186
+ if (n.has(id)) n.delete(id);
187
+ else n.add(id);
188
+ return n;
189
+ });
190
+
191
+ // Outliner Cast "+" — run a REAL `cast add` command through the same path the Console
192
+ // 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).
195
+ const addCast = () => {
196
+ const run = consoleApi.current.run;
197
+ if (!run) {
198
+ showToast("Console not ready — try again", "tip");
199
+ return;
200
+ }
201
+ const taken = new Set(data.cast.map((c) => c.name.toLowerCase()));
202
+ 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);
206
+ };
207
+
208
+ // Generate a brand-new scene from a plain-English sentence. This is the headline action:
209
+ // 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.
211
+ const generateScene = async () => {
212
+ const p = newScenePrompt.trim();
213
+ if (!p || generating) return;
214
+ setGenerating(true);
215
+ 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) {
220
+ setGenerating(false);
221
+ showToast("Couldn't generate that scene — try rewording it", "tip");
222
+ return;
223
+ }
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
+ };
236
+
237
+ // "Continue scene" — add another shot to the CURRENT scene (same cast & set) so you can
238
+ // build a longer video without regenerating. Appends a medium shot; the user directs it next.
239
+ const continueScene = async () => {
240
+ if (continuing || generating || !docExists) return;
241
+ setContinuing(true);
242
+ setPlaying(false);
243
+ const existing = new Set(data.shots.map((s) => s.id));
244
+ let n = data.shots.length + 1;
245
+ let id = `shot-${n}`;
246
+ while (existing.has(id)) id = `shot-${++n}`;
247
+ const res = await runSceneCommand(`shot add --id ${id} --preset medium --duration 8`);
248
+ setContinuing(false);
249
+ if (!res.ok) {
250
+ showToast("Couldn't add a shot — try again", "tip");
251
+ return;
252
+ }
253
+ await hydrate();
254
+ setSel({ type: "shot", id });
255
+ showToast("Shot added — same cast & set. Direct it, or tell the AI what happens next.", "ok");
256
+ };
257
+
258
+ // Render hits the REAL pipeline via POST /api/render (dev middleware -> render-live /
259
+ // warm render server). Progress easing runs while the request is in flight (renders are
260
+ // not streamed); on success the rendered webm/poster is loaded into the Stage and a
261
+ // 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;
264
+ const isShot = scope === "shot";
265
+ if (isShot && !currentShot) return;
266
+ setRendering(true);
267
+ setRenderPct(0);
268
+ 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);
278
+ };
279
+ requestAnimationFrame(ease);
280
+
281
+ // Render the current shot's time-range when scope is "shot", else the whole sequence.
282
+ 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;
290
+ }
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
+ });
313
+ };
314
+
315
+ return (
316
+ <div className="app">
317
+ <Topbar
318
+ scene={data.title}
319
+ onRender={() => doRender("sequence")}
320
+ rendering={rendering}
321
+ viewMode={viewMode}
322
+ setViewMode={setViewMode}
323
+ // Honest save status: while a render is in flight the working document is being
324
+ // committed/processed (not idle); it's "saved" only when no render is running.
325
+ saved={!rendering}
326
+ />
327
+ <div className={"newscene-bar" + (generating ? " busy" : "")}>
328
+ <span className="newscene-spark">✨</span>
329
+ <span className="newscene-label">New scene</span>
330
+ {generating ? (
331
+ <span className="newscene-status">
332
+ <span className="newscene-spinner" />
333
+ Building your scene — cast, set, shots &amp; dialogue… (a few seconds)
334
+ </span>
335
+ ) : (
336
+ <input
337
+ className="newscene-input"
338
+ type="text"
339
+ placeholder="Describe a scene in plain English — e.g. “a mechanic and a customer arguing about a broken car”"
340
+ value={newScenePrompt}
341
+ onChange={(e) => setNewScenePrompt(e.target.value)}
342
+ onKeyDown={(e) => {
343
+ if (e.key === "Enter") void generateScene();
344
+ }}
345
+ />
346
+ )}
347
+ <button
348
+ className={"newscene-btn" + (generating ? " busy" : "")}
349
+ disabled={generating || !newScenePrompt.trim()}
350
+ onClick={() => void generateScene()}
351
+ >
352
+ {generating ? "Generating…" : "Generate scene"}
353
+ </button>
354
+ <span className="newscene-div" />
355
+ <button
356
+ className="newscene-btn newscene-continue"
357
+ disabled={generating || continuing || !docExists}
358
+ onClick={() => void continueScene()}
359
+ title="Add a shot to this scene — keeps the same characters & set"
360
+ >
361
+ {continuing ? "Adding…" : "+ Continue scene"}
362
+ </button>
363
+ </div>
364
+ <div className="body">
365
+ <div className="col split">
366
+ <Outliner
367
+ data={data}
368
+ sel={sel}
369
+ onSelect={onSelect}
370
+ hidden={hidden}
371
+ onToggle={toggleVis}
372
+ onOpenPalette={() => setPaletteOpen(true)}
373
+ onAddCast={addCast}
374
+ />
375
+ <Inspector data={data} sel={sel} />
376
+ </div>
377
+ <div className="col center">
378
+ <Stage
379
+ data={data}
380
+ shot={currentShot}
381
+ time={time}
382
+ playing={playing}
383
+ onPlay={() => setPlaying((p) => !p)}
384
+ onScrub={setTime}
385
+ onStep={stepShot}
386
+ viewMode={viewMode}
387
+ rendering={rendering}
388
+ renderPct={renderPct}
389
+ renderVideo={renderVideo}
390
+ renderPoster={renderPoster}
391
+ empty={!docExists}
392
+ />
393
+ <Timeline data={data} time={time} onScrub={setTime} selShot={currentShot?.id ?? null} onSelShot={selectShot} />
394
+ </div>
395
+ <div className="col">
396
+ <Console
397
+ transcript={transcript}
398
+ setTranscript={setTranscript}
399
+ selShot={currentShot?.id ?? null}
400
+ onRender={doRender}
401
+ onSceneCommit={hydrate}
402
+ api={consoleApi}
403
+ />
404
+ </div>
405
+ </div>
406
+
407
+ <Palette
408
+ open={paletteOpen}
409
+ setOpen={setPaletteOpen}
410
+ data={data}
411
+ api={consoleApi}
412
+ onJumpShot={selectShot}
413
+ onSelectEntity={onSelect}
414
+ onRender={doRender}
415
+ onView={setViewMode}
416
+ />
417
+
418
+ {toast && (
419
+ <div className="toast-wrap">
420
+ <div className="toast">
421
+ <span className="i" style={{ color: toast.kind === "ok" ? "var(--ok)" : "var(--acc)" }}>
422
+ <Icon name={toast.kind === "ok" ? "check" : "sparkles"} size={16} />
423
+ </span>
424
+ {toast.msg}
425
+ </div>
426
+ </div>
427
+ )}
428
+ </div>
429
+ );
430
+ }