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,297 @@
1
+ /**
2
+ * texture-bake.ts — dependency-free procedural BITMAP TEXTURE baker for the authored
3
+ * animation-studio cast (companion to build-characters.ts, Phase M1 character fidelity).
4
+ *
5
+ * It paints a single 1024×1024 RGBA base-colour ATLAS per character and encodes it as a
6
+ * real PNG (node `zlib.deflateSync` — no `sharp`, no native module, so the script stays
7
+ * runnable standalone exactly like its sibling). The atlas is split into per-material
8
+ * REGIONS (body / skin / dark / glow / accent); each region carries genuine shading
9
+ * detail instead of one flat colour:
10
+ *
11
+ * - skin: a vertical tone gradient (lighter forehead → warmer jaw) + soft cheek blush +
12
+ * subtle ambient-occlusion vignette at the region edges (crease shading),
13
+ * - body (clothing): a woven fabric pattern (fine warp/weft modulation) over the body
14
+ * colour with a top-lit vertical shade ramp + edge AO,
15
+ * - accent (belt/collar/hair): brushed directional streaks + edge AO,
16
+ * - dark (boots/pupils/mouth): near-flat dark with a faint specular sheen band,
17
+ * - glow (eyes): a radial hot-centre → rim falloff so eyes read as lit, not flat discs.
18
+ *
19
+ * build-characters.ts assigns each material's vertices PLANAR UVs into that material's
20
+ * region (see assignAtlasUVs there), sets baseColorFactor to white, and references the
21
+ * PNG as an embedded glTF image + texture + baseColorTexture. The result is a genuine
22
+ * UV-mapped albedo map — honestly procedural (not scanned/artist-painted), but textured
23
+ * rather than flat vertex colour.
24
+ */
25
+
26
+ import { deflateSync } from "node:zlib";
27
+
28
+ export type RGBA = [number, number, number, number];
29
+
30
+ export const ATLAS_SIZE = 1024;
31
+
32
+ /** A material's rectangular slot in the shared atlas, in normalised [0,1] UV space. */
33
+ export interface AtlasRegion {
34
+ readonly u0: number;
35
+ readonly v0: number;
36
+ readonly u1: number;
37
+ readonly v1: number;
38
+ }
39
+
40
+ /**
41
+ * Atlas layout: five regions on a 1024² sheet. A 4px gutter is left around each cell so
42
+ * bilinear sampling at the edges never bleeds a neighbouring material in.
43
+ */
44
+ export const ATLAS_REGIONS = {
45
+ body: { u0: 0.0, v0: 0.0, u1: 0.5, v1: 0.5 },
46
+ skin: { u0: 0.5, v0: 0.0, u1: 1.0, v1: 0.5 },
47
+ accent: { u0: 0.0, v0: 0.5, u1: 0.5, v1: 1.0 },
48
+ dark: { u0: 0.5, v0: 0.5, u1: 0.75, v1: 1.0 },
49
+ glow: { u0: 0.75, v0: 0.5, u1: 1.0, v1: 1.0 }
50
+ } as const satisfies Record<string, AtlasRegion>;
51
+
52
+ export type RegionName = keyof typeof ATLAS_REGIONS;
53
+
54
+ /** Inputs the painter needs from a CharacterDesign (colours in linear 0..1). */
55
+ export interface BakeColors {
56
+ readonly bodyColor: RGBA;
57
+ readonly accentColor: RGBA;
58
+ readonly skinColor: RGBA;
59
+ readonly trimColor: RGBA;
60
+ /** Dark material (boots/pupils/mouth) — fixed, matches the build's dark material. */
61
+ readonly darkColor: RGBA;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Tiny value-noise (deterministic) for fabric weave + brushed streaks.
66
+ // ---------------------------------------------------------------------------
67
+ function hash2(x: number, y: number): number {
68
+ const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
69
+ return s - Math.floor(s);
70
+ }
71
+
72
+ function clamp01(v: number): number {
73
+ return v < 0 ? 0 : v > 1 ? 1 : v;
74
+ }
75
+
76
+ function lerp(a: number, b: number, t: number): number {
77
+ return a + (b - a) * t;
78
+ }
79
+
80
+ /**
81
+ * Edge ambient-occlusion factor inside a region: 1.0 in the middle, darkening toward the
82
+ * cell borders so creases/seams read. `fx`,`fy` are 0..1 within the region.
83
+ */
84
+ function edgeAO(fx: number, fy: number): number {
85
+ const dx = Math.min(fx, 1 - fx);
86
+ const dy = Math.min(fy, 1 - fy);
87
+ const d = Math.min(dx, dy);
88
+ // Full strength shadow within ~6% of the edge, easing to none by ~18%.
89
+ return clamp01(0.62 + 0.38 * clamp01((d - 0.04) / 0.14));
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Per-region painters. Each fills its region of the RGBA buffer (row-major,
94
+ // origin top-left, 4 bytes/pixel). `put` writes one pixel.
95
+ // ---------------------------------------------------------------------------
96
+ type Painter = (fx: number, fy: number, base: RGBA) => RGBA;
97
+
98
+ /** Clothing fabric: body colour + woven warp/weft + top-lit shade ramp + edge AO. */
99
+ const paintBody: Painter = (fx, fy, base) => {
100
+ const ao = edgeAO(fx, fy);
101
+ // Top-lit vertical ramp (brighter near the top of the region → simulates key light).
102
+ const shade = lerp(1.12, 0.82, fy);
103
+ // Woven pattern: fine alternating warp (x) and weft (y) threads.
104
+ const warp = 0.5 + 0.5 * Math.sin(fx * Math.PI * 220);
105
+ const weft = 0.5 + 0.5 * Math.sin(fy * Math.PI * 220);
106
+ const weave = 1 + (warp * 0.06 - 0.03) + (weft * 0.06 - 0.03);
107
+ // A touch of broad cloth folds via low-frequency noise.
108
+ const fold = 1 + (hash2(Math.floor(fx * 12), Math.floor(fy * 16)) - 0.5) * 0.07;
109
+ const k = ao * shade * weave * fold;
110
+ return [clamp01(base[0] * k), clamp01(base[1] * k), clamp01(base[2] * k), 1];
111
+ };
112
+
113
+ /** Skin: forehead→jaw tone gradient + cheek blush + edge AO (face/limb shading). */
114
+ const paintSkin: Painter = (fx, fy, base) => {
115
+ const ao = edgeAO(fx, fy);
116
+ // Vertical tone: a little lighter at the top (forehead), warmer/darker toward the jaw.
117
+ const tone = lerp(1.08, 0.9, fy);
118
+ // Cheek blush: two soft warm lobes left/right of centre, mid-height.
119
+ const blushL = Math.exp(-(((fx - 0.34) ** 2 + (fy - 0.55) ** 2) / 0.01));
120
+ const blushR = Math.exp(-(((fx - 0.66) ** 2 + (fy - 0.55) ** 2) / 0.01));
121
+ const blush = (blushL + blushR) * 0.12;
122
+ const r = base[0] * tone * ao + blush * 0.22;
123
+ const g = base[1] * tone * ao + blush * 0.05;
124
+ const b = base[2] * tone * ao + blush * 0.04;
125
+ return [clamp01(r), clamp01(g), clamp01(b), 1];
126
+ };
127
+
128
+ /** Accent (belt/collar/hair): brushed directional streaks + edge AO. */
129
+ const paintAccent: Painter = (fx, fy, base) => {
130
+ const ao = edgeAO(fx, fy);
131
+ const shade = lerp(1.1, 0.85, fy);
132
+ // Horizontal brushed streaks: high-frequency noise stretched along x.
133
+ const streak = 1 + (hash2(Math.floor(fy * 160), Math.floor(fx * 6)) - 0.5) * 0.16;
134
+ const k = ao * shade * streak;
135
+ return [clamp01(base[0] * k), clamp01(base[1] * k), clamp01(base[2] * k), 1];
136
+ };
137
+
138
+ /** Dark (boots/pupils/mouth): near-flat dark with a faint top sheen band. */
139
+ const paintDark: Painter = (fx, fy, base) => {
140
+ const ao = edgeAO(fx, fy);
141
+ // Specular sheen near the top quarter (boot/pupil highlight).
142
+ const sheen = Math.exp(-(((fy - 0.22) ** 2) / 0.006)) * 0.25;
143
+ const k = ao;
144
+ return [clamp01(base[0] * k + sheen), clamp01(base[1] * k + sheen), clamp01(base[2] * k + sheen), 1];
145
+ };
146
+
147
+ /** Glow (eyes): radial hot-centre → rim falloff so the iris reads as lit. */
148
+ const paintGlow: Painter = (fx, fy, base) => {
149
+ const d = Math.hypot(fx - 0.5, fy - 0.5) / 0.7071;
150
+ const hot = clamp01(1.0 - d * d); // bright core, soft rim
151
+ const k = lerp(0.7, 1.25, hot);
152
+ return [clamp01(base[0] * k), clamp01(base[1] * k), clamp01(base[2] * k), 1];
153
+ };
154
+
155
+ const PAINTERS: Record<RegionName, Painter> = {
156
+ body: paintBody,
157
+ skin: paintSkin,
158
+ accent: paintAccent,
159
+ dark: paintDark,
160
+ glow: paintGlow
161
+ };
162
+
163
+ function regionBase(name: RegionName, colors: BakeColors): RGBA {
164
+ switch (name) {
165
+ case "body":
166
+ return colors.bodyColor;
167
+ case "skin":
168
+ return colors.skinColor;
169
+ case "accent":
170
+ return colors.trimColor;
171
+ case "dark":
172
+ return colors.darkColor;
173
+ case "glow":
174
+ return colors.accentColor;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Bake the full RGBA atlas (ATLAS_SIZE²) for one character. Returns the raw pixel buffer.
180
+ * Background (gutters between regions) is a neutral mid grey so any sampling outside a
181
+ * mapped region is innocuous.
182
+ */
183
+ export function bakeAtlasPixels(colors: BakeColors): Uint8Array {
184
+ const N = ATLAS_SIZE;
185
+ const px = new Uint8Array(N * N * 4);
186
+ // Neutral background.
187
+ for (let i = 0; i < px.length; i += 4) {
188
+ px[i] = 60;
189
+ px[i + 1] = 60;
190
+ px[i + 2] = 66;
191
+ px[i + 3] = 255;
192
+ }
193
+ for (const [name, region] of Object.entries(ATLAS_REGIONS) as [RegionName, AtlasRegion][]) {
194
+ const painter = PAINTERS[name];
195
+ const base = regionBase(name, colors);
196
+ const x0 = Math.round(region.u0 * N);
197
+ const y0 = Math.round(region.v0 * N);
198
+ const x1 = Math.round(region.u1 * N);
199
+ const y1 = Math.round(region.v1 * N);
200
+ const w = x1 - x0;
201
+ const h = y1 - y0;
202
+ for (let y = y0; y < y1; y += 1) {
203
+ const fy = (y - y0) / (h - 1);
204
+ for (let x = x0; x < x1; x += 1) {
205
+ const fx = (x - x0) / (w - 1);
206
+ const c = painter(fx, fy, base);
207
+ const o = (y * N + x) * 4;
208
+ px[o] = Math.round(linearToSrgb(c[0]) * 255);
209
+ px[o + 1] = Math.round(linearToSrgb(c[1]) * 255);
210
+ px[o + 2] = Math.round(linearToSrgb(c[2]) * 255);
211
+ px[o + 3] = 255;
212
+ }
213
+ }
214
+ }
215
+ return px;
216
+ }
217
+
218
+ // glTF base-colour textures are sampled in sRGB; the design colours are authored as the
219
+ // same factors used for baseColorFactor (which the engine treats as sRGB-ish display
220
+ // colour), so encode straight through (identity) to keep parity with the prior flat look.
221
+ function linearToSrgb(v: number): number {
222
+ return clamp01(v);
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Minimal PNG encoder (RGBA8, no external deps). Produces a standard PNG whose
227
+ // IHDR width/height the resolver's sniffImageDim reads to report textureMaxDim.
228
+ // ---------------------------------------------------------------------------
229
+ const PNG_SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
230
+
231
+ function crc32(bytes: Uint8Array): number {
232
+ let crc = 0xffffffff;
233
+ for (let i = 0; i < bytes.length; i += 1) {
234
+ crc ^= bytes[i]!;
235
+ for (let b = 0; b < 8; b += 1) {
236
+ crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
237
+ }
238
+ }
239
+ return (crc ^ 0xffffffff) >>> 0;
240
+ }
241
+
242
+ function chunk(type: string, data: Uint8Array): Uint8Array {
243
+ const typeBytes = new TextEncoder().encode(type);
244
+ const out = new Uint8Array(12 + data.length);
245
+ const dv = new DataView(out.buffer);
246
+ dv.setUint32(0, data.length, false);
247
+ out.set(typeBytes, 4);
248
+ out.set(data, 8);
249
+ const crcInput = new Uint8Array(typeBytes.length + data.length);
250
+ crcInput.set(typeBytes, 0);
251
+ crcInput.set(data, typeBytes.length);
252
+ dv.setUint32(8 + data.length, crc32(crcInput), false);
253
+ return out;
254
+ }
255
+
256
+ /** Encode an RGBA8 pixel buffer (row-major, top-left origin) as PNG bytes. */
257
+ export function encodePNG(pixels: Uint8Array, width: number, height: number): Uint8Array {
258
+ // IHDR.
259
+ const ihdr = new Uint8Array(13);
260
+ const dv = new DataView(ihdr.buffer);
261
+ dv.setUint32(0, width, false);
262
+ dv.setUint32(4, height, false);
263
+ ihdr[8] = 8; // bit depth
264
+ ihdr[9] = 6; // colour type 6 = RGBA
265
+ ihdr[10] = 0; // compression
266
+ ihdr[11] = 0; // filter
267
+ ihdr[12] = 0; // interlace
268
+
269
+ // Raw scanlines with a per-row filter byte (0 = none).
270
+ const stride = width * 4;
271
+ const raw = new Uint8Array((stride + 1) * height);
272
+ for (let y = 0; y < height; y += 1) {
273
+ raw[y * (stride + 1)] = 0;
274
+ raw.set(pixels.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1);
275
+ }
276
+ const idatData = deflateSync(raw, { level: 9 });
277
+
278
+ const sig = PNG_SIGNATURE;
279
+ const ihdrChunk = chunk("IHDR", ihdr);
280
+ const idatChunk = chunk("IDAT", new Uint8Array(idatData.buffer, idatData.byteOffset, idatData.byteLength));
281
+ const iendChunk = chunk("IEND", new Uint8Array(0));
282
+
283
+ const total = sig.length + ihdrChunk.length + idatChunk.length + iendChunk.length;
284
+ const png = new Uint8Array(total);
285
+ let o = 0;
286
+ png.set(sig, o); o += sig.length;
287
+ png.set(ihdrChunk, o); o += ihdrChunk.length;
288
+ png.set(idatChunk, o); o += idatChunk.length;
289
+ png.set(iendChunk, o);
290
+ return png;
291
+ }
292
+
293
+ /** Bake + PNG-encode in one call. */
294
+ export function bakeAtlasPNG(colors: BakeColors): { png: Uint8Array; size: number } {
295
+ const px = bakeAtlasPixels(colors);
296
+ return { png: encodePNG(px, ATLAS_SIZE, ATLAS_SIZE), size: ATLAS_SIZE };
297
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * verify-extracted-overlay-cli.ts — HONEST visual proof that the reconciled EXTRACTED clips render
3
+ * UPRIGHT (M6 / B3).
4
+ *
5
+ * Unlike `skeleton-overlay-cli.ts` / `motion-evidence-cli.ts` (which visualize the PROCEDURAL
6
+ * standard library), this CLI loads the on-disk `public/clip-library/*.json` EXTRACTED clips via
7
+ * `loadExtractedClipLibrary`, drives the canonical rest skeleton with them through the SAME FK
8
+ * projection `buildSkeletonStrip` uses (which is equivalent to retargeting onto an identity-rest
9
+ * standard rig — `Rt = Rt0·Rs0⁻¹·Ra` collapses to applying the clip rotation directly), and:
10
+ *
11
+ * 1. writes a first/mid/final skeleton-overlay PNG strip per extracted clip, and
12
+ * 2. measures UPRIGHTNESS from the projected world bone positions — head above hips above feet,
13
+ * head near the top of the figure, feet near the ground — plus per-frame motion.
14
+ *
15
+ * It reports, per clip: worstFloor (gate metric), upright?, head/hip/foot heights, motion present.
16
+ * This is the proof the PRD's M6 acceptance demands: extracted mocap is only usable if it renders
17
+ * UPRIGHT, not merely if it passes the numeric gate.
18
+ *
19
+ * Usage: tsx scripts/verify-extracted-overlay-cli.ts [--out dist/extracted-overlays]
20
+ */
21
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
22
+ import { dirname, resolve, join } from "node:path";
23
+ import { createRequire } from "node:module";
24
+ import { fileURLToPath } from "node:url";
25
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
26
+ // @ts-ignore — resolved at runtime to the freshly-built monorepo dist (has co-located .d.ts).
27
+ import type { AnimationClipRegistry } from "@aura3d/animation";
28
+ import { buildSkeletonStrip } from "./skeleton-overlay.js";
29
+ import { loadExtractedClipLibrary, worstRestFloor } from "./build-clip-library.js";
30
+
31
+ const __dirname = dirname(fileURLToPath(import.meta.url));
32
+ const CLIP_DIR = resolve(__dirname, "..", "public", "clip-library");
33
+ const INTENTS = ["idle", "talk", "gesture", "point", "nod", "walk", "run", "react"] as const;
34
+
35
+ // --- sharp loader (same standalone resolution as the sibling CLIs) -------------------------------
36
+ interface SharpModule {
37
+ (input: Buffer | Uint8Array, options?: { raw: { width: number; height: number; channels: number } }): {
38
+ png(): { toBuffer(): Promise<Buffer> };
39
+ };
40
+ }
41
+ function loadSharp(): SharpModule | null {
42
+ try {
43
+ return createRequire(import.meta.url)("sharp") as SharpModule;
44
+ } catch {
45
+ const roots = [process.cwd(), __dirname];
46
+ for (const start of roots) {
47
+ let dir = start;
48
+ for (let up = 0; up < 8; up += 1) {
49
+ const storePkg = resolve(dir, "node_modules/.pnpm/sharp@0.33.5/node_modules/sharp/package.json");
50
+ if (existsSync(storePkg)) return createRequire(storePkg)("sharp") as SharpModule;
51
+ const parent = dirname(dir);
52
+ if (parent === dir) break;
53
+ dir = parent;
54
+ }
55
+ }
56
+ return null; // PNG is optional evidence; numeric uprightness is the load-bearing proof.
57
+ }
58
+ }
59
+ async function rawRgbaToPng(pixels: Uint8Array, width: number, height: number): Promise<Uint8Array | null> {
60
+ const sharp = loadSharp();
61
+ if (!sharp) return null;
62
+ const buffer = await sharp(Buffer.from(pixels.buffer, pixels.byteOffset, pixels.byteLength), {
63
+ raw: { width, height, channels: 4 }
64
+ })
65
+ .png()
66
+ .toBuffer();
67
+ return new Uint8Array(buffer);
68
+ }
69
+
70
+ function arg(name: string, fallback: string): string {
71
+ const i = process.argv.indexOf(`--${name}`);
72
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
73
+ }
74
+
75
+ // --- FK uprightness measurement (mirrors skeleton-overlay's REST_SKELETON + FK) -------------------
76
+ type Vec3 = readonly [number, number, number];
77
+ type Quat = readonly [number, number, number, number];
78
+ const REST: Readonly<Record<string, { parent: string | null; offset: Vec3 }>> = {
79
+ hips: { parent: null, offset: [0, 0.95, 0] },
80
+ spine: { parent: "hips", offset: [0, 0.12, 0] },
81
+ chest: { parent: "spine", offset: [0, 0.14, 0] },
82
+ upperChest: { parent: "chest", offset: [0, 0.1, 0] },
83
+ neck: { parent: "upperChest", offset: [0, 0.08, 0] },
84
+ head: { parent: "neck", offset: [0, 0.12, 0] },
85
+ leftShoulder: { parent: "upperChest", offset: [0.06, 0.05, 0] },
86
+ leftUpperArm: { parent: "leftShoulder", offset: [0.14, 0, 0] },
87
+ leftLowerArm: { parent: "leftUpperArm", offset: [0.26, 0, 0] },
88
+ leftHand: { parent: "leftLowerArm", offset: [0.24, 0, 0] },
89
+ rightShoulder: { parent: "upperChest", offset: [-0.06, 0.05, 0] },
90
+ rightUpperArm: { parent: "rightShoulder", offset: [-0.14, 0, 0] },
91
+ rightLowerArm: { parent: "rightUpperArm", offset: [-0.26, 0, 0] },
92
+ rightHand: { parent: "rightLowerArm", offset: [-0.24, 0, 0] },
93
+ leftUpperLeg: { parent: "hips", offset: [0.09, -0.04, 0] },
94
+ leftLowerLeg: { parent: "leftUpperLeg", offset: [0, -0.42, 0] },
95
+ leftFoot: { parent: "leftLowerLeg", offset: [0, -0.42, 0.04] },
96
+ rightUpperLeg: { parent: "hips", offset: [-0.09, -0.04, 0] },
97
+ rightLowerLeg: { parent: "rightUpperLeg", offset: [0, -0.42, 0] },
98
+ rightFoot: { parent: "rightLowerLeg", offset: [0, -0.42, 0.04] }
99
+ };
100
+ const I: Quat = [0, 0, 0, 1];
101
+ function qMulV(q: Quat, v: Vec3): Vec3 {
102
+ const [x, y, z, w] = q;
103
+ const tx = 2 * (y * v[2] - z * v[1]);
104
+ const ty = 2 * (z * v[0] - x * v[2]);
105
+ const tz = 2 * (x * v[1] - y * v[0]);
106
+ return [v[0] + w * tx + (y * tz - z * ty), v[1] + w * ty + (z * tx - x * tz), v[2] + w * tz + (x * ty - y * tx)];
107
+ }
108
+ function qMul(a: Quat, b: Quat): Quat {
109
+ return [
110
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
111
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
112
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
113
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]
114
+ ];
115
+ }
116
+ interface SampledClip {
117
+ duration: number;
118
+ loop: boolean;
119
+ tracks: readonly { target: string; sample(t: number): readonly number[] }[];
120
+ }
121
+ function solveWorld(clip: SampledClip, t: number): Record<string, Vec3> {
122
+ const dur = clip.duration > 0 ? clip.duration : 1;
123
+ const local = clip.loop ? t % dur : Math.min(t, dur);
124
+ const rot: Record<string, Quat> = {};
125
+ let hips: Vec3 = [0, 0, 0];
126
+ for (const tr of clip.tracks) {
127
+ const d = tr.target.lastIndexOf(".");
128
+ if (d < 0) continue;
129
+ const bone = tr.target.slice(0, d);
130
+ const path = tr.target.slice(d + 1);
131
+ const v = tr.sample(local);
132
+ if (path === "rotation" && v.length >= 4) rot[bone] = [v[0]!, v[1]!, v[2]!, v[3]!];
133
+ else if (path === "translation" && bone === "hips" && v.length >= 3) hips = [v[0]!, v[1]!, v[2]!];
134
+ }
135
+ const wp: Record<string, Vec3> = {};
136
+ const wr: Record<string, Quat> = {};
137
+ for (const [bone, r] of Object.entries(REST)) {
138
+ const lr = rot[bone] ?? I;
139
+ if (r.parent === null) {
140
+ wr[bone] = lr;
141
+ wp[bone] = [r.offset[0] + hips[0], r.offset[1] + hips[1], r.offset[2] + hips[2]];
142
+ } else {
143
+ const pr = wr[r.parent] ?? I;
144
+ const pp = wp[r.parent] ?? [0, 0, 0];
145
+ const ro = qMulV(pr, r.offset);
146
+ wp[bone] = [pp[0] + ro[0], pp[1] + ro[1], pp[2] + ro[2]];
147
+ wr[bone] = qMul(pr, lr);
148
+ }
149
+ }
150
+ return wp;
151
+ }
152
+
153
+ interface UprightReport {
154
+ upright: boolean;
155
+ minHeadY: number;
156
+ maxFootY: number;
157
+ headAboveHipsPct: number;
158
+ hipsAboveFeetPct: number;
159
+ headTravel: number;
160
+ }
161
+ function measureUpright(clip: SampledClip): UprightReport {
162
+ const dur = clip.duration > 0 ? clip.duration : 1;
163
+ const N = 24;
164
+ let minHeadY = Infinity;
165
+ let maxFootY = -Infinity;
166
+ let headAbove = 0;
167
+ let hipsAbove = 0;
168
+ let samples = 0;
169
+ let headTravel = 0;
170
+ const head0 = solveWorld(clip, 0).head!;
171
+ for (let i = 0; i <= N; i += 1) {
172
+ const w = solveWorld(clip, (dur * i) / N);
173
+ const head = w.head!;
174
+ const hips = w.hips!;
175
+ const footY = Math.min(w.leftFoot?.[1] ?? 99, w.rightFoot?.[1] ?? 99);
176
+ minHeadY = Math.min(minHeadY, head[1]);
177
+ maxFootY = Math.max(maxFootY, footY);
178
+ headTravel = Math.max(headTravel, Math.hypot(head[0] - head0[0], head[1] - head0[1], head[2] - head0[2]));
179
+ if (head[1] > hips[1]) headAbove += 1;
180
+ if (hips[1] > footY) hipsAbove += 1;
181
+ samples += 1;
182
+ }
183
+ const headAboveHipsPct = headAbove / samples;
184
+ const hipsAboveFeetPct = hipsAbove / samples;
185
+ return {
186
+ upright: headAboveHipsPct > 0.9 && hipsAboveFeetPct > 0.9 && minHeadY > 1.0,
187
+ minHeadY,
188
+ maxFootY,
189
+ headAboveHipsPct,
190
+ hipsAboveFeetPct,
191
+ headTravel
192
+ };
193
+ }
194
+
195
+ function round(n: number): number {
196
+ return Math.round(n * 1000) / 1000;
197
+ }
198
+
199
+ async function main(): Promise<void> {
200
+ const outRoot = resolve(arg("out", "dist/extracted-overlays"));
201
+ mkdirSync(outRoot, { recursive: true });
202
+ const registry = loadExtractedClipLibrary(CLIP_DIR) as unknown as AnimationClipRegistry;
203
+
204
+ const rows: Record<string, unknown> = {};
205
+ let allUpright = true;
206
+ let allAccepted = true;
207
+ for (const intent of INTENTS) {
208
+ const clip = (registry.get?.(intent) ?? registry.require?.(intent)) as SampledClip | undefined;
209
+ const extracted = Boolean((clip as { metadata?: { extracted?: boolean } } | undefined)?.metadata?.extracted);
210
+ if (!clip) continue;
211
+ const upright = measureUpright(clip);
212
+ const floor = worstRestFloor(
213
+ clip.tracks.map((tr) => ({
214
+ target: tr.target,
215
+ keyframes: (tr as unknown as { keyframes: { value: number[] }[] }).keyframes
216
+ }))
217
+ );
218
+ const accepted = floor.value < 0.15;
219
+ allUpright &&= upright.upright;
220
+ if (extracted) allAccepted &&= accepted;
221
+
222
+ const strip = buildSkeletonStrip({ intent, registry });
223
+ const png = await rawRgbaToPng(strip.rgba, strip.width, strip.height);
224
+ let pngPath: string | null = null;
225
+ if (png) {
226
+ pngPath = join(outRoot, `${intent}.png`);
227
+ writeFileSync(pngPath, png);
228
+ }
229
+ rows[intent] = {
230
+ extracted,
231
+ worstFloor: round(floor.value),
232
+ worstFloorBone: floor.bone,
233
+ gate: accepted ? "ACCEPT" : "REJECT",
234
+ upright: upright.upright,
235
+ minHeadY: round(upright.minHeadY),
236
+ maxFootY: round(upright.maxFootY),
237
+ headAboveHipsPct: round(upright.headAboveHipsPct),
238
+ hipsAboveFeetPct: round(upright.hipsAboveFeetPct),
239
+ headTravel: round(upright.headTravel),
240
+ firstFinalDiff: strip.firstFinalMaxDiff,
241
+ png: pngPath
242
+ };
243
+ // eslint-disable-next-line no-console
244
+ console.error(
245
+ ` [${intent.padEnd(8)}] ${extracted ? "EXTRACTED" : "procedural"} worstFloor=${round(floor.value)
246
+ .toString()
247
+ .padEnd(5)} ${accepted ? "ACCEPT" : "REJECT"} ${upright.upright ? "UPRIGHT" : "CONTORTED"} ` +
248
+ `head>=${round(upright.minHeadY)}m foot<=${round(upright.maxFootY)}m travel=${round(upright.headTravel)}m`
249
+ );
250
+ }
251
+
252
+ writeFileSync(
253
+ join(outRoot, "summary.json"),
254
+ JSON.stringify({ generated: "M6 reconciled-extracted-clip uprightness proof", allUpright, allAccepted, clips: rows }, null, 2)
255
+ );
256
+ // eslint-disable-next-line no-console
257
+ console.log(JSON.stringify({ ok: true, outRoot, allUpright, allAccepted, clips: rows }));
258
+ }
259
+
260
+ main().catch((err) => {
261
+ // eslint-disable-next-line no-console
262
+ console.error(JSON.stringify({ ok: false, error: (err as Error).message, stack: (err as Error).stack }));
263
+ process.exitCode = 1;
264
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * animation-episode-validator.ts — the scene-coherence gate.
3
+ *
4
+ * Proves a generated/edited EpisodeDocument is VALID + COHERENT before it is rendered or
5
+ * shipped: characters + props stay on the walkable set, cameras frame near the action,
6
+ * every scheduled clip exists on the asset, every shot is covered, plus the structural
7
+ * checks from `validateEpisodeDocumentShape`.
8
+ *
9
+ * HONEST CAVEAT (design doc): this proves a scene is *valid/coherent*, NOT that it is
10
+ * *well-directed or watchable*. "Good" needs the human-review rubric — automation can
11
+ * only keep a scene from being broken, not make it good.
12
+ */
13
+
14
+ import { validateEpisodeDocumentShape, type EpisodeDocument, type Vec3 } from "./episode-document";
15
+
16
+ export interface EpisodeDocumentValidation {
17
+ readonly ok: boolean;
18
+ readonly errors: readonly string[];
19
+ readonly warnings: readonly string[];
20
+ }
21
+
22
+ export interface ValidateOptions {
23
+ /** GLB clip lists per character, so scheduled clips can be checked to exist. */
24
+ readonly availableClipsByCharacter?: Record<string, readonly string[]>;
25
+ }
26
+
27
+ export function validateEpisodeDocument(doc: EpisodeDocument, opts: ValidateOptions = {}): EpisodeDocumentValidation {
28
+ const shape = validateEpisodeDocumentShape(doc);
29
+ const errors: string[] = [...shape.errors];
30
+ const warnings: string[] = [];
31
+
32
+ // 0. An empty cast is a valid skeleton (fresh prompt-built scene) — warn so the agent knows
33
+ // to resolve real characters with `animation-scene cast add`, but don't break the scene.
34
+ if (doc.assets.characters.length === 0) {
35
+ warnings.push("no characters yet — add real cast with `animation-scene cast add --id <id> --query \"<look>\"`");
36
+ }
37
+
38
+ const bounds = doc.walkableBounds;
39
+ const margin = 0.6;
40
+ const inBounds = (p: Vec3): boolean =>
41
+ !bounds ||
42
+ (p[0] >= bounds.min[0] - margin &&
43
+ p[0] <= bounds.max[0] + margin &&
44
+ p[2] >= bounds.min[2] - margin &&
45
+ p[2] <= bounds.max[2] + margin);
46
+
47
+ // 1. Blocking stays on the walkable set (hard error — a character off the set is broken).
48
+ for (const b of doc.blocking) {
49
+ for (const s of b.shots) {
50
+ for (const w of s.waypoints) {
51
+ if (!inBounds(w.position)) {
52
+ errors.push(`${b.characterId} blocking in ${s.shotId} leaves the walkable set at [${w.position.map((n) => n.toFixed(2)).join(",")}]`);
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ // 2. Props on the walkable set (warning — off-set props are ugly, not fatal).
59
+ for (const d of doc.setDressing) {
60
+ if (!inBounds(d.position)) warnings.push(`prop ${d.propId} placed off the walkable set`);
61
+ }
62
+
63
+ // 3. Camera frames near the action.
64
+ for (const shot of doc.shots) {
65
+ if (!inBounds([shot.cameraSubject[0], 0, shot.cameraSubject[2]])) {
66
+ warnings.push(`shot ${shot.shotId} camera subject is far from the walkable set`);
67
+ }
68
+ }
69
+
70
+ // 4. Every scheduled clip exists on the asset (fuzzy match; warn + note fallback).
71
+ if (opts.availableClipsByCharacter) {
72
+ for (const b of doc.blocking) {
73
+ const clips = opts.availableClipsByCharacter[b.characterId];
74
+ if (!clips || clips.length === 0) continue;
75
+ for (const s of b.shots) {
76
+ const want = s.clip.toLowerCase();
77
+ const has = clips.some((c) => {
78
+ const lc = c.toLowerCase();
79
+ return lc === want || lc.includes(want) || want.includes(lc);
80
+ });
81
+ if (!has) warnings.push(`${b.characterId} clip "${s.clip}" in ${s.shotId} not found; falls back to "${clips[0]}"`);
82
+ }
83
+ }
84
+ }
85
+
86
+ // 5. Every character has blocking for every shot.
87
+ for (const c of doc.assets.characters) {
88
+ const cb = doc.blocking.find((b) => b.characterId === c.id);
89
+ for (const shot of doc.shots) {
90
+ if (!cb?.shots.find((s) => s.shotId === shot.shotId)) {
91
+ warnings.push(`${c.id} has no blocking for ${shot.shotId} (will hold a fallback mark)`);
92
+ }
93
+ }
94
+ }
95
+
96
+ return { ok: errors.length === 0, errors, warnings };
97
+ }