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,572 @@
1
+ /**
2
+ * hifi-cast-validate.ts — RENDER-VALIDATION PRE-FILTER for candidate hi-fi character GLBs.
3
+ *
4
+ * WHY THIS EXISTS (the lesson that bit us before): a downloaded character can pass a shallow
5
+ * "is it rigged / does it have a humanoid mapping" proxy check and STILL render broken — legs
6
+ * collapsed, arms contorted, the mesh folded into a knot — because the rig's bone orientation or
7
+ * proportions don't survive retargeting. The only true acceptance gate is a GPU render + a human
8
+ * looking at the pixels. This script does NOT replace that. It is a FAST, BROWSER-FREE,
9
+ * GPU-FREE PRE-FILTER that rejects obviously-broken rigs (the ones that would waste a render) by:
10
+ *
11
+ * 1. Loading + grading the GLB (inferHumanoidRig on its bone names → gradeRig). Reports the
12
+ * body-acting grade, capabilities (legs/knees/ankles/feet), and mappedBoneCount.
13
+ * 2. Building the STANDARD_LIBRARY_RIG → inferred-rig retarget map and reporting mapping
14
+ * coverage (how many standard bones land on this rig). Low coverage (<60%) ⇒ flag
15
+ * "retargeting will be poor — body acting won't transfer".
16
+ * 3. Sampling a procedural TALK + WALK pose from the shared standard clip library, retargeting
17
+ * it onto this rig, running forward kinematics on a canonical rest skeleton, and CHECKING the
18
+ * resulting per-bone world positions for the EXACT failure signatures we saw:
19
+ * • legs collapsed (a foot ends up ABOVE the hips),
20
+ * • arms sagging / hands near the floor (a hand far below where a hand should sit),
21
+ * • any bone NaN / exploded (non-finite or absurd magnitude).
22
+ * Reports PASS (upright, legs below hips, hands at the sides) or FAIL (which signature).
23
+ * 4. Writing a per-GLB JSON verdict to tests/reports/animation-studio/hifi-cast-validation.json.
24
+ *
25
+ * ⚠️ HONESTY: the FK check is a PROXY, not a render. It drives a *canonical* rest skeleton with the
26
+ * retargeted local rotations — it does NOT skin the actual mesh, does NOT use the GLB's real
27
+ * bind pose / bone lengths, and cannot see texture/weight/mesh problems. A rig can pass this FK
28
+ * pre-filter and still render wrong. FINAL ACCEPTANCE ALWAYS REQUIRES A REAL GPU RENDER +
29
+ * VISUAL REVIEW. The script prints this warning loudly.
30
+ *
31
+ * Does NOT touch product runtime (scene-player / animation-performance / render-live) and does NOT
32
+ * render (no Playwright, no port). Read-only over the GLBs.
33
+ *
34
+ * Usage:
35
+ * tsx scripts/hifi-cast-validate.ts # validate every GLB under public/hifi-cast/
36
+ * # + the known richer aura-assets rigs
37
+ * tsx scripts/hifi-cast-validate.ts <glb> [<glb>…] # validate explicit GLB paths
38
+ */
39
+
40
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
41
+ import { dirname, relative, resolve } from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+
44
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
45
+ // @ts-ignore — resolved at runtime to the freshly-built monorepo dist (has co-located .d.ts).
46
+ import {
47
+ createHumanoidRetargetingMap,
48
+ createStandardHumanoidClipDefinitions,
49
+ gradeRig,
50
+ HUMANOID_BONES,
51
+ inferHumanoidRig,
52
+ retargetHumanoidPose,
53
+ STANDARD_LIBRARY_RIG,
54
+ type AnimationPose,
55
+ type AnimationPoseTransform,
56
+ type HumanoidBoneName,
57
+ type HumanoidRigDefinition,
58
+ type RigGrade
59
+ } from "@aura3d/animation";
60
+
61
+ const __dirname = dirname(fileURLToPath(import.meta.url));
62
+ const TEMPLATE_DIR = resolve(__dirname, "..");
63
+ const HIFI_DIR = resolve(TEMPLATE_DIR, "public", "hifi-cast");
64
+ const AURA_ASSETS_DIR = resolve(TEMPLATE_DIR, "public", "aura-assets");
65
+ const REPORT_DIR = resolve(TEMPLATE_DIR, "tests", "reports", "animation-studio");
66
+ const REPORT_FILE = resolve(REPORT_DIR, "hifi-cast-validation.json");
67
+
68
+ /** Known richer-rig comparison assets pulled in alongside whatever lands in hifi-cast/. */
69
+ const COMPARISON_GLBS = ["rusty.catalog.glb", "luma.catalog.glb"];
70
+
71
+ /** Coverage below this means the standard performance vocabulary won't land — body acting fails. */
72
+ const POOR_COVERAGE_THRESHOLD = 0.6;
73
+
74
+ type Vec3 = readonly [number, number, number];
75
+ type Quat = readonly [number, number, number, number];
76
+
77
+ // ------------------------------------------------------------------------------------------------
78
+ // GLB parsing — read the skeleton joint node NAMES (what inference needs).
79
+ // ------------------------------------------------------------------------------------------------
80
+
81
+ interface GltfJson {
82
+ nodes?: { name?: string; mesh?: number; children?: number[] }[];
83
+ skins?: { joints?: number[] }[];
84
+ animations?: { name?: string }[];
85
+ meshes?: unknown[];
86
+ }
87
+
88
+ /** Parse just the JSON chunk of a .glb. Returns null when it isn't a binary glTF. */
89
+ function parseGlbJson(buf: Buffer): GltfJson | null {
90
+ if (buf.length < 20 || buf.readUInt32LE(0) !== 0x46546c67) return null; // "glTF"
91
+ const jsonLen = buf.readUInt32LE(12);
92
+ try {
93
+ return JSON.parse(buf.slice(20, 20 + jsonLen).toString("utf8")) as GltfJson;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /** Named skeleton joint nodes (falls back to all named nodes when there is no skin). */
100
+ function jointNodeNames(json: GltfJson): string[] {
101
+ const nodes = json.nodes ?? [];
102
+ const jointIdx = json.skins?.[0]?.joints ?? nodes.map((_, i) => i);
103
+ return jointIdx
104
+ .map((ni) => nodes[ni]?.name)
105
+ .filter((n): n is string => typeof n === "string" && n.length > 0);
106
+ }
107
+
108
+ // ------------------------------------------------------------------------------------------------
109
+ // Canonical rest skeleton + FK (mirrors scripts/skeleton-overlay.ts — a neutral ~1.75m humanoid in
110
+ // metres, hips at ~0.95m). The FK proxy drives THIS canonical skeleton with the retargeted local
111
+ // rotations; it deliberately does not use the GLB's own bind pose (see the honesty note up top).
112
+ // ------------------------------------------------------------------------------------------------
113
+
114
+ interface RestBone {
115
+ readonly parent: HumanoidBoneName | null;
116
+ readonly offset: Vec3;
117
+ }
118
+
119
+ const REST_SKELETON: Readonly<Record<HumanoidBoneName, RestBone>> = {
120
+ hips: { parent: null, offset: [0, 0.95, 0] },
121
+ spine: { parent: "hips", offset: [0, 0.12, 0] },
122
+ chest: { parent: "spine", offset: [0, 0.14, 0] },
123
+ upperChest: { parent: "chest", offset: [0, 0.1, 0] },
124
+ neck: { parent: "upperChest", offset: [0, 0.08, 0] },
125
+ head: { parent: "neck", offset: [0, 0.12, 0] },
126
+ leftShoulder: { parent: "upperChest", offset: [0.06, 0.05, 0] },
127
+ leftUpperArm: { parent: "leftShoulder", offset: [0.14, 0, 0] },
128
+ leftLowerArm: { parent: "leftUpperArm", offset: [0.26, 0, 0] },
129
+ leftHand: { parent: "leftLowerArm", offset: [0.24, 0, 0] },
130
+ rightShoulder: { parent: "upperChest", offset: [-0.06, 0.05, 0] },
131
+ rightUpperArm: { parent: "rightShoulder", offset: [-0.14, 0, 0] },
132
+ rightLowerArm: { parent: "rightUpperArm", offset: [-0.26, 0, 0] },
133
+ rightHand: { parent: "rightLowerArm", offset: [-0.24, 0, 0] },
134
+ leftUpperLeg: { parent: "hips", offset: [0.09, -0.04, 0] },
135
+ leftLowerLeg: { parent: "leftUpperLeg", offset: [0, -0.42, 0] },
136
+ leftFoot: { parent: "leftLowerLeg", offset: [0, -0.42, 0.04] },
137
+ leftToes: { parent: "leftFoot", offset: [0, -0.06, 0.12] },
138
+ rightUpperLeg: { parent: "hips", offset: [-0.09, -0.04, 0] },
139
+ rightLowerLeg: { parent: "rightUpperLeg", offset: [0, -0.42, 0] },
140
+ rightFoot: { parent: "rightLowerLeg", offset: [0, -0.42, 0.04] },
141
+ rightToes: { parent: "rightFoot", offset: [0, -0.06, 0.12] }
142
+ };
143
+
144
+ const IDENTITY: Quat = [0, 0, 0, 1];
145
+
146
+ function qMulV(q: Quat, v: Vec3): Vec3 {
147
+ const [x, y, z, w] = q;
148
+ const tx = 2 * (y * v[2] - z * v[1]);
149
+ const ty = 2 * (z * v[0] - x * v[2]);
150
+ const tz = 2 * (x * v[1] - y * v[0]);
151
+ return [
152
+ v[0] + w * tx + (y * tz - z * ty),
153
+ v[1] + w * ty + (z * tx - x * tz),
154
+ v[2] + w * tz + (x * ty - y * tx)
155
+ ];
156
+ }
157
+
158
+ function qMul(a: Quat, b: Quat): Quat {
159
+ return [
160
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
161
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
162
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
163
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]
164
+ ];
165
+ }
166
+
167
+ /** Forward kinematics over the canonical rest skeleton given per-bone local rotations + hips offset. */
168
+ export function solveWorld(rotations: Partial<Record<HumanoidBoneName, Quat>>, hipsOffset: Vec3): Record<HumanoidBoneName, Vec3> {
169
+ const worldPos = {} as Record<HumanoidBoneName, Vec3>;
170
+ const worldRot = {} as Record<HumanoidBoneName, Quat>;
171
+ // REST_SKELETON is declared parent-before-child, so a single pass resolves the chain.
172
+ for (const bone of Object.keys(REST_SKELETON) as HumanoidBoneName[]) {
173
+ const rest = REST_SKELETON[bone];
174
+ const localRot = rotations[bone] ?? IDENTITY;
175
+ if (rest.parent === null) {
176
+ worldRot[bone] = localRot;
177
+ worldPos[bone] = [rest.offset[0] + hipsOffset[0], rest.offset[1] + hipsOffset[1], rest.offset[2] + hipsOffset[2]];
178
+ } else {
179
+ const pRot = worldRot[rest.parent] ?? IDENTITY;
180
+ const pPos = worldPos[rest.parent] ?? ([0, 0, 0] as Vec3);
181
+ const rotated = qMulV(pRot, rest.offset);
182
+ worldPos[bone] = [pPos[0] + rotated[0], pPos[1] + rotated[1], pPos[2] + rotated[2]];
183
+ worldRot[bone] = qMul(pRot, localRot);
184
+ }
185
+ }
186
+ return worldPos;
187
+ }
188
+
189
+ // ------------------------------------------------------------------------------------------------
190
+ // Pose sampling + retargeting.
191
+ // ------------------------------------------------------------------------------------------------
192
+
193
+ const STANDARD_CLIPS = createStandardHumanoidClipDefinitions();
194
+
195
+ interface ClipLike {
196
+ readonly duration: number;
197
+ readonly loop: boolean;
198
+ readonly tracks: readonly { readonly target: string; readonly valueType: string; sample(t: number): unknown }[];
199
+ }
200
+
201
+ /**
202
+ * Sample a standard library clip at `t` into an {@link AnimationPose} keyed by the canonical
203
+ * HUMANOID_BONES (the standard clips' tracks target `<bone>.rotation` / `hips.translation`). This
204
+ * is the SOURCE pose handed to {@link retargetHumanoidPose}.
205
+ */
206
+ function sampleStandardPose(clipId: string, t: number): AnimationPose {
207
+ const clip = STANDARD_CLIPS.find((c) => c.id === clipId) as ClipLike | undefined;
208
+ if (!clip) throw new Error(`standard clip not found: ${clipId}`);
209
+ const duration = clip.duration > 0 ? clip.duration : 1;
210
+ const local = clip.loop ? t % duration : Math.min(t, duration);
211
+ const bones: Record<string, { position?: { x: number; y: number; z: number }; rotation?: { x: number; y: number; z: number; w: number } }> = {};
212
+ for (const track of clip.tracks) {
213
+ const dot = track.target.lastIndexOf(".");
214
+ if (dot < 0) continue;
215
+ const bone = track.target.slice(0, dot);
216
+ const path = track.target.slice(dot + 1);
217
+ const v = track.sample(local) as number[];
218
+ bones[bone] ??= {};
219
+ if (path === "rotation" && Array.isArray(v) && v.length >= 4) {
220
+ bones[bone].rotation = { x: v[0]!, y: v[1]!, z: v[2]!, w: v[3]! };
221
+ } else if (path === "translation" && Array.isArray(v) && v.length >= 3) {
222
+ bones[bone].position = { x: v[0]!, y: v[1]!, z: v[2]! };
223
+ }
224
+ }
225
+ return { bones } as AnimationPose;
226
+ }
227
+
228
+ /** Quat object → tuple. */
229
+ function quatTuple(q: { x: number; y: number; z: number; w: number } | undefined): Quat {
230
+ return q ? [q.x, q.y, q.z, q.w] : IDENTITY;
231
+ }
232
+
233
+ /**
234
+ * Retarget a standard-library source pose onto the inferred rig, then collapse the result back to
235
+ * the CANONICAL bone names so the FK rest skeleton can be driven. `retargetHumanoidPose` keys the
236
+ * output by the TARGET rig's node names; we ask for `includeSemanticBoneNames` so the canonical
237
+ * bone keys are also present, and read those directly.
238
+ */
239
+ function retargetToCanonical(
240
+ sourcePose: AnimationPose,
241
+ map: ReturnType<typeof createHumanoidRetargetingMap>
242
+ ): { rotations: Partial<Record<HumanoidBoneName, Quat>>; hipsOffset: Vec3 } {
243
+ const retargeted = retargetHumanoidPose(sourcePose, map, { includeSemanticBoneNames: true });
244
+ const rotations: Partial<Record<HumanoidBoneName, Quat>> = {};
245
+ let hipsOffset: Vec3 = [0, 0, 0];
246
+ for (const bone of HUMANOID_BONES) {
247
+ const t: AnimationPoseTransform | undefined = retargeted.bones[bone];
248
+ if (!t) continue; // bone didn't map onto this rig — leave it at rest (a sparse rig stays sparse)
249
+ if (t.rotation) rotations[bone] = quatTuple(t.rotation);
250
+ if (bone === "hips" && t.position) hipsOffset = [t.position.x, t.position.y, t.position.z];
251
+ }
252
+ return { rotations, hipsOffset };
253
+ }
254
+
255
+ // ------------------------------------------------------------------------------------------------
256
+ // Failure-signature checks on FK world positions.
257
+ // ------------------------------------------------------------------------------------------------
258
+
259
+ function isFinite3(v: Vec3): boolean {
260
+ return Number.isFinite(v[0]) && Number.isFinite(v[1]) && Number.isFinite(v[2]);
261
+ }
262
+
263
+ function magnitude(v: Vec3): number {
264
+ return Math.hypot(v[0], v[1], v[2]);
265
+ }
266
+
267
+ export type FkFailureSignature = "legs-collapsed" | "arms-sagging" | "bone-exploded" | "bone-nan";
268
+
269
+ export interface FkFailure {
270
+ readonly signature: FkFailureSignature;
271
+ readonly detail: string;
272
+ }
273
+
274
+ /**
275
+ * Inspect a single FK frame for the failure signatures we saw on broken downloads. Returns every
276
+ * failure found (empty ⇒ this frame is clean). Thresholds are deliberately generous — this is a
277
+ * pre-filter that should only reject the OBVIOUSLY broken, never a borderline-but-renderable rig.
278
+ */
279
+ export function checkFrame(
280
+ world: Record<HumanoidBoneName, Vec3>,
281
+ mapped: Set<HumanoidBoneName>
282
+ ): FkFailure[] {
283
+ const failures: FkFailure[] = [];
284
+
285
+ // NaN / explosion: any non-finite or absurdly-far bone (a healthy ~1.8m human stays well under 5m).
286
+ for (const bone of Object.keys(world) as HumanoidBoneName[]) {
287
+ const p = world[bone];
288
+ if (!isFinite3(p)) {
289
+ failures.push({ signature: "bone-nan", detail: `${bone} is non-finite (${p.join(", ")})` });
290
+ continue;
291
+ }
292
+ if (magnitude(p) > 5) {
293
+ failures.push({ signature: "bone-exploded", detail: `${bone} flew to |p|=${magnitude(p).toFixed(2)}m (>5m)` });
294
+ }
295
+ }
296
+ if (failures.length > 0) return failures; // explosion dominates — don't bother with the geometry checks
297
+
298
+ const hipY = world.hips[1];
299
+
300
+ // Legs collapsed: a foot ends up at or ABOVE the hips (a standing human's feet are ~0.9m below).
301
+ // Only meaningful when the foot bone actually mapped (an unmapped foot stays at rest = below hips).
302
+ for (const foot of ["leftFoot", "rightFoot"] as const) {
303
+ if (!mapped.has(foot)) continue;
304
+ const footY = world[foot][1];
305
+ if (footY > hipY - 0.1) {
306
+ failures.push({
307
+ signature: "legs-collapsed",
308
+ detail: `${foot} Y=${footY.toFixed(2)} is not below hips Y=${hipY.toFixed(2)} (leg folded up)`
309
+ });
310
+ }
311
+ }
312
+
313
+ // Arms sagging / hands at the floor: a hand should sit roughly between knee and shoulder height.
314
+ // Flag a hand that drops to near the floor (Y < 0.2m) — the "arms dragging on the ground" look.
315
+ for (const hand of ["leftHand", "rightHand"] as const) {
316
+ if (!mapped.has(hand)) continue;
317
+ const handY = world[hand][1];
318
+ if (handY < 0.2) {
319
+ failures.push({
320
+ signature: "arms-sagging",
321
+ detail: `${hand} Y=${handY.toFixed(2)} near the floor (<0.2m — arm sagging/dragging)`
322
+ });
323
+ }
324
+ }
325
+
326
+ return failures;
327
+ }
328
+
329
+ // ------------------------------------------------------------------------------------------------
330
+ // Per-GLB validation.
331
+ // ------------------------------------------------------------------------------------------------
332
+
333
+ /** Sample times (seconds) across each clip — start, two beats in, near the end. */
334
+ const SAMPLE_TIMES = [0, 0.3, 0.6, 0.9, 1.2, 1.6];
335
+ const FK_CLIPS = ["talk", "walk"] as const;
336
+
337
+ export interface FkClipResult {
338
+ readonly clip: string;
339
+ readonly pass: boolean;
340
+ readonly failures: readonly FkFailure[];
341
+ }
342
+
343
+ export interface Verdict {
344
+ readonly file: string;
345
+ readonly bytes: number;
346
+ readonly parsed: boolean;
347
+ readonly jointCount: number;
348
+ readonly clips: readonly string[];
349
+ // --- rig grade ---
350
+ readonly rigGrade: RigGrade | null;
351
+ readonly mappedBoneCount: number;
352
+ readonly capabilities: { hasLegs: boolean; hasKnees: boolean; hasAnkles: boolean; hasFeet: boolean } | null;
353
+ readonly gradeReasons: readonly string[];
354
+ // --- retarget coverage ---
355
+ readonly retargetCoverage: number;
356
+ readonly retargetRequiredCoverage: number;
357
+ readonly retargetingPoor: boolean;
358
+ // --- FK proxy ---
359
+ readonly fkPass: boolean;
360
+ readonly fkClips: readonly FkClipResult[];
361
+ readonly fkSignatures: readonly string[];
362
+ // --- overall ---
363
+ readonly verdict: "PASS" | "FAIL" | "UNPARSEABLE";
364
+ readonly notes: readonly string[];
365
+ }
366
+
367
+ export interface FkProxyResult {
368
+ readonly fkPass: boolean;
369
+ readonly fkClips: readonly FkClipResult[];
370
+ readonly signatures: readonly string[];
371
+ /** Canonical bones that mapped onto the rig (drives which failure checks are meaningful). */
372
+ readonly mapped: ReadonlySet<HumanoidBoneName>;
373
+ }
374
+
375
+ /**
376
+ * Run the TALK + WALK FK proxy for an inferred rig built from `nodeNames`. Sampled across several
377
+ * beats; every frame is checked for the failure signatures (collapsed legs / sagging arms / NaN /
378
+ * exploded bones). Exposed (with {@link validateGlb}) so the unit-graded probe can drive it on both
379
+ * a clean canonical skeleton and deliberately-broken inputs without touching the filesystem.
380
+ */
381
+ export function runFkProxy(nodeNames: readonly string[]): FkProxyResult {
382
+ const inferred = inferHumanoidRig(nodeNames, { id: "hifi-fk-proxy" });
383
+ const mapped = new Set<HumanoidBoneName>(
384
+ HUMANOID_BONES.filter((bone) => {
385
+ const b = inferred.bones[bone];
386
+ return Boolean(b && typeof b.name === "string" && b.name.length > 0);
387
+ })
388
+ );
389
+ const map = createHumanoidRetargetingMap(STANDARD_LIBRARY_RIG, inferred, { minRequiredCoverage: 0 });
390
+ const fkClips: FkClipResult[] = [];
391
+ const signatures = new Set<string>();
392
+ for (const clipId of FK_CLIPS) {
393
+ const clipFailures: FkFailure[] = [];
394
+ for (const t of SAMPLE_TIMES) {
395
+ const source = sampleStandardPose(clipId, t);
396
+ const { rotations, hipsOffset } = retargetToCanonical(source, map);
397
+ const world = solveWorld(rotations, hipsOffset);
398
+ for (const f of checkFrame(world, mapped)) {
399
+ clipFailures.push(f);
400
+ signatures.add(f.signature);
401
+ }
402
+ }
403
+ const unique = new Map<string, FkFailure>();
404
+ for (const f of clipFailures) if (!unique.has(f.signature)) unique.set(f.signature, f);
405
+ fkClips.push({ clip: clipId, pass: clipFailures.length === 0, failures: [...unique.values()] });
406
+ }
407
+ return { fkPass: fkClips.every((c) => c.pass), fkClips, signatures: [...signatures], mapped };
408
+ }
409
+
410
+ export function validateGlb(absPath: string): Verdict {
411
+ const file = relative(TEMPLATE_DIR, absPath);
412
+ const buf = readFileSync(absPath);
413
+ const bytes = buf.length;
414
+ const json = parseGlbJson(buf);
415
+
416
+ if (!json) {
417
+ return {
418
+ file, bytes, parsed: false, jointCount: 0, clips: [],
419
+ rigGrade: null, mappedBoneCount: 0, capabilities: null, gradeReasons: [],
420
+ retargetCoverage: 0, retargetRequiredCoverage: 0, retargetingPoor: true,
421
+ fkPass: false, fkClips: [], fkSignatures: [],
422
+ verdict: "UNPARSEABLE", notes: ["not a binary glTF (.glb)"]
423
+ };
424
+ }
425
+
426
+ const names = jointNodeNames(json);
427
+ const clips = (json.animations ?? []).map((a) => a.name).filter((n): n is string => typeof n === "string");
428
+ const inferred: HumanoidRigDefinition = inferHumanoidRig(names, { id: `hifi:${file}`, name: file });
429
+ const rigReport = gradeRig(inferred);
430
+
431
+ // Retarget map: STANDARD library rig → this character's inferred rig. minRequiredCoverage at 0 so
432
+ // we always get a map back (we report coverage ourselves rather than letting it gate to !ok).
433
+ const map = createHumanoidRetargetingMap(STANDARD_LIBRARY_RIG, inferred, { minRequiredCoverage: 0 });
434
+ const retargetingPoor = map.coverage < POOR_COVERAGE_THRESHOLD;
435
+
436
+ // FK proxy across talk + walk (shared with the unit-graded probe via runFkProxy).
437
+ const { fkPass, fkClips, signatures, mapped } = runFkProxy(names);
438
+
439
+ const notes: string[] = [];
440
+ if (rigReport.grade === "D") notes.push("rig graded D — not suitable for body acting (refuse).");
441
+ if (retargetingPoor) {
442
+ notes.push(`retargeting will be poor — body acting won't transfer (coverage ${(map.coverage * 100).toFixed(0)}% < ${POOR_COVERAGE_THRESHOLD * 100}%).`);
443
+ }
444
+ if (!rigReport.hasFeet && !mapped.has("leftFoot") && !mapped.has("rightFoot")) {
445
+ notes.push("no feet mapped — locomotion / standing pose can't be validated by FK.");
446
+ }
447
+ if (!fkPass) notes.push(`FK proxy detected: ${[...signatures].join(", ")}.`);
448
+
449
+ // Overall verdict. UNPARSEABLE handled above. A clean rig is one that grades C-or-better AND
450
+ // passes the FK proxy. (FK pass is necessary, NOT sufficient — see the render warning.)
451
+ const verdict: Verdict["verdict"] =
452
+ rigReport.grade === "D" ? "FAIL" : fkPass && !retargetingPoor ? "PASS" : "FAIL";
453
+
454
+ return {
455
+ file, bytes, parsed: true, jointCount: names.length, clips,
456
+ rigGrade: rigReport.grade,
457
+ mappedBoneCount: rigReport.mappedBoneCount,
458
+ capabilities: {
459
+ hasLegs: rigReport.hasLegs, hasKnees: rigReport.hasKnees,
460
+ hasAnkles: rigReport.hasAnkles, hasFeet: rigReport.hasFeet
461
+ },
462
+ gradeReasons: rigReport.reasons,
463
+ retargetCoverage: +map.coverage.toFixed(3),
464
+ retargetRequiredCoverage: +map.requiredCoverage.toFixed(3),
465
+ retargetingPoor,
466
+ fkPass, fkClips, fkSignatures: [...signatures],
467
+ verdict, notes
468
+ };
469
+ }
470
+
471
+ // ------------------------------------------------------------------------------------------------
472
+ // Discovery + CLI.
473
+ // ------------------------------------------------------------------------------------------------
474
+
475
+ /** Recursively collect every .glb under `dir` (hifi-cast/ has nested rpm/ + quaternius/ subdirs). */
476
+ function collectGlbs(dir: string): string[] {
477
+ if (!existsSync(dir)) return [];
478
+ const out: string[] = [];
479
+ for (const entry of readdirSync(dir)) {
480
+ const p = resolve(dir, entry);
481
+ const st = statSync(p);
482
+ if (st.isDirectory()) out.push(...collectGlbs(p));
483
+ else if (entry.toLowerCase().endsWith(".glb")) out.push(p);
484
+ }
485
+ return out.sort();
486
+ }
487
+
488
+ function discoverTargets(): string[] {
489
+ const targets = collectGlbs(HIFI_DIR);
490
+ for (const name of COMPARISON_GLBS) {
491
+ const p = resolve(AURA_ASSETS_DIR, name);
492
+ if (existsSync(p)) targets.push(p);
493
+ }
494
+ return targets;
495
+ }
496
+
497
+ function printWarning(): void {
498
+ console.error("");
499
+ console.error(" ┌──────────────────────────────────────────────────────────────────────────┐");
500
+ console.error(" │ ⚠ FK PRE-FILTER ONLY — NOT A RENDER. │");
501
+ console.error(" │ A PASS here means the rig survived a fast forward-kinematics sanity pass │");
502
+ console.error(" │ (no collapsed legs / sagging arms / exploded or NaN bones) on a canonical │");
503
+ console.error(" │ rest skeleton. It does NOT skin the real mesh and cannot see texture, │");
504
+ console.error(" │ weighting, or bind-pose problems. FINAL ACCEPTANCE STILL REQUIRES A REAL │");
505
+ console.error(" │ GPU RENDER + VISUAL REVIEW. Use this only to reject obviously-broken rigs │");
506
+ console.error(" │ before spending a render. │");
507
+ console.error(" └──────────────────────────────────────────────────────────────────────────┘");
508
+ console.error("");
509
+ }
510
+
511
+ function main(): void {
512
+ const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
513
+ const targets = argv.length > 0 ? argv.map((a) => resolve(process.cwd(), a)) : discoverTargets();
514
+
515
+ printWarning();
516
+
517
+ if (targets.length === 0) {
518
+ console.error(` no GLBs found under ${relative(process.cwd(), HIFI_DIR)} (and no comparison assets present).`);
519
+ }
520
+
521
+ const verdicts: Verdict[] = [];
522
+ for (const t of targets) {
523
+ if (!existsSync(t)) {
524
+ console.error(` SKIP (missing): ${t}`);
525
+ continue;
526
+ }
527
+ let v: Verdict;
528
+ try {
529
+ v = validateGlb(t);
530
+ } catch (err) {
531
+ console.error(` ERROR validating ${t}: ${(err as Error).message}`);
532
+ continue;
533
+ }
534
+ verdicts.push(v);
535
+ const cov = `${(v.retargetCoverage * 100).toFixed(0)}%`;
536
+ const cap = v.capabilities
537
+ ? `legs=${v.capabilities.hasLegs ? "Y" : "n"} knees=${v.capabilities.hasKnees ? "Y" : "n"} feet=${v.capabilities.hasFeet ? "Y" : "n"}`
538
+ : "—";
539
+ console.error(
540
+ ` ${v.verdict.padEnd(11)} ${v.file}\n` +
541
+ ` grade=${v.rigGrade ?? "—"} mappedBones=${v.mappedBoneCount}/${HUMANOID_BONES.length} ${cap} ` +
542
+ `retargetCoverage=${cov}${v.retargetingPoor ? " (POOR)" : ""} joints=${v.jointCount} clips=${v.clips.length}\n` +
543
+ ` FK ${v.fkPass ? "PASS" : "FAIL"}${v.fkSignatures.length ? ` [${v.fkSignatures.join(", ")}]` : ""}` +
544
+ (v.notes.length ? `\n notes: ${v.notes.join(" ")}` : "")
545
+ );
546
+ }
547
+
548
+ const report = {
549
+ generatedAt: new Date().toISOString(),
550
+ note:
551
+ "FK PRE-FILTER ONLY — NOT A RENDER. A PASS means the rig survived a canonical forward-" +
552
+ "kinematics sanity pass (no collapsed legs / sagging arms / exploded or NaN bones). It does " +
553
+ "NOT skin the real mesh and cannot see texture/weight/bind-pose problems. Final acceptance " +
554
+ "requires a real GPU render + visual review.",
555
+ poorCoverageThreshold: POOR_COVERAGE_THRESHOLD,
556
+ fkClips: FK_CLIPS,
557
+ sampleTimes: SAMPLE_TIMES,
558
+ count: verdicts.length,
559
+ passCount: verdicts.filter((v) => v.verdict === "PASS").length,
560
+ failCount: verdicts.filter((v) => v.verdict !== "PASS").length,
561
+ verdicts
562
+ };
563
+ mkdirSync(REPORT_DIR, { recursive: true });
564
+ writeFileSync(REPORT_FILE, `${JSON.stringify(report, null, 2)}\n`);
565
+ console.error(`\n wrote ${relative(process.cwd(), REPORT_FILE)} (${verdicts.length} verdict(s), ${report.passCount} PASS / ${report.failCount} FAIL)`);
566
+ printWarning();
567
+ }
568
+
569
+ // Run only when invoked directly (not when imported by the unit-graded probe / other tooling).
570
+ if (process.argv[1] && /hifi-cast-validate\.(ts|js)$/.test(process.argv[1])) {
571
+ main();
572
+ }