create-aura3d 1.1.2 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/package.json +10 -9
- package/templates/{cartoon-channel → animation-channel}/README.md +11 -11
- package/templates/{cartoon-studio → animation-channel}/index.html +1 -1
- package/templates/{cartoon-channel → animation-channel}/package.json +2 -2
- package/templates/{cartoon-channel → animation-channel}/src/episode.ts +22 -22
- package/templates/{cartoon-channel → animation-channel}/src/experimental/README.md +5 -5
- package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.css +3 -3
- package/templates/{cartoon-channel → animation-channel}/src/experimental/concept-episode-2-5d.ts +7 -7
- package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.css +4 -4
- package/templates/{cartoon-channel → animation-channel}/src/experimental/image-puppet-episode.ts +6 -6
- package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.css +3 -3
- package/templates/{cartoon-channel → animation-channel}/src/experimental/puppet-episode-2d.ts +7 -7
- package/templates/{cartoon-channel → animation-channel}/src/main.ts +32 -32
- package/templates/{cartoon-channel → animation-channel}/src/render-plan.ts +13 -13
- package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.css +34 -34
- package/templates/{cartoon-channel → animation-channel}/src/sample-episode-visual.ts +20 -20
- package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d-animation.spec.ts +3 -3
- package/templates/{cartoon-channel → animation-channel}/tests/concept-2-5d.spec.ts +6 -6
- package/templates/{cartoon-channel → animation-channel}/tests/image-puppet-animation.spec.ts +3 -3
- package/templates/{cartoon-channel → animation-channel}/tests/image-puppet.spec.ts +5 -5
- package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d-animation.spec.ts +3 -3
- package/templates/{cartoon-channel → animation-channel}/tests/puppet-2d.spec.ts +4 -4
- package/templates/{prompt-cartoon-channel → animation-channel}/tests/route-health.spec.ts +1 -1
- package/templates/{cartoon-channel → animation-channel}/tests/sample-episode-visual.spec.ts +5 -5
- package/templates/{cartoon-channel → animation-channel}/tests/storyboard-playback.spec.ts +7 -7
- package/templates/animation-studio/AGENTS.md +71 -0
- package/templates/{cartoon-studio → animation-studio}/ASSET-LICENSES.md +7 -7
- package/templates/{cartoon-studio → animation-studio}/README.md +6 -6
- package/templates/animation-studio/aura.assets.json +669 -0
- package/templates/animation-studio/dist/episodes/scene/episode-3d.webm +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/action.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/dialogue.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/final.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/first.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/mouth-closed.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/frames/mouth-open.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/render-live-summary.json +10284 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-1.png +0 -0
- package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-2.png +0 -0
- package/templates/animation-studio/dist/generated/second-scene.document.json +1210 -0
- package/templates/animation-studio/dist/generated/studio-edited.document.json +1196 -0
- package/templates/animation-studio/dist/scene/inshader-accept.document.json +1 -0
- package/templates/animation-studio/dist/scene/inshader-test.document.json +1 -0
- package/templates/animation-studio/dist/scene/integ.document.json +1 -0
- package/templates/animation-studio/dist/scene/working.document.json +723 -0
- package/templates/animation-studio/dist/scene/working.history.json +1 -0
- package/templates/{cartoon-studio → animation-studio}/live-route.html +1 -1
- package/templates/{cartoon-studio → animation-studio}/package-lock.json +2 -2
- package/templates/animation-studio/package.json +32 -0
- package/templates/animation-studio/public/aura-assets/cast-a.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/cast-b.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/luma.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/luma2.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/miko.catalog.glb +0 -0
- package/templates/{cartoon-studio → animation-studio}/public/aura-assets/moonGarden.gltf +1 -1
- package/templates/animation-studio/public/aura-assets/mushroom.catalog.glb +0 -0
- package/templates/animation-studio/public/aura-assets/rusty.catalog.glb +0 -0
- package/templates/animation-studio/public/cast-library/cast-library.json +71 -0
- package/templates/animation-studio/public/clip-library/gesture.json +173903 -0
- package/templates/animation-studio/public/clip-library/idle.json +23035 -0
- package/templates/animation-studio/public/clip-library/manifest.json +154 -0
- package/templates/animation-studio/public/clip-library/nod.json +173903 -0
- package/templates/animation-studio/public/clip-library/point.json +69879 -0
- package/templates/animation-studio/public/clip-library/react.json +66084 -0
- package/templates/animation-studio/public/clip-library/run.json +9648 -0
- package/templates/animation-studio/public/clip-library/talk.json +69879 -0
- package/templates/animation-studio/public/clip-library/walk.json +8283 -0
- package/templates/animation-studio/public/hifi-cast/BrainStem.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/CesiumMan.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/RiggedFigure.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Barbarian.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Knight.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Mage.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Rogue.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/kaykit/Rogue_Hooded.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/khronos-report.json +295 -0
- package/templates/animation-studio/public/hifi-cast/quaternius-report.json +449 -0
- package/templates/animation-studio/public/hifi-cast/rpm/cesiumman.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/rpm/fox.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/rpm/riggedfigure.glb +0 -0
- package/templates/animation-studio/public/hifi-cast/rpm-report.json +301 -0
- package/templates/animation-studio/scripts/anim.ts +103 -0
- package/templates/animation-studio/scripts/animation-scene.ts +457 -0
- package/templates/animation-studio/scripts/asset-motion-probe.ts +450 -0
- package/templates/animation-studio/scripts/asset-render-probe.ts +152 -0
- package/templates/animation-studio/scripts/author-office-scene.ts +65 -0
- package/templates/animation-studio/scripts/build-cast-library.ts +97 -0
- package/templates/animation-studio/scripts/build-characters.ts +1543 -0
- package/templates/animation-studio/scripts/build-clip-library.ts +972 -0
- package/templates/animation-studio/scripts/clip-decision-cli.ts +51 -0
- package/templates/animation-studio/scripts/determinism-check.ts +48 -0
- package/templates/animation-studio/scripts/generate-scene.ts +95 -0
- package/templates/animation-studio/scripts/hifi-cast-validate.ts +572 -0
- package/templates/animation-studio/scripts/motion-evidence-cli.ts +150 -0
- package/templates/animation-studio/scripts/motion-evidence.ts +362 -0
- package/templates/animation-studio/scripts/preview-server.ts +52 -0
- package/templates/animation-studio/scripts/regen-manifest.ts +156 -0
- package/templates/animation-studio/scripts/render-core.ts +360 -0
- package/templates/animation-studio/scripts/render-live.ts +813 -0
- package/templates/animation-studio/scripts/render-parallel.ts +70 -0
- package/templates/animation-studio/scripts/render-server.ts +119 -0
- package/templates/animation-studio/scripts/resolve-asset.ts +669 -0
- package/templates/animation-studio/scripts/skeleton-overlay-cli.ts +82 -0
- package/templates/animation-studio/scripts/skeleton-overlay.ts +293 -0
- package/templates/animation-studio/scripts/studio-tools-demo.ts +45 -0
- package/templates/animation-studio/scripts/texture-bake.ts +297 -0
- package/templates/{cartoon-studio → animation-studio}/scripts/validate-characters.ts +0 -0
- package/templates/animation-studio/scripts/verify-extracted-overlay-cli.ts +264 -0
- package/templates/animation-studio/src/animation-episode-validator.ts +97 -0
- package/templates/animation-studio/src/animation-performance.ts +556 -0
- package/templates/animation-studio/src/aura-assets.ts +71 -0
- package/templates/animation-studio/src/character.ts +40 -0
- package/templates/animation-studio/src/director/compile-episode-document.ts +45 -0
- package/templates/animation-studio/src/director/director-heuristics.ts +697 -0
- package/templates/animation-studio/src/director/prompt-to-scene.ts +369 -0
- package/templates/animation-studio/src/empty-document.ts +77 -0
- package/templates/animation-studio/src/episode-document.ts +442 -0
- package/templates/animation-studio/src/examples/moon-garden.example.ts +183 -0
- package/templates/animation-studio/src/fidelity.ts +129 -0
- package/templates/animation-studio/src/graph.ts +14 -0
- package/templates/animation-studio/src/procedural-hdri.ts +243 -0
- package/templates/animation-studio/src/profile.ts +97 -0
- package/templates/animation-studio/src/render-live-route.ts +42 -0
- package/templates/animation-studio/src/render-modes.ts +137 -0
- package/templates/animation-studio/src/scene-player.ts +867 -0
- package/templates/animation-studio/src/set-templates.ts +361 -0
- package/templates/animation-studio/src/studio/episode-document-store.ts +72 -0
- package/templates/animation-studio/src/studio/scene-tool-registry.ts +86 -0
- package/templates/animation-studio/src/studio/scene-tools.ts +169 -0
- package/templates/animation-studio/tests/reports/animation-studio/hifi-cast-validation.json +938 -0
- package/templates/animation-studio/tsconfig.json +26 -0
- package/templates/{cartoon-studio → animation-studio}/vite.config.ts +6 -1
- package/templates/character-controller/README.md +33 -0
- package/templates/character-controller/index.html +19 -0
- package/templates/character-controller/package.json +23 -0
- package/templates/character-controller/playwright.config.ts +14 -0
- package/templates/character-controller/src/controller.ts +44 -0
- package/templates/character-controller/src/main.ts +62 -0
- package/templates/character-controller/tests/route-health.spec.ts +26 -0
- package/templates/character-controller/tests/screenshot.spec.ts +9 -0
- package/templates/character-controller/tsconfig.json +15 -0
- package/templates/cinematic-scene/package.json +1 -1
- package/templates/episode-builder/README.md +4 -4
- package/templates/episode-builder/package.json +1 -1
- package/templates/episode-builder/src/aura-assets.ts +1 -1
- package/templates/episode-builder/src/builder.ts +4 -4
- package/templates/episode-builder/src/episode.ts +21 -21
- package/templates/episode-builder/src/main.ts +20 -20
- package/templates/episode-builder/src/render-plan.ts +13 -13
- package/templates/episode-builder/tests/storyboard-playback.spec.ts +7 -7
- package/templates/fighting-game/package.json +1 -1
- package/templates/mini-game/package.json +1 -1
- package/templates/product-viewer/package.json +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/README.md +5 -5
- package/templates/{cartoon-channel → prompt-animation-channel}/index.html +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/package.json +2 -2
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/aura-assets.ts +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/episode.ts +22 -22
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/main.ts +28 -28
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/render-plan.ts +13 -13
- package/templates/{cartoon-channel → prompt-animation-channel}/tests/route-health.spec.ts +1 -1
- package/templates/{prompt-cartoon-channel → prompt-animation-channel}/tests/storyboard-playback.spec.ts +8 -8
- package/templates/cartoon-studio/aura.assets.json +0 -400
- package/templates/cartoon-studio/package.json +0 -33
- package/templates/cartoon-studio/public/aura-assets/luma.authored.glb +0 -0
- package/templates/cartoon-studio/public/aura-assets/miko.authored.glb +0 -0
- package/templates/cartoon-studio/scripts/build-characters.ts +0 -738
- package/templates/cartoon-studio/scripts/build-dialogue-audio.ts +0 -262
- package/templates/cartoon-studio/scripts/episode.ts +0 -323
- package/templates/cartoon-studio/scripts/render-live.ts +0 -873
- package/templates/cartoon-studio/src/aura-assets.ts +0 -64
- package/templates/cartoon-studio/src/characters.ts +0 -65
- package/templates/cartoon-studio/src/episode-renderer.ts +0 -605
- package/templates/cartoon-studio/src/episode.ts +0 -306
- package/templates/cartoon-studio/src/main.ts +0 -400
- package/templates/cartoon-studio/src/render-live-route.ts +0 -913
- package/templates/cartoon-studio/src/render-plan.ts +0 -567
- package/templates/cartoon-studio/src/review.ts +0 -99
- package/templates/cartoon-studio/src/studio.ts +0 -101
- package/templates/cartoon-studio/test-results/.last-run.json +0 -4
- package/templates/cartoon-studio/tests/episode-render.spec.ts +0 -44
- package/templates/cartoon-studio/tests/motion-quality.spec.ts +0 -45
- package/templates/cartoon-studio/tests/route-health.spec.ts +0 -7
- package/templates/cartoon-studio/tests/storyboard-playback.spec.ts +0 -99
- package/templates/cartoon-studio/tests/visual-review.spec.ts +0 -21
- package/templates/cartoon-studio/tsconfig.json +0 -14
- package/templates/prompt-cartoon-channel/index.html +0 -13
- package/templates/prompt-cartoon-channel/src/sets.ts +0 -14
- /package/templates/{cartoon-channel → animation-channel}/aura.assets.json +0 -0
- /package/templates/{cartoon-channel → animation-channel}/playwright.config.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.047f5e5f.glb +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/luma.thumb.svg +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.047f5e5f.glb +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/miko.thumb.svg +0 -0
- /package/templates/{cartoon-channel → animation-channel}/public/aura-assets/moon-garden-feature-frame.png +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/aura-assets.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/characters.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/contract.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/src/sets.ts +0 -0
- /package/templates/{cartoon-channel → animation-channel}/test-results/.last-run.json +0 -0
- /package/templates/{cartoon-channel → animation-channel}/tests/reports/prompt-animation/cartoon-sample-episode.png +0 -0
- /package/templates/{cartoon-channel → animation-channel}/tsconfig.json +0 -0
- /package/templates/{cartoon-studio → animation-studio}/playwright.config.ts +0 -0
- /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/luma.thumb.svg +0 -0
- /package/templates/{cartoon-studio → animation-studio}/public/aura-assets/miko.thumb.svg +0 -0
- /package/templates/{cartoon-studio → animation-studio}/src/contract.ts +0 -0
- /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/playwright.config.ts +0 -0
- /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/characters.ts +0 -0
- /package/templates/{prompt-cartoon-channel → prompt-animation-channel}/src/contract.ts +0 -0
- /package/templates/{cartoon-studio → prompt-animation-channel}/src/sets.ts +0 -0
- /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
|
+
}
|