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,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* asset-motion-probe.ts — the "do the embedded clips actually MOVE anything?" gate.
|
|
3
|
+
*
|
|
4
|
+
* Clip COUNT lies as badly as texture metadata: a catalog character can advertise five
|
|
5
|
+
* animations that are all a 2cm idle sway, a jaw-only lip-flap, or a root-bone slide with a
|
|
6
|
+
* frozen body. Those are useless for acting. This probe reads each embedded GLB animation's
|
|
7
|
+
* sampler data straight off the BIN chunk (no synthesis), measures the real motion AMPLITUDE
|
|
8
|
+
* per channel, works out WHICH humanoid bones each clip drives, scores the clip, and maps the
|
|
9
|
+
* useful ones onto the standard performance vocabulary (idle/talk/gesture/point/nod/walk/run/
|
|
10
|
+
* react). It then decides whether the character carries a "minimum viable acting pack" or must
|
|
11
|
+
* fall back to the shared retargeted clip library.
|
|
12
|
+
*
|
|
13
|
+
* Honest by construction: a clip that only animates root + mouth + a tiny idle is REJECTED, and
|
|
14
|
+
* a name-agnostic vocabulary mapping (no keyword in the clip name) is recorded as low confidence.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
HUMANOID_BONES,
|
|
19
|
+
STANDARD_CLIP_IDS,
|
|
20
|
+
inferHumanoidRigDetailed,
|
|
21
|
+
type HumanoidBoneName,
|
|
22
|
+
type StandardClipId
|
|
23
|
+
} from "@aura3d/animation";
|
|
24
|
+
|
|
25
|
+
// ----------------------------------------------------------------------------------------------
|
|
26
|
+
// GLB parsing — JSON chunk + BIN chunk, enough to read animation sampler accessors.
|
|
27
|
+
// (Self-contained: this probe must run on a raw downloaded Buffer before the GLB is even saved.)
|
|
28
|
+
// ----------------------------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
interface GltfAccessor { bufferView?: number; byteOffset?: number; componentType: number; count: number; type: string }
|
|
31
|
+
interface GltfBufferView { buffer: number; byteOffset?: number; byteLength: number; byteStride?: number }
|
|
32
|
+
interface GltfChannel { sampler: number; target: { node?: number; path: string } }
|
|
33
|
+
interface GltfSampler { input: number; output: number; interpolation?: "LINEAR" | "STEP" | "CUBICSPLINE" }
|
|
34
|
+
interface GltfAnimation { name?: string; channels: GltfChannel[]; samplers: GltfSampler[] }
|
|
35
|
+
interface GltfJson {
|
|
36
|
+
accessors?: GltfAccessor[];
|
|
37
|
+
bufferViews?: GltfBufferView[];
|
|
38
|
+
nodes?: { name?: string; mesh?: number }[];
|
|
39
|
+
skins?: { joints?: number[] }[];
|
|
40
|
+
meshes?: { primitives?: { targets?: unknown[] }[] }[];
|
|
41
|
+
animations?: GltfAnimation[];
|
|
42
|
+
}
|
|
43
|
+
interface ParsedGlb { readonly json: GltfJson; readonly bin: Buffer }
|
|
44
|
+
|
|
45
|
+
function parseGlb(buf: Buffer): ParsedGlb | null {
|
|
46
|
+
if (buf.length < 20 || buf.readUInt32LE(0) !== 0x46546c67) return null; // "glTF"
|
|
47
|
+
const jsonLen = buf.readUInt32LE(12);
|
|
48
|
+
const jsonStart = 20;
|
|
49
|
+
let json: GltfJson;
|
|
50
|
+
try { json = JSON.parse(buf.slice(jsonStart, jsonStart + jsonLen).toString("utf8")) as GltfJson; }
|
|
51
|
+
catch { return null; }
|
|
52
|
+
let bin = Buffer.alloc(0);
|
|
53
|
+
const binHeader = jsonStart + jsonLen;
|
|
54
|
+
if (binHeader + 8 <= buf.length && buf.readUInt32LE(binHeader + 4) === 0x004e4942) {
|
|
55
|
+
const binLen = buf.readUInt32LE(binHeader);
|
|
56
|
+
bin = buf.slice(binHeader + 8, binHeader + 8 + binLen);
|
|
57
|
+
}
|
|
58
|
+
return { json, bin };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const COMPONENT_BYTES: Record<number, number> = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
|
|
62
|
+
const TYPE_COMPONENTS: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 };
|
|
63
|
+
|
|
64
|
+
/** Read a FLOAT accessor as tuples (one per element). Non-float / truncated → honest empty. */
|
|
65
|
+
function readAccessor(glb: ParsedGlb, index: number): number[][] {
|
|
66
|
+
const acc = glb.json.accessors?.[index];
|
|
67
|
+
if (!acc || acc.bufferView === undefined || acc.componentType !== 5126) return [];
|
|
68
|
+
const view = glb.json.bufferViews?.[acc.bufferView];
|
|
69
|
+
if (!view) return [];
|
|
70
|
+
const comps = TYPE_COMPONENTS[acc.type] ?? 1;
|
|
71
|
+
const elemBytes = comps * (COMPONENT_BYTES[acc.componentType] ?? 4);
|
|
72
|
+
const stride = view.byteStride && view.byteStride > 0 ? view.byteStride : elemBytes;
|
|
73
|
+
const base = (view.byteOffset ?? 0) + (acc.byteOffset ?? 0);
|
|
74
|
+
const out: number[][] = [];
|
|
75
|
+
for (let i = 0; i < acc.count; i += 1) {
|
|
76
|
+
const elemStart = base + i * stride;
|
|
77
|
+
const tuple: number[] = [];
|
|
78
|
+
for (let c = 0; c < comps; c += 1) {
|
|
79
|
+
const off = elemStart + c * 4;
|
|
80
|
+
if (off + 4 > glb.bin.length) return out; // truncated — stop honestly
|
|
81
|
+
tuple.push(glb.bin.readFloatLE(off));
|
|
82
|
+
}
|
|
83
|
+
out.push(tuple);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ----------------------------------------------------------------------------------------------
|
|
89
|
+
// Amplitude measurement.
|
|
90
|
+
// ----------------------------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/** Angle (radians) between two unit quaternions [x,y,z,w]. Clamped + finite-safe. */
|
|
93
|
+
function quatAngle(a: number[], b: number[]): number {
|
|
94
|
+
const dot = Math.min(1, Math.abs(a[0]! * b[0]! + a[1]! * b[1]! + a[2]! * b[2]! + a[3]! * b[3]!));
|
|
95
|
+
if (!Number.isFinite(dot)) return 0;
|
|
96
|
+
return 2 * Math.acos(dot);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Max pairwise angular spread (radians) across a list of quaternion keyframes. */
|
|
100
|
+
function rotationAmplitude(keys: number[][]): number {
|
|
101
|
+
if (keys.length < 2) return 0;
|
|
102
|
+
// Spread from the first key is a faithful, cheap proxy for the clip's rotational range.
|
|
103
|
+
let max = 0;
|
|
104
|
+
const ref = keys[0]!;
|
|
105
|
+
for (let i = 1; i < keys.length; i += 1) max = Math.max(max, quatAngle(ref, keys[i]!));
|
|
106
|
+
// Also check spread from the mid key (catches clips that swing symmetrically around rest).
|
|
107
|
+
const mid = keys[Math.floor(keys.length / 2)]!;
|
|
108
|
+
for (let i = 0; i < keys.length; i += 1) max = Math.max(max, quatAngle(mid, keys[i]!));
|
|
109
|
+
return max;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Max per-axis translation span across vector3 keyframes (world/local units). */
|
|
113
|
+
function translationAmplitude(keys: number[][]): number {
|
|
114
|
+
if (keys.length < 2) return 0;
|
|
115
|
+
const min = [Infinity, Infinity, Infinity];
|
|
116
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
117
|
+
for (const k of keys) for (let a = 0; a < 3; a += 1) { min[a] = Math.min(min[a]!, k[a]!); max[a] = Math.max(max[a]!, k[a]!); }
|
|
118
|
+
return Math.max(max[0]! - min[0]!, max[1]! - min[1]!, max[2]! - min[2]!);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ----------------------------------------------------------------------------------------------
|
|
122
|
+
// Node classification: which humanoid bone (if any) does a node drive; is it root/mouth?
|
|
123
|
+
// ----------------------------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const MOUTH_FACE_HINTS = ["mouth", "jaw", "teeth", "tongue", "lip", "face", "eye", "brow", "blink", "cheek"];
|
|
126
|
+
const ROOT_HINTS = ["root", "armature", "scene", "rootnode", "rignode"];
|
|
127
|
+
|
|
128
|
+
interface NodeRoles {
|
|
129
|
+
/** nodeIndex → humanoid bone it maps to (via inference), if any. */
|
|
130
|
+
readonly boneOf: Map<number, HumanoidBoneName>;
|
|
131
|
+
/** nodeIndex → true when the node name reads as a mouth/face/jaw bone. */
|
|
132
|
+
readonly mouthOf: Set<number>;
|
|
133
|
+
/** nodeIndex → true when the node name reads as a pure root/armature carrier. */
|
|
134
|
+
readonly rootOf: Set<number>;
|
|
135
|
+
/** Count of mapped REQUIRED humanoid bones present in the rig. */
|
|
136
|
+
readonly mappedRequired: number;
|
|
137
|
+
/** Total mapped humanoid bones present in the rig. */
|
|
138
|
+
readonly mappedTotal: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function classifyNodes(glb: ParsedGlb): NodeRoles {
|
|
142
|
+
const nodes = glb.json.nodes ?? [];
|
|
143
|
+
const jointIdx = glb.json.skins?.[0]?.joints ?? nodes.map((_, i) => i);
|
|
144
|
+
const named = jointIdx
|
|
145
|
+
.map((ni) => ({ ni, name: nodes[ni]?.name }))
|
|
146
|
+
.filter((j): j is { ni: number; name: string } => typeof j.name === "string" && j.name.length > 0);
|
|
147
|
+
|
|
148
|
+
const inference = inferHumanoidRigDetailed(named.map((j) => j.name), { id: "probe-source", name: "Probe source rig" });
|
|
149
|
+
const nameToNode = new Map<string, number>();
|
|
150
|
+
for (const j of named) nameToNode.set(j.name, j.ni);
|
|
151
|
+
|
|
152
|
+
const boneOf = new Map<number, HumanoidBoneName>();
|
|
153
|
+
for (const bone of HUMANOID_BONES) {
|
|
154
|
+
const binding = inference.rig.bones[bone];
|
|
155
|
+
if (!binding) continue;
|
|
156
|
+
const ni = nameToNode.get(binding.name);
|
|
157
|
+
if (ni !== undefined) boneOf.set(ni, bone);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const mouthOf = new Set<number>();
|
|
161
|
+
const rootOf = new Set<number>();
|
|
162
|
+
for (let ni = 0; ni < nodes.length; ni += 1) {
|
|
163
|
+
if (boneOf.has(ni)) continue; // a real body bone is never reclassified as mouth/root
|
|
164
|
+
const name = String(nodes[ni]?.name ?? "").toLowerCase();
|
|
165
|
+
if (!name) continue;
|
|
166
|
+
if (MOUTH_FACE_HINTS.some((h) => name.includes(h))) mouthOf.add(ni);
|
|
167
|
+
else if (ROOT_HINTS.some((h) => name.includes(h))) rootOf.add(ni);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
boneOf,
|
|
172
|
+
mouthOf,
|
|
173
|
+
rootOf,
|
|
174
|
+
mappedRequired: HUMANOID_BONES.length - inference.missingRequired.length,
|
|
175
|
+
mappedTotal: boneOf.size
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ----------------------------------------------------------------------------------------------
|
|
180
|
+
// Per-clip scoring + vocabulary mapping.
|
|
181
|
+
// ----------------------------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/** Bones that count as "body acting" (NOT root/hips-translation, NOT mouth/face). */
|
|
184
|
+
const BODY_ACTING_BONES = new Set<HumanoidBoneName>(
|
|
185
|
+
HUMANOID_BONES.filter((b) => b !== "hips")
|
|
186
|
+
);
|
|
187
|
+
const ARM_BONES = new Set<HumanoidBoneName>([
|
|
188
|
+
"leftShoulder", "leftUpperArm", "leftLowerArm", "leftHand",
|
|
189
|
+
"rightShoulder", "rightUpperArm", "rightLowerArm", "rightHand"
|
|
190
|
+
]);
|
|
191
|
+
const LEG_BONES = new Set<HumanoidBoneName>([
|
|
192
|
+
"leftUpperLeg", "leftLowerLeg", "leftFoot",
|
|
193
|
+
"rightUpperLeg", "rightLowerLeg", "rightFoot"
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
/** Minimum rotation (radians, ~5.7°) for a channel to count as "this bone actually moves". */
|
|
197
|
+
const MIN_ROT_AMPLITUDE = 0.1;
|
|
198
|
+
/** Minimum root translation (units) for locomotion to count as real travel, not jitter. */
|
|
199
|
+
const MIN_ROOT_TRAVEL = 0.15;
|
|
200
|
+
|
|
201
|
+
/** Token-boundary keyword match (so "nod" doesn't match "RootNodeAction"). Mirrors build-clip-library. */
|
|
202
|
+
function clipNameMatches(name: string, keywords: readonly string[]): boolean {
|
|
203
|
+
const tokens = name.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z]+/).filter(Boolean);
|
|
204
|
+
const inflections = ["", "s", "ing", "ed", "er"];
|
|
205
|
+
return keywords.some((k) => tokens.some((tok) => inflections.some((suf) => tok === k + suf)));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const VOCAB_KEYWORDS: Readonly<Record<StandardClipId, readonly string[]>> = {
|
|
209
|
+
idle: ["idle", "breathing", "stand", "breath"],
|
|
210
|
+
talk: ["talk", "speak", "speaking", "talking", "dialogue", "conversation"],
|
|
211
|
+
gesture: ["wave", "waving", "gesture", "greet", "hello", "clap"],
|
|
212
|
+
point: ["point", "pointing", "aim", "indicate"],
|
|
213
|
+
nod: ["nod", "nodding", "yes", "agree"],
|
|
214
|
+
walk: ["walk", "walking", "stride"],
|
|
215
|
+
run: ["run", "running", "sprint", "jog"],
|
|
216
|
+
react: ["react", "reaction", "surprised", "flinch", "recoil", "hit", "damage", "dodge"]
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export interface ClipMotionScore {
|
|
220
|
+
/** Embedded clip name (or synthetic animation-N). */
|
|
221
|
+
readonly name: string;
|
|
222
|
+
/** Clip duration in seconds. */
|
|
223
|
+
readonly duration: number;
|
|
224
|
+
/** 0–100 usefulness: amplitude across real body bones, breadth of bones, locomotion. */
|
|
225
|
+
readonly score: number;
|
|
226
|
+
/** Largest body-bone rotation in the clip (radians). */
|
|
227
|
+
readonly maxBodyRotation: number;
|
|
228
|
+
/** Largest root/hips travel in the clip (units). */
|
|
229
|
+
readonly rootTravel: number;
|
|
230
|
+
/** Count of distinct body bones (excl. hips) the clip rotates beyond MIN_ROT_AMPLITUDE. */
|
|
231
|
+
readonly movingBodyBones: number;
|
|
232
|
+
/** Count of arm bones that actually move (gesture/talk signal). */
|
|
233
|
+
readonly movingArmBones: number;
|
|
234
|
+
/** Count of leg bones that actually move (locomotion signal). */
|
|
235
|
+
readonly movingLegBones: number;
|
|
236
|
+
/** True when the clip moves the mouth/jaw/face beyond MIN_ROT_AMPLITUDE. */
|
|
237
|
+
readonly movesMouth: boolean;
|
|
238
|
+
/** True when the clip drives meaningful body motion (passes the amplitude gate). */
|
|
239
|
+
readonly useful: boolean;
|
|
240
|
+
/** Reason the clip was rejected (when !useful). */
|
|
241
|
+
readonly rejectReason?: string;
|
|
242
|
+
/** Standard vocabulary slot this clip best fills, if any. */
|
|
243
|
+
readonly mappedIntent?: StandardClipId;
|
|
244
|
+
/** True when the name (token-matched) confirms the intent; false = inferred from motion only. */
|
|
245
|
+
readonly intentConfident: boolean;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface ClipAnalysis {
|
|
249
|
+
readonly name: string;
|
|
250
|
+
duration: number;
|
|
251
|
+
maxBodyRotation: number;
|
|
252
|
+
rootTravel: number;
|
|
253
|
+
readonly movingBody: Set<HumanoidBoneName>;
|
|
254
|
+
movesMouth: boolean;
|
|
255
|
+
/** Total non-body channels (mouth/root/unmapped) that carry motion. */
|
|
256
|
+
noiseChannels: number;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Analyse one GLB animation: amplitude per channel, bucketed by node role. */
|
|
260
|
+
function analyzeAnimation(glb: ParsedGlb, anim: GltfAnimation, roles: NodeRoles, idx: number): ClipAnalysis {
|
|
261
|
+
const out: ClipAnalysis = {
|
|
262
|
+
name: anim.name ?? `animation-${idx}`,
|
|
263
|
+
duration: 0,
|
|
264
|
+
maxBodyRotation: 0,
|
|
265
|
+
rootTravel: 0,
|
|
266
|
+
movingBody: new Set(),
|
|
267
|
+
movesMouth: false,
|
|
268
|
+
noiseChannels: 0
|
|
269
|
+
};
|
|
270
|
+
for (const ch of anim.channels) {
|
|
271
|
+
const node = ch.target.node;
|
|
272
|
+
if (node === undefined) continue;
|
|
273
|
+
const path = ch.target.path;
|
|
274
|
+
if (path !== "rotation" && path !== "translation" && path !== "weights") continue;
|
|
275
|
+
const sampler = anim.samplers[ch.sampler];
|
|
276
|
+
if (!sampler) continue;
|
|
277
|
+
const times = readAccessor(glb, sampler.input);
|
|
278
|
+
if (times.length) out.duration = Math.max(out.duration, times[times.length - 1]![0]!);
|
|
279
|
+
const raw = readAccessor(glb, sampler.output);
|
|
280
|
+
if (raw.length < 2) continue;
|
|
281
|
+
// CUBICSPLINE packs 3 values per key (in-tan, value, out-tan); sample the middle value.
|
|
282
|
+
const cubic = sampler.interpolation === "CUBICSPLINE";
|
|
283
|
+
const comps = path === "rotation" ? 4 : path === "translation" ? 3 : (raw[0]?.length ?? 1);
|
|
284
|
+
const flat = raw.flat();
|
|
285
|
+
const keys: number[][] = [];
|
|
286
|
+
const per = cubic ? comps * 3 : comps;
|
|
287
|
+
for (let k = 0; k * per + (cubic ? comps : 0) + comps <= flat.length; k += 1) {
|
|
288
|
+
keys.push(flat.slice(k * per + (cubic ? comps : 0), k * per + (cubic ? comps : 0) + comps));
|
|
289
|
+
}
|
|
290
|
+
if (keys.length < 2) continue;
|
|
291
|
+
|
|
292
|
+
const bone = roles.boneOf.get(node);
|
|
293
|
+
if (path === "rotation") {
|
|
294
|
+
const amp = rotationAmplitude(keys);
|
|
295
|
+
if (amp < MIN_ROT_AMPLITUDE) continue;
|
|
296
|
+
if (bone && bone !== "hips") { out.movingBody.add(bone); out.maxBodyRotation = Math.max(out.maxBodyRotation, amp); }
|
|
297
|
+
else if (roles.mouthOf.has(node)) out.movesMouth = true;
|
|
298
|
+
else out.noiseChannels += 1; // root/unmapped rotation = idle noise
|
|
299
|
+
} else if (path === "translation") {
|
|
300
|
+
const amp = translationAmplitude(keys);
|
|
301
|
+
// Translation only matters on the root/hips (locomotion); limb translation doesn't retarget.
|
|
302
|
+
if ((bone === "hips" || roles.rootOf.has(node)) && amp >= MIN_ROOT_TRAVEL) out.rootTravel = Math.max(out.rootTravel, amp);
|
|
303
|
+
else if (amp >= MIN_ROOT_TRAVEL) out.noiseChannels += 1;
|
|
304
|
+
} else {
|
|
305
|
+
// morph weights = mouth/blendshape; treat as mouth motion if it actually changes.
|
|
306
|
+
const amp = translationAmplitude(keys.map((k) => [k[0] ?? 0, 0, 0]));
|
|
307
|
+
if (amp > 0.05) out.movesMouth = true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Score + classify one analysed clip into the standard vocabulary. */
|
|
314
|
+
function scoreClip(a: ClipAnalysis): ClipMotionScore {
|
|
315
|
+
const movingBodyBones = a.movingBody.size;
|
|
316
|
+
const movingArmBones = [...a.movingBody].filter((b) => ARM_BONES.has(b)).length;
|
|
317
|
+
const movingLegBones = [...a.movingBody].filter((b) => LEG_BONES.has(b)).length;
|
|
318
|
+
const hasLocomotion = a.rootTravel >= MIN_ROOT_TRAVEL && movingLegBones >= 2;
|
|
319
|
+
|
|
320
|
+
// Usefulness gate: a clip that only animates root/mouth/tiny-idle is NOT acting motion.
|
|
321
|
+
let useful = true;
|
|
322
|
+
let rejectReason: string | undefined;
|
|
323
|
+
if (movingBodyBones === 0 && !hasLocomotion) {
|
|
324
|
+
useful = false;
|
|
325
|
+
rejectReason = a.movesMouth
|
|
326
|
+
? "mouth/face only — no body bones move"
|
|
327
|
+
: a.noiseChannels > 0
|
|
328
|
+
? "root/idle noise only — no body bones move beyond threshold"
|
|
329
|
+
: "no bone moves beyond amplitude threshold";
|
|
330
|
+
} else if (movingBodyBones === 1 && a.maxBodyRotation < 0.25 && !hasLocomotion) {
|
|
331
|
+
useful = false;
|
|
332
|
+
rejectReason = "tiny single-bone idle — below acting amplitude";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Score (0–100): breadth of body bones + amplitude + locomotion bonus.
|
|
336
|
+
const score = useful
|
|
337
|
+
? Math.min(100, Math.round(
|
|
338
|
+
movingBodyBones * 8 +
|
|
339
|
+
Math.min(a.maxBodyRotation, Math.PI) * 12 +
|
|
340
|
+
movingArmBones * 4 +
|
|
341
|
+
(hasLocomotion ? 25 : 0)
|
|
342
|
+
))
|
|
343
|
+
: 0;
|
|
344
|
+
|
|
345
|
+
// Vocabulary mapping: name keyword wins (confident); else infer from the motion shape.
|
|
346
|
+
let mappedIntent: StandardClipId | undefined;
|
|
347
|
+
let intentConfident = false;
|
|
348
|
+
for (const id of STANDARD_CLIP_IDS) {
|
|
349
|
+
if (clipNameMatches(a.name, VOCAB_KEYWORDS[id])) { mappedIntent = id; intentConfident = true; break; }
|
|
350
|
+
}
|
|
351
|
+
if (!mappedIntent && useful) {
|
|
352
|
+
if (hasLocomotion) mappedIntent = a.rootTravel > 1.2 ? "run" : "walk";
|
|
353
|
+
else if (movingArmBones >= 2 && a.maxBodyRotation >= 0.6) mappedIntent = "gesture";
|
|
354
|
+
else if (movingArmBones >= 1) mappedIntent = "talk";
|
|
355
|
+
else if (a.movingBody.has("head") && movingBodyBones <= 2) mappedIntent = "nod";
|
|
356
|
+
else if (a.maxBodyRotation >= 1.0) mappedIntent = "react";
|
|
357
|
+
else mappedIntent = "idle";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
name: a.name,
|
|
362
|
+
duration: +a.duration.toFixed(2),
|
|
363
|
+
score,
|
|
364
|
+
maxBodyRotation: +a.maxBodyRotation.toFixed(3),
|
|
365
|
+
rootTravel: +a.rootTravel.toFixed(3),
|
|
366
|
+
movingBodyBones,
|
|
367
|
+
movingArmBones,
|
|
368
|
+
movingLegBones,
|
|
369
|
+
movesMouth: a.movesMouth,
|
|
370
|
+
useful,
|
|
371
|
+
rejectReason,
|
|
372
|
+
mappedIntent,
|
|
373
|
+
intentConfident
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ----------------------------------------------------------------------------------------------
|
|
378
|
+
// Public API.
|
|
379
|
+
// ----------------------------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
/** The four roles a usable acting character must be able to perform from its OWN clips. */
|
|
382
|
+
export const MINIMUM_ACTING_PACK: readonly StandardClipId[] = ["idle", "talk", "walk", "react"];
|
|
383
|
+
|
|
384
|
+
export interface MotionProbeReport {
|
|
385
|
+
/** Per-clip scores, useful first. */
|
|
386
|
+
readonly clips: readonly ClipMotionScore[];
|
|
387
|
+
/** Useful clips count (passed the amplitude gate). */
|
|
388
|
+
readonly usefulClips: number;
|
|
389
|
+
/** Vocabulary slots the embedded clips cover (deduped). */
|
|
390
|
+
readonly coveredIntents: readonly StandardClipId[];
|
|
391
|
+
/** True when the embedded clips alone cover the minimum viable acting pack. */
|
|
392
|
+
readonly hasViableActingPack: boolean;
|
|
393
|
+
/** Acting-pack slots NOT covered by embedded clips (filled by the shared library). */
|
|
394
|
+
readonly missingActingSlots: readonly StandardClipId[];
|
|
395
|
+
/** True when motion must come from the shared retargeted library, not the GLB. */
|
|
396
|
+
readonly libraryFallback: boolean;
|
|
397
|
+
/** True when at least one useful clip drives real body motion (the motionPass gate). */
|
|
398
|
+
readonly motionPass: boolean;
|
|
399
|
+
/** Whether the rig animates a mouth/face at all (dialogue capability signal). */
|
|
400
|
+
readonly hasMouth: boolean;
|
|
401
|
+
/** Plain-language reason when motionPass is false. */
|
|
402
|
+
readonly reason: string;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Inspect a downloaded character GLB's embedded clips and produce a motion report.
|
|
407
|
+
* `requireMouth` (dialogue scenes) makes a missing mouth/face a hard reason in the summary text,
|
|
408
|
+
* though the caller decides the final accept/reject (it also weighs the rig grade + render probe).
|
|
409
|
+
*/
|
|
410
|
+
export function probeEmbeddedMotion(buf: Buffer): MotionProbeReport {
|
|
411
|
+
const glb = parseGlb(buf);
|
|
412
|
+
if (!glb || !glb.json.animations?.length) {
|
|
413
|
+
return {
|
|
414
|
+
clips: [], usefulClips: 0, coveredIntents: [], hasViableActingPack: false,
|
|
415
|
+
missingActingSlots: [...MINIMUM_ACTING_PACK], libraryFallback: true, motionPass: false,
|
|
416
|
+
hasMouth: false, reason: glb ? "no embedded animations" : "unparseable GLB"
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const roles = classifyNodes(glb);
|
|
420
|
+
const analyses = glb.json.animations.map((anim, i) => analyzeAnimation(glb, anim, roles, i));
|
|
421
|
+
const clips = analyses.map(scoreClip).sort((x, y) => y.score - x.score);
|
|
422
|
+
|
|
423
|
+
const useful = clips.filter((c) => c.useful);
|
|
424
|
+
const hasMouth = analyses.some((a) => a.movesMouth);
|
|
425
|
+
const covered = new Set<StandardClipId>();
|
|
426
|
+
for (const c of useful) if (c.mappedIntent) covered.add(c.mappedIntent);
|
|
427
|
+
// talk and gesture are interchangeable for the "talk/gesture" acting-pack slot.
|
|
428
|
+
const coversTalkOrGesture = covered.has("talk") || covered.has("gesture");
|
|
429
|
+
const missingActingSlots = MINIMUM_ACTING_PACK.filter((slot) =>
|
|
430
|
+
slot === "talk" ? !coversTalkOrGesture : !covered.has(slot)
|
|
431
|
+
);
|
|
432
|
+
const hasViableActingPack = missingActingSlots.length === 0;
|
|
433
|
+
const motionPass = useful.length > 0;
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
clips,
|
|
437
|
+
usefulClips: useful.length,
|
|
438
|
+
coveredIntents: [...covered],
|
|
439
|
+
hasViableActingPack,
|
|
440
|
+
missingActingSlots,
|
|
441
|
+
libraryFallback: !hasViableActingPack,
|
|
442
|
+
motionPass,
|
|
443
|
+
hasMouth,
|
|
444
|
+
reason: motionPass
|
|
445
|
+
? hasViableActingPack
|
|
446
|
+
? "embedded clips cover a viable acting pack"
|
|
447
|
+
: `embedded clips usable but incomplete (missing ${missingActingSlots.join("/")}) — shared library fallback`
|
|
448
|
+
: "no embedded clip drives real body motion (root/mouth/tiny-idle only) — shared library fallback"
|
|
449
|
+
};
|
|
450
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* asset-render-probe.ts — the REAL "does it actually render?" gate for cast resolution.
|
|
3
|
+
*
|
|
4
|
+
* Metadata lies: a GLB can declare embedded textures yet render as a flat white silhouette
|
|
5
|
+
* (malformed Sketchfab/FBX exports — alphaMode=BLEND, nested transforms, textures not wired
|
|
6
|
+
* to baseColor). The only reliable test is to RENDER it and look at the pixels. This probe
|
|
7
|
+
* loads ONE candidate against a NEUTRAL GREYSCALE background, captures a raw frame, and
|
|
8
|
+
* measures whether the character body actually carries colour or detail. A robot that comes
|
|
9
|
+
* out uniform white (low chroma AND low luma variance) is rejected before it can be cast.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { chromium } from "@playwright/test";
|
|
13
|
+
import { dirname, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { writeFileSync } from "node:fs";
|
|
16
|
+
import { startWarmVite, seekAndReadPixels, rawRgbaToPng } from "./render-core.js";
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const TEMPLATE_ROOT = resolve(__dirname, "..");
|
|
20
|
+
const MONOREPO_ROOT = resolve(TEMPLATE_ROOT, "../../../..");
|
|
21
|
+
|
|
22
|
+
const PROBE_W = 384;
|
|
23
|
+
const PROBE_H = 384;
|
|
24
|
+
|
|
25
|
+
export interface ProbeVerdict {
|
|
26
|
+
readonly ok: boolean;
|
|
27
|
+
readonly meanChroma: number;
|
|
28
|
+
readonly lumaStd: number;
|
|
29
|
+
readonly bodyFraction: number;
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
/**
|
|
32
|
+
* RENDERED detail grade (A/B/C) from the captured pixels — complements resolve-asset's
|
|
33
|
+
* metadata mesh grade (poly/texture/material). A character that renders with both real surface
|
|
34
|
+
* colour AND shading variance reads A; flat-but-coloured reads B; barely-textured reads C.
|
|
35
|
+
* This is the "material validity" lever measured at the pixel level, where metadata can lie.
|
|
36
|
+
*/
|
|
37
|
+
readonly detailGrade: "A" | "B" | "C";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Grade the RENDERED body detail from measured chroma (surface colour) + luma variance (form). */
|
|
41
|
+
function gradeRenderedDetail(meanChroma: number, lumaStd: number): "A" | "B" | "C" {
|
|
42
|
+
if (meanChroma > 0.08 && lumaStd > 0.18) return "A"; // rich colour + strong form shading
|
|
43
|
+
if (meanChroma > 0.03 || lumaStd > 0.22) return "B"; // some colour OR good form
|
|
44
|
+
return "C"; // flat / low-detail body
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Minimal one-character document on a neutral greyscale stage, close-up on the body. */
|
|
48
|
+
function probeDocument(url: string, clip: string, scale: number, heightUnits: number): unknown {
|
|
49
|
+
const grey3: [number, number, number] = [0.2, 0.2, 0.21];
|
|
50
|
+
const white: [number, number, number] = [1, 1, 1];
|
|
51
|
+
const h = Math.max(0.6, heightUnits * scale);
|
|
52
|
+
return {
|
|
53
|
+
id: "probe",
|
|
54
|
+
duration: 2,
|
|
55
|
+
assets: { characters: [{ id: "probe", url, scale, defaultClip: clip, mouthMorphIndex: -1 }], props: [] },
|
|
56
|
+
set: {
|
|
57
|
+
clearColor: [0.04, 0.04, 0.05, 1],
|
|
58
|
+
studioLightingScale: 0.5,
|
|
59
|
+
environment: {
|
|
60
|
+
color: grey3, intensity: 0.5,
|
|
61
|
+
proceduralMap: { skyColor: grey3, horizonColor: grey3, groundColor: grey3, specularColor: grey3, intensity: 0.5, specularIntensity: 0.4 }
|
|
62
|
+
},
|
|
63
|
+
pieces: [],
|
|
64
|
+
// NEUTRAL WHITE 3-point rig (mirrors moon-garden's intensities, which light the astronaut
|
|
65
|
+
// properly). White so the background stays greyscale and the body shows its TRUE colour —
|
|
66
|
+
// a real textured character lights up; the broken-white one stays flat.
|
|
67
|
+
lights: [
|
|
68
|
+
{ id: "probe-key", kind: "point", color: white, position: [-2.6, 3.6, 2.6], intensity: 4.4, range: 18 },
|
|
69
|
+
{ id: "probe-fill", kind: "point", color: white, position: [2.4, 2.0, 1.4], intensity: 2.2, range: 13 },
|
|
70
|
+
{ id: "probe-rim", kind: "point", color: white, position: [0.4, 1.8, -2.0], intensity: 2.8, range: 13 }
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
walkableBounds: { min: [-2, 0, -2], max: [2, 0, 2] },
|
|
74
|
+
shots: [{ shotId: "probe-shot", presetId: "two-shot", startTime: 0, endTime: 2, cameraSubject: [0, h * 0.45, 0] }],
|
|
75
|
+
blocking: [{ characterId: "probe", shots: [{ shotId: "probe-shot", clip, waypoints: [{ time: 0, position: [0, 0, 0], yaw: 0 }] }] }],
|
|
76
|
+
setDressing: [],
|
|
77
|
+
worldState: { glowSpanSeconds: 2 }
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function measureBody(raw: Uint8Array): ProbeVerdict {
|
|
82
|
+
// Body pixels = clearly brighter than the dark neutral background.
|
|
83
|
+
let n = 0, chromaSum = 0, lumaSum = 0;
|
|
84
|
+
const lumas: number[] = [];
|
|
85
|
+
for (let i = 0; i < raw.length; i += 4) {
|
|
86
|
+
const r = raw[i]! / 255, g = raw[i + 1]! / 255, b = raw[i + 2]! / 255;
|
|
87
|
+
const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
88
|
+
if (luma < 0.12) continue;
|
|
89
|
+
const chroma = Math.max(r, g, b) - Math.min(r, g, b);
|
|
90
|
+
n += 1; chromaSum += chroma; lumaSum += luma; lumas.push(luma);
|
|
91
|
+
}
|
|
92
|
+
const total = raw.length / 4;
|
|
93
|
+
const bodyFraction = n / total;
|
|
94
|
+
if (n < total * 0.01) return { ok: false, meanChroma: 0, lumaStd: 0, bodyFraction, reason: "body-not-visible", detailGrade: "C" };
|
|
95
|
+
const meanChroma = chromaSum / n;
|
|
96
|
+
const meanLuma = lumaSum / n;
|
|
97
|
+
let varSum = 0;
|
|
98
|
+
for (const l of lumas) varSum += (l - meanLuma) * (l - meanLuma);
|
|
99
|
+
const lumaStd = Math.sqrt(varSum / n);
|
|
100
|
+
// The reliable signal is CHROMA (real surface colour). A flat untextured body reads ~0
|
|
101
|
+
// chroma no matter how it is lit; luma variance is fooled by the rig's shading gradients.
|
|
102
|
+
// A very high luma-variance fallback still admits a genuinely detailed grey/metallic body.
|
|
103
|
+
const ok = meanChroma > 0.025 || lumaStd > 0.30;
|
|
104
|
+
return { ok, meanChroma: +meanChroma.toFixed(3), lumaStd: +lumaStd.toFixed(3), bodyFraction: +bodyFraction.toFixed(3), reason: ok ? "renders-textured" : "flat-untextured-body", detailGrade: gradeRenderedDetail(meanChroma, lumaStd) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A WARM probe harness: one Vite + Chromium, reused to render-test MANY candidates (the
|
|
109
|
+
* resolver may sift dozens). Start once, probe each, close once — instead of paying the
|
|
110
|
+
* ~10s browser/Vite startup per candidate.
|
|
111
|
+
*/
|
|
112
|
+
export class ProbeSession {
|
|
113
|
+
private constructor(private readonly vite: Awaited<ReturnType<typeof startWarmVite>>, private readonly browser: import("@playwright/test").Browser, private readonly routeUrl: string) {}
|
|
114
|
+
|
|
115
|
+
static async start(): Promise<ProbeSession> {
|
|
116
|
+
const vite = await startWarmVite(TEMPLATE_ROOT, MONOREPO_ROOT);
|
|
117
|
+
const address = vite.httpServer?.address();
|
|
118
|
+
if (!address || typeof address === "string") { await vite.close(); throw new Error("probe vite failed"); }
|
|
119
|
+
const browser = await chromium.launch({ args: ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swdecoder", "--ignore-gpu-blocklist"] });
|
|
120
|
+
return new ProbeSession(vite, browser, `http://127.0.0.1:${address.port}/live-route.html`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async probe(url: string, clip: string, scale: number, heightUnits: number): Promise<ProbeVerdict> {
|
|
124
|
+
const page = await this.browser.newPage({ viewport: { width: PROBE_W + 40, height: PROBE_H + 40 } });
|
|
125
|
+
try {
|
|
126
|
+
await page.addInitScript(() => { (window as unknown as { __AURA_LIVE_ROUTE_HEADLESS__: boolean }).__AURA_LIVE_ROUTE_HEADLESS__ = true; });
|
|
127
|
+
await page.addInitScript((d) => { (window as unknown as { __AURA_EPISODE_DOCUMENT__: unknown }).__AURA_EPISODE_DOCUMENT__ = d; }, probeDocument(url, clip, scale, heightUnits));
|
|
128
|
+
await page.goto(this.routeUrl, { waitUntil: "load", timeout: 60_000 });
|
|
129
|
+
await page.waitForFunction(() => {
|
|
130
|
+
const w = window as unknown as { __AURA_LIVE_ROUTE_READY__?: unknown; __AURA_LIVE_ROUTE_ERROR__?: string };
|
|
131
|
+
if (w.__AURA_LIVE_ROUTE_ERROR__) throw new Error(`route error: ${w.__AURA_LIVE_ROUTE_ERROR__}`);
|
|
132
|
+
return Boolean(w.__AURA_LIVE_ROUTE_READY__);
|
|
133
|
+
}, { timeout: 60_000 });
|
|
134
|
+
const { raw } = await seekAndReadPixels(page, 0.5, PROBE_W, PROBE_H);
|
|
135
|
+
if (process.env.AURA_PROBE_DEBUG) writeFileSync(resolve(TEMPLATE_ROOT, `dist/probe-${process.env.AURA_PROBE_DEBUG}.png`), await rawRgbaToPng(raw, PROBE_W, PROBE_H));
|
|
136
|
+
return measureBody(raw);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return { ok: false, meanChroma: 0, lumaStd: 0, bodyFraction: 0, reason: `probe-error: ${err instanceof Error ? err.message : String(err)}`, detailGrade: "C" };
|
|
139
|
+
} finally {
|
|
140
|
+
await page.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async close(): Promise<void> { await this.browser.close(); await this.vite.close(); }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One-shot convenience wrapper (starts + closes its own session). */
|
|
148
|
+
export async function probeCharacterRender(url: string, clip: string, scale: number, heightUnits: number): Promise<ProbeVerdict> {
|
|
149
|
+
const session = await ProbeSession.start();
|
|
150
|
+
try { return await session.probe(url, clip, scale, heightUnits); }
|
|
151
|
+
finally { await session.close(); }
|
|
152
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* author-office-scene.ts — demonstrates the INTENDED architecture: the AI harness AUTHORS the
|
|
3
|
+
* dialogue (a coherent exchange), and the CLI/director stages + renders it. This replaces the
|
|
4
|
+
* template's generic `synthesizeDialogue` filler ("look at this part" pointing at nothing) with a
|
|
5
|
+
* real, readable argument. Run: `tsx scripts/author-office-scene.ts` then `episode:render-3d`.
|
|
6
|
+
*/
|
|
7
|
+
import { writeFileSync } from "node:fs";
|
|
8
|
+
import { resolve, dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { generateSceneFromPrompt } from "../src/director/prompt-to-scene.js";
|
|
11
|
+
import { directScene } from "../src/director/director-heuristics.js";
|
|
12
|
+
import { estimateSpeechDuration } from "../src/episode-document.js";
|
|
13
|
+
|
|
14
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
|
|
16
|
+
// Base scene gives us the cast (worker-1/worker-2), the OFFICE set, camera shots, assets.
|
|
17
|
+
const base = generateSceneFromPrompt("two office workers arguing about a deadline");
|
|
18
|
+
const doc = base.document as any;
|
|
19
|
+
const [a, b] = base.cast;
|
|
20
|
+
|
|
21
|
+
// AUTHORED dialogue — a coherent argument about a release deadline. Each line makes sense as a
|
|
22
|
+
// reply to the previous one (no hollow "look at this part"). This is what the AI harness writes.
|
|
23
|
+
const authored: { s: string; t: string }[] = [
|
|
24
|
+
{ s: a, t: "We are not shipping on Friday." },
|
|
25
|
+
{ s: b, t: "Yes we are. The login feature is done." },
|
|
26
|
+
{ s: a, t: "It is not done. The tests are still failing." },
|
|
27
|
+
{ s: b, t: "Those tests are flaky. We can ignore them." },
|
|
28
|
+
{ s: a, t: "We are not ignoring failing tests before a release." },
|
|
29
|
+
{ s: b, t: "Fine. Give me one more day and I will fix them." }
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
let cursor = 0.4;
|
|
33
|
+
const lines = authored.map((x, i) => {
|
|
34
|
+
const start = Number(cursor.toFixed(3));
|
|
35
|
+
const dur = estimateSpeechDuration(x.t);
|
|
36
|
+
let end = Number((start + dur).toFixed(3));
|
|
37
|
+
if (end > doc.duration) end = doc.duration;
|
|
38
|
+
cursor = end + 0.3; // a beat between lines
|
|
39
|
+
return { lineId: `l${i}`, speakerId: x.s, text: x.t, startTime: start, endTime: end };
|
|
40
|
+
});
|
|
41
|
+
doc.dialogue = { language: "en", lines };
|
|
42
|
+
|
|
43
|
+
// Re-direct so the acting (who talks / reacts / gestures, and when) matches the AUTHORED dialogue.
|
|
44
|
+
const directed = directScene(doc.id, {
|
|
45
|
+
duration: doc.duration,
|
|
46
|
+
characters: [
|
|
47
|
+
{ id: a, entersFrom: "left" },
|
|
48
|
+
{ id: b, entersFrom: "right" }
|
|
49
|
+
],
|
|
50
|
+
shots: doc.shots,
|
|
51
|
+
dialogue: lines.map((l) => ({ lineId: l.lineId, speakerId: l.speakerId, startTime: l.startTime, endTime: l.endTime, text: l.text })),
|
|
52
|
+
walkableBounds: doc.walkableBounds,
|
|
53
|
+
props: [],
|
|
54
|
+
durationEstimator: estimateSpeechDuration
|
|
55
|
+
}) as any;
|
|
56
|
+
doc.blocking = directed.blocking;
|
|
57
|
+
doc.shots = directed.shots;
|
|
58
|
+
doc.setDressing = directed.setDressing;
|
|
59
|
+
doc.worldState = directed.worldState;
|
|
60
|
+
|
|
61
|
+
const out = JSON.stringify(doc, null, 2) + "\n";
|
|
62
|
+
writeFileSync(resolve(ROOT, "dist/scene/working.document.json"), out);
|
|
63
|
+
writeFileSync(resolve(ROOT, "dist/episodes/acceptance/office.document.json"), out);
|
|
64
|
+
console.log(`authored office scene → ${lines.length} lines, cast ${base.cast.join(", ")}, set ${base.setTemplateId}`);
|
|
65
|
+
for (const l of lines) console.log(` [${l.startTime}-${l.endTime}] ${l.speakerId}: "${l.text}"`);
|