create-aura3d 1.1.2 → 1.3.0

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 +5 -3
  4. package/dist/index.js.map +1 -1
  5. package/package.json +11 -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,867 @@
1
+ /**
2
+ * scene-player.ts — the GENERIC animation scene player.
3
+ *
4
+ * It consumes an EpisodeDocument and renders it. It contains ZERO scene-specific
5
+ * constants — blocking, camera framing, the clip per beat, prop layout, the set + lights,
6
+ * and the dim→sparkle world-state all come from the document via the sampling functions.
7
+ * It can play ANY document; the Moon Garden specifics live in `moon-garden-document.ts`.
8
+ *
9
+ * Dialogue/caption/viseme still come from the episode (`render-plan.ts`) — that timed
10
+ * track is the AuraVoice contract and is shared across the pipeline.
11
+ *
12
+ * Imports resolve from the published `@aura3d/engine` (advanced-runtime A3DRenderer +
13
+ * production-runtime typed GLB actors + rendering primitives), exactly as the old route.
14
+ */
15
+
16
+ import { A3DRenderer, TextureBinding as EngineTextureBinding } from "@aura3d/engine/advanced-runtime";
17
+ import { createStudioLighting, createTypedGLBActor, type TypedGLBActor } from "@aura3d/engine/production-runtime";
18
+ import { createCameraPathFromPreset, sampleCameraPath, type CameraPath } from "@aura3d/engine";
19
+ // I1 clean-room correctness: import the rendering/scene PRIMITIVES from the engine's own subpaths
20
+ // (`@aura3d/engine/rendering` / `@aura3d/engine/scene`) — the SAME copy the A3DRenderer uses — NOT
21
+ // the standalone `@aura3d/rendering` / `@aura3d/scene` packages. In a clean-room (tarball) install
22
+ // those standalone packages are a SECOND copy of the rendering code, so a `Geometry`/`PBRMaterial`/
23
+ // `TextureBinding` built from them is a DIFFERENT class than the renderer's, and the renderer's
24
+ // `MaterialBinding.bind` (`value instanceof TextureBinding`) rejects EVERY PBR material with a
25
+ // `MaterialBindingError` ("u_environmentMapTexture must be texture2d"). Importing through the engine
26
+ // gives ONE shared copy, so the bindings match. (In the monorepo these resolve to the same deduped
27
+ // dist either way, so this never changes the monorepo render.)
28
+ import { Geometry, PBRMaterial, type CollectedLight, type RenderItem, type RenderSource } from "@aura3d/engine/rendering";
29
+ import { bakeSetHdri, type BakedHdri } from "./procedural-hdri";
30
+ import { composeMat4, multiplyMat4, perspectiveMat4, PointLight, quatFromEuler, type Mat4 } from "@aura3d/engine/scene";
31
+ import {
32
+ sampleBlocking,
33
+ sampleCaption,
34
+ sampleVisemeOpenness,
35
+ sampleWorldStateGlow,
36
+ shotAtTime,
37
+ type EpisodeDocument,
38
+ type SetPiece,
39
+ type Vec3
40
+ } from "./episode-document";
41
+ // I2 — GLOBAL BLEND/alpha ghost fix policy + cel/render-mode clarification (one canonical home).
42
+ import { forceOpaqueAcrossRenderItems } from "./render-modes";
43
+ import {
44
+ loadSharedClipLibrary,
45
+ createPerformanceRig,
46
+ resolveIntent,
47
+ embeddedClipForIntent,
48
+ type PerformanceRig,
49
+ type ClipDecision,
50
+ type ClipSource
51
+ } from "./animation-performance";
52
+ // B6 — rig-capability + true-foot-IK helpers. These come from the SAME freshly-built monorepo
53
+ // dist that `animation-performance.ts` imports (vite aliases @aura3d/* to dist; this relative path
54
+ // is the one specifier the route does NOT alias — @aura3d/animation — so we reach it by path, just
55
+ // like animation-performance.ts does). `gradeRig` tells us whether a rig actually HAS the
56
+ // leg/knee/ankle/foot chain a two-bone foot-IK solve needs; `createFootIkRig` is the engine's real
57
+ // two-bone solver (used only for rigs that have the chain). On sparse/mascot rigs we keep the
58
+ // honest `rootGrounding` (root pinned to the ground mark — NOT IK).
59
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
60
+ // @ts-ignore — resolved at runtime to the freshly-built monorepo dist (has co-located .d.ts).
61
+ import {
62
+ gradeRig,
63
+ inferHumanoidRig,
64
+ createFootIkRig,
65
+ type RigQualityReport,
66
+ type FootIkRig,
67
+ type GroundRaycaster
68
+ } from "@aura3d/animation";
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Proof shapes (unchanged from the old route so the capture script keeps working).
72
+ // ---------------------------------------------------------------------------
73
+ interface LiveRouteSeekProof {
74
+ readonly time: number;
75
+ readonly drawCalls: number;
76
+ readonly skinnedRenderItems: number;
77
+ readonly gardenGlow: number;
78
+ readonly shot: {
79
+ readonly shotId: string;
80
+ readonly presetId: string;
81
+ readonly episodeTime: number;
82
+ readonly cameraPosition: Vec3;
83
+ readonly fov: number;
84
+ };
85
+ readonly caption: { readonly text: string; readonly speakerId: string; readonly captionId: string };
86
+ /**
87
+ * B1 debug overlay — the current clip + intent + source for EVERY character at this frame,
88
+ * inspectable per-seek (e.g. "miko: talk←talk [extracted] 9 bones / 0.412rad").
89
+ */
90
+ readonly debugOverlay: readonly string[];
91
+ readonly characters: readonly {
92
+ readonly id: string;
93
+ readonly clip: string;
94
+ readonly position: Vec3;
95
+ readonly sweeping: boolean;
96
+ readonly tracksApplied: number;
97
+ readonly skinningPalettesUpdated: number;
98
+ readonly skinningBindingCount: number;
99
+ readonly mouthOpenness: number;
100
+ readonly visemeId: string;
101
+ readonly mouthMorphWeight: number;
102
+ readonly mouthMorphIndex: number;
103
+ readonly primitiveMouthOpen: number;
104
+ /** B1 per-character/per-beat clip-decision record (intent/clipId/source/bonesTouched/…). */
105
+ readonly clipDecision: ClipDecision & { readonly characterId: string; readonly time: number };
106
+ }[];
107
+ }
108
+
109
+ interface LiveRouteReadyProof {
110
+ readonly ready: true;
111
+ readonly backend: string;
112
+ readonly characters: readonly {
113
+ readonly id: string;
114
+ readonly url: string;
115
+ readonly clip: string;
116
+ readonly clips: readonly string[];
117
+ readonly skinningBindingCount: number;
118
+ readonly skinnedRenderItemCount: number;
119
+ }[];
120
+ }
121
+
122
+ interface LiveSeekOptions {
123
+ readonly mouthOverride?: number;
124
+ }
125
+
126
+ type LiveRouteWindow = Window & {
127
+ __AURA_LIVE_ROUTE_READY__?: LiveRouteReadyProof;
128
+ __auraSeek__?: (time: number, options?: LiveSeekOptions) => LiveRouteSeekProof;
129
+ __AURA_LIVE_ROUTE_HEADLESS__?: boolean;
130
+ __AURA_LIVE_ROUTE_ERROR__?: string;
131
+ };
132
+
133
+ interface LiveCharacter {
134
+ readonly id: string;
135
+ readonly actor: TypedGLBActor;
136
+ readonly defaultClip: string;
137
+ readonly availableClips: readonly string[];
138
+ readonly scale: number;
139
+ readonly mouthMorphIndex: number;
140
+ readonly morphRenderables: { morphWeights: number[] }[];
141
+ /** Shared-library performance driver (state graph + retarget map) for this character (Phase 2.5). */
142
+ readonly perf: PerformanceRig;
143
+ /**
144
+ * B6 — body-acting suitability grade for this character's inferred rig (A/B/C/D). Drives the
145
+ * HONEST foot-handling decision: only rigs that actually HAVE a leg/knee/ankle/foot chain
146
+ * (grade A/B with ankles) get true two-bone foot IK; everything else keeps `rootGrounding`.
147
+ */
148
+ readonly rigGrade: RigQualityReport;
149
+ /**
150
+ * B6 — the engine's real two-bone foot-IK rig, present ONLY when this character's rig has the
151
+ * ankle chain. `undefined` ⇒ no true IK is available; the player uses `rootGrounding` and SAYS so.
152
+ */
153
+ readonly footIk?: FootIkRig;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Shared base geometry (built once; the document scales them via modelMatrix).
158
+ // ---------------------------------------------------------------------------
159
+ const BASE_GEOMETRY: Record<SetPiece["geometry"], Geometry> = {
160
+ cube: Geometry.litCube(1),
161
+ sphere: Geometry.uvSphere(0.5, 24, 16),
162
+ cylinder: Geometry.cylinder({ radius: 0.5, height: 1, segments: 20, capped: true })
163
+ };
164
+
165
+ /**
166
+ * Resolve a requested clip to the GLB's real clip list (exact → substring). Returns `undefined`
167
+ * when NO real clip matches — an unknown/fictional clip name must FALL THROUGH to the shared
168
+ * library (B3), NOT silently collapse to clip #0 (which is how a whole cast ended up looping one
169
+ * embedded idle). Callers treat `undefined` as "no embedded clip; use the library pose."
170
+ */
171
+ function pickClip(requested: string, available: readonly string[]): string | undefined {
172
+ if (available.length === 0) return undefined;
173
+ const want = requested.toLowerCase();
174
+ const exact = available.find((c) => c.toLowerCase() === want);
175
+ if (exact) return exact;
176
+ return available.find((c) => c.toLowerCase().includes(want) || want.includes(c.toLowerCase()));
177
+ }
178
+
179
+ /** Right-handed look-at view matrix (column-major, matches perspectiveMat4). */
180
+ function lookAtViewMatrix(eye: Vec3, target: Vec3, up: Vec3 = [0, 1, 0]): Mat4 {
181
+ const fx = target[0] - eye[0];
182
+ const fy = target[1] - eye[1];
183
+ const fz = target[2] - eye[2];
184
+ const fl = Math.hypot(fx, fy, fz) || 1;
185
+ const f: [number, number, number] = [fx / fl, fy / fl, fz / fl];
186
+ let sx = f[1] * up[2] - f[2] * up[1];
187
+ let sy = f[2] * up[0] - f[0] * up[2];
188
+ let sz = f[0] * up[1] - f[1] * up[0];
189
+ const sl = Math.hypot(sx, sy, sz) || 1;
190
+ sx /= sl;
191
+ sy /= sl;
192
+ sz /= sl;
193
+ const ux = sy * f[2] - sz * f[1];
194
+ const uy = sz * f[0] - sx * f[2];
195
+ const uz = sx * f[1] - sy * f[0];
196
+ return [
197
+ sx, ux, -f[0], 0,
198
+ sy, uy, -f[1], 0,
199
+ sz, uz, -f[2], 0,
200
+ -(sx * eye[0] + sy * eye[1] + sz * eye[2]),
201
+ -(ux * eye[0] + uy * eye[1] + uz * eye[2]),
202
+ f[0] * eye[0] + f[1] * eye[1] + f[2] * eye[2],
203
+ 1
204
+ ];
205
+ }
206
+
207
+ function cameraViewProjection(
208
+ path: CameraPath,
209
+ time: number,
210
+ aspect: number
211
+ ): { readonly viewProjectionMatrix: Mat4; readonly position: Vec3; readonly fov: number } {
212
+ const sample = sampleCameraPath(path, time);
213
+ const view = lookAtViewMatrix(sample.position as Vec3, sample.target as Vec3);
214
+ const projection = perspectiveMat4((sample.fov * Math.PI) / 180, aspect, 0.1, 100);
215
+ return {
216
+ viewProjectionMatrix: multiplyMat4(projection, view),
217
+ position: sample.position as Vec3,
218
+ fov: sample.fov
219
+ };
220
+ }
221
+
222
+ const SHADOW_MATERIAL = new PBRMaterial({
223
+ name: "live-route-contact-shadow",
224
+ baseColor: [0.03, 0.08, 0.07, 1],
225
+ metallic: 0,
226
+ roughness: 1,
227
+ emissiveColor: [0, 0, 0],
228
+ emissiveStrength: 0
229
+ });
230
+
231
+ /**
232
+ * Mount the generic player for `doc`. Loads its assets, builds its set/lights/props,
233
+ * exposes the headless `__auraSeek__` hook + ready proof, and renders.
234
+ */
235
+ export async function mountScenePlayer(doc: EpisodeDocument): Promise<void> {
236
+ const root = document.querySelector<HTMLDivElement>("#app");
237
+ if (!root) throw new Error("scene-player: missing #app root element.");
238
+ root.innerHTML = `
239
+ <main style="margin:0;background:#0b0f1a;min-height:100vh;display:grid;place-items:center;position:relative;">
240
+ <canvas id="live-canvas" width="960" height="540"
241
+ style="width:960px;height:540px;display:block;background:#0b0f1a;"></canvas>
242
+ <pre id="aura-debug-overlay" aria-hidden="true" style="
243
+ position:absolute;left:8px;top:8px;margin:0;padding:6px 8px;max-width:944px;
244
+ font:11px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace;color:#9effa2;
245
+ background:rgba(8,12,22,0.72);border:1px solid rgba(158,255,162,0.35);border-radius:6px;
246
+ white-space:pre-wrap;pointer-events:none;display:none;"></pre>
247
+ </main>
248
+ `;
249
+ const canvas = root.querySelector<HTMLCanvasElement>("#live-canvas");
250
+ if (!canvas) throw new Error("scene-player: missing #live-canvas.");
251
+
252
+ // B1 — per-character clip + intent debug overlay, gated behind AURA_DEBUG_OVERLAY=1 (surfaced to
253
+ // the browser route via Vite as VITE_AURA_DEBUG_OVERLAY). Off by default; when on, the proof DOM
254
+ // shows the live clip←intent [source] + body-motion numbers per character per frame.
255
+ const debugOverlayEnabled =
256
+ String(
257
+ (import.meta as unknown as { env?: Record<string, string> }).env?.VITE_AURA_DEBUG_OVERLAY ?? ""
258
+ ).trim() === "1";
259
+ const debugOverlayEl = root.querySelector<HTMLPreElement>("#aura-debug-overlay");
260
+ if (debugOverlayEl && debugOverlayEnabled) debugOverlayEl.style.display = "block";
261
+
262
+ const liveWindow = window as LiveRouteWindow;
263
+ const width = canvas.width;
264
+ const height = canvas.height;
265
+ const aspect = width / height;
266
+
267
+ // 1. Characters.
268
+ // Shared, rig-neutral clip library (idle/talk/gesture/point/nod/walk/run/react) built ONCE and
269
+ // reused by every character's performance rig. B3: the EXTRACTED universal catalog library
270
+ // (public/clip-library/<intent>.json) is the DEFAULT first choice; intents with no usable
271
+ // extracted clip fall back to the procedural standard baseline (both logged).
272
+ const sharedLibrary = await loadSharedClipLibrary();
273
+ const sharedClipRegistry = sharedLibrary.registry;
274
+ const characters: LiveCharacter[] = [];
275
+ for (const spec of doc.assets.characters) {
276
+ const actor = await createTypedGLBActor({ asset: { url: spec.url }, id: spec.id, name: spec.id, width, height });
277
+ // FIX (ghost characters): many catalog GLBs — especially Sketchfab/FBX exports — set
278
+ // alphaMode=BLEND on an OPAQUE (opacity 1) material, so the textured character renders
279
+ // as a translucent white silhouette. The GLOBAL ghost-fix policy (render-modes.ts) forces
280
+ // each opaque-but-BLENDed material OPAQUE (blend off, depth write/test on) so its real
281
+ // textures show solid. The SAME policy runs on props + set dressing below, so the fix is
282
+ // provably global (not character-only).
283
+ forceOpaqueAcrossRenderItems(actor.collectRenderItems());
284
+ const snapshot = actor.snapshot();
285
+ if (snapshot.clips.length < 1) throw new Error(`scene-player: ${spec.id} GLB exposes no animation clips.`);
286
+ const scene = actor.pipeline.resources.scene;
287
+ const morphRenderables: { morphWeights: number[] }[] = scene
288
+ .collectRenderables()
289
+ .map((entry) => entry.renderable)
290
+ .filter((renderable) => renderable.morphWeights.length > 0);
291
+ const mouthMorphIndex = spec.mouthMorphIndex ?? (morphRenderables.length > 0 ? 0 : -1);
292
+
293
+ // Phase 2.5: infer this GLB's humanoid rig from its skeleton node names and build a performance
294
+ // rig (retarget map standard-library-rig → this rig + a performance state graph). This is what
295
+ // lets EVERY character — even a catalog GLB that ships only a single embedded idle — talk,
296
+ // gesture, walk and react from the SHARED clip library instead of looping one embedded clip.
297
+ const nodeNames: string[] = [];
298
+ scene.root.traverse((node) => {
299
+ if (node.name) nodeNames.push(node.name);
300
+ });
301
+ const perf = createPerformanceRig(sharedClipRegistry, {
302
+ nodeNames,
303
+ embeddedClips: snapshot.clips,
304
+ sources: sharedLibrary.sources
305
+ });
306
+
307
+ // B6 — HONEST foot handling. Grade the inferred rig: only rigs that actually carry a
308
+ // leg/knee/ankle/foot chain (grade A/B with `hasAnkles`) can run TRUE two-bone foot IK.
309
+ // Sparse/mascot rigs (grade C — head+torso, stub arms, no ankle chain) keep `rootGrounding`
310
+ // (root pinned to the ground mark — NOT IK) and we LOG exactly that, never claiming IK.
311
+ const rig = inferHumanoidRig(nodeNames);
312
+ const rigGrade = gradeRig(rig);
313
+ let footIk: FootIkRig | undefined;
314
+ const canFootIk = rigGrade.hasLegs && rigGrade.hasKnees && rigGrade.hasAnkles;
315
+ if (canFootIk) {
316
+ try {
317
+ // Flat ground at y=0 (the staged ground mark): a downward ray returns the floor under
318
+ // each ankle. This is a REAL two-bone solve (hip→knee→ankle) with foot-lock, not a pin.
319
+ const flatGround: GroundRaycaster = {
320
+ raycastDown(origin, maxDistance) {
321
+ const distance = origin[1] - 0;
322
+ if (distance < 0 || distance > maxDistance) return undefined;
323
+ return { point: [origin[0], 0, origin[2]], normal: [0, 1, 0], distance };
324
+ }
325
+ };
326
+ // Rest-pose leg chains in the standard (meters) frame; per-frame ankle targets come from
327
+ // the staged ground in `solveFootPlacement`. Hip≈0.9m, knee≈0.5m, ankle≈0.08m.
328
+ footIk = createFootIkRig({
329
+ raycaster: flatGround,
330
+ legs: [
331
+ { side: "left", hip: [-0.1, 0.9, 0], knee: [-0.1, 0.5, 0.02], ankle: [-0.1, 0.08, 0] },
332
+ { side: "right", hip: [0.1, 0.9, 0], knee: [0.1, 0.5, 0.02], ankle: [0.1, 0.08, 0] }
333
+ ]
334
+ });
335
+ console.info(`[scene-player] ${spec.id}: true two-bone foot IK enabled (rig grade ${rigGrade.grade}: has leg/knee/ankle chain).`);
336
+ } catch (err) {
337
+ footIk = undefined;
338
+ console.warn(`[scene-player] ${spec.id}: foot-IK rig construction failed (${(err as Error).message}) → rootGrounding.`);
339
+ }
340
+ } else {
341
+ console.info(
342
+ `[scene-player] ${spec.id}: rootGrounding (rig grade ${rigGrade.grade}: no ankle chain → true foot IK unavailable).`
343
+ );
344
+ }
345
+
346
+ characters.push({
347
+ id: spec.id,
348
+ actor,
349
+ defaultClip: spec.defaultClip,
350
+ availableClips: snapshot.clips,
351
+ scale: spec.scale,
352
+ mouthMorphIndex,
353
+ morphRenderables,
354
+ perf,
355
+ rigGrade,
356
+ footIk
357
+ });
358
+ }
359
+
360
+ // 2. Props (loaded once; instanced per set-dressing placement).
361
+ const propActors = new Map<string, TypedGLBActor>();
362
+ for (const prop of doc.assets.props) {
363
+ // A prop with no url is a prompt-derived object with no resolved mesh — recorded in the doc for
364
+ // provenance, but there is nothing to load. Skip it (procedural set dressing fills the space)
365
+ // instead of 404-ing on a fictional GLB.
366
+ if (!prop.url) continue;
367
+ const propActor = await createTypedGLBActor({ asset: { url: prop.url }, id: prop.id, name: prop.id, width, height });
368
+ // Same GLOBAL ghost fix as characters: catalog prop GLBs can also ship opaque-but-BLENDed
369
+ // materials that render as translucent ghosts; force them opaque here too.
370
+ forceOpaqueAcrossRenderItems(propActor.collectRenderItems());
371
+ propActors.set(prop.id, propActor);
372
+ }
373
+ function collectSetDressing(): RenderItem[] {
374
+ const items: RenderItem[] = [];
375
+ for (const placement of doc.setDressing) {
376
+ const actor = propActors.get(placement.propId);
377
+ if (!actor) continue;
378
+ actor.pipeline.resources.scene.root.transform
379
+ .setPosition(placement.position[0], placement.feetOffset * placement.scale, placement.position[2])
380
+ .setRotation(0, 0, 0, 1)
381
+ .setScale(placement.scale, placement.scale, placement.scale);
382
+ items.push(...actor.collectRenderItems());
383
+ }
384
+ return items;
385
+ }
386
+
387
+ // 3. Set pieces (primitive geometry + materials from the document) + glow registry.
388
+ // M2 — IN-SHADER CEL BY DEFAULT for STYLIZED renders. NON-GLOW pieces render with the engine's
389
+ // REAL AnimationToonMaterial (GPU banded N·L + Fresnel rim). Glow pieces stay on PBR because the
390
+ // dim→sparkle world-state drives their emissive uniform.
391
+ //
392
+ // Default policy (M2): stylized is the DEFAULT, so cel is ON unless the document/flag picks PBR.
393
+ // - `doc.set.inShaderCel === false` → explicit PBR opt-out (realistic asset / PBR mode).
394
+ // - `import.meta.env.VITE_AURA_RENDER_STYLE === "pbr"` → flag opt-out (mirrors the server-side
395
+ // AURA_RENDER_STYLE=toon|pbr; the browser route reads it via Vite's import.meta.env).
396
+ // - otherwise (undefined / true) → stylized → cel ON.
397
+ // M2 — resolve the REAL engine cel material LAZILY + NON-FATALLY: if the aliased @aura3d/rendering
398
+ // build predates AnimationToonMaterial the import yields undefined and we fall back to PBR — never
399
+ // a hard module-link crash.
400
+ const renderStylePbr =
401
+ ((import.meta as unknown as { env?: Record<string, string> }).env?.VITE_AURA_RENDER_STYLE ?? "")
402
+ .trim()
403
+ .toLowerCase() === "pbr";
404
+ const inShaderCel = doc.set.inShaderCel !== false && !renderStylePbr;
405
+ const celKeyDir: Vec3 = [0.4, 0.82, 0.45]; // upper-front key, matching the softbox/3-point key
406
+ type CelCtor = new (o: { name: string; baseColor: [number, number, number, number]; bands: number; shadowFloor: number; lightDirection: Vec3; rimColor?: [number, number, number]; rimPower?: number; rimIntensity: number }) => RenderItem["material"];
407
+ let CelMaterial: CelCtor | undefined;
408
+ if (inShaderCel) {
409
+ try {
410
+ const mod = (await import("@aura3d/rendering")) as unknown as { AnimationToonMaterial?: CelCtor };
411
+ CelMaterial = mod.AnimationToonMaterial;
412
+ if (!CelMaterial) console.warn("[scene-player] inShaderCel (stylized default) requested but AnimationToonMaterial absent from the build; falling back to PBR.");
413
+ else console.info("[scene-player] inShaderCel ON (stylized default): non-glow set pieces use AnimationToonMaterial.");
414
+ } catch (err) {
415
+ console.warn(`[scene-player] AnimationToonMaterial import failed (${(err as Error).message}) → PBR set pieces.`);
416
+ }
417
+ } else {
418
+ console.info(`[scene-player] inShaderCel OFF → PBR set pieces (${renderStylePbr ? "VITE_AURA_RENDER_STYLE=pbr" : "doc.set.inShaderCel=false"}).`);
419
+ }
420
+ const glowPieces: { material: PBRMaterial; dim: number; full: number }[] = [];
421
+ const setItems: RenderItem[] = doc.set.pieces.map((piece) => {
422
+ const rgba = [...piece.baseColor];
423
+ const material: RenderItem["material"] =
424
+ CelMaterial && !piece.glow
425
+ ? new CelMaterial({
426
+ name: `set-${piece.id}`,
427
+ baseColor: [rgba[0] ?? 0, rgba[1] ?? 0, rgba[2] ?? 0, rgba[3] ?? 1],
428
+ // M2 cel tuning for a CLEAN look: 4 bands give crisp, readable cel steps without the
429
+ // posterized banding 5+ produces on smooth primitives; shadowFloor 0.32 keeps the
430
+ // darkest band off pure black so shapes stay readable in shadow; a soft, tight rim
431
+ // (cool color, power 3) pops the silhouette without haloing.
432
+ bands: 4,
433
+ shadowFloor: 0.32,
434
+ lightDirection: celKeyDir,
435
+ rimColor: [0.7, 0.82, 1],
436
+ rimPower: 3,
437
+ rimIntensity: 0.4
438
+ })
439
+ : new PBRMaterial({
440
+ name: `set-${piece.id}`,
441
+ baseColor: [...piece.baseColor],
442
+ metallic: piece.metallic ?? 0,
443
+ roughness: piece.roughness ?? 0.85,
444
+ emissiveColor: piece.emissiveColor ? [...piece.emissiveColor] : [0, 0, 0],
445
+ emissiveStrength: piece.glow ? piece.glow.dim : piece.emissiveStrength ?? 0
446
+ });
447
+ if (piece.glow && material instanceof PBRMaterial) glowPieces.push({ material, dim: piece.glow.dim, full: piece.glow.full });
448
+ return {
449
+ label: piece.id,
450
+ geometry: BASE_GEOMETRY[piece.geometry],
451
+ material,
452
+ modelMatrix: composeMat4([...piece.position], quatFromEuler(0, 0, piece.roll ?? 0), [...piece.scale]) as Mat4,
453
+ includeInAutoFrame: piece.includeInAutoFrame ?? false
454
+ };
455
+ });
456
+ /** Drive every glow piece's emissive from the eased dim→sparkle world-state. */
457
+ function applyWorldGlow(eased: number): void {
458
+ for (const g of glowPieces) g.material.setParameter("u_emissiveStrength", g.dim + (g.full - g.dim) * eased);
459
+ }
460
+ applyWorldGlow(sampleWorldStateGlow(doc, 0));
461
+
462
+ // 4. Lights: M3 — a REAL 3-point rig (key/fill/rim) balanced for characters + the document's
463
+ // point rig. The `inspection` preset is a true 3-point setup (warm front key with shadows, cool
464
+ // side fill, warm back rim) that reads as intentional studio lighting and separates the character
465
+ // from the backdrop; the softbox preset previously used had NO rim (flat). The key (and only the
466
+ // key) casts shadow maps so we get one clean shadow, not three.
467
+ //
468
+ // M3 — SHADOWS DEFAULT-ON for the studio render unless a flag disables. Real shadow maps ground
469
+ // the character (cast/receive) and add depth. Disable paths:
470
+ // - `doc.set.realShadows === false` → explicit document opt-out.
471
+ // - `import.meta.env.VITE_AURA_SHADOWS === "off"` → flag opt-out.
472
+ // The cheap contact-shadow blobs below still ground every character regardless.
473
+ const shadowsDisabledByFlag =
474
+ ((import.meta as unknown as { env?: Record<string, string> }).env?.VITE_AURA_SHADOWS ?? "")
475
+ .trim()
476
+ .toLowerCase() === "off";
477
+ const realShadows = doc.set.realShadows !== false && !shadowsDisabledByFlag;
478
+ console.info(`[scene-player] real shadow maps ${realShadows ? "ON (studio key casts shadows)" : "OFF"}; 3-point key/fill/rim lighting rig.`);
479
+ const lights: readonly CollectedLight[] = [
480
+ ...createStudioLighting({ preset: "inspection", shadows: realShadows, intensityScale: doc.set.studioLightingScale }),
481
+ ...doc.set.lights.map((l): CollectedLight => {
482
+ const source = new PointLight(l.id);
483
+ source.intensity = l.intensity;
484
+ source.range = l.range;
485
+ return {
486
+ kind: "point",
487
+ color: l.color,
488
+ intensity: l.intensity,
489
+ position: l.position,
490
+ direction: [0, -1, 0],
491
+ range: l.range,
492
+ spotAngle: 0,
493
+ penumbra: 0,
494
+ castsShadow: false,
495
+ layerMask: 0xffffffff,
496
+ source
497
+ };
498
+ })
499
+ ];
500
+
501
+ // 5. Contact shadows (one per character, at its staged ground position).
502
+ const shadowState = new Map<string, { position: Vec3; scale: number }>();
503
+ function collectCharacterShadows(): RenderItem[] {
504
+ const items: RenderItem[] = [];
505
+ for (const [, s] of shadowState) {
506
+ const r = 0.4 * s.scale;
507
+ items.push({
508
+ label: "contact-shadow",
509
+ geometry: BASE_GEOMETRY.sphere,
510
+ material: SHADOW_MATERIAL,
511
+ modelMatrix: composeMat4([s.position[0], 0.02, s.position[2] + 0.05], quatFromEuler(0, 0, 0), [r, 0.01, r * 0.7]) as Mat4,
512
+ includeInAutoFrame: false
513
+ });
514
+ }
515
+ return items;
516
+ }
517
+
518
+ // 6. Per-shot camera paths (framing from the document).
519
+ const cameraPathByShot = new Map<string, CameraPath>(
520
+ doc.shots.map((shot) => [
521
+ shot.shotId,
522
+ createCameraPathFromPreset({
523
+ id: `live-cam:${shot.shotId}`,
524
+ presetId: shot.presetId,
525
+ startTime: shot.startTime,
526
+ endTime: shot.endTime,
527
+ subjectPosition: [...shot.cameraSubject]
528
+ })
529
+ ])
530
+ );
531
+
532
+ // 7. Renderer + render source.
533
+ const renderer = await A3DRenderer.create({
534
+ canvas,
535
+ width,
536
+ height,
537
+ backend: "webgl2",
538
+ alpha: false,
539
+ clearColor: [...doc.set.clearColor]
540
+ });
541
+
542
+ // M3 IBL — if the set carries an `hdri` spec, BAKE a 1024×512 equirectangular environment image
543
+ // and wire it as the engine's real `environmentMapTexture` (image-based lighting for PBR mode). It
544
+ // composes ON TOP of the procedural ambient: the procedural map stays the floor, the sampled HDRI
545
+ // adds image-based diffuse/specular + a directional sun highlight. Absent → procedural map only.
546
+ let bakedHdri: BakedHdri | undefined;
547
+ const hdriSpec = doc.set.environment.hdri;
548
+ if (hdriSpec) {
549
+ bakedHdri = bakeSetHdri(doc.id, hdriSpec);
550
+ console.info(
551
+ `[scene-player] IBL ON — baked 1024×512 equirect HDRI (env map intensity ${bakedHdri.intensity}, specular ${bakedHdri.specularIntensity})${hdriSpec.sun ? " + sun disc" : ""}.`
552
+ );
553
+ } else {
554
+ console.info("[scene-player] IBL — procedural environment map only (no per-set HDRI bitmap).");
555
+ }
556
+ // I1 clean-room hardening: the baked HDRI env map is an OPTIONAL enhancement on top of the
557
+ // procedural ambient — it must NEVER be a hard render requirement. The binding MUST degrade
558
+ // gracefully (warn + continue without IBL) instead of crashing the render.
559
+ //
560
+ // Root cause of the clean-room crash (diagnosed by rendering it): in the monorepo a SINGLE deduped
561
+ // @aura3d/engine is loaded, so every `TextureBinding` — ours, the engine's auto-generated env-map
562
+ // mip chain, and its BRDF-LUT — is the SAME class and the renderer's `MaterialBinding.bind`
563
+ // (`value instanceof TextureBinding`, see rendering/MaterialBinding.ts) accepts them. In a CLEAN-ROOM
564
+ // install from tarballs the engine resolves into SEVERAL Vite-prebundled chunks: the app's
565
+ // `@aura3d/rendering` (which builds our env map) and `@aura3d/engine/advanced-runtime` (the renderer,
566
+ // which builds the internal env-map/BRDF-LUT bindings) get DIFFERENT `TextureBinding` class objects,
567
+ // so the `instanceof` check fails and the renderer throws `MaterialBindingError`
568
+ // ("u_environmentMapTexture must be texture2d" / "Unsupported uniform value"). This is unfixable from
569
+ // the template (the engine's OWN internal BRDF-LUT binding crosses the chunk boundary too), and a
570
+ // failed render permanently poisons the engine's environment resources — so we must decide BEFORE any
571
+ // render and never bind the env map when the engine is multi-chunk.
572
+ //
573
+ // The reliable, NON-POISONING signal is exactly that class-identity divergence: if our baked binding
574
+ // (from `@aura3d/rendering`) is NOT `instanceof` the engine renderer's own `TextureBinding` (from
575
+ // `@aura3d/engine/advanced-runtime`), the engine is multi-chunk and its internal env bindings will be
576
+ // rejected too → skip IBL. When they ARE the same class (the monorepo / a correctly-deduped install)
577
+ // IBL stays on, so this never changes the monorepo render's success.
578
+ let iblBinding:
579
+ | {
580
+ readonly environmentMapTexture: BakedHdri["binding"];
581
+ readonly environmentMapIntensity: number;
582
+ readonly environmentMapSpecularIntensity: number;
583
+ readonly environmentMapEncoding: "srgb";
584
+ readonly environmentMapMipCount: number;
585
+ }
586
+ | undefined;
587
+ if (bakedHdri) {
588
+ const engineWillAcceptBinding = bakedHdri.binding instanceof EngineTextureBinding;
589
+ if (engineWillAcceptBinding) {
590
+ iblBinding = {
591
+ environmentMapTexture: bakedHdri.binding,
592
+ environmentMapIntensity: bakedHdri.intensity,
593
+ environmentMapSpecularIntensity: bakedHdri.specularIntensity,
594
+ environmentMapEncoding: "srgb" as const,
595
+ environmentMapMipCount: 1
596
+ };
597
+ } else {
598
+ console.warn(
599
+ `[scene-player] IBL DISABLED — the baked HDRI env map cannot be bound by the renderer ` +
600
+ `(its TextureBinding is a different class than the engine renderer's, i.e. @aura3d/engine resolved ` +
601
+ `into multiple chunks). Continuing with procedural ambient lighting only; the scene still renders ` +
602
+ `WITHOUT image-based lighting. (Expected in a clean-room install; IBL is an enhancement, not a requirement.)`
603
+ );
604
+ }
605
+ }
606
+
607
+ const source: RenderSource = {
608
+ collectRenderItems: () => [
609
+ ...setItems,
610
+ ...collectSetDressing(),
611
+ ...collectCharacterShadows(),
612
+ ...characters.flatMap((c) => c.actor.collectRenderItems())
613
+ ],
614
+ cameraPolicy: "auto-frame",
615
+ cameraFrameBounds: { min: [-2.6, -0.1, -1.6], max: [2.6, 3.4, 1.6] },
616
+ collectedLights: lights,
617
+ environmentLighting: {
618
+ color: [...doc.set.environment.color],
619
+ intensity: doc.set.environment.intensity,
620
+ proceduralMap: {
621
+ skyColor: [...doc.set.environment.proceduralMap.skyColor],
622
+ horizonColor: [...doc.set.environment.proceduralMap.horizonColor],
623
+ groundColor: [...doc.set.environment.proceduralMap.groundColor],
624
+ specularColor: [...doc.set.environment.proceduralMap.specularColor],
625
+ intensity: doc.set.environment.proceduralMap.intensity,
626
+ specularIntensity: doc.set.environment.proceduralMap.specularIntensity
627
+ },
628
+ // Real IBL: equirect 2D environment map sampled for diffuse irradiance + specular reflection.
629
+ // `iblBinding` is set above ONLY when the baked env map is the engine's own `TextureBinding`
630
+ // class (so the renderer's `instanceof` validator accepts it); otherwise it is undefined and
631
+ // IBL is skipped, leaving the procedural ambient as the lighting floor.
632
+ ...(iblBinding ?? {})
633
+ }
634
+ };
635
+
636
+ // 8. Pose at episode time `t`, drive camera + lip-sync + world-state, render.
637
+ const poseAt = (time: number, options: LiveSeekOptions = {}): LiveRouteSeekProof => {
638
+ const shot = shotAtTime(doc, time);
639
+ const gardenGlow = sampleWorldStateGlow(doc, time);
640
+ applyWorldGlow(gardenGlow);
641
+
642
+ // Resolve the active speaker (the AuraVoice dialogue contract) once per frame: it drives both
643
+ // the per-character intent (talk vs react/listen) and the look-at (everyone faces the speaker).
644
+ const captionNow = sampleCaption(doc, time);
645
+ const activeSpeakerId = captionNow.speakerId;
646
+ const anyDialogue = activeSpeakerId.length > 0;
647
+ // Stage each character's ground mark first so look-at can aim non-speakers at the speaker.
648
+ const staged = characters.map((character) => ({
649
+ character,
650
+ blocking: sampleBlocking(doc, character.id, time, { position: [0, 0, 0], yaw: 0, clip: character.defaultClip })
651
+ }));
652
+ const speakerMark = staged.find((s) => s.character.id === activeSpeakerId)?.blocking.position;
653
+
654
+ // B6 — per-character foot-handling status for this frame (surfaced in the free-form debug
655
+ // overlay below). `rootGrounding` for sparse rigs; `footIK(n grounded)` where true IK ran.
656
+ const footStatus = new Map<string, string>();
657
+
658
+ const characterProofs: LiveRouteSeekProof["characters"] = staged.map(({ character, blocking }) => {
659
+ const speaking = character.id === activeSpeakerId;
660
+ // Run when traversing a long distance (the standard `run` intent); else walk while moving.
661
+ const running = blocking.moving && /run|sprint/i.test(blocking.clip);
662
+ // B7 — speaking performance: a talking beat must move MORE than the mouth. `resolveIntent`
663
+ // returns `talk` for the active speaker (independent of whether the rig even HAS a mouth
664
+ // morph), so `poseFor("talk")` below plays the standard talk clip — head + torso/shoulder
665
+ // body motion — for EVERY speaker, including mouthless rigs (mouthMorphIndex < 0). The mouth
666
+ // morph (when present) is layered ON TOP, never a substitute for the body performance.
667
+ const intent = resolveIntent({
668
+ clip: blocking.clip,
669
+ moving: blocking.moving,
670
+ running,
671
+ speaking,
672
+ anyDialogue
673
+ });
674
+
675
+ // 2.6 / B7 lip-sync: mouth-openness from the dialogue/viseme track (speaker only). The morph
676
+ // (if the rig has one) is driven below. B7 — prevent a long STATIC mouth-open hold: the viseme
677
+ // must PULSE with the dialogue. `sampleVisemeOpenness` oscillates but never closes (min ≈0.18),
678
+ // so a held line would read as a frozen open mouth. We gate it with a faster syllable-cadence
679
+ // pulse (≈3.3 Hz) that dips toward closed between syllables — only while this character is the
680
+ // active speaker — so the mouth visibly opens AND closes across the line instead of holding open.
681
+ const viseme = sampleVisemeOpenness(doc, time, character.id);
682
+ let mouthOpenness = options.mouthOverride ?? viseme.mouthOpenness;
683
+ if (options.mouthOverride === undefined && speaking && mouthOpenness > 0) {
684
+ // Syllable gate in [0,1]: a raised-cosine pulse that returns near 0 between syllables, so the
685
+ // mouth closes (not just dims) between beats. ~3.3 syllables/sec ≈ natural speaking cadence.
686
+ const syllableGate = 0.5 - 0.5 * Math.cos(time * Math.PI * 2 * 3.3);
687
+ // Map the (never-closing) sampled openness through the gate: full open at a syllable peak,
688
+ // near-closed in the trough — eliminating the static open hold while keeping lip motion lively.
689
+ mouthOpenness = mouthOpenness * (0.18 + 0.82 * syllableGate);
690
+ }
691
+
692
+ // B3 motion-source order (explicit + logged via decision.source):
693
+ // (1) extracted universal-catalog clip for this intent → (2) procedural standard clip
694
+ // — both produced by `poseFor` (the library is the DEFAULT first choice),
695
+ // (3) a RICH, genuinely-matching embedded GLB clip ONLY when the library pose is empty
696
+ // (idle-fallback) or carries no real body motion,
697
+ // (4) idle last resort (already handled inside `poseFor`).
698
+ const libraryResult = character.perf.poseFor(intent, time);
699
+ let decision: ClipDecision = libraryResult.decision;
700
+
701
+ // A rich embedded clip is a fallback, not the default: only consider it when the library gave
702
+ // us nothing usable for this intent (no body motion) AND the GLB ships a non-trivial match.
703
+ const libraryWeak = decision.source === "idle-fallback" || decision.bonesTouched === 0;
704
+ const embedded =
705
+ libraryWeak && character.perf.preferEmbedded
706
+ ? pickClip(embeddedClipForIntent(intent, character.availableClips) ?? "", character.availableClips)
707
+ : undefined;
708
+
709
+ let apply: ReturnType<TypedGLBActor["playClip"]>;
710
+ let resolvedClip: string;
711
+ if (embedded) {
712
+ apply = character.actor.playClip(embedded, time);
713
+ resolvedClip = embedded;
714
+ // We played the GLB's own embedded clip; record that as the motion source. (Per-bone body
715
+ // amplitude isn't measured for embedded playback here — the gate treats embedded as a
716
+ // distinct, lower-confidence source than the rig-neutral library pose.)
717
+ decision = { ...decision, clipId: intent, source: "embedded" as ClipSource };
718
+ } else {
719
+ const pose = libraryResult.pose;
720
+ // GLTFScenePose accepts {x,y,z}/{x,y,z,w} transforms; same shape the retargeter emits.
721
+ apply = character.actor.applyRetargetedPose(pose as never, time);
722
+ resolvedClip = intent;
723
+ }
724
+ // B1 — the pose/clip actually reached the GLB skeleton at runtime when the actor reported it
725
+ // wrote ≥1 skinned track (tracksApplied) or updated ≥1 skinning palette. A decision whose
726
+ // motion never touched a real skeleton node (tracksApplied===0) is a SILENT no-op and must NOT
727
+ // be trusted by the gate as "the body moved" — `reachedGLBRuntime:false` surfaces that.
728
+ decision = {
729
+ ...decision,
730
+ reachedGLBRuntime: apply.tracksApplied > 0 || apply.skinningPalettesUpdated > 0
731
+ };
732
+
733
+ // 2.7 look-at: non-speakers turn to face the active speaker; otherwise honor the directed yaw.
734
+ let yaw = blocking.yaw;
735
+ if (!speaking && speakerMark && !blocking.moving) {
736
+ const dx = speakerMark[0] - blocking.position[0];
737
+ const dz = speakerMark[2] - blocking.position[2];
738
+ if (Math.hypot(dx, dz) > 0.05) yaw = Math.atan2(dx, dz);
739
+ }
740
+ const rotation = quatFromEuler(0, yaw, 0);
741
+ // B6 — `rootGrounding` (NOT true IK): pin the staged root at y from the blocking mark
742
+ // (ground = 0) so the retargeted hip-bob / walk cycle animates the body WITHOUT the
743
+ // character floating off the floor. This is honest root grounding — it does NOT solve the
744
+ // leg chain. It is the baseline for EVERY character and the ONLY foot handling for sparse /
745
+ // mascot rigs (grade C, no ankle chain). The contact-shadow blob tracks the same mark below.
746
+ let rootY = blocking.position[1];
747
+ // B6 — TRUE foot IK: ONLY for rigs that actually carry the leg/knee/ankle/foot chain (set up
748
+ // at load time as `character.footIk`). The engine's real two-bone solver drops the hip so the
749
+ // lower foot reaches the ground and foot-locks a planted foot in world space (no slide). We
750
+ // apply the solved hip offset on top of rootGrounding; sparse rigs skip this entirely.
751
+ let footIkGroundedFeet = -1; // -1 ⇒ rootGrounding only (no true IK on this rig).
752
+ if (character.footIk) {
753
+ try {
754
+ const ik = character.footIk.solveFootPlacement();
755
+ rootY += ik.hipOffset; // hipOffset ≤ 0: lower the hip so the planted foot reaches ground.
756
+ footIkGroundedFeet = ik.groundedFeet;
757
+ } catch {
758
+ // Non-fatal: fall back to rootGrounding for this frame if the solve throws.
759
+ footIkGroundedFeet = -1;
760
+ }
761
+ }
762
+ character.actor.pipeline.resources.scene.root.transform
763
+ .setPosition(blocking.position[0], rootY, blocking.position[2])
764
+ .setRotation(rotation[0], rotation[1], rotation[2], rotation[3])
765
+ .setScale(character.scale, character.scale, character.scale);
766
+ footStatus.set(
767
+ character.id,
768
+ footIkGroundedFeet >= 0
769
+ ? `footIK(grade ${character.rigGrade.grade}, ${footIkGroundedFeet} grounded)`
770
+ : `rootGrounding(grade ${character.rigGrade.grade})`
771
+ );
772
+ shadowState.set(character.id, { position: blocking.position, scale: character.scale });
773
+
774
+ let mouthMorphWeight = -1;
775
+ if (character.mouthMorphIndex >= 0) {
776
+ mouthMorphWeight = mouthOpenness;
777
+ for (const renderable of character.morphRenderables) {
778
+ while (renderable.morphWeights.length <= character.mouthMorphIndex) renderable.morphWeights.push(0);
779
+ renderable.morphWeights[character.mouthMorphIndex] = mouthMorphWeight;
780
+ }
781
+ }
782
+
783
+ return {
784
+ id: character.id,
785
+ clip: embedded ? `${resolvedClip} (embedded:${intent})` : `${intent} (${decision.source})`,
786
+ position: blocking.position,
787
+ sweeping: blocking.sweeping,
788
+ tracksApplied: apply.tracksApplied,
789
+ skinningPalettesUpdated: apply.skinningPalettesUpdated,
790
+ skinningBindingCount: character.actor.snapshot().skinningBindingCount,
791
+ mouthOpenness,
792
+ visemeId: viseme.visemeId,
793
+ mouthMorphWeight,
794
+ mouthMorphIndex: character.mouthMorphIndex,
795
+ primitiveMouthOpen: 0,
796
+ clipDecision: { ...decision, characterId: character.id, time: +time.toFixed(3) }
797
+ };
798
+ });
799
+
800
+ const captionCue = sampleCaption(doc, time);
801
+ const caption = { text: captionCue.text, speakerId: captionCue.speakerId, captionId: captionCue.lineId };
802
+
803
+ const path = cameraPathByShot.get(shot.shotId)!;
804
+ const camera = cameraViewProjection(path, time, aspect);
805
+ const diagnostics = renderer.render(source, { viewProjectionMatrix: camera.viewProjectionMatrix });
806
+
807
+ const skinnedRenderItems = characters.reduce(
808
+ (sum, character) => sum + character.actor.collectRenderItems().filter((item) => item.skinning).length,
809
+ 0
810
+ );
811
+ // B1 debug overlay: one inspectable line per character with clip←intent [source] + body motion.
812
+ const debugOverlay = characterProofs.map((c) => {
813
+ const d = c.clipDecision;
814
+ const foot = footStatus.get(c.id) ?? "rootGrounding";
815
+ const glb = d.reachedGLBRuntime ? "GLB✓" : "GLB✗";
816
+ return `${c.id}: ${d.clipId}←${d.intent} [${d.source}] ${d.bonesTouched}bones ${d.maxRotAmplitudeRad.toFixed(3)}rad transΔ${d.maxTransAmplitude.toFixed(3)} rootΔ${d.rootTranslation.toFixed(3)} ${glb} ${foot}`;
817
+ });
818
+ // B1 — paint the overlay into the proof DOM each frame (only when AURA_DEBUG_OVERLAY=1). The
819
+ // overlay element is always present so a test can assert it exists; it stays hidden otherwise.
820
+ if (debugOverlayEl && debugOverlayEnabled) {
821
+ debugOverlayEl.textContent = `t=${time.toFixed(2)}s shot=${shot.shotId}\n${debugOverlay.join("\n")}`;
822
+ }
823
+ return {
824
+ time,
825
+ drawCalls: diagnostics.drawCalls,
826
+ skinnedRenderItems,
827
+ gardenGlow,
828
+ shot: {
829
+ shotId: shot.shotId,
830
+ presetId: String(shot.presetId),
831
+ episodeTime: (shot.startTime + shot.endTime) / 2,
832
+ cameraPosition: camera.position,
833
+ fov: camera.fov
834
+ },
835
+ caption,
836
+ debugOverlay,
837
+ characters: characterProofs
838
+ };
839
+ };
840
+
841
+ liveWindow.__auraSeek__ = poseAt;
842
+ liveWindow.__AURA_LIVE_ROUTE_READY__ = {
843
+ ready: true,
844
+ backend: renderer.device.kind,
845
+ characters: characters.map((character) => {
846
+ const snapshot = character.actor.snapshot();
847
+ return {
848
+ id: character.id,
849
+ url: character.actor.asset.url,
850
+ clip: pickClip(character.defaultClip, character.availableClips) ?? character.defaultClip,
851
+ clips: snapshot.clips,
852
+ skinningBindingCount: snapshot.skinningBindingCount,
853
+ skinnedRenderItemCount: character.actor.evidence.skinnedRenderItemCount
854
+ };
855
+ })
856
+ };
857
+
858
+ // Live preview loop (skipped during headless seek capture).
859
+ let start = performance.now();
860
+ const loop = (): void => {
861
+ if (!liveWindow.__AURA_LIVE_ROUTE_HEADLESS__) poseAt((performance.now() - start) / 1000);
862
+ window.requestAnimationFrame(loop);
863
+ };
864
+ poseAt(0);
865
+ start = performance.now();
866
+ window.requestAnimationFrame(loop);
867
+ }