create-aura3d 1.1.2 → 1.3.1

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 (215) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -4
  4. package/dist/index.js.map +1 -1
  5. package/package.json +10 -9
  6. package/templates/{cartoon-channel → animation-channel}/README.md +11 -11
  7. package/templates/{cartoon-studio → animation-channel}/index.html +1 -1
  8. package/templates/{cartoon-channel → animation-channel}/package.json +2 -2
  9. package/templates/{cartoon-channel → animation-channel}/src/episode.ts +22 -22
  10. package/templates/{cartoon-channel → animation-channel}/src/experimental/README.md +5 -5
  11. package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.css +3 -3
  12. package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.ts +7 -7
  13. package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.css +4 -4
  14. package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.ts +6 -6
  15. package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.css +3 -3
  16. package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.ts +7 -7
  17. package/templates/{cartoon-channel → animation-channel}/src/main.ts +32 -32
  18. package/templates/{cartoon-channel → animation-channel}/src/render-plan.ts +13 -13
  19. package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.css +34 -34
  20. package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.ts +20 -20
  21. package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d-animation.spec.ts +3 -3
  22. package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d.spec.ts +6 -6
  23. package/templates/{cartoon-channel → animation-channel}/tests/image-puppet-animation.spec.ts +3 -3
  24. package/templates/{cartoon-channel → animation-channel}/tests/image-puppet.spec.ts +5 -5
  25. package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d-animation.spec.ts +3 -3
  26. package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d.spec.ts +4 -4
  27. package/templates/{prompt-cartoon-channel → animation-channel}/tests/route-health.spec.ts +1 -1
  28. package/templates/{cartoon-channel → animation-channel}/tests/sample-episode-visual.spec.ts +5 -5
  29. package/templates/{cartoon-channel → animation-channel}/tests/storyboard-playback.spec.ts +7 -7
  30. package/templates/animation-studio/AGENTS.md +71 -0
  31. package/templates/{cartoon-studio → animation-studio}/ASSET-LICENSES.md +7 -7
  32. package/templates/{cartoon-studio → animation-studio}/README.md +6 -6
  33. package/templates/animation-studio/aura.assets.json +669 -0
  34. package/templates/animation-studio/dist/episodes/scene/episode-3d.webm +0 -0
  35. package/templates/animation-studio/dist/episodes/scene/frames/action.png +0 -0
  36. package/templates/animation-studio/dist/episodes/scene/frames/dialogue.png +0 -0
  37. package/templates/animation-studio/dist/episodes/scene/frames/final.png +0 -0
  38. package/templates/animation-studio/dist/episodes/scene/frames/first.png +0 -0
  39. package/templates/animation-studio/dist/episodes/scene/frames/mouth-closed.png +0 -0
  40. package/templates/animation-studio/dist/episodes/scene/frames/mouth-open.png +0 -0
  41. package/templates/animation-studio/dist/episodes/scene/render-live-summary.json +10284 -0
  42. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-1.png +0 -0
  43. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-2.png +0 -0
  44. package/templates/animation-studio/dist/generated/second-scene.document.json +1210 -0
  45. package/templates/animation-studio/dist/generated/studio-edited.document.json +1196 -0
  46. package/templates/animation-studio/dist/scene/inshader-accept.document.json +1 -0
  47. package/templates/animation-studio/dist/scene/inshader-test.document.json +1 -0
  48. package/templates/animation-studio/dist/scene/integ.document.json +1 -0
  49. package/templates/animation-studio/dist/scene/working.document.json +723 -0
  50. package/templates/animation-studio/dist/scene/working.history.json +1 -0
  51. package/templates/{cartoon-studio → animation-studio}/live-route.html +1 -1
  52. package/templates/{cartoon-studio → animation-studio}/package-lock.json +2 -2
  53. package/templates/animation-studio/package.json +32 -0
  54. package/templates/animation-studio/public/aura-assets/cast-a.catalog.glb +0 -0
  55. package/templates/animation-studio/public/aura-assets/cast-b.catalog.glb +0 -0
  56. package/templates/animation-studio/public/aura-assets/luma.catalog.glb +0 -0
  57. package/templates/animation-studio/public/aura-assets/luma2.catalog.glb +0 -0
  58. package/templates/animation-studio/public/aura-assets/miko.catalog.glb +0 -0
  59. package/templates/{cartoon-studio → animation-studio}/public/aura-assets/moonGarden.gltf +1 -1
  60. package/templates/animation-studio/public/aura-assets/mushroom.catalog.glb +0 -0
  61. package/templates/animation-studio/public/aura-assets/rusty.catalog.glb +0 -0
  62. package/templates/animation-studio/public/cast-library/cast-library.json +71 -0
  63. package/templates/animation-studio/public/clip-library/gesture.json +173903 -0
  64. package/templates/animation-studio/public/clip-library/idle.json +23035 -0
  65. package/templates/animation-studio/public/clip-library/manifest.json +154 -0
  66. package/templates/animation-studio/public/clip-library/nod.json +173903 -0
  67. package/templates/animation-studio/public/clip-library/point.json +69879 -0
  68. package/templates/animation-studio/public/clip-library/react.json +66084 -0
  69. package/templates/animation-studio/public/clip-library/run.json +9648 -0
  70. package/templates/animation-studio/public/clip-library/talk.json +69879 -0
  71. package/templates/animation-studio/public/clip-library/walk.json +8283 -0
  72. package/templates/animation-studio/public/hifi-cast/BrainStem.glb +0 -0
  73. package/templates/animation-studio/public/hifi-cast/CesiumMan.glb +0 -0
  74. package/templates/animation-studio/public/hifi-cast/RiggedFigure.glb +0 -0
  75. package/templates/animation-studio/public/hifi-cast/kaykit/Barbarian.glb +0 -0
  76. package/templates/animation-studio/public/hifi-cast/kaykit/Knight.glb +0 -0
  77. package/templates/animation-studio/public/hifi-cast/kaykit/Mage.glb +0 -0
  78. package/templates/animation-studio/public/hifi-cast/kaykit/Rogue.glb +0 -0
  79. package/templates/animation-studio/public/hifi-cast/kaykit/Rogue_Hooded.glb +0 -0
  80. package/templates/animation-studio/public/hifi-cast/khronos-report.json +295 -0
  81. package/templates/animation-studio/public/hifi-cast/quaternius-report.json +449 -0
  82. package/templates/animation-studio/public/hifi-cast/rpm/cesiumman.glb +0 -0
  83. package/templates/animation-studio/public/hifi-cast/rpm/fox.glb +0 -0
  84. package/templates/animation-studio/public/hifi-cast/rpm/riggedfigure.glb +0 -0
  85. package/templates/animation-studio/public/hifi-cast/rpm-report.json +301 -0
  86. package/templates/animation-studio/scripts/anim.ts +103 -0
  87. package/templates/animation-studio/scripts/animation-scene.ts +457 -0
  88. package/templates/animation-studio/scripts/asset-motion-probe.ts +450 -0
  89. package/templates/animation-studio/scripts/asset-render-probe.ts +152 -0
  90. package/templates/animation-studio/scripts/author-office-scene.ts +65 -0
  91. package/templates/animation-studio/scripts/build-cast-library.ts +97 -0
  92. package/templates/animation-studio/scripts/build-characters.ts +1543 -0
  93. package/templates/animation-studio/scripts/build-clip-library.ts +972 -0
  94. package/templates/animation-studio/scripts/clip-decision-cli.ts +51 -0
  95. package/templates/animation-studio/scripts/determinism-check.ts +48 -0
  96. package/templates/animation-studio/scripts/generate-scene.ts +95 -0
  97. package/templates/animation-studio/scripts/hifi-cast-validate.ts +572 -0
  98. package/templates/animation-studio/scripts/motion-evidence-cli.ts +150 -0
  99. package/templates/animation-studio/scripts/motion-evidence.ts +362 -0
  100. package/templates/animation-studio/scripts/preview-server.ts +52 -0
  101. package/templates/animation-studio/scripts/regen-manifest.ts +156 -0
  102. package/templates/animation-studio/scripts/render-core.ts +360 -0
  103. package/templates/animation-studio/scripts/render-live.ts +813 -0
  104. package/templates/animation-studio/scripts/render-parallel.ts +70 -0
  105. package/templates/animation-studio/scripts/render-server.ts +119 -0
  106. package/templates/animation-studio/scripts/resolve-asset.ts +669 -0
  107. package/templates/animation-studio/scripts/skeleton-overlay-cli.ts +82 -0
  108. package/templates/animation-studio/scripts/skeleton-overlay.ts +293 -0
  109. package/templates/animation-studio/scripts/studio-tools-demo.ts +45 -0
  110. package/templates/animation-studio/scripts/texture-bake.ts +297 -0
  111. package/templates/{cartoon-studio → animation-studio}/scripts/validate-characters.ts +0 -0
  112. package/templates/animation-studio/scripts/verify-extracted-overlay-cli.ts +264 -0
  113. package/templates/animation-studio/src/animation-episode-validator.ts +97 -0
  114. package/templates/animation-studio/src/animation-performance.ts +556 -0
  115. package/templates/animation-studio/src/aura-assets.ts +71 -0
  116. package/templates/animation-studio/src/character.ts +40 -0
  117. package/templates/animation-studio/src/director/compile-episode-document.ts +45 -0
  118. package/templates/animation-studio/src/director/director-heuristics.ts +697 -0
  119. package/templates/animation-studio/src/director/prompt-to-scene.ts +369 -0
  120. package/templates/animation-studio/src/empty-document.ts +77 -0
  121. package/templates/animation-studio/src/episode-document.ts +442 -0
  122. package/templates/animation-studio/src/examples/moon-garden.example.ts +183 -0
  123. package/templates/animation-studio/src/fidelity.ts +129 -0
  124. package/templates/animation-studio/src/graph.ts +14 -0
  125. package/templates/animation-studio/src/procedural-hdri.ts +243 -0
  126. package/templates/animation-studio/src/profile.ts +97 -0
  127. package/templates/animation-studio/src/render-live-route.ts +42 -0
  128. package/templates/animation-studio/src/render-modes.ts +137 -0
  129. package/templates/animation-studio/src/scene-player.ts +867 -0
  130. package/templates/animation-studio/src/set-templates.ts +361 -0
  131. package/templates/animation-studio/src/studio/episode-document-store.ts +72 -0
  132. package/templates/animation-studio/src/studio/scene-tool-registry.ts +86 -0
  133. package/templates/animation-studio/src/studio/scene-tools.ts +169 -0
  134. package/templates/animation-studio/tests/reports/animation-studio/hifi-cast-validation.json +938 -0
  135. package/templates/animation-studio/tsconfig.json +26 -0
  136. package/templates/{cartoon-studio → animation-studio}/vite.config.ts +6 -1
  137. package/templates/character-controller/README.md +33 -0
  138. package/templates/character-controller/index.html +19 -0
  139. package/templates/character-controller/package.json +23 -0
  140. package/templates/character-controller/playwright.config.ts +14 -0
  141. package/templates/character-controller/src/controller.ts +44 -0
  142. package/templates/character-controller/src/main.ts +62 -0
  143. package/templates/character-controller/tests/route-health.spec.ts +26 -0
  144. package/templates/character-controller/tests/screenshot.spec.ts +9 -0
  145. package/templates/character-controller/tsconfig.json +15 -0
  146. package/templates/cinematic-scene/package.json +1 -1
  147. package/templates/episode-builder/README.md +4 -4
  148. package/templates/episode-builder/package.json +1 -1
  149. package/templates/episode-builder/src/aura-assets.ts +1 -1
  150. package/templates/episode-builder/src/builder.ts +4 -4
  151. package/templates/episode-builder/src/episode.ts +21 -21
  152. package/templates/episode-builder/src/main.ts +20 -20
  153. package/templates/episode-builder/src/render-plan.ts +13 -13
  154. package/templates/episode-builder/tests/storyboard-playback.spec.ts +7 -7
  155. package/templates/fighting-game/package.json +1 -1
  156. package/templates/mini-game/package.json +1 -1
  157. package/templates/product-viewer/package.json +1 -1
  158. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/README.md +5 -5
  159. package/templates/{cartoon-channel → prompt-animation-channel}/index.html +1 -1
  160. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/package.json +2 -2
  161. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/aura-assets.ts +1 -1
  162. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/episode.ts +22 -22
  163. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/main.ts +28 -28
  164. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/render-plan.ts +13 -13
  165. package/templates/{cartoon-channel → prompt-animation-channel}/tests/route-health.spec.ts +1 -1
  166. package/templates/{prompt-cartoon-channel → prompt-animation-channel}/tests/storyboard-playback.spec.ts +8 -8
  167. package/templates/cartoon-studio/aura.assets.json +0 -400
  168. package/templates/cartoon-studio/package.json +0 -33
  169. package/templates/cartoon-studio/public/aura-assets/luma.authored.glb +0 -0
  170. package/templates/cartoon-studio/public/aura-assets/miko.authored.glb +0 -0
  171. package/templates/cartoon-studio/scripts/build-characters.ts +0 -738
  172. package/templates/cartoon-studio/scripts/build-dialogue-audio.ts +0 -262
  173. package/templates/cartoon-studio/scripts/episode.ts +0 -323
  174. package/templates/cartoon-studio/scripts/render-live.ts +0 -873
  175. package/templates/cartoon-studio/src/aura-assets.ts +0 -64
  176. package/templates/cartoon-studio/src/characters.ts +0 -65
  177. package/templates/cartoon-studio/src/episode-renderer.ts +0 -605
  178. package/templates/cartoon-studio/src/episode.ts +0 -306
  179. package/templates/cartoon-studio/src/main.ts +0 -400
  180. package/templates/cartoon-studio/src/render-live-route.ts +0 -913
  181. package/templates/cartoon-studio/src/render-plan.ts +0 -567
  182. package/templates/cartoon-studio/src/review.ts +0 -99
  183. package/templates/cartoon-studio/src/studio.ts +0 -101
  184. package/templates/cartoon-studio/test-results/.last-run.json +0 -4
  185. package/templates/cartoon-studio/tests/episode-render.spec.ts +0 -44
  186. package/templates/cartoon-studio/tests/motion-quality.spec.ts +0 -45
  187. package/templates/cartoon-studio/tests/route-health.spec.ts +0 -7
  188. package/templates/cartoon-studio/tests/storyboard-playback.spec.ts +0 -99
  189. package/templates/cartoon-studio/tests/visual-review.spec.ts +0 -21
  190. package/templates/cartoon-studio/tsconfig.json +0 -14
  191. package/templates/prompt-cartoon-channel/index.html +0 -13
  192. package/templates/prompt-cartoon-channel/src/sets.ts +0 -14
  193. /package/templates/{cartoon-channel → animation-channel}/aura.assets.json +0 -0
  194. /package/templates/{cartoon-channel → animation-channel}/playwright.config.ts +0 -0
  195. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.047f5e5f.glb +0 -0
  196. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.thumb.svg +0 -0
  197. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.047f5e5f.glb +0 -0
  198. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.thumb.svg +0 -0
  199. /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/moon-garden-feature-frame.png +0 -0
  200. /package/templates/{cartoon-channel → animation-channel}/src/aura-assets.ts +0 -0
  201. /package/templates/{cartoon-channel → animation-channel}/src/characters.ts +0 -0
  202. /package/templates/{cartoon-channel → animation-channel}/src/contract.ts +0 -0
  203. /package/templates/{cartoon-channel → animation-channel}/src/sets.ts +0 -0
  204. /package/templates/{cartoon-channel → animation-channel}/test-results/.last-run.json +0 -0
  205. /package/templates/{cartoon-channel → animation-channel}/tests/reports/prompt-animation/cartoon-sample-episode.png +0 -0
  206. /package/templates/{cartoon-channel → animation-channel}/tsconfig.json +0 -0
  207. /package/templates/{cartoon-studio → animation-studio}/playwright.config.ts +0 -0
  208. /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/luma.thumb.svg +0 -0
  209. /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/miko.thumb.svg +0 -0
  210. /package/templates/{cartoon-studio → animation-studio}/src/contract.ts +0 -0
  211. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/playwright.config.ts +0 -0
  212. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/characters.ts +0 -0
  213. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/contract.ts +0 -0
  214. /package/templates/{cartoon-studio → prompt-animation-channel}/src/sets.ts +0 -0
  215. /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/tsconfig.json +0 -0
