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,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolve-asset.ts — pull a real asset from the Aura3D hosted catalog (#3) into the
|
|
3
|
+
* template's public dir, so a scene's cast/props are DYNAMIC (resolved from the prompt),
|
|
4
|
+
* not the Moon Garden defaults. Reuses the hosted-catalog worker (Objaverse direct GLBs)
|
|
5
|
+
* + the hardened loader (auto weight-normalization). Records provenance (CC-BY).
|
|
6
|
+
*
|
|
7
|
+
* Used by `animation-scene cast add` / `prop add`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { ProbeSession } from "./asset-render-probe.js";
|
|
15
|
+
import { probeEmbeddedMotion, type ClipMotionScore, type MotionProbeReport } from "./asset-motion-probe.js";
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const PUBLIC_DIR = resolve(__dirname, "..", "public", "aura-assets");
|
|
19
|
+
const REPORT_DIR = resolve(__dirname, "..", "dist", "scene");
|
|
20
|
+
const WORKER = "https://aura3d-asset-index-cron.newsroom.workers.dev/search";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Rig grade: how faithfully will the standard humanoid retarget onto this skeleton?
|
|
24
|
+
* A — full humanoid (spine+arms+legs+head, plenty of joints): body acting retargets cleanly.
|
|
25
|
+
* B — humanoid torso+arms but sparse legs / fewer joints: upper-body acting good, locomotion iffy.
|
|
26
|
+
* C — minimal/partial rig (few mapped bones): only crude motion; body acting unreliable.
|
|
27
|
+
* D — not retargetable (no skeleton or no humanoid mapping): refuse body acting.
|
|
28
|
+
*
|
|
29
|
+
* We prefer the engine's `gradeRig` (from a future RigQuality module shipped in the dist) when it
|
|
30
|
+
* is available at runtime; otherwise we inline this bone-count/humanoid heuristic (the PRD's
|
|
31
|
+
* fallback). The inline heuristic uses the SAME signals the engine would: humanoid mapping, joint
|
|
32
|
+
* count, and presence of the arm/leg chains.
|
|
33
|
+
*/
|
|
34
|
+
export type RigGrade = "A" | "B" | "C" | "D";
|
|
35
|
+
|
|
36
|
+
const ARM_HINTS = ["shoulder", "arm", "forearm", "hand", "clavicle", "wrist"];
|
|
37
|
+
const LEG_HINTS = ["leg", "thigh", "calf", "shin", "foot", "ankle", "upleg", "femur", "knee"];
|
|
38
|
+
|
|
39
|
+
function gradeRigInline(info: GlbInfo): { grade: RigGrade; reason: string } {
|
|
40
|
+
if (!info.skinned || info.jointCount === 0) return { grade: "D", reason: "no skeleton" };
|
|
41
|
+
if (!info.humanoid) return { grade: "D", reason: "no humanoid bone mapping" };
|
|
42
|
+
const hasArms = info.boneHintHits.arms;
|
|
43
|
+
const hasLegs = info.boneHintHits.legs;
|
|
44
|
+
if (info.jointCount >= 20 && hasArms && hasLegs) return { grade: "A", reason: "full humanoid skeleton (spine+arms+legs)" };
|
|
45
|
+
if (hasArms && (hasLegs || info.jointCount >= 12)) return { grade: "B", reason: "humanoid torso+arms, sparse/partial legs" };
|
|
46
|
+
if (info.jointCount >= 8) return { grade: "C", reason: "minimal humanoid mapping — crude retarget only" };
|
|
47
|
+
return { grade: "D", reason: "too few mapped bones to retarget" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Try the engine's `gradeRig` (RigQuality) via the runtime module if it exists; fall back to the
|
|
52
|
+
* inline heuristic. Kept async + defensive: the import must never break the resolver if the symbol
|
|
53
|
+
* is absent (current dist) — the PRD explicitly allows the inline fallback.
|
|
54
|
+
*/
|
|
55
|
+
async function gradeRig(info: GlbInfo): Promise<{ grade: RigGrade; reason: string }> {
|
|
56
|
+
try {
|
|
57
|
+
const mod = (await import("@aura3d/animation")) as Record<string, unknown>;
|
|
58
|
+
const fn = mod["gradeRig"];
|
|
59
|
+
if (typeof fn === "function") {
|
|
60
|
+
const r = (fn as (j: number, h: boolean, a: boolean, l: boolean) => RigGrade)(
|
|
61
|
+
info.jointCount, info.humanoid, info.boneHintHits.arms, info.boneHintHits.legs
|
|
62
|
+
);
|
|
63
|
+
if (r === "A" || r === "B" || r === "C" || r === "D") return { grade: r, reason: "graded by @aura3d/animation RigQuality" };
|
|
64
|
+
}
|
|
65
|
+
} catch { /* not available at runtime — inline heuristic below */ }
|
|
66
|
+
return gradeRigInline(info);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ResolvedAsset {
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly url: string; // /aura-assets/<file>.glb (served path)
|
|
72
|
+
readonly hash: string;
|
|
73
|
+
readonly bounds: readonly [number, number, number];
|
|
74
|
+
readonly clips: readonly string[];
|
|
75
|
+
readonly clipCount: number;
|
|
76
|
+
readonly humanoid: boolean;
|
|
77
|
+
readonly skinned: boolean;
|
|
78
|
+
readonly textured: boolean;
|
|
79
|
+
readonly attribution: string;
|
|
80
|
+
readonly sourceUrl: string;
|
|
81
|
+
readonly license: string;
|
|
82
|
+
// ── Quality report (characters only; props leave these undefined). ──
|
|
83
|
+
readonly rigGrade?: RigGrade;
|
|
84
|
+
/** Mesh/material detail grade (poly count + texture resolution + material validity). */
|
|
85
|
+
readonly meshGrade?: MeshGrade;
|
|
86
|
+
/** Combined fidelity letter (worse of rig + mesh) — the headline grade preferred A-first. */
|
|
87
|
+
readonly fidelity?: RigGrade;
|
|
88
|
+
readonly tris?: number;
|
|
89
|
+
readonly textureMaxDim?: number;
|
|
90
|
+
/** Per-embedded-clip motion quality scores (amplitude + bones affected + vocabulary mapping). */
|
|
91
|
+
readonly clipScores?: readonly ClipMotionScore[];
|
|
92
|
+
/** Count of embedded clips that drive real body motion (passed the amplitude gate). */
|
|
93
|
+
readonly usefulClips?: number;
|
|
94
|
+
/** True when embedded clips alone cover idle/talk-or-gesture/walk/react. */
|
|
95
|
+
readonly hasViableActingPack?: boolean;
|
|
96
|
+
/** True when motion must come from the shared retargeted library (no viable pack). */
|
|
97
|
+
readonly libraryFallback?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One row of the CHARACTER RESOLVER REPORT — one per candidate sifted for a scene's cast slot. */
|
|
101
|
+
export interface CharacterResolverRow {
|
|
102
|
+
readonly source: string;
|
|
103
|
+
readonly title: string;
|
|
104
|
+
readonly license: string;
|
|
105
|
+
readonly url: string;
|
|
106
|
+
readonly rigGrade: RigGrade;
|
|
107
|
+
readonly meshGrade: MeshGrade;
|
|
108
|
+
readonly fidelity: RigGrade;
|
|
109
|
+
readonly tris: number;
|
|
110
|
+
readonly textureMaxDim: number;
|
|
111
|
+
readonly clipCount: number;
|
|
112
|
+
readonly usefulClips: number;
|
|
113
|
+
readonly renderPass: boolean;
|
|
114
|
+
readonly motionPass: boolean;
|
|
115
|
+
readonly hasMouth: boolean;
|
|
116
|
+
readonly accepted: boolean;
|
|
117
|
+
readonly libraryFallback: boolean;
|
|
118
|
+
readonly rejectReason?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The full resolver report written to disk for the chosen cast slot. */
|
|
122
|
+
export interface CharacterResolverReport {
|
|
123
|
+
readonly id: string;
|
|
124
|
+
readonly query: string;
|
|
125
|
+
readonly requireDialogue: boolean;
|
|
126
|
+
readonly candidatesTried: number;
|
|
127
|
+
readonly accepted: CharacterResolverRow | null;
|
|
128
|
+
readonly rows: readonly CharacterResolverRow[];
|
|
129
|
+
readonly generatedAt: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface CatalogResult { id: string; title: string; source: string; url: string; license?: string; attribution?: string }
|
|
133
|
+
|
|
134
|
+
async function searchCatalog(query: string): Promise<CatalogResult[]> {
|
|
135
|
+
// The hosted ~850k Aura3D catalog (Objaverse + Sketchfab + Poly Pizza + …). Pull a WIDE
|
|
136
|
+
// ranked list so the metadata + render-test gates downstream have many real candidates to
|
|
137
|
+
// sift — most catalog "robots" are static props, so we need depth to find a rigged one
|
|
138
|
+
// whose textures actually bind. Accept any direct .glb (not just objaverse).
|
|
139
|
+
const url = `${WORKER}?q=${encodeURIComponent(query)}&limit=60&commercial=true`;
|
|
140
|
+
const res = await fetch(url);
|
|
141
|
+
if (!res.ok) throw new Error(`catalog ${res.status}`);
|
|
142
|
+
const data = (await res.json()) as { results?: CatalogResult[] };
|
|
143
|
+
return (data.results ?? []).filter((r) => typeof r.url === "string" && /\.glb($|\?)/.test(r.url));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface GlbInfo { skinned: boolean; textured: boolean; embeddedTextures: boolean; clips: string[]; bounds: [number, number, number]; tris: number; verts: number; jointCount: number; humanoid: boolean; boneHintHits: { arms: boolean; legs: boolean }; textureMaxDim: number; materialCount: number; validMaterials: number }
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* MESH/MATERIAL fidelity grade (separate from the RIG grade): how much visual detail does the
|
|
150
|
+
* asset actually carry? This is the PRD's "poly count, texture resolution, material validity"
|
|
151
|
+
* lever — a rig can be A-grade yet render as a flat 200-triangle blob, so we grade the mesh too
|
|
152
|
+
* and prefer A across BOTH axes.
|
|
153
|
+
*
|
|
154
|
+
* A — high detail: ≥8k tris AND (a ≥512px texture OR multiple valid materials) → reads crisp.
|
|
155
|
+
* B — moderate: ≥2k tris with at least one valid material / small texture.
|
|
156
|
+
* C — low: <2k tris or no real material variety — previz-grade silhouette.
|
|
157
|
+
* D — degenerate: no geometry.
|
|
158
|
+
*/
|
|
159
|
+
export type MeshGrade = "A" | "B" | "C" | "D";
|
|
160
|
+
|
|
161
|
+
function gradeMesh(info: GlbInfo): { grade: MeshGrade; reason: string } {
|
|
162
|
+
if (info.tris === 0) return { grade: "D", reason: "no geometry" };
|
|
163
|
+
const tex = info.textureMaxDim;
|
|
164
|
+
const mats = info.validMaterials;
|
|
165
|
+
const richTexture = tex >= 512;
|
|
166
|
+
const richMaterials = mats >= 2;
|
|
167
|
+
if (info.tris >= 8_000 && (richTexture || richMaterials)) {
|
|
168
|
+
return { grade: "A", reason: `${info.tris} tris, ${tex || 0}px texture, ${mats} valid material(s)` };
|
|
169
|
+
}
|
|
170
|
+
if (info.tris >= 2_000 && (mats >= 1 || tex >= 128)) {
|
|
171
|
+
return { grade: "B", reason: `${info.tris} tris, ${tex || 0}px texture, ${mats} valid material(s)` };
|
|
172
|
+
}
|
|
173
|
+
return { grade: "C", reason: `low detail: ${info.tris} tris, ${tex || 0}px texture, ${mats} valid material(s)` };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Combine rig + mesh into a single fidelity letter (the worse of the two dominates). */
|
|
177
|
+
export function combineFidelity(rig: RigGrade, mesh: MeshGrade): RigGrade {
|
|
178
|
+
const order: RigGrade[] = ["A", "B", "C", "D"];
|
|
179
|
+
return order[Math.max(order.indexOf(rig), order.indexOf(mesh as RigGrade))]!;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Bone names that signal a humanoid skeleton (Mixamo / VRM / generic rigs). We only need a
|
|
183
|
+
// cheap heuristic: if a handful of these turn up among the skin's joint node names, the rig is
|
|
184
|
+
// retargetable to our standard humanoid.
|
|
185
|
+
const HUMANOID_BONE_HINTS = ["hips", "spine", "neck", "head", "shoulder", "arm", "forearm", "hand", "upleg", "leg", "foot", "thigh", "calf", "pelvis", "clavicle"];
|
|
186
|
+
|
|
187
|
+
function inspectGlb(buf: Buffer): GlbInfo | null {
|
|
188
|
+
if (buf.readUInt32LE(0) !== 0x46546c67) return null;
|
|
189
|
+
const jlen = buf.readUInt32LE(12);
|
|
190
|
+
const json = JSON.parse(buf.slice(20, 20 + jlen).toString("utf8")) as Record<string, any>;
|
|
191
|
+
const prims = (json.meshes ?? []).flatMap((m: any) => m.primitives ?? []);
|
|
192
|
+
const imgs = json.images ?? [];
|
|
193
|
+
const min = [1e9, 1e9, 1e9];
|
|
194
|
+
const max = [-1e9, -1e9, -1e9];
|
|
195
|
+
let tris = 0;
|
|
196
|
+
let verts = 0;
|
|
197
|
+
for (const p of prims) {
|
|
198
|
+
const a = json.accessors?.[p.attributes?.POSITION];
|
|
199
|
+
if (a?.min && a?.max) for (let i = 0; i < 3; i += 1) { min[i] = Math.min(min[i]!, a.min[i]); max[i] = Math.max(max[i]!, a.max[i]); }
|
|
200
|
+
if (a?.count) verts += a.count;
|
|
201
|
+
const idx = json.accessors?.[p.indices];
|
|
202
|
+
if (idx) tris += idx.count / 3;
|
|
203
|
+
}
|
|
204
|
+
// ── Texture resolution: parse PNG/JPEG headers embedded in the BIN to read pixel dimensions.
|
|
205
|
+
// glTF images don't carry width/height, so we sniff the actual encoded bytes. Largest wins.
|
|
206
|
+
const binStart = 20 + jlen + 8; // glb header(12) + json chunk header(8) + json + bin chunk header(8)
|
|
207
|
+
const views = (json.bufferViews ?? []) as any[];
|
|
208
|
+
let textureMaxDim = 0;
|
|
209
|
+
for (const img of imgs) {
|
|
210
|
+
if (img.bufferView === undefined) continue;
|
|
211
|
+
const v = views[img.bufferView];
|
|
212
|
+
if (!v) continue;
|
|
213
|
+
const off = binStart + (v.byteOffset ?? 0);
|
|
214
|
+
const dim = sniffImageDim(buf, off, v.byteLength ?? 0);
|
|
215
|
+
if (dim > textureMaxDim) textureMaxDim = dim;
|
|
216
|
+
}
|
|
217
|
+
// ── Material validity: a material counts as "valid" when it actually drives a surface colour
|
|
218
|
+
// (a baseColorTexture, a non-default baseColorFactor, or an emissive). Flat default-white
|
|
219
|
+
// materials don't add visual detail and aren't counted.
|
|
220
|
+
const materials = (json.materials ?? []) as any[];
|
|
221
|
+
let validMaterials = 0;
|
|
222
|
+
for (const m of materials) {
|
|
223
|
+
const pbr = m.pbrMetallicRoughness ?? {};
|
|
224
|
+
const hasTex = pbr.baseColorTexture !== undefined || m.normalTexture !== undefined || m.emissiveTexture !== undefined;
|
|
225
|
+
const bcf = pbr.baseColorFactor as number[] | undefined;
|
|
226
|
+
const nonDefaultColor = Array.isArray(bcf) && (Math.abs((bcf[0] ?? 1) - 1) > 0.02 || Math.abs((bcf[1] ?? 1) - 1) > 0.02 || Math.abs((bcf[2] ?? 1) - 1) > 0.02);
|
|
227
|
+
const ef = m.emissiveFactor as number[] | undefined;
|
|
228
|
+
const hasEmissive = Array.isArray(ef) && (ef[0] > 0.01 || ef[1] > 0.01 || ef[2] > 0.01);
|
|
229
|
+
if (hasTex || nonDefaultColor || hasEmissive) validMaterials += 1;
|
|
230
|
+
}
|
|
231
|
+
// Joint/skeleton inspection: count skin joints and check their node names for humanoid bones.
|
|
232
|
+
const skins = (json.skins ?? []) as any[];
|
|
233
|
+
const nodes = (json.nodes ?? []) as any[];
|
|
234
|
+
const jointIdx = new Set<number>();
|
|
235
|
+
for (const s of skins) for (const j of (s.joints ?? [])) jointIdx.add(j);
|
|
236
|
+
const jointCount = jointIdx.size;
|
|
237
|
+
let hits = 0;
|
|
238
|
+
let hasArms = false;
|
|
239
|
+
let hasLegs = false;
|
|
240
|
+
for (const j of jointIdx) {
|
|
241
|
+
const name = String(nodes[j]?.name ?? "").toLowerCase();
|
|
242
|
+
if (HUMANOID_BONE_HINTS.some((h) => name.includes(h))) hits += 1;
|
|
243
|
+
if (ARM_HINTS.some((h) => name.includes(h))) hasArms = true;
|
|
244
|
+
if (LEG_HINTS.some((h) => name.includes(h))) hasLegs = true;
|
|
245
|
+
}
|
|
246
|
+
// A humanoid rig has a meaningful joint count and several recognisable bone names.
|
|
247
|
+
const humanoid = jointCount >= 8 && hits >= 4;
|
|
248
|
+
return {
|
|
249
|
+
skinned: skins.length > 0,
|
|
250
|
+
textured: imgs.length > 0,
|
|
251
|
+
embeddedTextures: imgs.every((i: any) => i.bufferView !== undefined),
|
|
252
|
+
clips: (json.animations ?? []).map((a: any) => a.name).filter(Boolean),
|
|
253
|
+
bounds: [Math.min(99, +(max[0]! - min[0]!).toFixed(2)), Math.min(99, +(max[1]! - min[1]!).toFixed(2)), Math.min(99, +(max[2]! - min[2]!).toFixed(2))],
|
|
254
|
+
tris,
|
|
255
|
+
verts,
|
|
256
|
+
jointCount,
|
|
257
|
+
humanoid,
|
|
258
|
+
boneHintHits: { arms: hasArms, legs: hasLegs },
|
|
259
|
+
textureMaxDim,
|
|
260
|
+
materialCount: materials.length,
|
|
261
|
+
validMaterials
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Read the pixel dimension (max of width/height) of an embedded PNG or JPEG from its header
|
|
267
|
+
* bytes at `off` in `buf`. Returns 0 for unknown formats. PNG: IHDR at byte 16; JPEG: scan SOF
|
|
268
|
+
* markers. Cheap + dependency-free — we only need a rough resolution tier for grading.
|
|
269
|
+
*/
|
|
270
|
+
function sniffImageDim(buf: Buffer, off: number, len: number): number {
|
|
271
|
+
if (len < 24 || off + 24 > buf.length) return 0;
|
|
272
|
+
// PNG signature 89 50 4E 47 0D 0A 1A 0A, then IHDR (width@16, height@20, big-endian).
|
|
273
|
+
if (buf[off] === 0x89 && buf[off + 1] === 0x50 && buf[off + 2] === 0x4e && buf[off + 3] === 0x47) {
|
|
274
|
+
const w = buf.readUInt32BE(off + 16);
|
|
275
|
+
const h = buf.readUInt32BE(off + 20);
|
|
276
|
+
return Math.max(w, h);
|
|
277
|
+
}
|
|
278
|
+
// JPEG starts FF D8; walk segment markers to a Start-Of-Frame (C0..CF except C4/C8/CC).
|
|
279
|
+
if (buf[off] === 0xff && buf[off + 1] === 0xd8) {
|
|
280
|
+
let p = off + 2;
|
|
281
|
+
const end = Math.min(off + len, buf.length) - 1;
|
|
282
|
+
while (p < end) {
|
|
283
|
+
if (buf[p] !== 0xff) { p += 1; continue; }
|
|
284
|
+
const marker = buf[p + 1]!;
|
|
285
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
286
|
+
if (p + 9 > buf.length) return 0;
|
|
287
|
+
const h = buf.readUInt16BE(p + 5);
|
|
288
|
+
const w = buf.readUInt16BE(p + 7);
|
|
289
|
+
return Math.max(w, h);
|
|
290
|
+
}
|
|
291
|
+
if (p + 4 > buf.length) return 0;
|
|
292
|
+
p += 2 + buf.readUInt16BE(p + 2); // skip this segment
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A downloaded + inspected candidate, kept in memory so we can rank before render-testing. */
|
|
299
|
+
interface ScoredCandidate { c: CatalogResult; buf: Buffer; info: GlbInfo; richness: number }
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Richness score for a character candidate (higher = better cast member). The big wins are a
|
|
303
|
+
* humanoid rig and MULTIPLE named clips — those bring real, retargetable motion. Single-clip
|
|
304
|
+
* rigs still score (the retargeting library covers them); non-rigged ones are filtered before
|
|
305
|
+
* scoring. Joint count is a small tie-breaker (a fuller skeleton retargets more faithfully).
|
|
306
|
+
*/
|
|
307
|
+
function scoreCharacter(info: GlbInfo): number {
|
|
308
|
+
let score = 0;
|
|
309
|
+
if (info.humanoid) score += 100; // a real humanoid rig dominates the ranking
|
|
310
|
+
score += Math.min(info.clips.length, 8) * 20; // more named clips = more motion to draw from
|
|
311
|
+
if (info.skinned) score += 10;
|
|
312
|
+
if (info.textured) score += 5;
|
|
313
|
+
score += Math.min(info.jointCount, 60) * 0.1; // tie-breaker: fuller skeletons retarget better
|
|
314
|
+
// MESH FIDELITY also lifts the ranking so an A-grade textured high-poly candidate is render-
|
|
315
|
+
// tested before a flat low-poly one (the PRD's "prefer A-grade" on poly/texture/material).
|
|
316
|
+
const mesh = gradeMesh(info);
|
|
317
|
+
score += { A: 40, B: 20, C: 5, D: 0 }[mesh.grade];
|
|
318
|
+
score += Math.min(info.tris, 60_000) * 0.0005; // gentle poly tie-breaker
|
|
319
|
+
score += Math.min(info.textureMaxDim, 2048) * 0.002; // gentle texture-resolution tie-breaker
|
|
320
|
+
return +score.toFixed(2);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Resolve an asset for `query`. role="character" requires skinned + animated + embedded
|
|
325
|
+
* textures + a clean standing extent; role="prop" accepts any self-contained GLB. For
|
|
326
|
+
* characters, all viable candidates are downloaded + inspected, then RANKED so multi-clip
|
|
327
|
+
* humanoid rigs are render-tested first (single-clip rigs are a still-acceptable fallback).
|
|
328
|
+
* The first candidate to pass the render-test gate is written to public/aura-assets.
|
|
329
|
+
*/
|
|
330
|
+
export async function resolveAsset(
|
|
331
|
+
query: string,
|
|
332
|
+
id: string,
|
|
333
|
+
role: "character" | "prop",
|
|
334
|
+
opts: { requireDialogue?: boolean } = {}
|
|
335
|
+
): Promise<ResolvedAsset> {
|
|
336
|
+
const requireDialogue = opts.requireDialogue ?? true; // animation-studio scenes are dialogue scenes
|
|
337
|
+
const candidates = await searchCatalog(query);
|
|
338
|
+
const file = `${id}.catalog.glb`;
|
|
339
|
+
|
|
340
|
+
function finalize(
|
|
341
|
+
c: CatalogResult,
|
|
342
|
+
buf: Buffer,
|
|
343
|
+
info: GlbInfo,
|
|
344
|
+
quality?: { rigGrade: RigGrade; motion: MotionProbeReport; meshGrade?: MeshGrade }
|
|
345
|
+
): ResolvedAsset {
|
|
346
|
+
mkdirSync(PUBLIC_DIR, { recursive: true });
|
|
347
|
+
writeFileSync(resolve(PUBLIC_DIR, file), buf);
|
|
348
|
+
const hash = `sha256-${createHash("sha256").update(buf).digest("hex")}`;
|
|
349
|
+
// Prefer the EFFECTIVE mesh grade (incl. rendered detail) the decision loop passes in; fall
|
|
350
|
+
// back to the metadata-only grade for the prop path that has no render probe.
|
|
351
|
+
const mesh = { grade: quality?.meshGrade ?? gradeMesh(info).grade };
|
|
352
|
+
return {
|
|
353
|
+
id,
|
|
354
|
+
url: `/aura-assets/${file}`,
|
|
355
|
+
hash,
|
|
356
|
+
bounds: info.bounds,
|
|
357
|
+
clips: info.clips,
|
|
358
|
+
clipCount: info.clips.length,
|
|
359
|
+
humanoid: info.humanoid,
|
|
360
|
+
skinned: info.skinned,
|
|
361
|
+
textured: info.textured,
|
|
362
|
+
attribution: `${c.title} (${c.source}, ${c.license ?? "CC-BY-4.0"})${c.attribution ? ` by ${c.attribution}` : ""}`,
|
|
363
|
+
sourceUrl: c.url,
|
|
364
|
+
license: c.license ?? "CC-BY-4.0",
|
|
365
|
+
meshGrade: mesh.grade,
|
|
366
|
+
tris: info.tris,
|
|
367
|
+
textureMaxDim: info.textureMaxDim,
|
|
368
|
+
...(quality
|
|
369
|
+
? {
|
|
370
|
+
rigGrade: quality.rigGrade,
|
|
371
|
+
fidelity: combineFidelity(quality.rigGrade, mesh.grade),
|
|
372
|
+
clipScores: quality.motion.clips,
|
|
373
|
+
usefulClips: quality.motion.usefulClips,
|
|
374
|
+
hasViableActingPack: quality.motion.hasViableActingPack,
|
|
375
|
+
libraryFallback: quality.motion.libraryFallback
|
|
376
|
+
}
|
|
377
|
+
: {})
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The CHARACTER RESOLVER REPORT accumulates one row per candidate we actually inspect, then is
|
|
382
|
+
// written to disk so a scene carries an honest, auditable record of what was accepted/rejected.
|
|
383
|
+
const reportRows: CharacterResolverRow[] = [];
|
|
384
|
+
function writeResolverReport(accepted: CharacterResolverRow | null): void {
|
|
385
|
+
const report: CharacterResolverReport = {
|
|
386
|
+
id,
|
|
387
|
+
query,
|
|
388
|
+
requireDialogue,
|
|
389
|
+
candidatesTried: candidates.length,
|
|
390
|
+
accepted,
|
|
391
|
+
rows: reportRows,
|
|
392
|
+
generatedAt: new Date().toISOString()
|
|
393
|
+
};
|
|
394
|
+
try {
|
|
395
|
+
mkdirSync(REPORT_DIR, { recursive: true });
|
|
396
|
+
writeFileSync(resolve(REPORT_DIR, `${id}.resolver-report.json`), `${JSON.stringify(report, null, 2)}\n`);
|
|
397
|
+
} catch { /* report is best-effort; never block resolution on a write failure */ }
|
|
398
|
+
console.error(`\n CHARACTER RESOLVER REPORT (${id}) — ${reportRows.length} candidate(s) graded, accepted=${accepted ? accepted.title : "NONE"}`);
|
|
399
|
+
for (const r of reportRows) {
|
|
400
|
+
console.error(` ${r.accepted ? "ACCEPT" : "reject"} [fidelity ${r.fidelity}: rig ${r.rigGrade}/mesh ${r.meshGrade}] ${r.title} — ${r.tris}tris ${r.textureMaxDim || 0}px clips ${r.usefulClips}/${r.clipCount} render=${r.renderPass ? "Y" : "n"} motion=${r.motionPass ? "Y" : "n"} mouth=${r.hasMouth ? "Y" : "n"}${r.libraryFallback ? " (library-fallback)" : ""}${r.rejectReason ? ` — ${r.rejectReason}` : ""}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Shared cheap metadata gate (self-contained textures + sane scale + tri budget).
|
|
405
|
+
function passesCheapGate(info: GlbInfo): boolean {
|
|
406
|
+
if (info.textured && !info.embeddedTextures) return false; // external-texture GLBs aren't self-contained
|
|
407
|
+
const maxExt = Math.max(...info.bounds);
|
|
408
|
+
if (maxExt > 30 || maxExt < 0.1) return false;
|
|
409
|
+
if (info.tris > 200_000) return false;
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── PROP: stream through candidates, take the first self-contained GLB with geometry. ──
|
|
414
|
+
if (role === "prop") {
|
|
415
|
+
for (const c of candidates) {
|
|
416
|
+
let buf: Buffer;
|
|
417
|
+
try {
|
|
418
|
+
const r = await fetch(c.url);
|
|
419
|
+
if (!r.ok) continue;
|
|
420
|
+
buf = Buffer.from(await r.arrayBuffer());
|
|
421
|
+
} catch { continue; }
|
|
422
|
+
const info = inspectGlb(buf);
|
|
423
|
+
if (!info || !passesCheapGate(info) || info.tris === 0) continue;
|
|
424
|
+
return finalize(c, buf, info);
|
|
425
|
+
}
|
|
426
|
+
throw new Error(`no suitable prop found for "${query}" (tried ${candidates.length} candidates)`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── CHARACTER: download + inspect every viable candidate, RANK by richness, then render-test. ──
|
|
430
|
+
const scored: ScoredCandidate[] = [];
|
|
431
|
+
for (const c of candidates) {
|
|
432
|
+
let buf: Buffer;
|
|
433
|
+
try {
|
|
434
|
+
const r = await fetch(c.url);
|
|
435
|
+
if (!r.ok) continue;
|
|
436
|
+
buf = Buffer.from(await r.arrayBuffer());
|
|
437
|
+
} catch { continue; }
|
|
438
|
+
const info = inspectGlb(buf);
|
|
439
|
+
if (!info || !passesCheapGate(info)) continue;
|
|
440
|
+
// A cast member must be a rigged, animated, textured GLB. Non-rigged/untextured/unanimated
|
|
441
|
+
// are rejected here exactly as before — the retargeting library covers single-clip rigs, so
|
|
442
|
+
// ≥1 clip is the floor; ≥2 clips + humanoid is merely PREFERRED via the score below.
|
|
443
|
+
if (!info.skinned || info.clips.length === 0 || !info.textured) continue;
|
|
444
|
+
scored.push({ c, buf, info, richness: scoreCharacter(info) });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Prefer multi-clip humanoid rigs first, then single-clip rigs, then the rest. Render-test in
|
|
448
|
+
// this order and accept the first that actually renders (passes the chroma/detail gate).
|
|
449
|
+
scored.sort((a, b) => b.richness - a.richness);
|
|
450
|
+
if (scored.length > 0) {
|
|
451
|
+
console.error(` ranked ${scored.length} rigged candidate(s) for "${query}" (preferring multi-clip humanoid rigs):`);
|
|
452
|
+
for (const s of scored.slice(0, 8)) {
|
|
453
|
+
console.error(` [${s.richness}] ${s.c.title} — ${s.info.clips.length} clip(s)${s.info.humanoid ? " +humanoid" : ""} (joints=${s.info.jointCount}) clips=[${s.info.clips.slice(0, 6).join(", ")}]`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// One warm probe harness reused across every render-tested character candidate.
|
|
458
|
+
let probe: ProbeSession | undefined;
|
|
459
|
+
const probeFile = `${id}.probe.glb`;
|
|
460
|
+
try {
|
|
461
|
+
for (const s of scored) {
|
|
462
|
+
const { c, buf, info } = s;
|
|
463
|
+
|
|
464
|
+
// ── GRADE 1/3: RIG GRADE (A/B/C/D). D = no skeleton or no humanoid mapping → reject. ──
|
|
465
|
+
const { grade: rigGrade, reason: gradeReason } = await gradeRig(info);
|
|
466
|
+
|
|
467
|
+
// ── MESH/MATERIAL GRADE: poly count + texture resolution + material validity (metadata).
|
|
468
|
+
// The rendered-detail grade from the render probe is folded in below (effMeshGrade). ──
|
|
469
|
+
const mesh = gradeMesh(info);
|
|
470
|
+
|
|
471
|
+
// ── GRADE 2/3: MOTION PROBE — do the embedded clips actually move the body? ──
|
|
472
|
+
const motion = probeEmbeddedMotion(buf);
|
|
473
|
+
|
|
474
|
+
// ── GRADE 3/3: RENDER PROBE — does it render with colour/detail (not a white ghost)? ──
|
|
475
|
+
const probeScale = info.bounds[1] > 0.1 && info.bounds[1] < 3 ? Math.min(4, Math.max(0.3, 1.6 / info.bounds[1])) : 1.6;
|
|
476
|
+
mkdirSync(PUBLIC_DIR, { recursive: true });
|
|
477
|
+
writeFileSync(resolve(PUBLIC_DIR, probeFile), buf); // serve under a probe name
|
|
478
|
+
probe ??= await ProbeSession.start();
|
|
479
|
+
const verdict = await probe.probe(`/aura-assets/${probeFile}`, info.clips[0]!, probeScale, info.bounds[1] || 1.6);
|
|
480
|
+
const renderPass = verdict.ok;
|
|
481
|
+
|
|
482
|
+
// Decide accept/reject against the FULL set of reject criteria (E1). Motion may be a
|
|
483
|
+
// library-fallback (embedded clips weak) WITHOUT rejecting the character — the shared
|
|
484
|
+
// retargeted library supplies the motion; that is recorded, not treated as a failure.
|
|
485
|
+
let rejectReason: string | undefined;
|
|
486
|
+
if (rigGrade === "D") rejectReason = `unusable rig: ${gradeReason}`; // no skeleton / no humanoid
|
|
487
|
+
else if (!renderPass) rejectReason = `render failed: ${verdict.reason}`; // broken/missing textures, bad bind
|
|
488
|
+
else if (requireDialogue && !motion.hasMouth) rejectReason = "no mouth/face for a dialogue scene";
|
|
489
|
+
|
|
490
|
+
const accepted = !rejectReason;
|
|
491
|
+
// Fold the RENDERED detail grade (measured pixels) into the mesh grade — metadata can claim
|
|
492
|
+
// textures the renderer never binds, so the worse of (metadata mesh, rendered detail) wins.
|
|
493
|
+
const effMeshGrade = combineFidelity(mesh.grade as RigGrade, verdict.detailGrade as RigGrade) as MeshGrade;
|
|
494
|
+
const effFidelity = combineFidelity(rigGrade, effMeshGrade);
|
|
495
|
+
const row: CharacterResolverRow = {
|
|
496
|
+
source: c.source,
|
|
497
|
+
title: c.title,
|
|
498
|
+
license: c.license ?? "CC-BY-4.0",
|
|
499
|
+
url: c.url,
|
|
500
|
+
rigGrade,
|
|
501
|
+
meshGrade: effMeshGrade,
|
|
502
|
+
fidelity: effFidelity,
|
|
503
|
+
tris: info.tris,
|
|
504
|
+
textureMaxDim: info.textureMaxDim,
|
|
505
|
+
clipCount: info.clips.length,
|
|
506
|
+
usefulClips: motion.usefulClips,
|
|
507
|
+
renderPass,
|
|
508
|
+
motionPass: motion.motionPass,
|
|
509
|
+
hasMouth: motion.hasMouth,
|
|
510
|
+
accepted,
|
|
511
|
+
libraryFallback: motion.libraryFallback,
|
|
512
|
+
rejectReason
|
|
513
|
+
};
|
|
514
|
+
reportRows.push(row);
|
|
515
|
+
console.error(
|
|
516
|
+
` probe ${c.title} [fidelity=${effFidelity} rig=${rigGrade} mesh=${effMeshGrade} (meta ${mesh.grade}/render ${verdict.detailGrade}), richness=${s.richness}, ${info.clips.length} clip(s)→${motion.usefulClips} useful]: ` +
|
|
517
|
+
`render=${renderPass ? "PASS" : "REJECT"} (chroma=${verdict.meanChroma} detail=${verdict.lumaStd} ${info.tris}tris ${info.textureMaxDim || 0}px) motion=${motion.motionPass ? "PASS" : "weak"} mouth=${motion.hasMouth ? "Y" : "n"} — ${rejectReason ?? motion.reason}`
|
|
518
|
+
);
|
|
519
|
+
if (!accepted) continue;
|
|
520
|
+
|
|
521
|
+
console.error(` ACCEPTED ${c.title}: fidelity ${effFidelity} (rig ${rigGrade}/mesh ${effMeshGrade}), ${motion.usefulClips}/${info.clips.length} useful clip(s)${motion.libraryFallback ? " (motion via shared library fallback)" : " (viable acting pack)"}`);
|
|
522
|
+
writeResolverReport(row);
|
|
523
|
+
return finalize(c, buf, info, { rigGrade, motion, meshGrade: effMeshGrade });
|
|
524
|
+
}
|
|
525
|
+
} finally {
|
|
526
|
+
if (probe) await probe.close();
|
|
527
|
+
}
|
|
528
|
+
writeResolverReport(null);
|
|
529
|
+
throw new Error(`no suitable character found for "${query}" (tried ${candidates.length} candidates, ${scored.length} rigged) — see resolver report`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* USER-UPLOAD PATH (M1). Resolve a character from a LOCAL .glb the user dropped in — bypassing
|
|
534
|
+
* the catalog entirely. The file is inspected, graded (rig + mesh), render-probed exactly like a
|
|
535
|
+
* catalog candidate, copied into public/aura-assets under `<id>.catalog.glb`, and a resolver
|
|
536
|
+
* report is written. Throws (with the grades in the message) if the GLB has no usable rig or
|
|
537
|
+
* fails the render probe, so a broken upload is rejected honestly rather than silently shipped.
|
|
538
|
+
*/
|
|
539
|
+
export async function resolveLocalGlb(
|
|
540
|
+
filePath: string,
|
|
541
|
+
id: string,
|
|
542
|
+
opts: { requireDialogue?: boolean; skipRenderProbe?: boolean } = {}
|
|
543
|
+
): Promise<ResolvedAsset> {
|
|
544
|
+
const requireDialogue = opts.requireDialogue ?? true;
|
|
545
|
+
const abs = resolve(process.cwd(), filePath);
|
|
546
|
+
if (!existsSync(abs)) throw new Error(`upload not found: ${abs}`);
|
|
547
|
+
const buf = readFileSync(abs);
|
|
548
|
+
const info = inspectGlb(buf);
|
|
549
|
+
if (!info) throw new Error(`not a binary glTF (.glb): ${filePath}`);
|
|
550
|
+
if (!info.skinned || info.jointCount === 0) throw new Error(`uploaded GLB has no skeleton — characters must be rigged (${filePath})`);
|
|
551
|
+
|
|
552
|
+
const { grade: rigGrade, reason: gradeReason } = await gradeRig(info);
|
|
553
|
+
const metaMesh = gradeMesh(info);
|
|
554
|
+
const motion = probeEmbeddedMotion(buf);
|
|
555
|
+
if (rigGrade === "D") throw new Error(`uploaded GLB rejected — unusable rig: ${gradeReason} (rig ${rigGrade}/mesh ${metaMesh.grade})`);
|
|
556
|
+
if (requireDialogue && !motion.hasMouth) {
|
|
557
|
+
console.error(` note: ${filePath} has no mouth/face morph — lip-sync will be disabled for this character.`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// RENDER PROBE (same gate as the catalog path) unless explicitly skipped (offline/no-browser).
|
|
561
|
+
// The rendered detail grade is folded into the mesh grade (worse-of metadata + rendered).
|
|
562
|
+
let renderPass = true;
|
|
563
|
+
let renderReason = "render probe skipped";
|
|
564
|
+
let mesh: MeshGrade = metaMesh.grade;
|
|
565
|
+
if (!opts.skipRenderProbe) {
|
|
566
|
+
const probeFile = `${id}.probe.glb`;
|
|
567
|
+
mkdirSync(PUBLIC_DIR, { recursive: true });
|
|
568
|
+
writeFileSync(resolve(PUBLIC_DIR, probeFile), buf);
|
|
569
|
+
const probeScale = info.bounds[1] > 0.1 && info.bounds[1] < 3 ? Math.min(4, Math.max(0.3, 1.6 / info.bounds[1])) : 1.6;
|
|
570
|
+
const probe = await ProbeSession.start();
|
|
571
|
+
try {
|
|
572
|
+
const verdict = await probe.probe(`/aura-assets/${probeFile}`, info.clips[0] ?? "", probeScale, info.bounds[1] || 1.6);
|
|
573
|
+
renderPass = verdict.ok;
|
|
574
|
+
renderReason = verdict.reason;
|
|
575
|
+
mesh = combineFidelity(metaMesh.grade as RigGrade, verdict.detailGrade as RigGrade) as MeshGrade;
|
|
576
|
+
} finally {
|
|
577
|
+
await probe.close();
|
|
578
|
+
}
|
|
579
|
+
if (!renderPass) throw new Error(`uploaded GLB rejected — render probe failed: ${renderReason} (rig ${rigGrade}/mesh ${mesh})`);
|
|
580
|
+
}
|
|
581
|
+
const fidelity = combineFidelity(rigGrade, mesh);
|
|
582
|
+
|
|
583
|
+
// Accept: copy into the served public dir under the catalog name + write a resolver report.
|
|
584
|
+
const file = `${id}.catalog.glb`;
|
|
585
|
+
mkdirSync(PUBLIC_DIR, { recursive: true });
|
|
586
|
+
writeFileSync(resolve(PUBLIC_DIR, file), buf);
|
|
587
|
+
const hash = `sha256-${createHash("sha256").update(buf).digest("hex")}`;
|
|
588
|
+
|
|
589
|
+
const row: CharacterResolverRow = {
|
|
590
|
+
source: "user-upload", title: `${id} (uploaded ${filePath})`, license: "user-provided", url: abs,
|
|
591
|
+
rigGrade, meshGrade: mesh, fidelity, tris: info.tris, textureMaxDim: info.textureMaxDim,
|
|
592
|
+
clipCount: info.clips.length, usefulClips: motion.usefulClips, renderPass,
|
|
593
|
+
motionPass: motion.motionPass, hasMouth: motion.hasMouth, accepted: true,
|
|
594
|
+
libraryFallback: motion.libraryFallback
|
|
595
|
+
};
|
|
596
|
+
try {
|
|
597
|
+
mkdirSync(REPORT_DIR, { recursive: true });
|
|
598
|
+
const report: CharacterResolverReport = {
|
|
599
|
+
id, query: `upload:${filePath}`, requireDialogue, candidatesTried: 1, accepted: row, rows: [row], generatedAt: new Date().toISOString()
|
|
600
|
+
};
|
|
601
|
+
writeFileSync(resolve(REPORT_DIR, `${id}.resolver-report.json`), `${JSON.stringify(report, null, 2)}\n`);
|
|
602
|
+
} catch { /* best-effort */ }
|
|
603
|
+
console.error(` UPLOAD ACCEPTED ${id}: fidelity ${fidelity} (rig ${rigGrade}/mesh ${mesh}), ${info.tris} tris, ${info.textureMaxDim || 0}px texture, ${motion.usefulClips}/${info.clips.length} useful clip(s)`);
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
id, url: `/aura-assets/${file}`, hash, bounds: info.bounds, clips: info.clips, clipCount: info.clips.length,
|
|
607
|
+
humanoid: info.humanoid, skinned: info.skinned, textured: info.textured,
|
|
608
|
+
attribution: `User-uploaded GLB (${filePath})`, sourceUrl: abs, license: "user-provided",
|
|
609
|
+
rigGrade, meshGrade: mesh, fidelity, tris: info.tris, textureMaxDim: info.textureMaxDim,
|
|
610
|
+
clipScores: motion.clips, usefulClips: motion.usefulClips, hasViableActingPack: motion.hasViableActingPack, libraryFallback: motion.libraryFallback
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Inspect + grade a GLB buffer WITHOUT any network or render probe (pure metadata). Used by the
|
|
616
|
+
* cast-library manifest generator and tests to report a deterministic fidelity grade for a file.
|
|
617
|
+
* Returns the rig + mesh + combined fidelity plus the raw mesh/skeleton stats.
|
|
618
|
+
*/
|
|
619
|
+
export function gradeCastGlb(buf: Buffer): {
|
|
620
|
+
rigGrade: RigGrade; meshGrade: MeshGrade; fidelity: RigGrade;
|
|
621
|
+
jointCount: number; humanoid: boolean; tris: number; verts: number;
|
|
622
|
+
textureMaxDim: number; materials: number; validMaterials: number;
|
|
623
|
+
clips: string[]; hasMouthMorph: boolean;
|
|
624
|
+
} {
|
|
625
|
+
const info = inspectGlb(buf);
|
|
626
|
+
if (!info) throw new Error("not a binary glTF (.glb)");
|
|
627
|
+
const rig = gradeRigInline(info).grade;
|
|
628
|
+
const mesh = gradeMesh(info).grade;
|
|
629
|
+
return {
|
|
630
|
+
rigGrade: rig, meshGrade: mesh, fidelity: combineFidelity(rig, mesh),
|
|
631
|
+
jointCount: info.jointCount, humanoid: info.humanoid, tris: info.tris, verts: info.verts,
|
|
632
|
+
textureMaxDim: info.textureMaxDim, materials: info.materialCount, validMaterials: info.validMaterials,
|
|
633
|
+
clips: info.clips, hasMouthMorph: hasMouthMorphTarget(buf)
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Does the GLB ship a face/mouth morph target (lip-sync CAPABILITY)? This checks for the morph
|
|
639
|
+
* itself — `meshes[].extras.targetNames` carrying a mouth/lip/jaw name, or any declared morph
|
|
640
|
+
* target weights — independent of whether a clip happens to animate it. (The motion probe's
|
|
641
|
+
* `hasMouth` instead asks whether a clip MOVES the mouth, a different question.)
|
|
642
|
+
*/
|
|
643
|
+
function hasMouthMorphTarget(buf: Buffer): boolean {
|
|
644
|
+
if (buf.readUInt32LE(0) !== 0x46546c67) return false;
|
|
645
|
+
const jlen = buf.readUInt32LE(12);
|
|
646
|
+
const json = JSON.parse(buf.subarray(20, 20 + jlen).toString("utf8")) as Record<string, any>;
|
|
647
|
+
const mouthRe = /mouth|lip|jaw|teeth|talk|viseme|open/i;
|
|
648
|
+
for (const m of (json.meshes ?? []) as any[]) {
|
|
649
|
+
const names = m.extras?.targetNames as string[] | undefined;
|
|
650
|
+
if (Array.isArray(names) && names.some((n) => mouthRe.test(String(n)))) return true;
|
|
651
|
+
// A primitive carrying morph targets at all is a lip-sync-capable signal for authored faces.
|
|
652
|
+
const prims = (m.primitives ?? []) as any[];
|
|
653
|
+
if (Array.isArray(names) && names.length > 0 && prims.some((p) => Array.isArray(p.targets) && p.targets.length > 0)) return true;
|
|
654
|
+
}
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// CLI helper: `resolve-asset <query> --id <id> --role character|prop` (used by tests).
|
|
659
|
+
if (process.argv[1] && process.argv[1].endsWith("resolve-asset.ts")) {
|
|
660
|
+
const query = process.argv.slice(2).find((a) => !a.startsWith("--"));
|
|
661
|
+
const idIdx = process.argv.indexOf("--id");
|
|
662
|
+
const roleIdx = process.argv.indexOf("--role");
|
|
663
|
+
const id = idIdx >= 0 ? process.argv[idIdx + 1]! : "asset";
|
|
664
|
+
const role = (roleIdx >= 0 ? process.argv[roleIdx + 1] : "prop") as "character" | "prop";
|
|
665
|
+
if (!query) { console.error("usage: resolve-asset <query> --id <id> --role character|prop"); process.exitCode = 1; }
|
|
666
|
+
else void resolveAsset(query, id, role).then((a) => console.log(JSON.stringify(a, null, 2))).catch((e) => { console.error(e.message); process.exitCode = 1; });
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (!existsSync(PUBLIC_DIR)) mkdirSync(PUBLIC_DIR, { recursive: true });
|