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,108 @@
1
+ /**
2
+ * Dev backend client — the LOCAL studio control surface.
3
+ *
4
+ * Talks to the Vite dev-server middleware (see `vite.config.ts`) which shells the
5
+ * REAL agent-native Scene-Tool CLI (`animation-scene.ts`) and the render pipeline
6
+ * against the shared working document. This is dev-only: the user's own coding agent
7
+ * remains the director. Command mode runs raw scene-tool commands here; Prompt mode
8
+ * only displays the intent (the agent drives the actual commands).
9
+ */
10
+
11
+ export interface SceneResult {
12
+ ok: boolean;
13
+ /** Raw CLI stdout/stderr — "ok …" on commit, "REJECTED — …" on validator rejection. */
14
+ output: string;
15
+ rejected?: boolean;
16
+ ms?: number;
17
+ /** Short content hash of the resulting working document ("doc @ xxx"). */
18
+ hash?: string;
19
+ error?: string;
20
+ }
21
+
22
+ export interface RenderResult {
23
+ ok: boolean;
24
+ output?: string;
25
+ ms?: number;
26
+ /** Served under /preview/* by the dev middleware (null if not produced). */
27
+ video?: string | null;
28
+ poster?: string | null;
29
+ hash?: string;
30
+ error?: string;
31
+ }
32
+
33
+ /** Fetch the REAL working document (or { exists:false } when none has been authored). */
34
+ export async function fetchDocument(): Promise<Record<string, unknown>> {
35
+ const r = await fetch("/api/document");
36
+ return (await r.json()) as Record<string, unknown>;
37
+ }
38
+
39
+ /** Fetch the REAL command/result history (array; [] when none). */
40
+ export async function fetchHistory(): Promise<unknown[]> {
41
+ const r = await fetch("/api/history");
42
+ const v = (await r.json()) as unknown;
43
+ return Array.isArray(v) ? v : [];
44
+ }
45
+
46
+ /** Fetch the EXISTING render (if any) so the Stage shows it on load without re-rendering. */
47
+ export async function fetchExistingRender(): Promise<{ video?: string | null; poster?: string | null; exists?: boolean }> {
48
+ try {
49
+ const r = await fetch("/api/render");
50
+ return (await r.json()) as { video?: string | null; poster?: string | null; exists?: boolean };
51
+ } catch {
52
+ return { exists: false };
53
+ }
54
+ }
55
+
56
+ /** Run one validated Scene-Tool command against the working document. */
57
+ export async function runSceneCommand(command: string): Promise<SceneResult> {
58
+ try {
59
+ const r = await fetch("/api/scene", {
60
+ method: "POST",
61
+ headers: { "content-type": "application/json" },
62
+ body: JSON.stringify({ command })
63
+ });
64
+ return (await r.json()) as SceneResult;
65
+ } catch (e) {
66
+ return { ok: false, output: e instanceof Error ? e.message : String(e), rejected: true };
67
+ }
68
+ }
69
+
70
+ /** Render the working document (low-fi by default) via the real pipeline. */
71
+ export async function runRender(opts: { lowFi?: boolean; range?: string } = {}): Promise<RenderResult> {
72
+ try {
73
+ const r = await fetch("/api/render", {
74
+ method: "POST",
75
+ headers: { "content-type": "application/json" },
76
+ body: JSON.stringify(opts)
77
+ });
78
+ return (await r.json()) as RenderResult;
79
+ } catch (e) {
80
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
81
+ }
82
+ }
83
+
84
+ /** Parse a committed/rejected CLI result into a card header verb + args + diff lines. */
85
+ export function parseCliResult(command: string, res: SceneResult): {
86
+ diffs: { op: "+" | "~" | "!"; k: "add" | "mod" | "del"; t: string }[];
87
+ } {
88
+ if (!res.ok || res.rejected) {
89
+ // The CLI prints "REJECTED — edit would break the scene:\n - reason\n - reason".
90
+ const lines = res.output
91
+ .split("\n")
92
+ .map((l) => l.replace(/^\s*-\s*/, "").trim())
93
+ .filter((l) => l && !/^REJECTED/i.test(l) && !/break the scene/i.test(l));
94
+ const reasons = lines.length ? lines : [res.output.replace(/^REJECTED[^:]*:?/i, "").trim() || "rejected"];
95
+ return { diffs: reasons.map((r) => ({ op: "!", k: "del", t: escapeHtml(r) })) };
96
+ }
97
+ // On success the CLI prints "ok" / "cast … ← …" / "set → …" plus warnings.
98
+ const lines = res.output.split("\n").map((l) => l.trim()).filter(Boolean);
99
+ const diffs = lines
100
+ .filter((l) => l !== "ok")
101
+ .map((l) => ({ op: "~" as const, k: "mod" as const, t: escapeHtml(l) }));
102
+ if (!diffs.length) diffs.push({ op: "~", k: "mod", t: escapeHtml(command) });
103
+ return { diffs };
104
+ }
105
+
106
+ function escapeHtml(s: string): string {
107
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
108
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * fidelity.ts (UI) — PRD Phase M7: honest quality tiering, surfaced in the studio shell.
3
+ *
4
+ * Mirrors the render-side rules in
5
+ * `packages/create-aura3d/templates/animation-studio/src/fidelity.ts` so the badge the
6
+ * user sees in the Outliner / scene header ALWAYS agrees with the resolver's fidelity
7
+ * report. A grade-C character/scene is "previz" — the UI labels it as such and never
8
+ * presents it as finished.
9
+ *
10
+ * The UI cannot import across the package boundary, so the (small, pure) grading rules
11
+ * are duplicated here; a unit test pins the two copies to identical verdicts.
12
+ */
13
+
14
+ export type FidelityGrade = "A" | "B" | "C";
15
+ export type FidelityRigGrade = "A" | "B" | "C" | "D";
16
+ export type FidelityProvenance = "curated" | "user-uploaded" | "catalog-resolved" | "authored-fallback";
17
+ export type FidelityMotionSource =
18
+ | "mocap"
19
+ | "extracted"
20
+ | "embedded"
21
+ | "procedural"
22
+ | "idle"
23
+ | "talk"
24
+ | "fallback"
25
+ | "unknown";
26
+
27
+ export interface CharacterFidelityInput {
28
+ id: string;
29
+ rigGrade?: FidelityRigGrade;
30
+ provenance: FidelityProvenance;
31
+ motionSource?: FidelityMotionSource;
32
+ shading?: "cel" | "pbr" | "none";
33
+ shadows?: boolean;
34
+ }
35
+
36
+ export interface CharacterFidelity {
37
+ id: string;
38
+ grade: FidelityGrade;
39
+ previz: boolean;
40
+ reason: string;
41
+ }
42
+
43
+ export interface SceneFidelity {
44
+ grade: FidelityGrade;
45
+ previz: boolean;
46
+ characters: CharacterFidelity[];
47
+ reason: string;
48
+ }
49
+
50
+ const MASCOT_PROVENANCE = new Set<FidelityProvenance>(["authored-fallback"]);
51
+ const REAL_MOTION = new Set<FidelityMotionSource>(["mocap", "extracted", "embedded"]);
52
+ const CURATED_PROVENANCE = new Set<FidelityProvenance>(["curated", "user-uploaded"]);
53
+
54
+ export function gradeCharacterFidelity(input: CharacterFidelityInput): CharacterFidelity {
55
+ const rig = input.rigGrade ?? "D";
56
+ const provenance = input.provenance;
57
+ const motion = input.motionSource ?? "unknown";
58
+ const shading = input.shading ?? "none";
59
+ const shadows = input.shadows ?? false;
60
+
61
+ if (MASCOT_PROVENANCE.has(provenance) || rig === "C" || rig === "D") {
62
+ const why = MASCOT_PROVENANCE.has(provenance)
63
+ ? "authored placeholder / mascot rig"
64
+ : `sparse rig (grade ${rig}) — crude retarget only`;
65
+ return { id: input.id, grade: "C", previz: true, reason: `previz: ${why}` };
66
+ }
67
+
68
+ const realMotion = REAL_MOTION.has(motion);
69
+ const shaded = shading === "cel" || shading === "pbr";
70
+ if (CURATED_PROVENANCE.has(provenance) && rig === "A" && realMotion && shaded && shadows) {
71
+ return {
72
+ id: input.id,
73
+ grade: "A",
74
+ previz: false,
75
+ reason: "curated/uploaded grade-A rig with real motion, shading and shadows"
76
+ };
77
+ }
78
+
79
+ const missing: string[] = [];
80
+ if (!realMotion) missing.push("procedural motion");
81
+ if (!shaded) missing.push("no cel/PBR shading");
82
+ if (!shadows) missing.push("shadows off");
83
+ if (!CURATED_PROVENANCE.has(provenance)) missing.push("catalog rig");
84
+ return {
85
+ id: input.id,
86
+ grade: "B",
87
+ previz: false,
88
+ reason: missing.length ? `catalog-grade: ${missing.join(", ")}` : "graded-ok rig"
89
+ };
90
+ }
91
+
92
+ export function gradeSceneFidelity(characters: CharacterFidelityInput[]): SceneFidelity {
93
+ const graded = characters.map(gradeCharacterFidelity);
94
+ if (graded.length === 0) {
95
+ return { grade: "C", previz: true, characters: graded, reason: "previz: no characters in the scene" };
96
+ }
97
+ const order: Record<FidelityGrade, number> = { A: 3, B: 2, C: 1 };
98
+ let worst: FidelityGrade = "A";
99
+ for (const g of graded) if (order[g.grade] < order[worst]) worst = g.grade;
100
+ const previz = worst === "C";
101
+ const reason = previz
102
+ ? `previz: lowest character grade is C (${graded.filter((g) => g.grade === "C").map((g) => g.id).join(", ")})`
103
+ : `scene grade ${worst} (floor of ${graded.length} character grade(s))`;
104
+ return { grade: worst, previz, characters: graded, reason };
105
+ }
106
+
107
+ /** Human one-word tier label for the badge. C is "Previz" — never "finished". */
108
+ export function fidelityLabel(grade: FidelityGrade): string {
109
+ return grade === "C" ? "Previz" : `Grade ${grade}`;
110
+ }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Maps the REAL runtime EpisodeDocument (as persisted to
3
+ * `dist/scene/working.document.json` by the Scene-Tool CLI) into the UI model
4
+ * (`types.ts`) that drives the Outliner / Stage / Inspector / Timeline.
5
+ *
6
+ * There is NO seed / fixture: the only source of truth is the live document
7
+ * fetched from `GET /api/document`. When no document exists the UI shows an
8
+ * empty state (see `App.tsx`).
9
+ */
10
+
11
+ import type {
12
+ Beat,
13
+ CastMember,
14
+ CastSource,
15
+ EpisodeDocument,
16
+ PropEntity,
17
+ SetEntity,
18
+ Shot,
19
+ TimelineSpan,
20
+ Turn
21
+ } from "./types";
22
+ import {
23
+ gradeSceneFidelity,
24
+ type CharacterFidelityInput,
25
+ type FidelityProvenance,
26
+ type FidelityRigGrade
27
+ } from "./fidelity";
28
+
29
+ /* ---- Runtime EpisodeDocument shape (subset the UI reads) ---- */
30
+
31
+ export interface RuntimeShot {
32
+ shotId: string;
33
+ presetId: string;
34
+ startTime: number;
35
+ endTime: number;
36
+ }
37
+
38
+ export interface RuntimeDialogueLine {
39
+ lineId: string;
40
+ speakerId: string;
41
+ startTime: number;
42
+ endTime: number;
43
+ text: string;
44
+ }
45
+
46
+ export interface RuntimeCharacter {
47
+ id: string;
48
+ role?: string;
49
+ /** Catalog/source URL when this cast member was resolved from the federated catalog. */
50
+ sourceUrl?: string;
51
+ /** Source title / attribution string the resolver persisted (catalog or upload). */
52
+ attribution?: string;
53
+ /** License string for catalog/uploaded assets. */
54
+ license?: string;
55
+ /**
56
+ * Explicit provenance class when the resolver/CLI recorded one. When absent it is INFERRED:
57
+ * a sourceUrl/attribution → catalog-resolved; otherwise authored-fallback.
58
+ */
59
+ source?: "authored-fallback" | "catalog-resolved" | "user-uploaded" | "curated";
60
+ /** M7 — rig grade (A/B/C/D) the resolver recorded, when available; drives the fidelity tier. */
61
+ rigGrade?: "A" | "B" | "C" | "D";
62
+ /** M7 — dominant motion source the render played (mocap/extracted/procedural…), when recorded. */
63
+ motionSource?: string;
64
+ }
65
+
66
+ export interface RuntimeShotBlocking {
67
+ shotId: string;
68
+ /** Standard performance clip the director assigned this beat (idle/talk/gesture/point/nod/walk/run/react). */
69
+ clip: string;
70
+ }
71
+
72
+ export interface RuntimeCharacterBlocking {
73
+ characterId: string;
74
+ shots?: RuntimeShotBlocking[];
75
+ }
76
+
77
+ export interface RuntimeProp {
78
+ id: string;
79
+ url?: string;
80
+ attribution?: string;
81
+ }
82
+
83
+ export interface RuntimeDocument {
84
+ id?: string;
85
+ duration?: number;
86
+ assets?: {
87
+ characters?: RuntimeCharacter[];
88
+ props?: RuntimeProp[];
89
+ };
90
+ set?: unknown;
91
+ shots?: RuntimeShot[];
92
+ blocking?: RuntimeCharacterBlocking[];
93
+ dialogue?: { language?: string; lines?: RuntimeDialogueLine[] };
94
+ }
95
+
96
+ export interface DocumentResponse {
97
+ exists?: boolean;
98
+ }
99
+
100
+ /** Human label for a shot from its camera preset id. */
101
+ const PRESET_LABELS: Record<string, string> = {
102
+ establishing: "Establishing",
103
+ "two-shot": "Two-shot",
104
+ medium: "Medium",
105
+ "close-up": "Close-up",
106
+ closeup: "Close-up",
107
+ wide: "Wide",
108
+ reverse: "Reverse",
109
+ "over-shoulder": "Over-shoulder"
110
+ };
111
+
112
+ export function presetLabel(presetId: string): string {
113
+ return PRESET_LABELS[presetId] ?? cap(presetId.replace(/[-_]/g, " "));
114
+ }
115
+
116
+ /* ---- Director acting-intent inference (F1) — mirrors src/director/director-heuristics.ts ----
117
+ * Used only as a FALLBACK when a beat has no real director clip in `blocking`, so the per-beat
118
+ * preview always shows a meaningful speaking/listener intent rather than a blank. The canonical
119
+ * rules live in the template's director; these match them for a question/emphasis/disagreement/
120
+ * movement line. */
121
+ const NEGATIONS = ["no", "not", "never", "wrong", "don't", "won't", "can't", "stop", "refuse", "disagree"];
122
+ const MOVEMENTS = ["walk", "go", "going", "run", "running", "cross", "leave", "come", "coming", "move", "follow"];
123
+
124
+ function lineWords(text: string): Set<string> {
125
+ return new Set(text.toLowerCase().replace(/[^a-z' ]+/g, " ").split(/\s+/).filter(Boolean));
126
+ }
127
+
128
+ export function inferSpeakingIntent(text: string): string {
129
+ const w = lineWords(text);
130
+ if (MOVEMENTS.some((m) => w.has(m))) return /\b(run|running)\b/i.test(text) ? "run" : "walk";
131
+ if (text.includes("!") || /\b[A-Z]{2,}\b/.test(text)) return "gesture";
132
+ return "talk";
133
+ }
134
+
135
+ export function inferListenerIntent(text: string): string {
136
+ const w = lineWords(text);
137
+ if (NEGATIONS.some((n) => w.has(n)) || text.includes("!") || /\b[A-Z]{2,}\b/.test(text)) return "react";
138
+ if (text.includes("?")) return "nod";
139
+ return "nod";
140
+ }
141
+
142
+ /** A small, stable accent palette assigned per id (shots + cast). */
143
+ const PALETTE = ["#6b6bff", "#5b6bd6", "#4f8fd6", "#7b6bd6", "#ff8a5b", "#4fc2ff", "#9a7bff", "#2dd4a7", "#ffb020"];
144
+
145
+ /** Deterministic accent for an id — stable across reloads. */
146
+ export function stableAccent(id: string): string {
147
+ let h = 0;
148
+ for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
149
+ return PALETTE[h % PALETTE.length]!;
150
+ }
151
+
152
+ function cap(s: string): string {
153
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
154
+ }
155
+
156
+ /**
157
+ * Phase E3 — classify a cast member's PROVENANCE so the Outliner can label it honestly and never
158
+ * sell an authored fallback as catalog evidence. Trust an explicit `source` the resolver recorded;
159
+ * otherwise INFER: a catalog/source URL or attribution → catalog-resolved; a local-file upload
160
+ * marker → user-uploaded; nothing → authored-fallback (built-in/placeholder).
161
+ */
162
+ export function castProvenance(c: RuntimeCharacter): { source: CastSource; sourceLabel: string } {
163
+ // An explicit `source` the resolver/CLI recorded always wins. (A `curated` cast member is a
164
+ // catalog-class asset for the Outliner badge; its higher fidelity tier is computed separately.)
165
+ if (c.source === "user-uploaded" || /^file:/i.test(c.sourceUrl ?? "")) {
166
+ return { source: "user-uploaded", sourceLabel: c.attribution ?? "user-uploaded GLB" };
167
+ }
168
+ if (c.source === "curated") {
169
+ return { source: "catalog-resolved", sourceLabel: c.attribution ?? "curated cast" };
170
+ }
171
+ if (c.source === "catalog-resolved" || c.sourceUrl || c.attribution) {
172
+ return { source: "catalog-resolved", sourceLabel: c.attribution ?? "catalog" };
173
+ }
174
+ return { source: "authored-fallback", sourceLabel: "authored fallback" };
175
+ }
176
+
177
+ /**
178
+ * M7 — map a runtime character to the fidelity-grading provenance enum. Unlike the Outliner
179
+ * badge (`castProvenance`) this keeps `curated` distinct, because a curated/uploaded rig is the
180
+ * only provenance that can reach grade A.
181
+ */
182
+ export function fidelityProvenance(c: RuntimeCharacter): FidelityProvenance {
183
+ if (c.source === "curated") return "curated";
184
+ if (c.source === "user-uploaded" || /^file:/i.test(c.sourceUrl ?? "")) return "user-uploaded";
185
+ if (c.source === "catalog-resolved" || c.sourceUrl || c.attribution) return "catalog-resolved";
186
+ return "authored-fallback";
187
+ }
188
+
189
+ function asRigGrade(value: unknown): FidelityRigGrade | undefined {
190
+ return value === "A" || value === "B" || value === "C" || value === "D" ? value : undefined;
191
+ }
192
+
193
+ /**
194
+ * Performance clips that read as a GESTURE / REACTION window on the timeline (Phase C2)
195
+ * — as opposed to ambient holds (idle/talk) or locomotion (walk/run). The director emits
196
+ * these per beat (gesture on emphasis, react/nod when another character speaks), so a
197
+ * blocking beat carrying one of these is a real gesture window the timeline surfaces.
198
+ */
199
+ const GESTURE_CLIPS = new Set<string>(["gesture", "point", "nod", "react", "wave"]);
200
+
201
+ /** Human label for a gesture clip id. */
202
+ function gestureLabel(clip: string): string {
203
+ return cap(clip);
204
+ }
205
+
206
+ /** True when the document response is a real authored document. */
207
+ export function documentExists(doc: RuntimeDocument & DocumentResponse): boolean {
208
+ return doc.exists !== false && Array.isArray(doc.shots);
209
+ }
210
+
211
+ /** Map the runtime EpisodeDocument → the UI model. */
212
+ export function mapDocument(doc: RuntimeDocument): EpisodeDocument {
213
+ const rShots = doc.shots ?? [];
214
+ const rLines = doc.dialogue?.lines ?? [];
215
+ const rChars = doc.assets?.characters ?? [];
216
+ const rProps = doc.assets?.props ?? [];
217
+ const rBlocking = doc.blocking ?? [];
218
+ const DUR = doc.duration ?? (rShots.length ? Math.max(...rShots.map((s) => s.endTime)) : 0);
219
+
220
+ // Lines spoken in a shot's [start,end) window — drives `who` + the inspector beat count.
221
+ const speakersInWindow = (start: number, end: number): string[] => {
222
+ const ids = new Set<string>();
223
+ for (const l of rLines) {
224
+ if (l.startTime < end && l.endTime > start) ids.add(l.speakerId);
225
+ }
226
+ return [...ids];
227
+ };
228
+
229
+ const shots: Shot[] = rShots.map((s) => ({
230
+ id: s.shotId,
231
+ name: presetLabel(s.presetId),
232
+ start: s.startTime,
233
+ dur: s.endTime - s.startTime,
234
+ cam: s.presetId,
235
+ // A real render thumbnail if one has been produced; else undefined (empty stage).
236
+ frame: "",
237
+ who: speakersInWindow(s.startTime, s.endTime),
238
+ color: stableAccent(s.shotId)
239
+ }));
240
+
241
+ const linesPerSpeaker = (id: string): number => rLines.filter((l) => l.speakerId === id).length;
242
+
243
+ // M7 — fidelity inputs per character (rig grade + provenance + motion source). Shading/shadows
244
+ // are scene-render properties the document does not carry, so the document-only UI grades on the
245
+ // rig+provenance+motion axes; the render-side `fidelity.ts` adds shading/shadows when present.
246
+ const fidelityInputs: CharacterFidelityInput[] = rChars.map((c) => ({
247
+ id: c.id,
248
+ rigGrade: asRigGrade(c.rigGrade),
249
+ provenance: fidelityProvenance(c),
250
+ motionSource: (c.motionSource as CharacterFidelityInput["motionSource"]) ?? undefined
251
+ }));
252
+ const sceneFidelity = gradeSceneFidelity(fidelityInputs);
253
+ const fidelityById = new Map(sceneFidelity.characters.map((f) => [f.id, f]));
254
+
255
+ const cast: CastMember[] = rChars.map((c) => {
256
+ const { source, sourceLabel } = castProvenance(c);
257
+ return {
258
+ id: c.id,
259
+ name: cap(c.id),
260
+ kind: c.role || "character",
261
+ color: stableAccent(c.id),
262
+ glyph: (c.id[0] ?? "?").toUpperCase(),
263
+ lines: linesPerSpeaker(c.id),
264
+ source,
265
+ sourceLabel,
266
+ fidelity:
267
+ fidelityById.get(c.id) ?? { id: c.id, grade: "C", previz: true, reason: "previz: ungraded" }
268
+ };
269
+ });
270
+
271
+ // The set is a single spec on the document — surface it as one outliner entry.
272
+ const sets: SetEntity[] = doc.set
273
+ ? [{ id: doc.id ? doc.id + "-set" : "set", name: doc.id ? cap(doc.id) + " — Set" : "Set", meta: "scene set", icon: "globe" }]
274
+ : [];
275
+
276
+ const props: PropEntity[] = dedupeProps(rProps);
277
+
278
+ const shotForTime = (t: number): string => {
279
+ const hit = rShots.find((s) => t >= s.startTime && t < s.endTime);
280
+ return hit ? hit.shotId : rShots.length ? rShots[0]!.shotId : "";
281
+ };
282
+ const presetForShot = (shotId: string): string => {
283
+ const hit = rShots.find((s) => s.shotId === shotId);
284
+ return hit ? presetLabel(hit.presetId) : "";
285
+ };
286
+ // The director's REAL per-beat clip for a character in a shot (from blocking), if present.
287
+ const clipFor = (characterId: string, shotId: string): string | undefined => {
288
+ const cb = (doc.blocking ?? []).find((b) => b.characterId === characterId);
289
+ return cb?.shots?.find((s) => s.shotId === shotId)?.clip;
290
+ };
291
+ const castIds = rChars.map((c) => c.id);
292
+
293
+ const beats: Beat[] = rLines.map((l) => {
294
+ const shot = shotForTime(l.startTime);
295
+ // The addressed party: the other cast member (1:1), else the first non-speaker.
296
+ const listener = castIds.find((id) => id !== l.speakerId) ?? "";
297
+ // Prefer the director's real assigned clip; else infer the intent from the line text (F1 rules).
298
+ const speakingIntent = clipFor(l.speakerId, shot) ?? inferSpeakingIntent(l.text);
299
+ const listenerIntent = listener ? clipFor(listener, shot) ?? inferListenerIntent(l.text) : "idle";
300
+ return {
301
+ id: l.lineId,
302
+ shot,
303
+ who: l.speakerId,
304
+ start: l.startTime,
305
+ dur: l.endTime - l.startTime,
306
+ text: l.text,
307
+ listener,
308
+ speakingIntent,
309
+ listenerIntent,
310
+ camera: presetForShot(shot)
311
+ };
312
+ });
313
+
314
+ // Camera track: one clip per shot, derived from its preset. FX: empty (no fx in the doc).
315
+ const camera: TimelineSpan[] = rShots.map((s) => ({
316
+ id: "cam-" + s.shotId,
317
+ start: s.startTime,
318
+ dur: s.endTime - s.startTime,
319
+ text: presetLabel(s.presetId),
320
+ color: "#2dd4a7"
321
+ }));
322
+ const fx: TimelineSpan[] = [];
323
+
324
+ // Gesture track (Phase C2): every blocking beat whose director-assigned clip is a
325
+ // gesture/reaction (gesture/point/nod/react/wave) becomes a window spanning the shot it
326
+ // plays in. These are REAL beat timings — the same shot windows the player schedules the
327
+ // clip over — so the timeline shows where gestures/reactions land, alongside speech.
328
+ const shotWindow = (shotId: string): RuntimeShot | undefined => rShots.find((s) => s.shotId === shotId);
329
+ const gestures: TimelineSpan[] = [];
330
+ for (const cb of rBlocking) {
331
+ for (const sb of cb.shots ?? []) {
332
+ if (!GESTURE_CLIPS.has(sb.clip)) continue;
333
+ const win = shotWindow(sb.shotId);
334
+ if (!win) continue;
335
+ gestures.push({
336
+ id: "gst-" + cb.characterId + "-" + sb.shotId,
337
+ start: win.startTime,
338
+ dur: win.endTime - win.startTime,
339
+ text: cap(cb.characterId) + " · " + gestureLabel(sb.clip),
340
+ color: stableAccent(cb.characterId)
341
+ });
342
+ }
343
+ }
344
+
345
+ return {
346
+ title: doc.id ? cap(doc.id) : "Untitled scene",
347
+ cast,
348
+ sets,
349
+ props,
350
+ shots,
351
+ beats,
352
+ camera,
353
+ gestures,
354
+ fx,
355
+ DUR,
356
+ fidelity: sceneFidelity
357
+ };
358
+ }
359
+
360
+ /** Collapse duplicate prop ids (e.g. set dressing instances) into one outliner row with a count. */
361
+ function dedupeProps(rProps: RuntimeProp[]): PropEntity[] {
362
+ const counts = new Map<string, { p: RuntimeProp; n: number }>();
363
+ for (const p of rProps) {
364
+ const e = counts.get(p.id);
365
+ if (e) e.n += 1;
366
+ else counts.set(p.id, { p, n: 1 });
367
+ }
368
+ return [...counts.values()].map(({ p, n }) => ({
369
+ id: p.id,
370
+ name: cap(p.id),
371
+ meta: n > 1 ? n + "×" : "prop",
372
+ icon: "cube"
373
+ }));
374
+ }
375
+
376
+ /* ---- History → Director Console transcript ---- */
377
+
378
+ interface RuntimeHistoryEntry {
379
+ verb?: string;
380
+ command?: string;
381
+ args?: string;
382
+ ok?: boolean;
383
+ rejected?: boolean;
384
+ output?: string;
385
+ diff?: string[];
386
+ ms?: number;
387
+ time?: number;
388
+ hash?: string;
389
+ }
390
+
391
+ /** Map the REAL command history into Director Console command cards. Empty array → no cards. */
392
+ export function mapHistory(hist: unknown): Turn[] {
393
+ if (!Array.isArray(hist)) return [];
394
+ return (hist as RuntimeHistoryEntry[]).map((h, i) => {
395
+ const raw = (h.command ?? h.verb ?? "").trim();
396
+ const verb = h.verb ?? raw.split(/\s+/)[0] ?? "—";
397
+ const args = h.args ?? (raw.startsWith(verb) ? raw.slice(verb.length).trim() : raw);
398
+ const bad = h.rejected === true || h.ok === false;
399
+ const diffLines = Array.isArray(h.diff) ? h.diff : h.output ? h.output.split("\n").filter(Boolean) : [];
400
+ return {
401
+ type: "cmd",
402
+ id: "h" + i,
403
+ verb,
404
+ args,
405
+ state: bad ? "bad" : "ok",
406
+ diffs: diffLines.map((t) => ({
407
+ op: bad ? "!" : "~",
408
+ k: bad ? "del" : "mod",
409
+ t: escapeHtml(t)
410
+ })),
411
+ dur: h.ms != null ? (h.ms / 1000).toFixed(1) + "s" : "—",
412
+ hash: h.hash ?? "—"
413
+ };
414
+ });
415
+ }
416
+
417
+ function escapeHtml(s: string): string {
418
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
419
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Scene-Tool command metadata + parser (autocomplete only).
3
+ *
4
+ * The Director Console no longer simulates mutations locally — Command mode runs the
5
+ * REAL agent-native Scene-Tool CLI (`animation-scene.ts`) via `POST /api/scene` (see
6
+ * `state/backend.ts`), and each committed/rejected card reflects the actual validated
7
+ * result + the resulting working-document revision. This module now only provides:
8
+ * - `VERBS` — the command catalog for the composer autocomplete + suggestion chips.
9
+ * - `parse` — a light tokenizer used to detect the verb (e.g. `render`) and flags.
10
+ * No mock mutations remain in the production path.
11
+ */
12
+
13
+ export interface VerbSpec {
14
+ verb: string;
15
+ tail: string;
16
+ desc: string;
17
+ }
18
+
19
+ 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" },
24
+ { 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" }
29
+ ];
30
+
31
+ export interface ParsedCommand {
32
+ verb: string;
33
+ rest: string[];
34
+ flags: Record<string, string>;
35
+ raw: string;
36
+ isCommand: boolean;
37
+ }
38
+
39
+ /** Naive command parser — extracts the (possibly two-word) verb + flags for routing. */
40
+ export function parse(raw: string): ParsedCommand {
41
+ const t = raw.trim();
42
+ const flags: Record<string, string> = {};
43
+ const fre = /--([a-z]+)\s+("[^"]*"|\S+)/g;
44
+ let m: RegExpExecArray | null;
45
+ while ((m = fre.exec(t))) flags[m[1]] = m[2].replace(/^"|"$/g, "");
46
+ const head = t.replace(/--[a-z]+\s+("[^"]*"|\S+)/g, "").trim();
47
+ const toks = head.split(/\s+/).filter(Boolean);
48
+ let verb = toks[0] || "";
49
+ let rest = toks.slice(1);
50
+ if (["cast", "shot", "light", "fx"].includes(verb) && rest[0]) {
51
+ verb += " " + rest[0];
52
+ rest = rest.slice(1);
53
+ }
54
+ return { verb, rest, flags, raw: t, isCommand: VERBS.some((v) => t.startsWith(v.verb.split(" ")[0])) };
55
+ }