@@ -0,0 +1,697 @@
1
+ /**
2
+ * director-heuristics.ts — the DETERMINISTIC tier of the Director.
3
+ *
4
+ * Given a scene's shots + dialogue + cast + walkable bounds + available props, it
5
+ * GENERATES the blocking / camera / performance / set-dressing / world-state of an
6
+ * EpisodeDocument — the parts that used to be hand-authored constants. No LLM, no
7
+ * randomness beyond a seeded scatter, fully deterministic.
8
+ *
9
+ * HONEST SCOPE (see docs/animation-studio/quality-and-limitations.md): these rules target
10
+ * the constrained genre the MVP targets — **1–2 characters, dialogue-driven, single
11
+ * walkable set**. They reliably stage conversation: characters at conversation distance
12
+ * facing each other, establishing→two-shot→close-up framing, the speaker emphasised in
13
+ * close-ups, props scattered over the walkable area, world-state ramped across the act.
14
+ * They do NOT handle action, crowds, complex choreography, or non-dialogue beats — those
15
+ * are the LLM director's job (and remain unproven). The output is always a *valid*
16
+ * document; "valid" is not "well-directed" — that needs the human rubric (the watchability gate).
17
+ */
18
+
19
+ import type { CameraPresetId } from "@aura3d/engine";
20
+ import type { CharacterBlocking, PropPlacement, ShotBlocking, ShotSpec, Vec3 } from "../episode-document";
21
+
22
+ export interface DirectorCharacter {
23
+ readonly id: string;
24
+ /** Where the character starts the scene (drives a walk-in on the opening shot). */
25
+ readonly entersFrom?: "left" | "right" | "none";
26
+ }
27
+
28
+ export interface DirectorShot {
29
+ readonly shotId: string;
30
+ readonly startTime: number;
31
+ readonly endTime: number;
32
+ }
33
+
34
+ export interface DirectorDialogueLine {
35
+ readonly lineId: string;
36
+ readonly speakerId: string;
37
+ readonly startTime: number;
38
+ readonly endTime: number;
39
+ /**
40
+ * The spoken line. Drives the acting rules (Phase F1): questions, emphasis,
41
+ * disagreement and movement verbs are read off this text to choose intents.
42
+ * Optional so legacy callers (timing-only inputs) still type-check; when absent
43
+ * the beat falls back to plain talk + attentive listening.
44
+ */
45
+ readonly text?: string;
46
+ }
47
+
48
+ export interface DirectorPropSpec {
49
+ readonly propId: string;
50
+ readonly count: number;
51
+ readonly scaleRange: readonly [number, number];
52
+ readonly feetOffset: number;
53
+ }
54
+
55
+ export interface DirectorSceneInput {
56
+ readonly duration: number;
57
+ readonly characters: readonly DirectorCharacter[];
58
+ readonly shots: readonly DirectorShot[];
59
+ readonly dialogue: readonly DirectorDialogueLine[];
60
+ /** Walkable area characters and props stay inside. */
61
+ readonly walkableBounds: { readonly min: Vec3; readonly max: Vec3 };
62
+ readonly props: readonly DirectorPropSpec[];
63
+ /**
64
+ * @deprecated Per-beat clips now come from the fixed {@link PERFORMANCE_VOCABULARY}
65
+ * (idle/talk/gesture/point/nod/walk/run/react) chosen by role, so a retargeted clip
66
+ * library resolves them. This override is no longer consulted by {@link directScene}.
67
+ */
68
+ readonly clips?: { readonly idle?: string; readonly walk?: string; readonly gesture?: string };
69
+ /**
70
+ * Optional duration estimator (Phase C2). When supplied, beat durations are taken
71
+ * from this (so the director shares Phase C's `estimateSpeechDuration`); otherwise
72
+ * the director uses its own self-contained words/165-wpm fallback so it never has to
73
+ * import Phase C's file.
74
+ */
75
+ readonly durationEstimator?: (text: string) => number;
76
+ /**
77
+ * Optional per-character body-acting {@link RigGrade} (from `@aura3d/engine`'s `gradeRig`).
78
+ * When provided, the director consults {@link gradeAwareIntent} so a character whose rig can't
79
+ * perform an intent gets it honestly downgraded — a D-grade (no usable skeleton) rig is never
80
+ * told to gesture/walk; it talks or holds. Omitted ⇒ no restriction (every rig treated as A/B).
81
+ */
82
+ readonly gradesByCharacter?: Readonly<Record<string, RigGrade>>;
83
+ }
84
+
85
+ export interface DirectedScene {
86
+ readonly shots: readonly ShotSpec[];
87
+ readonly blocking: readonly CharacterBlocking[];
88
+ readonly setDressing: readonly PropPlacement[];
89
+ readonly worldState: { readonly glowSpanSeconds: number };
90
+ }
91
+
92
+ // --- small deterministic helpers ---
93
+ function mulberry32(seed: number): () => number {
94
+ let a = seed >>> 0;
95
+ return () => {
96
+ a |= 0;
97
+ a = (a + 0x6d2b79f5) | 0;
98
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
99
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
100
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
101
+ };
102
+ }
103
+ function hashSeed(s: string): number {
104
+ let h = 2166136261;
105
+ for (let i = 0; i < s.length; i += 1) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
106
+ return h >>> 0;
107
+ }
108
+ function yawFacing(from: Vec3, to: Vec3): number {
109
+ return Math.atan2(to[0] - from[0], to[2] - from[2]);
110
+ }
111
+
112
+ /**
113
+ * The STANDARD performance vocabulary — the fixed contract of clip intents the Director
114
+ * emits per beat. A downstream retargeted clip library exposes exactly these states, so
115
+ * the player can resolve every beat to a real animation. The Director must only ever
116
+ * write one of these ids into a beat's `clip` (never a fictional / asset-specific name).
117
+ */
118
+ export const PERFORMANCE_VOCABULARY = [
119
+ "idle",
120
+ "talk",
121
+ "gesture",
122
+ "point",
123
+ "nod",
124
+ "walk",
125
+ "run",
126
+ "react"
127
+ ] as const;
128
+ export type PerformanceClip = (typeof PERFORMANCE_VOCABULARY)[number];
129
+ const VOCAB = new Set<string>(PERFORMANCE_VOCABULARY);
130
+
131
+ /** Distance (world units) beyond which a traversal reads as a run rather than a walk. */
132
+ const RUN_DISTANCE = 3.0;
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Phase F1 — inspectable per-beat ACTING record + C2 beat timing
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /** The coarse camera intent the director assigns a beat (mirrors the shot presets). */
139
+ export type CameraIntent = "establishing" | "two-shot" | "medium" | "close-up";
140
+
141
+ /**
142
+ * One fully-inspectable performance beat (Phase F1). Every dialogue line becomes a
143
+ * beat; the record is the director's *explanation* of how the scene should be acted —
144
+ * who speaks, who listens, what gesture lands, how the camera frames it and how long
145
+ * the beat runs (C2). It is data, so tests and a UI preview can read the intent
146
+ * directly instead of inferring it from blocking.
147
+ */
148
+ export interface DirectorBeat {
149
+ readonly lineId: string;
150
+ readonly speaker: string;
151
+ /** The character being addressed (the other party in a 1:1, else the next non-speaker). */
152
+ readonly listener: string | null;
153
+ readonly text: string;
154
+ /** What the speaker DOES while talking (talk / gesture / point / nod / walk / run). */
155
+ readonly speakingIntent: PerformanceClip;
156
+ /** What the listener DOES in response (react / nod / idle), never frozen. */
157
+ readonly listenerIntent: PerformanceClip;
158
+ /** Convenience mirror of {@link speakingIntent} for non-talk emphasis beats. */
159
+ readonly gesture: PerformanceClip;
160
+ readonly cameraIntent: CameraIntent;
161
+ /** Estimated beat length in seconds (C2). */
162
+ readonly durationSeconds: number;
163
+ }
164
+
165
+ /** Words that signal the speaker is disagreeing / negating — these earn a stronger react. */
166
+ const NEGATION_WORDS = [
167
+ "no",
168
+ "not",
169
+ "never",
170
+ "wrong",
171
+ "don't",
172
+ "dont",
173
+ "won't",
174
+ "wont",
175
+ "can't",
176
+ "cant",
177
+ "isn't",
178
+ "isnt",
179
+ "stop",
180
+ "refuse",
181
+ "disagree",
182
+ "nonsense"
183
+ ];
184
+
185
+ /** Movement verbs that turn a beat into locomotion (walk/turn/reposition). */
186
+ const MOVEMENT_WORDS = ["walk", "go", "going", "run", "running", "cross", "leave", "leaving", "come", "coming", "move", "follow"];
187
+
188
+ function words(text: string): string[] {
189
+ return text
190
+ .toLowerCase()
191
+ .replace(/[^a-z' ]+/g, " ")
192
+ .split(/\s+/)
193
+ .filter(Boolean);
194
+ }
195
+
196
+ function isQuestion(text: string): boolean {
197
+ return text.includes("?");
198
+ }
199
+
200
+ /** Emphasis = a shout ("!") or a SHOUTED word (a 2+ letter ALL-CAPS run). */
201
+ function isEmphatic(text: string): boolean {
202
+ if (text.includes("!")) return true;
203
+ return /\b[A-Z]{2,}\b/.test(text);
204
+ }
205
+
206
+ function isDisagreement(text: string): boolean {
207
+ const w = new Set(words(text));
208
+ return NEGATION_WORDS.some((n) => w.has(n));
209
+ }
210
+
211
+ function hasMovement(text: string): boolean {
212
+ const w = new Set(words(text));
213
+ return MOVEMENT_WORDS.some((m) => w.has(m));
214
+ }
215
+
216
+ /**
217
+ * The director's self-contained speech-duration fallback (C2). Mirrors Phase C's
218
+ * model — words / 165 wpm + punctuation pauses, clamped — but lives here so the
219
+ * director never imports the document module. A caller may inject Phase C's real
220
+ * estimator via {@link DirectorSceneInput.durationEstimator} instead.
221
+ */
222
+ export function estimateBeatDuration(text: string): number {
223
+ const trimmed = (text ?? "").trim();
224
+ if (trimmed.length === 0) return 0.9;
225
+ const wordCount = trimmed.split(/\s+/).filter(Boolean).length;
226
+ const speaking = (wordCount / 165) * 60;
227
+ const soft = (trimmed.match(/[,;:]/g) ?? []).length * 0.25;
228
+ const hard = (trimmed.match(/[.!?]/g) ?? []).length * 0.4;
229
+ return Math.min(22, Math.max(0.9, speaking + soft + hard));
230
+ }
231
+
232
+ /**
233
+ * The speaker's varied performance intents, cycled across that speaker's emphasis
234
+ * beats so a talky scene isn't all low-motion `talk`. Order: gesture → point → nod.
235
+ */
236
+ const SPEAKER_EMPHASIS_CYCLE: readonly PerformanceClip[] = ["gesture", "point", "nod"];
237
+
238
+ /**
239
+ * Map a dialogue line (+ its position in a speaker's run of beats) to a speaking
240
+ * intent. Rules (F1):
241
+ * - movement verb → walk / run (locomotion wins; you can't argue mid-stride)
242
+ * - emphasis (!/ALL-CAPS) → a varied gesture (gesture/point/nod, rotated)
243
+ * - question ("?") → talk (the *listener* tilts/reacts; see listener rule)
244
+ * - plain line → mostly talk, but every Nth plain beat is upgraded to a
245
+ * rotated gesture so a speaker is never all-talk.
246
+ */
247
+ function speakingIntentFor(text: string, emphasisOrdinal: number, plainOrdinal: number): PerformanceClip {
248
+ if (hasMovement(text)) {
249
+ // Long movement words ("run"/"running") read as a run; else a walk/reposition.
250
+ return /\b(run|running)\b/i.test(text) ? "run" : "walk";
251
+ }
252
+ if (isEmphatic(text)) {
253
+ return SPEAKER_EMPHASIS_CYCLE[emphasisOrdinal % SPEAKER_EMPHASIS_CYCLE.length]!;
254
+ }
255
+ // Plain line: upgrade every 3rd plain beat to a rotated gesture so it's not all talk.
256
+ if (plainOrdinal > 0 && plainOrdinal % 3 === 0) {
257
+ return SPEAKER_EMPHASIS_CYCLE[(plainOrdinal / 3) % SPEAKER_EMPHASIS_CYCLE.length]!;
258
+ }
259
+ return "talk";
260
+ }
261
+
262
+ /**
263
+ * Map a dialogue line to the LISTENER's reaction (F1). The listener is never frozen:
264
+ * - question → nod (head-tilt / acknowledging the question)
265
+ * - disagreement → react (a stronger recoil / pose change)
266
+ * - emphasis → react (they flinch at the shout)
267
+ * - plain line → nod / idle-attentive (alternated so it's not static)
268
+ */
269
+ function listenerIntentFor(text: string, listenerOrdinal: number): PerformanceClip {
270
+ if (isDisagreement(text) || isEmphatic(text)) return "react";
271
+ if (isQuestion(text)) return "nod";
272
+ // Plain: alternate nod ↔ react so the listener visibly tracks the conversation.
273
+ return listenerOrdinal % 2 === 0 ? "nod" : "react";
274
+ }
275
+
276
+ const BEAT_CAMERA_CYCLE: readonly CameraIntent[] = ["establishing", "two-shot", "medium", "close-up"];
277
+
278
+ /**
279
+ * Build the inspectable beat-by-beat acting plan (F1 + C2) from the scene's dialogue.
280
+ * One beat per dialogue line, in time order. The speaker's intent varies (gesture/
281
+ * point/nod rotate, movement verbs walk); the listener always has a non-idle reaction;
282
+ * each beat carries an estimated duration. Reactions land AFTER the line that triggers
283
+ * them by construction (a beat's listener reacts to that same beat's speaker line).
284
+ */
285
+ export function directBeats(input: DirectorSceneInput): DirectorBeat[] {
286
+ const estimate = input.durationEstimator ?? estimateBeatDuration;
287
+ const lines = [...input.dialogue].sort((a, b) => a.startTime - b.startTime);
288
+ const cast = input.characters.map((c) => c.id);
289
+
290
+ // Per-speaker counters so emphasis gestures and plain-beat upgrades rotate per speaker.
291
+ const emphasisCount = new Map<string, number>();
292
+ const plainCount = new Map<string, number>();
293
+ let listenerOrdinal = 0;
294
+
295
+ return lines.map((line, index) => {
296
+ const text = line.text ?? "";
297
+ // The listener is the other party in a 1:1; otherwise the first cast member who
298
+ // isn't the speaker (so a non-speaking character always has someone to react to).
299
+ const listener = cast.find((id) => id !== line.speakerId) ?? null;
300
+
301
+ const emphatic = isEmphatic(text) && !hasMovement(text);
302
+ const plain = !emphatic && !hasMovement(text) && !isEmphatic(text);
303
+ const emphasisOrdinal = emphasisCount.get(line.speakerId) ?? 0;
304
+ const plainOrdinal = plainCount.get(line.speakerId) ?? 0;
305
+
306
+ const speakingIntent = speakingIntentFor(text, emphasisOrdinal, plainOrdinal);
307
+ if (emphatic) emphasisCount.set(line.speakerId, emphasisOrdinal + 1);
308
+ if (plain) plainCount.set(line.speakerId, plainOrdinal + 1);
309
+
310
+ const listenerIntent = listenerIntentFor(text, listenerOrdinal);
311
+ listenerOrdinal += 1;
312
+
313
+ const cameraIntent = BEAT_CAMERA_CYCLE[Math.min(index, BEAT_CAMERA_CYCLE.length - 1)]!;
314
+ const durationSeconds = estimate(text);
315
+
316
+ return {
317
+ lineId: line.lineId,
318
+ speaker: line.speakerId,
319
+ listener,
320
+ text,
321
+ speakingIntent,
322
+ listenerIntent,
323
+ gesture: speakingIntent,
324
+ cameraIntent,
325
+ durationSeconds
326
+ };
327
+ });
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Phase B5 — rig-grade-aware intent restriction
332
+ // ---------------------------------------------------------------------------
333
+
334
+ /**
335
+ * Body-acting suitability grade for a character's rig — mirrors {@link RigGrade} from
336
+ * `@aura3d/engine`'s `gradeRig`/RigQuality (A full humanoid … D no usable skeleton). We
337
+ * re-declare the union locally (rather than import the engine internal) so the director
338
+ * stays a leaf module, but the values are the SAME contract `gradeRig` reports.
339
+ *
340
+ * - **A/B** — enough body to act → the full performance vocabulary is allowed.
341
+ * - **C** — mascot/sparse (head + torso only, no real limb chains) → no full-limb
342
+ * gestures or locomotion; head/torso acting (nod / react / talk / idle) only.
343
+ * - **D** — not suitable for body acting (root/props only) → talk-only / minimal; any
344
+ * body-acting intent is honestly downgraded to `talk` (or `idle` if it was
345
+ * a non-locomotion non-vocal hold).
346
+ */
347
+ export type RigGrade = "A" | "B" | "C" | "D";
348
+
349
+ /** Intents that need real limb chains (full arms/legs) — unsafe on a C (mascot) or D rig. */
350
+ const LIMB_INTENTS = new Set<PerformanceClip>(["gesture", "point", "walk", "run"]);
351
+ /** Head/torso-only intents a C (mascot) rig CAN still perform honestly. */
352
+ const HEAD_TORSO_INTENTS = new Set<PerformanceClip>(["idle", "talk", "nod", "react"]);
353
+
354
+ /**
355
+ * Restrict a chosen performance intent to what a character's rig can honestly play, given its
356
+ * body-acting {@link RigGrade}. This is the director's one HONEST gate between the rich acting
357
+ * vocabulary and a rig that physically cannot perform it — a D-grade (no usable skeleton) rig
358
+ * must never be told to `walk`/`gesture`, only to talk or hold.
359
+ *
360
+ * The director consults this whenever a per-character grade is supplied (see
361
+ * {@link directScene}'s `gradesByCharacter` option); with no grade it is a no-op (returns the
362
+ * intent unchanged), so an ungraded pipeline is unaffected.
363
+ *
364
+ * Rules:
365
+ * - **A / B** (or undefined): no restriction — return the intent unchanged.
366
+ * - **C** (mascot / sparse): drop limb work — `gesture`/`point` → `nod` (head acting),
367
+ * `walk`/`run` → `idle` (it can't locomote); head/torso intents pass through.
368
+ * - **D** (no usable skeleton): talk-only / minimal — any vocal-ish intent collapses to
369
+ * `talk`; any pure body intent (gesture/point/walk/run/nod/react) collapses to `idle`.
370
+ */
371
+ export function gradeAwareIntent(intent: PerformanceClip, grade?: RigGrade): PerformanceClip {
372
+ if (grade == null || grade === "A" || grade === "B") return intent;
373
+
374
+ if (grade === "C") {
375
+ // Mascot: keep head/torso acting; convert limb gestures to a nod, locomotion to a hold.
376
+ if (intent === "gesture" || intent === "point") return "nod";
377
+ if (intent === "walk" || intent === "run") return "idle";
378
+ return HEAD_TORSO_INTENTS.has(intent) ? intent : "idle";
379
+ }
380
+
381
+ // grade === "D": not suitable for body acting. Talk-only / minimal — never gesture or walk.
382
+ if (intent === "talk") return "talk";
383
+ // A body-acting intent on a D rig is dishonest; collapse it to a static hold.
384
+ return LIMB_INTENTS.has(intent) || intent === "nod" || intent === "react" ? "idle" : intent;
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // Phase F1 — director VALIDATION gate
389
+ // ---------------------------------------------------------------------------
390
+
391
+ export interface DirectorValidationIssue {
392
+ readonly code: "ALL_LOW_MOTION" | "STATIC_CHARACTER" | "NO_GESTURE" | "NO_REACTION";
393
+ readonly message: string;
394
+ readonly characterId?: string;
395
+ }
396
+
397
+ export interface DirectorValidationResult {
398
+ readonly ok: boolean;
399
+ readonly issues: readonly DirectorValidationIssue[];
400
+ }
401
+
402
+ /** Intents that count as "low motion" — a scene made only of these reads as dead. */
403
+ const LOW_MOTION = new Set<PerformanceClip>(["idle", "talk"]);
404
+
405
+ /**
406
+ * The director validation gate (F1). A directed scene FAILS when:
407
+ * - every beat's intents are only idle/talk (the whole scene is low-motion), OR
408
+ * - any character is static — never given a non-idle intent across the whole scene
409
+ * (as speaker OR listener) — unless that character is explicitly `staged` (e.g. a
410
+ * background extra the director deliberately froze).
411
+ * It also flags the absence of any gesture or any listener reaction, which the
412
+ * prompt-to-scene proof asserts must both be present.
413
+ *
414
+ * `stagedStatic` lists character ids that are *allowed* to be motionless on purpose.
415
+ */
416
+ export function validateDirectedActing(
417
+ beats: readonly DirectorBeat[],
418
+ options: { readonly cast?: readonly string[]; readonly stagedStatic?: readonly string[] } = {}
419
+ ): DirectorValidationResult {
420
+ const issues: DirectorValidationIssue[] = [];
421
+ const staged = new Set(options.stagedStatic ?? []);
422
+
423
+ if (beats.length === 0) {
424
+ return { ok: false, issues: [{ code: "ALL_LOW_MOTION", message: "Scene has no acting beats." }] };
425
+ }
426
+
427
+ // (1) Whole-scene low-motion check: at least one beat must carry a non-idle/talk intent.
428
+ const anyHighMotion = beats.some(
429
+ (b) => !LOW_MOTION.has(b.speakingIntent) || !LOW_MOTION.has(b.listenerIntent)
430
+ );
431
+ if (!anyHighMotion) {
432
+ issues.push({
433
+ code: "ALL_LOW_MOTION",
434
+ message: "Every beat is idle/talk only — no gestures, reactions or movement anywhere in the scene."
435
+ });
436
+ }
437
+
438
+ // (2) Per-character static check: each character must get a non-idle intent at least
439
+ // once (speaking or listening) unless explicitly staged as static.
440
+ const nonIdleByCharacter = new Map<string, boolean>();
441
+ const touched = new Set<string>();
442
+ const mark = (id: string | null, intent: PerformanceClip) => {
443
+ if (id == null) return;
444
+ touched.add(id);
445
+ if (intent !== "idle") nonIdleByCharacter.set(id, true);
446
+ else if (!nonIdleByCharacter.has(id)) nonIdleByCharacter.set(id, false);
447
+ };
448
+ for (const b of beats) {
449
+ mark(b.speaker, b.speakingIntent);
450
+ mark(b.listener, b.listenerIntent);
451
+ }
452
+ const cast = options.cast ?? [...touched];
453
+ for (const id of cast) {
454
+ if (staged.has(id)) continue;
455
+ const moved = nonIdleByCharacter.get(id);
456
+ if (moved !== true) {
457
+ issues.push({
458
+ code: "STATIC_CHARACTER",
459
+ characterId: id,
460
+ message: `Character "${id}" is static for the whole scene (no non-idle intent). Stage it explicitly or give it a reaction.`
461
+ });
462
+ }
463
+ }
464
+
465
+ // (3) Variety asserts the prompt-to-scene proof depends on.
466
+ const GESTURE_INTENTS = new Set<PerformanceClip>(["gesture", "point", "nod"]);
467
+ if (!beats.some((b) => GESTURE_INTENTS.has(b.speakingIntent))) {
468
+ issues.push({ code: "NO_GESTURE", message: "No speaker ever gestures/points/nods — acting is all talk." });
469
+ }
470
+ const REACTION_INTENTS = new Set<PerformanceClip>(["react", "nod"]);
471
+ if (!beats.some((b) => b.listener != null && REACTION_INTENTS.has(b.listenerIntent))) {
472
+ issues.push({ code: "NO_REACTION", message: "No listener ever reacts — non-speakers are frozen." });
473
+ }
474
+
475
+ return { ok: issues.length === 0, issues };
476
+ }
477
+
478
+ /**
479
+ * Render a human-readable beat-by-beat director report (for CLI inspection / proof).
480
+ */
481
+ export function formatDirectorReport(beats: readonly DirectorBeat[]): string {
482
+ const lines: string[] = ["Director beat plan (speaker → listener · camera · intents · duration):"];
483
+ beats.forEach((b, i) => {
484
+ const listener = b.listener ?? "(none)";
485
+ lines.push(
486
+ ` ${String(i + 1).padStart(2, "0")}. [${b.cameraIntent}] ${b.speaker} ${b.speakingIntent.toUpperCase()} ` +
487
+ `→ ${listener} ${b.listenerIntent} (${b.durationSeconds.toFixed(2)}s) "${b.text}"`
488
+ );
489
+ });
490
+ return lines.join("\n");
491
+ }
492
+
493
+ /** Was this character speaking at any point during the shot window? */
494
+ function isSpeakingDuring(input: DirectorSceneInput, characterId: string, shot: DirectorShot): boolean {
495
+ for (const line of input.dialogue) {
496
+ if (line.speakerId !== characterId) continue;
497
+ const overlap = Math.min(line.endTime, shot.endTime) - Math.max(line.startTime, shot.startTime);
498
+ if (overlap > 0) return true;
499
+ }
500
+ return false;
501
+ }
502
+
503
+ /** Is there ANY dialogue (from anyone) during the shot window? */
504
+ function hasDialogueDuring(input: DirectorSceneInput, shot: DirectorShot): boolean {
505
+ for (const line of input.dialogue) {
506
+ const overlap = Math.min(line.endTime, shot.endTime) - Math.max(line.startTime, shot.startTime);
507
+ if (overlap > 0) return true;
508
+ }
509
+ return false;
510
+ }
511
+
512
+ /** Concatenated dialogue text spoken during the shot window (empty if none / untexted). */
513
+ function dialogueTextDuring(input: DirectorSceneInput, shot: DirectorShot): string {
514
+ const parts: string[] = [];
515
+ for (const line of input.dialogue) {
516
+ const overlap = Math.min(line.endTime, shot.endTime) - Math.max(line.startTime, shot.startTime);
517
+ if (overlap > 0 && line.text) parts.push(line.text);
518
+ }
519
+ return parts.join(" ");
520
+ }
521
+
522
+ /** Pick walk vs run from the planar travel distance between two marks. */
523
+ function locomotionClip(from: Vec3, to: Vec3): PerformanceClip {
524
+ const dist = Math.hypot(to[0] - from[0], to[2] - from[2]);
525
+ return dist >= RUN_DISTANCE ? "run" : "walk";
526
+ }
527
+
528
+ /** The character who speaks most during a shot (the shot's focus), or the first cast member. */
529
+ function shotSpeaker(input: DirectorSceneInput, shot: DirectorShot): string {
530
+ const tally = new Map<string, number>();
531
+ for (const line of input.dialogue) {
532
+ const overlap = Math.min(line.endTime, shot.endTime) - Math.max(line.startTime, shot.startTime);
533
+ if (overlap > 0) tally.set(line.speakerId, (tally.get(line.speakerId) ?? 0) + overlap);
534
+ }
535
+ let best = input.characters[0]?.id ?? "";
536
+ let bestV = -1;
537
+ for (const [id, v] of tally) if (v > bestV) { bestV = v; best = id; }
538
+ return best;
539
+ }
540
+
541
+ /**
542
+ * Stage a 1–2 character dialogue scene. Characters take base marks at conversation
543
+ * distance facing each other; cameras cycle establishing → two-shot → close-up (the
544
+ * close-up frames the active speaker); props scatter over the walkable bounds.
545
+ */
546
+ export function directScene(sceneId: string, input: DirectorSceneInput): DirectedScene {
547
+ const cast = input.characters;
548
+ const { min, max } = input.walkableBounds;
549
+ const cx = (min[0] + max[0]) / 2;
550
+ const cz = (min[2] + max[2]) / 2;
551
+
552
+ // Base marks: spread the cast left→right across ~26% of the walkable width, facing center.
553
+ // Tighter than before (was 40%) so a 2-shot frames the cast LARGE instead of two small figures
554
+ // marooned in a wide empty plate.
555
+ const span = (max[0] - min[0]) * 0.26;
556
+ const baseMark = (i: number): Vec3 => {
557
+ if (cast.length === 1) return [cx, 0, cz];
558
+ const t = cast.length === 1 ? 0.5 : i / (cast.length - 1);
559
+ return [cx + (t - 0.5) * span, 0, cz];
560
+ };
561
+ const center: Vec3 = [cx, 0, cz];
562
+
563
+ // Camera presets cycle establishing → two-shot → medium. We DELIBERATELY avoid the tight
564
+ // "close-up": its camera offset is fixed and does not know which way the character faces,
565
+ // so for a face-off (characters turned to ±X) it frames their side/back. A stable two-shot
566
+ // / medium frames BOTH characters from the front at conversation height — never a body part.
567
+ // Open WIDE to show the place, then push to the tighter MEDIUM for the conversation so the cast
568
+ // fills the frame rather than sitting small under a big empty backdrop.
569
+ const presets: CameraPresetId[] = ["establishing", "medium", "medium"];
570
+
571
+ const shots: ShotSpec[] = input.shots.map((shot, index) => {
572
+ const presetId = presets[Math.min(index, presets.length - 1)]!;
573
+ // Frame the conversation midpoint at GROUND level — the preset itself supplies the camera
574
+ // height + head-height target (~1.25m), which createCameraPathFromPreset adds to this. So
575
+ // pass the ground point; passing a raised Y would double the height and frame over the head.
576
+ const cameraSubject: Vec3 = [cx, 0, cz];
577
+ return { shotId: shot.shotId, presetId, startTime: shot.startTime, endTime: shot.endTime, cameraSubject };
578
+ });
579
+
580
+ const blocking: CharacterBlocking[] = cast.map((character, i) => {
581
+ const mark = baseMark(i);
582
+ const faceCenter = yawFacing(mark, center);
583
+ // Per-character rotation counters so a speaker's emphasis/plain gestures vary across
584
+ // shots (F1: not all low-motion talk) and a listener's reactions alternate.
585
+ let shotEmphasis = 0;
586
+ let shotPlain = 0;
587
+ let shotListener = 0;
588
+ const beats: ShotBlocking[] = input.shots.map((shot, index) => {
589
+ // Opening shot: optional walk-in from the side the character enters from.
590
+ if (index === 0 && character.entersFrom && character.entersFrom !== "none") {
591
+ const offX = character.entersFrom === "left" ? min[0] - 0.5 : max[0] + 0.5;
592
+ const enterStart: Vec3 = [offX, 0, mark[2]];
593
+ const enterDur = Math.min(shot.endTime - shot.startTime, (input.duration) * 0.12);
594
+ return {
595
+ // Entering = traversing between marks → walk, or run for a long entrance.
596
+ shotId: shot.shotId,
597
+ clip: locomotionClip(enterStart, mark),
598
+ waypoints: [
599
+ { time: shot.startTime, position: enterStart, yaw: yawFacing(enterStart, mark) },
600
+ { time: shot.startTime + enterDur, position: mark, yaw: faceCenter }
601
+ ]
602
+ };
603
+ }
604
+ // Middle beats: traverse toward the focus ONLY when that is a REAL walk — a meaningful
605
+ // distance covered over the shot (≥ ~0.35 m/s). A small converge over a long shot is an
606
+ // imperceptible drift (e.g. 0.65m over 8s ≈ 0.08 m/s) that reads as "walking in place while
607
+ // standing still". Below that speed the character HOLDS its mark and PERFORMS instead (it
608
+ // falls through to the speaker/listener acting logic below) — so we never generate a
609
+ // meaningless micro-traverse. Real repositioning (entrance, or a movement-verb line) still walks.
610
+ const isMiddle = index > 0 && index < input.shots.length - 1;
611
+ if (isMiddle) {
612
+ const converge: Vec3 = [mark[0] + (cx - mark[0]) * (index === 1 ? 0.45 : 0.25), 0, mark[2] + 0.25];
613
+ const traverseDist = Math.hypot(converge[0] - mark[0], converge[2] - mark[2]);
614
+ const shotDur = Math.max(1e-3, shot.endTime - shot.startTime);
615
+ if (traverseDist / shotDur >= 0.35) {
616
+ return {
617
+ // Traversing between marks at a real walking speed → walk (or run for a large move).
618
+ shotId: shot.shotId,
619
+ clip: locomotionClip(mark, converge),
620
+ sweeping: i === 0,
621
+ waypoints: [
622
+ { time: shot.startTime, position: mark, yaw: yawFacing(mark, converge) },
623
+ { time: shot.endTime, position: converge, yaw: yawFacing(converge, center) }
624
+ ]
625
+ };
626
+ }
627
+ // else: fall through to hold-mark + perform (no pointless drift / walk-in-place).
628
+ }
629
+ // First/last beats: hold the mark, but FACE the active speaker (look-at / reaction).
630
+ // The CLIP now expresses the beat's ROLE from the standard vocabulary so a retargeted
631
+ // clip library can play it:
632
+ // - active speaker → `talk`, upgraded to `gesture` on the emphasis/close beat.
633
+ // - addressed non-speaker (someone IS speaking, just not them) → `nod` on the close
634
+ // (active listening) else `react`.
635
+ // - nobody speaking (or solo, silent) → `idle`.
636
+ const thisShot = input.shots[index]!;
637
+ const speaker = shotSpeaker(input, thisShot);
638
+ const spkIdx = Math.max(0, cast.findIndex((c) => c.id === speaker));
639
+ const spkMark = baseMark(spkIdx);
640
+ const facing = i === spkIdx ? faceCenter : yawFacing(mark, spkMark);
641
+ const isClose = index === input.shots.length - 1;
642
+ const speakingNow = isSpeakingDuring(input, character.id, thisShot);
643
+ const anyDialogue = hasDialogueDuring(input, thisShot);
644
+ // The line(s) spoken during this shot — drives the acting rules when text is present.
645
+ const shotText = dialogueTextDuring(input, thisShot);
646
+
647
+ let beatClip: PerformanceClip;
648
+ if (speakingNow) {
649
+ // Active speaker. With dialogue TEXT, the acting rules pick the intent
650
+ // (emphasis → gesture/point, movement → walk/run, else talk); on the close-up
651
+ // a plain hold is still emphasised to a gesture so it's never a flat lip-flap.
652
+ if (shotText) {
653
+ const acted = speakingIntentFor(shotText, shotEmphasis, shotPlain);
654
+ if (acted === "talk") {
655
+ // Plain hold: emphasise on the close-up so it's never a flat lip-flap.
656
+ beatClip = isClose ? "gesture" : "talk";
657
+ shotPlain += 1;
658
+ } else {
659
+ beatClip = acted;
660
+ shotEmphasis += 1;
661
+ }
662
+ } else {
663
+ beatClip = isClose ? "gesture" : "talk";
664
+ }
665
+ } else if (anyDialogue) {
666
+ // Being addressed. With text, react/nod follows the rules (disagreement/emphasis →
667
+ // stronger react, question → nod); otherwise settle into a nod on the close-up.
668
+ if (shotText) {
669
+ beatClip = listenerIntentFor(shotText, shotListener++);
670
+ } else {
671
+ beatClip = isClose ? "nod" : "react";
672
+ }
673
+ } else {
674
+ beatClip = "idle";
675
+ }
676
+ // B5: honestly restrict the chosen intent to what THIS character's rig can play. With no
677
+ // grade supplied this is a no-op; a D-grade rig never holds a gesture/walk it can't perform.
678
+ beatClip = gradeAwareIntent(beatClip, input.gradesByCharacter?.[character.id]);
679
+ return { shotId: shot.shotId, clip: beatClip, waypoints: [{ time: shot.startTime, position: mark, yaw: facing }] };
680
+ });
681
+ return { characterId: character.id, shots: beats };
682
+ });
683
+
684
+ // Deterministic prop scatter over the walkable bounds (seeded by scene id).
685
+ const rand = mulberry32(hashSeed(sceneId));
686
+ const setDressing: PropPlacement[] = [];
687
+ for (const prop of input.props) {
688
+ for (let n = 0; n < prop.count; n += 1) {
689
+ const x = min[0] + rand() * (max[0] - min[0]);
690
+ const z = min[2] + rand() * (max[2] - min[2]);
691
+ const scale = prop.scaleRange[0] + rand() * (prop.scaleRange[1] - prop.scaleRange[0]);
692
+ setDressing.push({ propId: prop.propId, position: [x, 0, z], scale, feetOffset: prop.feetOffset });
693
+ }
694
+ }
695
+
696
+ return { shots, blocking, setDressing, worldState: { glowSpanSeconds: input.duration } };
697
+ }