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.
- package/dist/cli.js +0 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/package.json +11 -3
- package/templates/animation-channel/package.json +1 -1
- package/templates/animation-channel/tests/screenshot.spec.ts +16 -0
- package/templates/animation-studio/dist/episodes/prompt-to-scene-proof/two-robots-fixing-a-car.document.json +680 -0
- package/templates/animation-studio/dist/episodes/scene/episode-3d.webm +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/action.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/dialogue.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/final.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/first.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/mouth-closed.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/mouth-open.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/render-live-summary.json +2021 -2542
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/broken.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/chefs.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/customer.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/friend-1.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/friend-2.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-1.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-2.png +0 -0
- package/templates/animation-studio/dist/scene/working.document.json +219 -269
- package/templates/animation-studio/index.html +12 -0
- package/templates/animation-studio/node_modules.publish-aside/.vite/deps/_metadata.json +8 -0
- package/templates/animation-studio/node_modules.publish-aside/.vite/deps/package.json +3 -0
- package/templates/animation-studio/package-lock.json +1307 -55
- package/templates/animation-studio/package.json +11 -5
- package/templates/animation-studio/scripts/asset-motion-probe.ts +7 -1
- package/templates/animation-studio/scripts/render-live.ts +24 -0
- package/templates/animation-studio/src/director/prompt-to-scene.ts +73 -5
- package/templates/animation-studio/studio/dist/assets/index-C7f8tHfi.css +1 -0
- package/templates/animation-studio/studio/dist/assets/index-CLo3GXyN.js +43 -0
- package/templates/animation-studio/studio/dist/index.html +19 -0
- package/templates/animation-studio/studio/index.html +18 -0
- package/templates/animation-studio/studio/src/App.tsx +498 -0
- package/templates/animation-studio/studio/src/components/Console.tsx +372 -0
- package/templates/animation-studio/studio/src/components/Icon.tsx +90 -0
- package/templates/animation-studio/studio/src/components/Inspector.tsx +265 -0
- package/templates/animation-studio/studio/src/components/Outliner.tsx +210 -0
- package/templates/animation-studio/studio/src/components/Palette.tsx +204 -0
- package/templates/animation-studio/studio/src/components/Stage.tsx +344 -0
- package/templates/animation-studio/studio/src/components/Timeline.tsx +230 -0
- package/templates/animation-studio/studio/src/components/Topbar.tsx +66 -0
- package/templates/animation-studio/studio/src/main.tsx +10 -0
- package/templates/animation-studio/studio/src/state/backend.ts +108 -0
- package/templates/animation-studio/studio/src/state/fidelity.ts +110 -0
- package/templates/animation-studio/studio/src/state/mapDocument.ts +430 -0
- package/templates/animation-studio/studio/src/state/sceneTool.ts +66 -0
- package/templates/animation-studio/studio/src/state/types.ts +173 -0
- package/templates/animation-studio/studio/src/state/util.ts +46 -0
- package/templates/animation-studio/studio/src/styles.css +528 -0
- package/templates/animation-studio/studio/vite.config.ts +175 -0
- package/templates/animation-studio/tests/route-health.spec.ts +33 -0
- package/templates/animation-studio/tests/screenshot.spec.ts +30 -0
- package/templates/character-controller/package.json +3 -3
- package/templates/character-controller/src/main.ts +18 -1
- package/templates/cinematic-scene/package.json +1 -1
- package/templates/cinematic-scene/tests/screenshot.spec.ts +3 -1
- package/templates/episode-builder/package.json +1 -1
- package/templates/episode-builder/tests/screenshot.spec.ts +15 -0
- package/templates/fighting-game/package.json +1 -1
- package/templates/fighting-game/tests/screenshot.spec.ts +15 -0
- package/templates/mini-game/package.json +1 -1
- package/templates/product-viewer/package.json +1 -1
- package/templates/product-viewer/tests/screenshot.spec.ts +3 -1
- package/templates/prompt-animation-channel/package.json +1 -1
- package/templates/prompt-animation-channel/tests/screenshot.spec.ts +15 -0
- package/templates/three-compat-architecture-interior/index.html +16 -0
- package/templates/three-compat-architecture-interior/package.json +21 -0
- package/templates/three-compat-architecture-interior/playwright.config.ts +14 -0
- package/templates/three-compat-architecture-interior/src/main.ts +27 -0
- package/templates/three-compat-architecture-interior/tests/route-health.spec.ts +16 -0
- package/templates/three-compat-architecture-interior/tests/screenshot.spec.ts +15 -0
- package/templates/three-compat-architecture-interior/tsconfig.json +19 -0
- package/templates/three-compat-asset-inspector/index.html +16 -0
- package/templates/three-compat-asset-inspector/package.json +21 -0
- package/templates/three-compat-asset-inspector/playwright.config.ts +14 -0
- package/templates/three-compat-asset-inspector/src/main.ts +17 -0
- package/templates/three-compat-asset-inspector/tests/route-health.spec.ts +16 -0
- package/templates/three-compat-asset-inspector/tests/screenshot.spec.ts +15 -0
- package/templates/three-compat-asset-inspector/tsconfig.json +19 -0
- package/templates/three-compat-character-viewer/index.html +16 -0
- package/templates/three-compat-character-viewer/package.json +21 -0
- package/templates/three-compat-character-viewer/playwright.config.ts +14 -0
- package/templates/three-compat-character-viewer/src/main.ts +17 -0
- package/templates/three-compat-character-viewer/tests/route-health.spec.ts +16 -0
- package/templates/three-compat-character-viewer/tests/screenshot.spec.ts +15 -0
- package/templates/three-compat-character-viewer/tsconfig.json +19 -0
- package/templates/three-compat-custom-threejs-migration/index.html +16 -0
- package/templates/three-compat-custom-threejs-migration/package.json +21 -0
- package/templates/three-compat-custom-threejs-migration/playwright.config.ts +14 -0
- package/templates/three-compat-custom-threejs-migration/src/main.ts +16 -0
- package/templates/three-compat-custom-threejs-migration/tests/route-health.spec.ts +16 -0
- package/templates/three-compat-custom-threejs-migration/tests/screenshot.spec.ts +15 -0
- package/templates/three-compat-custom-threejs-migration/tsconfig.json +19 -0
- package/templates/three-compat-large-scene/index.html +16 -0
- package/templates/three-compat-large-scene/package.json +21 -0
- package/templates/three-compat-large-scene/playwright.config.ts +14 -0
- package/templates/three-compat-large-scene/src/main.ts +31 -0
- package/templates/three-compat-large-scene/tests/route-health.spec.ts +16 -0
- package/templates/three-compat-large-scene/tests/screenshot.spec.ts +15 -0
- package/templates/three-compat-large-scene/tsconfig.json +19 -0
- package/templates/three-compat-material-authoring/index.html +16 -0
- package/templates/three-compat-material-authoring/package.json +21 -0
- package/templates/three-compat-material-authoring/playwright.config.ts +14 -0
- package/templates/three-compat-material-authoring/src/main.ts +33 -0
- package/templates/three-compat-material-authoring/tests/route-health.spec.ts +16 -0
- package/templates/three-compat-material-authoring/tests/screenshot.spec.ts +15 -0
- package/templates/three-compat-material-authoring/tsconfig.json +19 -0
- package/templates/three-compat-postprocess-scene/index.html +16 -0
- package/templates/three-compat-postprocess-scene/package.json +21 -0
- package/templates/three-compat-postprocess-scene/playwright.config.ts +14 -0
- package/templates/three-compat-postprocess-scene/src/main.ts +17 -0
- package/templates/three-compat-postprocess-scene/tests/route-health.spec.ts +16 -0
- package/templates/three-compat-postprocess-scene/tests/screenshot.spec.ts +15 -0
- package/templates/three-compat-postprocess-scene/tsconfig.json +19 -0
- package/templates/three-compat-premium-product-viewer/index.html +16 -0
- package/templates/three-compat-premium-product-viewer/package.json +21 -0
- package/templates/three-compat-premium-product-viewer/playwright.config.ts +14 -0
- package/templates/three-compat-premium-product-viewer/src/main.ts +18 -0
- package/templates/three-compat-premium-product-viewer/tests/route-health.spec.ts +16 -0
- package/templates/three-compat-premium-product-viewer/tests/screenshot.spec.ts +15 -0
- 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
|
+
}
|