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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/package.json +11 -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,972 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* build-clip-library.ts — Phase 3.2: the HONEST "fetch the library from the prompt CLI".
|
|
3
|
+
*
|
|
4
|
+
* For each motion in the STANDARD performance vocabulary (idle / talk / gesture / point / nod /
|
|
5
|
+
* walk / run / react) this:
|
|
6
|
+
*
|
|
7
|
+
* 1. searches the live ~850k Aura3D catalog (the same hosted worker + direct-GLB filter as
|
|
8
|
+
* `resolve-asset.ts`) for a rigged, animated GLB carrying that motion
|
|
9
|
+
* (`assets resolve "<motion> animation"` — the `--animated` intent),
|
|
10
|
+
* 2. downloads + inspects it, requiring a humanoid skin and >=1 embedded clip,
|
|
11
|
+
* 3. EXTRACTS the embedded clip's real keyframe tracks straight out of the GLB binary
|
|
12
|
+
* (accessor float data — not synthesized), maps each animated node to a canonical humanoid
|
|
13
|
+
* bone via `inferHumanoidRig` from `@aura3d/animation`, and RE-EXPRESSES every track on the
|
|
14
|
+
* STANDARD humanoid rig (`STANDARD_LIBRARY_RIG`, canonical `HUMANOID_BONES`). The result is
|
|
15
|
+
* rig-neutral: its track targets are `<humanoidBone>.rotation` / `.translation`, exactly the
|
|
16
|
+
* shape the procedural `standardHumanoidClips` use, so it retargets onto any character rig
|
|
17
|
+
* via an identity-or-inferred `createHumanoidRetargetingMap` downstream.
|
|
18
|
+
* 4. writes each extracted clip to `public/clip-library/<id>.json` + a `manifest.json` recording
|
|
19
|
+
* per-clip provenance (source title, url, license, attribution, original clip name, hash).
|
|
20
|
+
*
|
|
21
|
+
* HONESTY: this depends on the live catalog actually returning a suitable rigged+animated GLB for
|
|
22
|
+
* a query. Most catalog hits for "<motion>" are static props or single-rig characters; many GLBs
|
|
23
|
+
* are external-texture or insane-scale. When a query yields nothing usable we DO NOT fabricate a
|
|
24
|
+
* clip — we fall back to the procedural standard clip (`createStandardHumanoidClipDefinitions`)
|
|
25
|
+
* and record `source: "procedural-fallback"` in the manifest, logging it loudly. The loader
|
|
26
|
+
* (`loadExtractedClipLibrary`) then augments/overrides the procedural registry only where a real
|
|
27
|
+
* extracted clip exists.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { dirname, resolve } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import {
|
|
35
|
+
AnimationClipRegistry,
|
|
36
|
+
AnimationTrack,
|
|
37
|
+
HUMANOID_BONES,
|
|
38
|
+
STANDARD_CLIP_IDS,
|
|
39
|
+
STANDARD_LIBRARY_RIG,
|
|
40
|
+
createStandardHumanoidClipDefinitions,
|
|
41
|
+
inferHumanoidRigDetailed,
|
|
42
|
+
type AnimationClipDefinition,
|
|
43
|
+
type HumanoidBoneName,
|
|
44
|
+
type StandardClipId
|
|
45
|
+
} from "@aura3d/animation";
|
|
46
|
+
|
|
47
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
const OUT_DIR = resolve(__dirname, "..", "public", "clip-library");
|
|
49
|
+
const WORKER = "https://aura3d-asset-index-cron.newsroom.workers.dev/search";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Per-motion catalog config:
|
|
53
|
+
* - `query`: catalog search string (`--animated` intent baked in).
|
|
54
|
+
* - `clipKeywords`: tokens we look for in the EMBEDDED clip name to confirm the clip actually
|
|
55
|
+
* depicts this motion. A candidate whose clip name matches is strongly preferred; only if no
|
|
56
|
+
* candidate's clip name matches do we accept a name-agnostic best-animated clip (recorded as a
|
|
57
|
+
* weaker `nameMatch:false` in provenance — honest about the looseness of catalog semantics).
|
|
58
|
+
*/
|
|
59
|
+
interface MotionConfig {
|
|
60
|
+
readonly query: string;
|
|
61
|
+
readonly clipKeywords: readonly string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Keywords are matched as whole tokens (+ short inflections) by `clipNameMatches`, so use base
|
|
65
|
+
// word stems that appear as standalone tokens in real clip names (Mixamo/Blender conventions).
|
|
66
|
+
const MOTION_QUERIES: Readonly<Record<StandardClipId, MotionConfig>> = {
|
|
67
|
+
idle: { query: "idle breathing character animation", clipKeywords: ["idle", "breathing", "stand"] },
|
|
68
|
+
talk: { query: "talking gesture character animation", clipKeywords: ["talk", "speak", "speaking", "talking", "dialogue"] },
|
|
69
|
+
gesture: { query: "wave gesture character animation", clipKeywords: ["wave", "waving", "gesture", "greet", "hello"] },
|
|
70
|
+
point: { query: "pointing character animation", clipKeywords: ["point", "pointing", "aim", "indicate"] },
|
|
71
|
+
nod: { query: "nodding head character animation", clipKeywords: ["nod", "nodding", "yes"] },
|
|
72
|
+
walk: { query: "walk cycle character animation", clipKeywords: ["walk", "walking", "stride"] },
|
|
73
|
+
run: { query: "running character animation", clipKeywords: ["run", "running", "sprint", "jog"] },
|
|
74
|
+
react: { query: "surprised reaction character animation", clipKeywords: ["react", "reaction", "surprised", "flinch", "recoil"] }
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ----------------------------------------------------------------------------------------------
|
|
78
|
+
// Catalog search (mirrors resolve-asset.ts: wide ranked list of direct .glb, commercial-OK)
|
|
79
|
+
// ----------------------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
interface CatalogResult {
|
|
82
|
+
id: string;
|
|
83
|
+
title: string;
|
|
84
|
+
source: string;
|
|
85
|
+
url: string;
|
|
86
|
+
license?: string;
|
|
87
|
+
attribution?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function searchCatalog(query: string): Promise<CatalogResult[]> {
|
|
91
|
+
const url = `${WORKER}?q=${encodeURIComponent(query)}&limit=60&commercial=true&animated=true`;
|
|
92
|
+
const res = await fetch(url);
|
|
93
|
+
if (!res.ok) throw new Error(`catalog ${res.status}`);
|
|
94
|
+
const data = (await res.json()) as { results?: CatalogResult[] };
|
|
95
|
+
return (data.results ?? []).filter((r) => typeof r.url === "string" && /\.glb($|\?)/.test(r.url));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ----------------------------------------------------------------------------------------------
|
|
99
|
+
// GLB parsing — JSON chunk + BIN chunk, enough to read animation sampler accessors.
|
|
100
|
+
// ----------------------------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
interface ParsedGlb {
|
|
103
|
+
readonly json: GltfJson;
|
|
104
|
+
readonly bin: Buffer;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface GltfJson {
|
|
108
|
+
accessors?: GltfAccessor[];
|
|
109
|
+
bufferViews?: GltfBufferView[];
|
|
110
|
+
buffers?: { byteLength: number; uri?: string }[];
|
|
111
|
+
nodes?: { name?: string }[];
|
|
112
|
+
skins?: { joints?: number[] }[];
|
|
113
|
+
meshes?: { primitives?: { attributes?: Record<string, number>; indices?: number }[] }[];
|
|
114
|
+
images?: { bufferView?: number }[];
|
|
115
|
+
animations?: GltfAnimation[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface GltfAccessor {
|
|
119
|
+
bufferView?: number;
|
|
120
|
+
byteOffset?: number;
|
|
121
|
+
componentType: number;
|
|
122
|
+
count: number;
|
|
123
|
+
type: "SCALAR" | "VEC2" | "VEC3" | "VEC4" | "MAT4" | string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface GltfBufferView {
|
|
127
|
+
buffer: number;
|
|
128
|
+
byteOffset?: number;
|
|
129
|
+
byteLength: number;
|
|
130
|
+
byteStride?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface GltfAnimation {
|
|
134
|
+
name?: string;
|
|
135
|
+
channels: { sampler: number; target: { node?: number; path: string } }[];
|
|
136
|
+
samplers: { input: number; output: number; interpolation?: "LINEAR" | "STEP" | "CUBICSPLINE" }[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseGlb(buf: Buffer): ParsedGlb | null {
|
|
140
|
+
if (buf.length < 20 || buf.readUInt32LE(0) !== 0x46546c67) return null; // "glTF"
|
|
141
|
+
const jsonLen = buf.readUInt32LE(12);
|
|
142
|
+
const jsonStart = 20;
|
|
143
|
+
const json = JSON.parse(buf.slice(jsonStart, jsonStart + jsonLen).toString("utf8")) as GltfJson;
|
|
144
|
+
// BIN chunk follows the JSON chunk (8-byte chunk header: length + type 0x004E4942).
|
|
145
|
+
let bin = Buffer.alloc(0);
|
|
146
|
+
const binHeader = jsonStart + jsonLen;
|
|
147
|
+
if (binHeader + 8 <= buf.length && buf.readUInt32LE(binHeader + 4) === 0x004e4942) {
|
|
148
|
+
const binLen = buf.readUInt32LE(binHeader);
|
|
149
|
+
bin = buf.slice(binHeader + 8, binHeader + 8 + binLen);
|
|
150
|
+
}
|
|
151
|
+
return { json, bin };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const COMPONENT_BYTES: Record<number, number> = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
|
|
155
|
+
const TYPE_COMPONENTS: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 };
|
|
156
|
+
|
|
157
|
+
/** Read an accessor as an array of number tuples (one tuple per element). Float accessors only. */
|
|
158
|
+
function readAccessor(glb: ParsedGlb, index: number): number[][] {
|
|
159
|
+
const acc = glb.json.accessors?.[index];
|
|
160
|
+
if (!acc || acc.bufferView === undefined) return [];
|
|
161
|
+
const view = glb.json.bufferViews?.[acc.bufferView];
|
|
162
|
+
if (!view) return [];
|
|
163
|
+
const comps = TYPE_COMPONENTS[acc.type] ?? 1;
|
|
164
|
+
const compBytes = COMPONENT_BYTES[acc.componentType] ?? 4;
|
|
165
|
+
const elemBytes = comps * compBytes;
|
|
166
|
+
const stride = view.byteStride && view.byteStride > 0 ? view.byteStride : elemBytes;
|
|
167
|
+
const base = (view.byteOffset ?? 0) + (acc.byteOffset ?? 0);
|
|
168
|
+
const out: number[][] = [];
|
|
169
|
+
for (let i = 0; i < acc.count; i += 1) {
|
|
170
|
+
const elemStart = base + i * stride;
|
|
171
|
+
const tuple: number[] = [];
|
|
172
|
+
for (let c = 0; c < comps; c += 1) {
|
|
173
|
+
const off = elemStart + c * compBytes;
|
|
174
|
+
if (off + compBytes > glb.bin.length) return out; // truncated/malformed — stop honestly
|
|
175
|
+
// We only consume FLOAT (5126) sampler data; quantized inputs are rare for animation.
|
|
176
|
+
tuple.push(acc.componentType === 5126 ? glb.bin.readFloatLE(off) : glb.bin.readInt32LE(off));
|
|
177
|
+
}
|
|
178
|
+
out.push(tuple);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ----------------------------------------------------------------------------------------------
|
|
184
|
+
// Normalization (rest-pose reconciliation): make extracted tracks USABLE on the standard rig.
|
|
185
|
+
//
|
|
186
|
+
// Raw catalog clips lay the character DOWN / contort it for three independent reasons:
|
|
187
|
+
// (a) UNITS — many GLBs author hips translation in centimetres / raw exporter units, so a single
|
|
188
|
+
// stride reads as ~30 instead of ~0.3m. We detect the source scale from the magnitude of the
|
|
189
|
+
// hips translation (a clean stride is well under ~1.5m; anything an order of magnitude larger
|
|
190
|
+
// is cm/raw) and divide every translation component by it.
|
|
191
|
+
// (b) HIPS REST-POSE OFFSET — the clip's bind/first frame is not the standard rig's rest pose: the
|
|
192
|
+
// hips carry a constant world-position offset (e.g. y≈32) AND a constant rotation offset (a
|
|
193
|
+
// Z-up→Y-up axis swap or a lean). We re-anchor by subtracting the first frame: translations
|
|
194
|
+
// become RELATIVE to frame 0 (dropping absolute world position).
|
|
195
|
+
// (c) PER-BONE REST-POSE OFFSET (the M6 blocker) — EVERY bone (toes/legs/feet/head/shoulders) of an
|
|
196
|
+
// extracted clip carries a baked, CONSTANT axis-conversion rest rotation (toes/feet ~180°, head
|
|
197
|
+
// ~90°). Because the standard library rig has an IDENTITY rest, retargeting (Rt = Rt0·Rs0⁻¹·Ra,
|
|
198
|
+
// Rs0 = I) applies that constant offset straight onto the target → the character renders
|
|
199
|
+
// laid-down / contorted, and a range-based gate misses it (a constant offset never moves).
|
|
200
|
+
//
|
|
201
|
+
// Reconciliation: estimate each rotation track's SOURCE REST rotation (the part that is CONSTANT
|
|
202
|
+
// across the clip — pure rest mismatch) and RE-ANCHOR every keyframe relative to it:
|
|
203
|
+
// R_motion(t) = R_rest⁻¹ · R(t).
|
|
204
|
+
// This removes the baked 90°/180° offset (R_motion(rest frame) = identity = standard rest = upright)
|
|
205
|
+
// while preserving the per-frame RELATIVE motion (leg swing, head turn). The rest estimate is the
|
|
206
|
+
// temporal-MEDIAN quaternion (robust to the swinging extremes of a walk/run), which for a static
|
|
207
|
+
// offset bone equals the offset and for a moving bone equals its mid-pose.
|
|
208
|
+
//
|
|
209
|
+
// Post-condition (matches the loader sanity gate in animation-performance.ts):
|
|
210
|
+
// hips: max |translation component| ≲ 1.0 and max (1 - |w|) ≲ 0.2;
|
|
211
|
+
// every rotation bone returns near rest at some frame: worstFloor = max over bones of
|
|
212
|
+
// min over keyframes of (1 - |w|) < 0.15.
|
|
213
|
+
// ----------------------------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
const HIPS_TRANS_TARGET = /(^|[.\/])hips\.translation$/i;
|
|
216
|
+
const ROT_TARGET = /\.rotation$/i;
|
|
217
|
+
|
|
218
|
+
/** Quaternion conjugate (= inverse for unit quaternions). Layout is [x, y, z, w]. */
|
|
219
|
+
function quatConjugate(q: readonly number[]): [number, number, number, number] {
|
|
220
|
+
return [-(q[0] ?? 0), -(q[1] ?? 0), -(q[2] ?? 0), q[3] ?? 1];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Hamilton product a*b for [x, y, z, w] quaternions. */
|
|
224
|
+
function quatMultiply(a: readonly number[], b: readonly number[]): [number, number, number, number] {
|
|
225
|
+
const [ax, ay, az, aw] = [a[0] ?? 0, a[1] ?? 0, a[2] ?? 0, a[3] ?? 1];
|
|
226
|
+
const [bx, by, bz, bw] = [b[0] ?? 0, b[1] ?? 0, b[2] ?? 0, b[3] ?? 1];
|
|
227
|
+
return [
|
|
228
|
+
aw * bx + ax * bw + ay * bz - az * by,
|
|
229
|
+
aw * by - ax * bz + ay * bw + az * bx,
|
|
230
|
+
aw * bz + ax * by - ay * bx + az * bw,
|
|
231
|
+
aw * bw - ax * bx - ay * by - az * bz
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function quatNormalize(q: readonly number[]): [number, number, number, number] {
|
|
236
|
+
const len = Math.hypot(q[0] ?? 0, q[1] ?? 0, q[2] ?? 0, q[3] ?? 1) || 1;
|
|
237
|
+
return [(q[0] ?? 0) / len, (q[1] ?? 0) / len, (q[2] ?? 0) / len, (q[3] ?? 1) / len];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Estimate a rotation track's CONSTANT part (its source rest rotation) as the temporal-median
|
|
242
|
+
* quaternion. We first flip every keyframe into the same hemisphere as the first (quaternions double-
|
|
243
|
+
* cover SO(3): q and -q are the same rotation), then take the component-wise median and renormalize.
|
|
244
|
+
*
|
|
245
|
+
* Why median, not mean or first-frame: the median is robust to the swinging extremes of a cyclic clip
|
|
246
|
+
* (a walk's legs reach far in both directions; the median sits at the neutral mid-pose), and for a
|
|
247
|
+
* bone that carries ONLY a baked rest offset (toes/feet never move) the median IS that offset exactly.
|
|
248
|
+
* Re-anchoring by R_rest⁻¹ then sends the neutral pose to identity (= the standard rig's upright rest)
|
|
249
|
+
* and keeps the swing symmetric about it. Returns identity for an empty/short track.
|
|
250
|
+
*/
|
|
251
|
+
function quatRestEstimate(quats: readonly (readonly number[])[]): [number, number, number, number] {
|
|
252
|
+
if (quats.length === 0) return [0, 0, 0, 1];
|
|
253
|
+
const ref = quatNormalize(quats[0]!);
|
|
254
|
+
const aligned = quats.map((raw) => {
|
|
255
|
+
const q = quatNormalize(raw);
|
|
256
|
+
const dot = q[0] * ref[0] + q[1] * ref[1] + q[2] * ref[2] + q[3] * ref[3];
|
|
257
|
+
return dot < 0 ? ([-q[0], -q[1], -q[2], -q[3]] as [number, number, number, number]) : q;
|
|
258
|
+
});
|
|
259
|
+
const median = (i: number): number => {
|
|
260
|
+
const col = aligned.map((q) => q[i]!).sort((a, b) => a - b);
|
|
261
|
+
const n = col.length;
|
|
262
|
+
return n % 2 ? col[(n - 1) / 2]! : (col[n / 2 - 1]! + col[n / 2]!) / 2;
|
|
263
|
+
};
|
|
264
|
+
return quatNormalize([median(0), median(1), median(2), median(3)]);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface HipsStats {
|
|
268
|
+
readonly maxTranslation: number;
|
|
269
|
+
readonly maxRotationOffset: number;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Measure the worst hips translation magnitude + rotation offset (1 - |w|) across a track list. */
|
|
273
|
+
export function measureHips(tracks: readonly { target: string; keyframes: readonly { value: readonly number[] }[] }[]): HipsStats {
|
|
274
|
+
let maxTranslation = 0;
|
|
275
|
+
let maxRotationOffset = 0;
|
|
276
|
+
const isHipsRot = (t: string): boolean => /(^|[.\/])hips\.rotation$/i.test(t);
|
|
277
|
+
for (const tr of tracks) {
|
|
278
|
+
const isT = HIPS_TRANS_TARGET.test(tr.target);
|
|
279
|
+
const isR = isHipsRot(tr.target);
|
|
280
|
+
if (!isT && !isR) continue;
|
|
281
|
+
for (const kf of tr.keyframes) {
|
|
282
|
+
const v = kf.value ?? [];
|
|
283
|
+
if (isT) for (const c of v) maxTranslation = Math.max(maxTranslation, Math.abs(c));
|
|
284
|
+
if (isR && v.length >= 4) maxRotationOffset = Math.max(maxRotationOffset, 1 - Math.abs(v[3]!));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { maxTranslation, maxRotationOffset };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Worst per-bone REST FLOOR across a track list: for each rotation track take the MIN offset from
|
|
292
|
+
* identity over its keyframes (the closest that bone ever gets to rest), then take the MAX over bones.
|
|
293
|
+
* This mirrors the loader sanity gate (animation-performance.ts): a bone with a baked constant offset
|
|
294
|
+
* never returns near rest → a high floor; a reconciled clip has every bone return to ~identity at its
|
|
295
|
+
* rest frame → a low floor. The gate ACCEPTS a clip iff this value < 0.15.
|
|
296
|
+
*/
|
|
297
|
+
export function worstRestFloor(
|
|
298
|
+
tracks: readonly { target: string; keyframes: readonly { value: readonly number[] }[] }[]
|
|
299
|
+
): { value: number; bone: string } {
|
|
300
|
+
let value = 0;
|
|
301
|
+
let bone = "";
|
|
302
|
+
for (const tr of tracks) {
|
|
303
|
+
if (!ROT_TARGET.test(tr.target)) continue;
|
|
304
|
+
let floor = Infinity;
|
|
305
|
+
for (const kf of tr.keyframes) {
|
|
306
|
+
const v = kf.value ?? [];
|
|
307
|
+
if (v.length < 4) continue;
|
|
308
|
+
floor = Math.min(floor, 1 - Math.abs(v[3]!));
|
|
309
|
+
}
|
|
310
|
+
if (floor !== Infinity && floor > value) {
|
|
311
|
+
value = floor;
|
|
312
|
+
bone = tr.target;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { value, bone };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Detect the source-unit scale of the hips translation. We look at the peak absolute translation
|
|
320
|
+
* component: a clean clip on a metre rig keeps this under ~1.5m (a stride plus a little bob). When it
|
|
321
|
+
* is far larger the clip is in cm/raw units, so we divide by the smallest power-of-ten that brings the
|
|
322
|
+
* peak back under ~1.5 (e.g. peak 32 → /100 → 0.32m; peak 320 → /1000). Returns 1 when already metric.
|
|
323
|
+
*/
|
|
324
|
+
function detectHipsScale(maxAbsTranslation: number): number {
|
|
325
|
+
if (maxAbsTranslation <= 1.5) return 1;
|
|
326
|
+
let scale = 1;
|
|
327
|
+
while (maxAbsTranslation / scale > 1.5) scale *= 10;
|
|
328
|
+
return scale;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* NORMALIZE a set of extracted tracks — returning a new array — with full rest-pose reconciliation.
|
|
333
|
+
*
|
|
334
|
+
* • hips.translation → scaled to metres + re-anchored to frame 0 (relative root motion).
|
|
335
|
+
* • EVERY rotation track (hips and every limb) → re-anchored by its estimated source rest rotation:
|
|
336
|
+
* R_motion(t) = R_rest⁻¹ · R(t), R_rest = temporal-median quaternion of the track.
|
|
337
|
+
* This strips the baked, constant 90°/180° axis-conversion offset each bone carries (so the
|
|
338
|
+
* reconciled clip sits at the standard rig's UPRIGHT identity rest at its neutral pose) while
|
|
339
|
+
* preserving the per-frame relative motion.
|
|
340
|
+
*
|
|
341
|
+
* Idempotent for the rotation part: re-normalizing an already-reconciled clip leaves the median at
|
|
342
|
+
* ~identity, so R_rest⁻¹·R(t) ≈ R(t). The hips-translation anchor is likewise a no-op once at origin.
|
|
343
|
+
*/
|
|
344
|
+
export function normalizeExtractedTracks<T extends AnimationTrack>(tracks: readonly T[]): AnimationTrack[] {
|
|
345
|
+
// 1) UNITS — detect the hips-translation scale from the raw peak magnitude.
|
|
346
|
+
let peakTrans = 0;
|
|
347
|
+
for (const tr of tracks) {
|
|
348
|
+
if (!HIPS_TRANS_TARGET.test(tr.target)) continue;
|
|
349
|
+
for (const kf of tr.keyframes) for (const c of kf.value as number[]) peakTrans = Math.max(peakTrans, Math.abs(c));
|
|
350
|
+
}
|
|
351
|
+
const scale = detectHipsScale(peakTrans);
|
|
352
|
+
|
|
353
|
+
return tracks.map((tr) => {
|
|
354
|
+
const isT = HIPS_TRANS_TARGET.test(tr.target);
|
|
355
|
+
const isR = ROT_TARGET.test(tr.target);
|
|
356
|
+
|
|
357
|
+
if (isT) {
|
|
358
|
+
// Scale to metres, then re-anchor: subtract the first frame so motion is RELATIVE to frame 0
|
|
359
|
+
// (drops the absolute hips world-position offset that pins the character above/below the floor).
|
|
360
|
+
const scaled = tr.keyframes.map((kf) => ({
|
|
361
|
+
time: kf.time,
|
|
362
|
+
interpolation: kf.interpolation,
|
|
363
|
+
value: (kf.value as number[]).map((c) => c / scale)
|
|
364
|
+
}));
|
|
365
|
+
const anchor = scaled[0]?.value ?? [0, 0, 0];
|
|
366
|
+
return new AnimationTrack({
|
|
367
|
+
target: tr.target,
|
|
368
|
+
valueType: tr.valueType,
|
|
369
|
+
keyframes: scaled.map((kf) => ({
|
|
370
|
+
time: kf.time,
|
|
371
|
+
interpolation: kf.interpolation,
|
|
372
|
+
value: kf.value.map((c, i) => +(c - (anchor[i] ?? 0)).toFixed(6)) as never
|
|
373
|
+
}))
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!isR) return tr; // non-hips translation (none today) / other tracks pass through.
|
|
378
|
+
|
|
379
|
+
// REST-POSE RECONCILIATION (the M6 fix): re-anchor by the CONSTANT (rest) part of this bone's
|
|
380
|
+
// rotation, estimated as the temporal-median quaternion. q'_k = inverse(R_rest) * q_k. For a
|
|
381
|
+
// static offset bone this sends every frame to ~identity; for a moving bone it centres the
|
|
382
|
+
// motion on identity — either way the bone returns near the standard rig's upright rest.
|
|
383
|
+
const quats = tr.keyframes.map((kf) => kf.value as number[]).filter((v) => Array.isArray(v) && v.length >= 4);
|
|
384
|
+
if (quats.length === 0) return tr;
|
|
385
|
+
const invRest = quatConjugate(quatRestEstimate(quats));
|
|
386
|
+
return new AnimationTrack({
|
|
387
|
+
target: tr.target,
|
|
388
|
+
valueType: tr.valueType,
|
|
389
|
+
keyframes: tr.keyframes.map((kf) => {
|
|
390
|
+
const v = kf.value as number[];
|
|
391
|
+
if (!Array.isArray(v) || v.length < 4) return kf;
|
|
392
|
+
const q = quatNormalize(v);
|
|
393
|
+
let rel = quatMultiply(invRest, q);
|
|
394
|
+
// Keep w >= 0 so the offset metric (1 - |w|) is read off the canonical hemisphere.
|
|
395
|
+
if (rel[3] < 0) rel = [-rel[0], -rel[1], -rel[2], -rel[3]];
|
|
396
|
+
return {
|
|
397
|
+
time: kf.time,
|
|
398
|
+
interpolation: kf.interpolation,
|
|
399
|
+
value: rel.map((c) => +c.toFixed(6)) as never
|
|
400
|
+
};
|
|
401
|
+
})
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ----------------------------------------------------------------------------------------------
|
|
407
|
+
// Extraction: GLB animation channels -> rig-neutral AnimationTrack[] on the standard humanoid.
|
|
408
|
+
// ----------------------------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
interface ExtractedClip {
|
|
411
|
+
readonly tracks: AnimationTrack[];
|
|
412
|
+
readonly duration: number;
|
|
413
|
+
readonly animatedHumanoidBones: number;
|
|
414
|
+
readonly sourceClipName: string;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Build node-index -> humanoid bone map by inferring the source rig from the skinned joint names
|
|
419
|
+
* (falling back to all node names). Returns a Map<nodeIndex, HumanoidBoneName>.
|
|
420
|
+
*/
|
|
421
|
+
function buildNodeBoneMap(glb: ParsedGlb): { map: Map<number, HumanoidBoneName>; mappedRequired: number } {
|
|
422
|
+
const nodes = glb.json.nodes ?? [];
|
|
423
|
+
// Prefer the first skin's joints (the actual deforming skeleton); else every named node.
|
|
424
|
+
const jointIdx = glb.json.skins?.[0]?.joints ?? nodes.map((_, i) => i);
|
|
425
|
+
const namedJoints = jointIdx
|
|
426
|
+
.map((ni) => ({ ni, name: nodes[ni]?.name }))
|
|
427
|
+
.filter((j): j is { ni: number; name: string } => typeof j.name === "string" && j.name.length > 0);
|
|
428
|
+
|
|
429
|
+
const inference = inferHumanoidRigDetailed(namedJoints.map((j) => j.name), {
|
|
430
|
+
id: "extracted-source",
|
|
431
|
+
name: "Extracted source rig"
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// inference.rig.bones[bone].name is the resolved source NODE NAME. Invert back to node index.
|
|
435
|
+
const nameToNode = new Map<string, number>();
|
|
436
|
+
for (const j of namedJoints) nameToNode.set(j.name, j.ni);
|
|
437
|
+
|
|
438
|
+
const map = new Map<number, HumanoidBoneName>();
|
|
439
|
+
for (const bone of HUMANOID_BONES) {
|
|
440
|
+
const binding = inference.rig.bones[bone];
|
|
441
|
+
if (!binding) continue;
|
|
442
|
+
const ni = nameToNode.get(binding.name);
|
|
443
|
+
if (ni !== undefined) map.set(ni, bone);
|
|
444
|
+
}
|
|
445
|
+
const required = HUMANOID_BONES.length - inference.missingRequired.length;
|
|
446
|
+
return { map, mappedRequired: required };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Extract one GLB animation (by index) onto the standard humanoid rig. Only translation/rotation
|
|
451
|
+
* channels targeting a mapped humanoid bone are kept; scale channels are dropped (the standard
|
|
452
|
+
* library is rotation/translation-only). Times are rebased to start at 0.
|
|
453
|
+
*/
|
|
454
|
+
function extractAnimation(
|
|
455
|
+
glb: ParsedGlb,
|
|
456
|
+
animIndex: number,
|
|
457
|
+
nodeBone: Map<number, HumanoidBoneName>
|
|
458
|
+
): ExtractedClip | null {
|
|
459
|
+
const anim = glb.json.animations?.[animIndex];
|
|
460
|
+
if (!anim) return null;
|
|
461
|
+
const tracks: AnimationTrack[] = [];
|
|
462
|
+
let minTime = Infinity;
|
|
463
|
+
let maxTime = 0;
|
|
464
|
+
let animatedHumanoidBones = 0;
|
|
465
|
+
const seen = new Set<string>();
|
|
466
|
+
|
|
467
|
+
for (const channel of anim.channels) {
|
|
468
|
+
const node = channel.target.node;
|
|
469
|
+
if (node === undefined) continue;
|
|
470
|
+
const bone = nodeBone.get(node);
|
|
471
|
+
if (!bone) continue; // not a humanoid bone -> not part of the rig-neutral library clip
|
|
472
|
+
const path = channel.target.path;
|
|
473
|
+
if (path !== "rotation" && path !== "translation") continue; // drop scale/weights
|
|
474
|
+
// The standard library is rotation-driven; only the hips (root) carry translation (bob/sway).
|
|
475
|
+
// Limb translations don't retarget meaningfully (no length scaling here) and bloat the clip.
|
|
476
|
+
if (path === "translation" && bone !== "hips") continue;
|
|
477
|
+
|
|
478
|
+
const sampler = anim.samplers[channel.sampler];
|
|
479
|
+
if (!sampler) continue;
|
|
480
|
+
const times = readAccessor(glb, sampler.input);
|
|
481
|
+
const values = readAccessor(glb, sampler.output);
|
|
482
|
+
if (times.length === 0 || values.length === 0) continue;
|
|
483
|
+
|
|
484
|
+
const valueType = path === "rotation" ? "quaternion" : "vector3";
|
|
485
|
+
const compsPerKey = path === "rotation" ? 4 : 3;
|
|
486
|
+
// CUBICSPLINE packs 3 values (in-tan, value, out-tan) per key; we take the middle value and
|
|
487
|
+
// emit linear keys (honest simplification — we don't re-derive tangents).
|
|
488
|
+
const cubic = sampler.interpolation === "CUBICSPLINE";
|
|
489
|
+
const valuesPerKey = cubic ? compsPerKey * 3 : compsPerKey;
|
|
490
|
+
const flat = values.flat();
|
|
491
|
+
|
|
492
|
+
const keyframes: { time: number; value: number[]; interpolation: "linear" }[] = [];
|
|
493
|
+
for (let k = 0; k < times.length; k += 1) {
|
|
494
|
+
const t = times[k]![0]!;
|
|
495
|
+
const start = k * valuesPerKey + (cubic ? compsPerKey : 0);
|
|
496
|
+
if (start + compsPerKey > flat.length) break;
|
|
497
|
+
const value = flat.slice(start, start + compsPerKey);
|
|
498
|
+
if (value.some((v) => !Number.isFinite(v))) continue;
|
|
499
|
+
keyframes.push({ time: t, value, interpolation: "linear" });
|
|
500
|
+
}
|
|
501
|
+
if (keyframes.length < 2) continue;
|
|
502
|
+
|
|
503
|
+
for (const kf of keyframes) {
|
|
504
|
+
if (kf.time < minTime) minTime = kf.time;
|
|
505
|
+
if (kf.time > maxTime) maxTime = kf.time;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const target = `${bone}.${path}`;
|
|
509
|
+
if (seen.has(target)) continue; // one track per bone/path (first sampler wins)
|
|
510
|
+
seen.add(target);
|
|
511
|
+
if (path === "rotation") animatedHumanoidBones += 1;
|
|
512
|
+
|
|
513
|
+
tracks.push(
|
|
514
|
+
new AnimationTrack({
|
|
515
|
+
target,
|
|
516
|
+
valueType,
|
|
517
|
+
keyframes: keyframes.map((kf) => ({
|
|
518
|
+
time: kf.time,
|
|
519
|
+
value: kf.value as never,
|
|
520
|
+
interpolation: kf.interpolation
|
|
521
|
+
}))
|
|
522
|
+
})
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (tracks.length === 0 || !Number.isFinite(minTime)) return null;
|
|
527
|
+
|
|
528
|
+
// Rebase times so the clip starts at t=0 (some exporters start mid-timeline).
|
|
529
|
+
const rebased = tracks.map(
|
|
530
|
+
(tr) =>
|
|
531
|
+
new AnimationTrack({
|
|
532
|
+
target: tr.target,
|
|
533
|
+
valueType: tr.valueType,
|
|
534
|
+
keyframes: tr.keyframes.map((kf) => ({ ...kf, time: +(kf.time - minTime).toFixed(6) }))
|
|
535
|
+
})
|
|
536
|
+
);
|
|
537
|
+
const duration = +(maxTime - minTime).toFixed(6);
|
|
538
|
+
// NORMALIZE the hips so the clip is usable on the standard rig (metres, rest-pose re-anchored to
|
|
539
|
+
// identity/origin) — otherwise raw-unit translation + bind offset displaces the character to the floor.
|
|
540
|
+
const normalized = normalizeExtractedTracks(rebased);
|
|
541
|
+
return {
|
|
542
|
+
tracks: normalized,
|
|
543
|
+
duration: duration > 0 ? duration : 1,
|
|
544
|
+
animatedHumanoidBones,
|
|
545
|
+
sourceClipName: anim.name ?? `animation-${animIndex}`
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ----------------------------------------------------------------------------------------------
|
|
550
|
+
// Per-motion resolution: search -> download -> inspect -> extract.
|
|
551
|
+
// ----------------------------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
interface MotionProvenance {
|
|
554
|
+
readonly clipId: StandardClipId;
|
|
555
|
+
readonly source: "catalog-extracted" | "procedural-fallback";
|
|
556
|
+
readonly sourceTitle?: string;
|
|
557
|
+
readonly sourceUrl?: string;
|
|
558
|
+
readonly license?: string;
|
|
559
|
+
readonly attribution?: string;
|
|
560
|
+
readonly originalClipName?: string;
|
|
561
|
+
/**
|
|
562
|
+
* The UNIVERSAL vocabulary slot this source clip was mapped onto (idle/talk/gesture/…). Stored
|
|
563
|
+
* EXPLICITLY in provenance (separate from the source's own `originalClipName`) so a catalog name
|
|
564
|
+
* like "Moon Walk" is never confused for the product-facing id — the id is always `clipId`, and
|
|
565
|
+
* `mappedIntent === clipId` records the mapping decision as provenance metadata, not a concept.
|
|
566
|
+
*/
|
|
567
|
+
readonly mappedIntent: StandardClipId;
|
|
568
|
+
/** ISO date the clip was resolved/extracted (provenance metadata). */
|
|
569
|
+
readonly date: string;
|
|
570
|
+
/**
|
|
571
|
+
* 0–100 confidence that this extracted clip is a faithful, retargetable source for `mappedIntent`:
|
|
572
|
+
* animated-humanoid-bone breadth + a confident clip-name match + a sane duration. A procedural
|
|
573
|
+
* fallback scores 0 (no real source motion).
|
|
574
|
+
*/
|
|
575
|
+
readonly qualityScore: number;
|
|
576
|
+
readonly hash?: string;
|
|
577
|
+
readonly duration: number;
|
|
578
|
+
readonly trackCount: number;
|
|
579
|
+
readonly animatedHumanoidBones?: number;
|
|
580
|
+
/**
|
|
581
|
+
* True when the embedded clip NAME contained one of the motion's keywords (a confident semantic
|
|
582
|
+
* match). False means we accepted a name-agnostic best-animated clip because no candidate's clip
|
|
583
|
+
* name matched — the motion is real and rig-neutral, but its semantic fit is unverified.
|
|
584
|
+
*/
|
|
585
|
+
readonly nameMatch?: boolean;
|
|
586
|
+
readonly note?: string;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Per-clip quality score (0–100) for an EXTRACTED source clip: breadth of animated humanoid bones
|
|
591
|
+
* (the body-acting signal), a confident clip-name match (semantic fit), and a sane duration. This
|
|
592
|
+
* is provenance metadata only — the universal `clipId` is always the product-facing id.
|
|
593
|
+
*/
|
|
594
|
+
function clipQualityScore(animatedHumanoidBones: number, nameMatch: boolean, duration: number): number {
|
|
595
|
+
const breadth = Math.min(animatedHumanoidBones, 24) * 3; // up to 72 for a full-body clip
|
|
596
|
+
const semantic = nameMatch ? 20 : 0; // confident keyword match
|
|
597
|
+
const durationOk = duration >= 0.5 && duration <= 30 ? 8 : 0;
|
|
598
|
+
return Math.max(0, Math.min(100, Math.round(breadth + semantic + durationOk)));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
interface MotionResult {
|
|
602
|
+
readonly clip: AnimationClipDefinition<StandardClipId>;
|
|
603
|
+
readonly provenance: MotionProvenance;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** A fully-extracted candidate, carried while we scan for the best (name-matching) one. */
|
|
607
|
+
interface Candidate {
|
|
608
|
+
readonly extracted: ExtractedClip;
|
|
609
|
+
readonly catalog: CatalogResult;
|
|
610
|
+
readonly hash: string;
|
|
611
|
+
readonly nameMatch: boolean;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function proceduralFor(clipId: StandardClipId, note: string): MotionResult {
|
|
615
|
+
const proc = createStandardHumanoidClipDefinitions().find((c) => c.id === clipId)!;
|
|
616
|
+
return {
|
|
617
|
+
clip: proc,
|
|
618
|
+
provenance: {
|
|
619
|
+
clipId,
|
|
620
|
+
source: "procedural-fallback",
|
|
621
|
+
mappedIntent: clipId,
|
|
622
|
+
date: new Date().toISOString(),
|
|
623
|
+
qualityScore: 0, // no real source motion — the procedural baseline carries no provenance quality
|
|
624
|
+
duration: proc.duration,
|
|
625
|
+
trackCount: proc.tracks?.length ?? 0,
|
|
626
|
+
note
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Match a motion keyword against a clip name on TOKEN boundaries, not raw substring — otherwise
|
|
633
|
+
* "nod" spuriously matches "RootNodeAction" ("root NODE action"). We split the name on common
|
|
634
|
+
* separators + camelCase humps, then require a token to EQUAL the keyword (or the keyword + a
|
|
635
|
+
* short inflection like "-s"/"-ing"/"-ed"). This keeps "nameMatch" an honest, conservative signal:
|
|
636
|
+
* "walk"/"walks"/"walking" match the walk motion, but "node" does not match "nod".
|
|
637
|
+
*/
|
|
638
|
+
function clipNameMatches(name: string, keywords: readonly string[]): boolean {
|
|
639
|
+
const tokens = name
|
|
640
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
641
|
+
.toLowerCase()
|
|
642
|
+
.split(/[^a-z]+/)
|
|
643
|
+
.filter(Boolean);
|
|
644
|
+
const inflections = ["", "s", "ing", "ed", "er"];
|
|
645
|
+
return keywords.some((k) => tokens.some((tok) => inflections.some((suf) => tok === k + suf)));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function resolveMotion(clipId: StandardClipId, config: MotionConfig): Promise<MotionResult> {
|
|
649
|
+
let candidates: CatalogResult[];
|
|
650
|
+
try {
|
|
651
|
+
candidates = await searchCatalog(config.query);
|
|
652
|
+
} catch (err) {
|
|
653
|
+
console.error(` [${clipId}] catalog search failed (${(err as Error).message}) -> procedural fallback`);
|
|
654
|
+
return proceduralFor(clipId, `catalog search failed: ${(err as Error).message}`);
|
|
655
|
+
}
|
|
656
|
+
console.error(` [${clipId}] "${config.query}" -> ${candidates.length} GLB candidates`);
|
|
657
|
+
|
|
658
|
+
// Scan candidates. A clip-name match short-circuits (best possible). Otherwise keep the
|
|
659
|
+
// name-agnostic candidate with the most animated humanoid bones as a weaker fallback.
|
|
660
|
+
let fallback: Candidate | null = null;
|
|
661
|
+
// Cap downloads per motion so a flaky/huge candidate list can't run forever.
|
|
662
|
+
let downloads = 0;
|
|
663
|
+
for (const c of candidates) {
|
|
664
|
+
if (downloads >= 12) break;
|
|
665
|
+
let buf: Buffer;
|
|
666
|
+
try {
|
|
667
|
+
const r = await fetch(c.url);
|
|
668
|
+
if (!r.ok) continue;
|
|
669
|
+
buf = Buffer.from(await r.arrayBuffer());
|
|
670
|
+
downloads += 1;
|
|
671
|
+
} catch {
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const glb = parseGlb(buf);
|
|
675
|
+
if (!glb) continue;
|
|
676
|
+
// We only need skeleton + animation data here. Require: a skin and at least one animation.
|
|
677
|
+
if (!(glb.json.skins?.length && glb.json.animations?.length)) continue;
|
|
678
|
+
if (buf.length > 60 * 1024 * 1024) continue; // keep extraction snappy
|
|
679
|
+
|
|
680
|
+
const { map, mappedRequired } = buildNodeBoneMap(glb);
|
|
681
|
+
if (mappedRequired < 10) continue; // require a usable humanoid
|
|
682
|
+
|
|
683
|
+
// Among this GLB's animations, prefer one whose NAME matches the motion; else most-animated.
|
|
684
|
+
let named: ExtractedClip | null = null;
|
|
685
|
+
let richest: ExtractedClip | null = null;
|
|
686
|
+
for (let ai = 0; ai < (glb.json.animations?.length ?? 0); ai += 1) {
|
|
687
|
+
const extracted = extractAnimation(glb, ai, map);
|
|
688
|
+
if (!extracted || extracted.animatedHumanoidBones < 3) continue;
|
|
689
|
+
if (!richest || extracted.animatedHumanoidBones > richest.animatedHumanoidBones) richest = extracted;
|
|
690
|
+
if (
|
|
691
|
+
clipNameMatches(extracted.sourceClipName, config.clipKeywords) &&
|
|
692
|
+
(!named || extracted.animatedHumanoidBones > named.animatedHumanoidBones)
|
|
693
|
+
) {
|
|
694
|
+
named = extracted;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const hash = `sha256-${createHash("sha256").update(buf).digest("hex")}`;
|
|
699
|
+
if (named) {
|
|
700
|
+
return buildResult(clipId, { extracted: named, catalog: c, hash, nameMatch: true });
|
|
701
|
+
}
|
|
702
|
+
if (richest && (!fallback || richest.animatedHumanoidBones > fallback.extracted.animatedHumanoidBones)) {
|
|
703
|
+
fallback = { extracted: richest, catalog: c, hash, nameMatch: false };
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (fallback) {
|
|
708
|
+
console.error(` [${clipId}] no name-matched clip; using best-animated catalog clip (nameMatch:false)`);
|
|
709
|
+
return buildResult(clipId, fallback);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
console.error(` [${clipId}] no usable rigged+animated GLB -> procedural fallback`);
|
|
713
|
+
return proceduralFor(clipId, "no catalog GLB yielded an extractable humanoid clip");
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function buildResult(clipId: StandardClipId, cand: Candidate): MotionResult {
|
|
717
|
+
const { extracted: best, catalog: c, hash, nameMatch } = cand;
|
|
718
|
+
const clip: AnimationClipDefinition<StandardClipId> = {
|
|
719
|
+
id: clipId,
|
|
720
|
+
name: clipId,
|
|
721
|
+
duration: best.duration,
|
|
722
|
+
frameRate: 30,
|
|
723
|
+
loop: clipId === "idle" || clipId === "talk" || clipId === "walk" || clipId === "run",
|
|
724
|
+
tags: [clipId, "performance", "extracted"],
|
|
725
|
+
tracks: best.tracks,
|
|
726
|
+
source: "aura3d.catalog-extracted",
|
|
727
|
+
metadata: {
|
|
728
|
+
rig: STANDARD_LIBRARY_RIG.id,
|
|
729
|
+
extracted: true,
|
|
730
|
+
nameMatch,
|
|
731
|
+
sourceClipName: best.sourceClipName,
|
|
732
|
+
sourceTitle: c.title,
|
|
733
|
+
sourceUrl: c.url,
|
|
734
|
+
license: c.license ?? "CC-BY-4.0",
|
|
735
|
+
attribution: c.attribution ?? c.source
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
console.error(
|
|
739
|
+
` [${clipId}] EXTRACTED "${best.sourceClipName}" from ${c.title} ` +
|
|
740
|
+
`(${best.tracks.length} tracks, ${best.animatedHumanoidBones} bones, ${best.duration.toFixed(2)}s, ` +
|
|
741
|
+
`nameMatch:${nameMatch}, ${c.source})`
|
|
742
|
+
);
|
|
743
|
+
return {
|
|
744
|
+
clip,
|
|
745
|
+
provenance: {
|
|
746
|
+
clipId,
|
|
747
|
+
source: "catalog-extracted",
|
|
748
|
+
sourceTitle: c.title,
|
|
749
|
+
sourceUrl: c.url,
|
|
750
|
+
license: c.license ?? "CC-BY-4.0",
|
|
751
|
+
attribution: c.attribution ?? c.source,
|
|
752
|
+
originalClipName: best.sourceClipName,
|
|
753
|
+
mappedIntent: clipId,
|
|
754
|
+
date: new Date().toISOString(),
|
|
755
|
+
qualityScore: clipQualityScore(best.animatedHumanoidBones, nameMatch, best.duration),
|
|
756
|
+
hash,
|
|
757
|
+
duration: best.duration,
|
|
758
|
+
trackCount: best.tracks.length,
|
|
759
|
+
animatedHumanoidBones: best.animatedHumanoidBones,
|
|
760
|
+
nameMatch
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ----------------------------------------------------------------------------------------------
|
|
766
|
+
// Serialization (write extracted clips + manifest under public/clip-library/).
|
|
767
|
+
// ----------------------------------------------------------------------------------------------
|
|
768
|
+
|
|
769
|
+
interface SerializedClipFile {
|
|
770
|
+
readonly id: StandardClipId;
|
|
771
|
+
readonly name: string;
|
|
772
|
+
readonly duration: number;
|
|
773
|
+
readonly frameRate: number;
|
|
774
|
+
readonly loop: boolean;
|
|
775
|
+
readonly tags: readonly string[];
|
|
776
|
+
readonly source: string;
|
|
777
|
+
readonly rig: string;
|
|
778
|
+
readonly tracks: { target: string; valueType: string; keyframes: unknown[] }[];
|
|
779
|
+
readonly provenance: MotionProvenance;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function serializeClip(result: MotionResult): SerializedClipFile {
|
|
783
|
+
const { clip, provenance } = result;
|
|
784
|
+
return {
|
|
785
|
+
id: clip.id,
|
|
786
|
+
name: clip.name ?? clip.id,
|
|
787
|
+
duration: clip.duration,
|
|
788
|
+
frameRate: clip.frameRate ?? 30,
|
|
789
|
+
loop: clip.loop ?? true,
|
|
790
|
+
tags: clip.tags ?? [],
|
|
791
|
+
source: clip.source ?? "unknown",
|
|
792
|
+
rig: STANDARD_LIBRARY_RIG.id,
|
|
793
|
+
tracks: (clip.tracks ?? []).map((tr) => {
|
|
794
|
+
const t = tr as unknown as AnimationTrack;
|
|
795
|
+
return { target: t.target, valueType: t.valueType, keyframes: [...t.keyframes] };
|
|
796
|
+
}),
|
|
797
|
+
provenance
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export interface ClipLibraryManifest {
|
|
802
|
+
readonly generatedAt: string;
|
|
803
|
+
readonly rig: string;
|
|
804
|
+
readonly vocabulary: readonly StandardClipId[];
|
|
805
|
+
readonly extractedCount: number;
|
|
806
|
+
readonly fallbackCount: number;
|
|
807
|
+
readonly clips: readonly MotionProvenance[];
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function build(): Promise<void> {
|
|
811
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
812
|
+
const results: MotionResult[] = [];
|
|
813
|
+
for (const clipId of STANDARD_CLIP_IDS) {
|
|
814
|
+
results.push(await resolveMotion(clipId, MOTION_QUERIES[clipId]));
|
|
815
|
+
}
|
|
816
|
+
const named = results.filter((r) => r.provenance.nameMatch === true).length;
|
|
817
|
+
console.error(` (${named}/${results.length} are confident clip-name matches)`);
|
|
818
|
+
|
|
819
|
+
for (const result of results) {
|
|
820
|
+
const file = serializeClip(result);
|
|
821
|
+
writeFileSync(resolve(OUT_DIR, `${result.clip.id}.json`), JSON.stringify(file, null, 2));
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const extractedCount = results.filter((r) => r.provenance.source === "catalog-extracted").length;
|
|
825
|
+
const manifest: ClipLibraryManifest = {
|
|
826
|
+
generatedAt: new Date().toISOString(),
|
|
827
|
+
rig: STANDARD_LIBRARY_RIG.id,
|
|
828
|
+
vocabulary: [...STANDARD_CLIP_IDS],
|
|
829
|
+
extractedCount,
|
|
830
|
+
fallbackCount: results.length - extractedCount,
|
|
831
|
+
clips: results.map((r) => r.provenance)
|
|
832
|
+
};
|
|
833
|
+
writeFileSync(resolve(OUT_DIR, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
834
|
+
|
|
835
|
+
console.error("");
|
|
836
|
+
console.error(
|
|
837
|
+
`Done: ${extractedCount}/${results.length} clips extracted from the live catalog, ` +
|
|
838
|
+
`${manifest.fallbackCount} procedural fallbacks. -> ${OUT_DIR}`
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ----------------------------------------------------------------------------------------------
|
|
843
|
+
// Loader: register extracted clips into an AnimationClipRegistry, augmenting the procedural set.
|
|
844
|
+
// ----------------------------------------------------------------------------------------------
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Deserialize a written clip-library file back into an {@link AnimationClipDefinition} on the
|
|
848
|
+
* standard rig (track targets are already `<humanoidBone>.rotation|translation`).
|
|
849
|
+
*/
|
|
850
|
+
function deserializeClipFile(file: SerializedClipFile): AnimationClipDefinition<StandardClipId> {
|
|
851
|
+
return {
|
|
852
|
+
id: file.id,
|
|
853
|
+
name: file.name,
|
|
854
|
+
duration: file.duration,
|
|
855
|
+
frameRate: file.frameRate,
|
|
856
|
+
loop: file.loop,
|
|
857
|
+
tags: file.tags,
|
|
858
|
+
source: file.source,
|
|
859
|
+
tracks: file.tracks.map(
|
|
860
|
+
(tr) =>
|
|
861
|
+
new AnimationTrack({
|
|
862
|
+
target: tr.target,
|
|
863
|
+
valueType: tr.valueType as never,
|
|
864
|
+
keyframes: tr.keyframes as never
|
|
865
|
+
})
|
|
866
|
+
),
|
|
867
|
+
metadata: { rig: file.rig, extracted: file.provenance.source === "catalog-extracted" }
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Build the shared, rig-neutral clip registry the studio drives. Starts from the procedural
|
|
873
|
+
* `standardHumanoidClips` (Phase 2.4) and OVERRIDES each id with the real extracted clip written
|
|
874
|
+
* under `public/clip-library/` when one exists. If the library hasn't been built yet (no dir),
|
|
875
|
+
* returns the pure procedural registry. The result speaks exactly the 8 STANDARD_CLIP_IDS and is
|
|
876
|
+
* authored on STANDARD_LIBRARY_RIG, so it retargets onto any inferred character rig.
|
|
877
|
+
*/
|
|
878
|
+
export function loadExtractedClipLibrary(
|
|
879
|
+
dir: string = OUT_DIR
|
|
880
|
+
): AnimationClipRegistry<StandardClipId> {
|
|
881
|
+
const registry = new AnimationClipRegistry<StandardClipId>(createStandardHumanoidClipDefinitions());
|
|
882
|
+
if (!existsSync(dir)) return registry;
|
|
883
|
+
|
|
884
|
+
for (const entry of readdirSync(dir)) {
|
|
885
|
+
if (!entry.endsWith(".json") || entry === "manifest.json") continue;
|
|
886
|
+
let file: SerializedClipFile;
|
|
887
|
+
try {
|
|
888
|
+
file = JSON.parse(readFileSync(resolve(dir, entry), "utf8")) as SerializedClipFile;
|
|
889
|
+
} catch {
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
// Only override with REAL extracted clips; procedural-fallback files leave the procedural
|
|
893
|
+
// registry entry in place (identical anyway), so we don't double-register noise.
|
|
894
|
+
if (file.provenance.source !== "catalog-extracted") continue;
|
|
895
|
+
if (!STANDARD_CLIP_IDS.includes(file.id)) continue;
|
|
896
|
+
registry.register(deserializeClipFile(file), { replace: true });
|
|
897
|
+
}
|
|
898
|
+
return registry;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Reconcile the already-extracted clips ON DISK (read each <id>.json, run full rest-pose
|
|
903
|
+
* reconciliation via {@link normalizeExtractedTracks} — hips units/anchor + per-bone median
|
|
904
|
+
* re-anchoring — rewrite), printing hips translation/rotation AND the per-bone worstFloor
|
|
905
|
+
* BEFORE→AFTER for each. This is the honest path when the live catalog is flaky/nondeterministic: it
|
|
906
|
+
* makes the existing extracted clips pass the loader sanity gate (worstFloor < 0.15) without
|
|
907
|
+
* re-downloading (which might return different assets). Only catalog-extracted files are touched;
|
|
908
|
+
* procedural-fallback files are already on the standard rig.
|
|
909
|
+
*/
|
|
910
|
+
export function normalizeClipLibraryInPlace(dir: string = OUT_DIR): void {
|
|
911
|
+
if (!existsSync(dir)) {
|
|
912
|
+
console.error(`no clip-library at ${dir} — nothing to normalize`);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
for (const entry of readdirSync(dir)) {
|
|
916
|
+
if (!entry.endsWith(".json") || entry === "manifest.json") continue;
|
|
917
|
+
let file: SerializedClipFile;
|
|
918
|
+
try {
|
|
919
|
+
file = JSON.parse(readFileSync(resolve(dir, entry), "utf8")) as SerializedClipFile;
|
|
920
|
+
} catch {
|
|
921
|
+
console.error(` [${entry}] unreadable JSON — skipped`);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (file.provenance?.source !== "catalog-extracted") {
|
|
925
|
+
console.error(` [${entry}] ${file.provenance?.source ?? "unknown"} (not catalog-extracted) — skipped`);
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
const before = measureHips(file.tracks as never);
|
|
929
|
+
const beforeFloor = worstRestFloor(file.tracks as never);
|
|
930
|
+
const tracks = (file.tracks ?? []).map(
|
|
931
|
+
(tr) =>
|
|
932
|
+
new AnimationTrack({
|
|
933
|
+
target: tr.target,
|
|
934
|
+
valueType: tr.valueType as never,
|
|
935
|
+
keyframes: tr.keyframes as never
|
|
936
|
+
})
|
|
937
|
+
);
|
|
938
|
+
const normalized = normalizeExtractedTracks(tracks);
|
|
939
|
+
const serializedTracks = normalized.map((tr) => ({
|
|
940
|
+
target: tr.target,
|
|
941
|
+
valueType: tr.valueType,
|
|
942
|
+
keyframes: [...tr.keyframes]
|
|
943
|
+
}));
|
|
944
|
+
const after = measureHips(serializedTracks as never);
|
|
945
|
+
const afterFloor = worstRestFloor(serializedTracks as never);
|
|
946
|
+
const rewritten = { ...file, tracks: serializedTracks };
|
|
947
|
+
writeFileSync(resolve(dir, entry), JSON.stringify(rewritten, null, 2));
|
|
948
|
+
// The clip is ACCEPTED by the loader sanity gate iff hips are sane AND every bone returns near
|
|
949
|
+
// rest at some frame (worstFloor < 0.15) — the rest-pose-reconciliation post-condition.
|
|
950
|
+
const pass =
|
|
951
|
+
after.maxTranslation <= 1.0 && after.maxRotationOffset <= 0.2 && afterFloor.value < 0.15;
|
|
952
|
+
console.error(
|
|
953
|
+
` [${entry}] hipsTrans ${before.maxTranslation.toFixed(2)} → ${after.maxTranslation.toFixed(2)} | ` +
|
|
954
|
+
`hipsRotOffset ${before.maxRotationOffset.toFixed(3)} → ${after.maxRotationOffset.toFixed(3)} | ` +
|
|
955
|
+
`worstFloor ${beforeFloor.value.toFixed(3)} (${beforeFloor.bone}) → ${afterFloor.value.toFixed(3)} (${afterFloor.bone}) ` +
|
|
956
|
+
`${pass ? "PASS (gate accepts)" : "STILL FAILING (gated)"}`
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// CLI entry.
|
|
962
|
+
if (process.argv[1] && process.argv[1].endsWith("build-clip-library.ts")) {
|
|
963
|
+
if (process.argv.includes("--normalize-in-place")) {
|
|
964
|
+
console.error("Normalizing existing extracted clips in place (no catalog download):");
|
|
965
|
+
normalizeClipLibraryInPlace();
|
|
966
|
+
} else {
|
|
967
|
+
void build().catch((err) => {
|
|
968
|
+
console.error(err);
|
|
969
|
+
process.exitCode = 1;
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
}
|