/** * validate-characters.ts — prove the authored GLBs are structurally valid AND that * the engine's REAL loader (the same path `createTypedGLBActor` uses) accepts them. * * Stage 1: parse each GLB's JSON chunk directly and assert skin (joints>0), * animations (>=3), and a `mouthOpen` morph target exist. * Stage 2: load through `loadProductionGLTFRenderPipeline` + build the * `GLTFSceneAnimationRuntime` (exactly what createTypedGLBActor does), * and confirm skinning bindings + the morph parse without error, then * drive a clip and the morph weight to prove the runtime applies them. * * Run from the MONOREPO ROOT so `@aura3d/*` resolve to the source build: * pnpm exec tsx --tsconfig tsconfig.base.json \ * packages/create-aura3d/templates/animation-studio/scripts/validate-characters.ts */ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { inflateSync } from "node:zlib"; import { createGLTFSceneAnimationRuntime, loadProductionGLTFRenderPipeline, type GLTFImageAsset } from "@aura3d/engine/assets"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ASSET_DIR = resolve(__dirname, "../public/aura-assets"); // The live cast files that build-characters.ts writes (and the manifest references). const FILES = ["miko.catalog.glb", "luma2.catalog.glb"]; /** * Minimal Node PNG decoder (8-bit RGBA, all 5 scanline filters, non-interlaced) so the * REAL engine loader can decode the embedded base-colour atlas in Node (the browser path * uses createImageBitmap; here we feed an explicit imageDecoder). Proves the baked PNG is * a genuinely decodable image whose pixels the renderer can upload — not just bytes. */ function decodePNG(bytes: Uint8Array): { width: number; height: number; data: Uint8Array } { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (dv.getUint32(0) !== 0x89504e47) throw new Error("not a PNG"); let off = 8; let width = 0; let height = 0; let bitDepth = 0; let colorType = 0; const idat: Uint8Array[] = []; while (off < bytes.length) { const len = dv.getUint32(off); const type = String.fromCharCode(bytes[off + 4]!, bytes[off + 5]!, bytes[off + 6]!, bytes[off + 7]!); const dataStart = off + 8; if (type === "IHDR") { width = dv.getUint32(dataStart); height = dv.getUint32(dataStart + 4); bitDepth = bytes[dataStart + 8]!; colorType = bytes[dataStart + 9]!; } else if (type === "IDAT") { idat.push(bytes.subarray(dataStart, dataStart + len)); } else if (type === "IEND") { break; } off = dataStart + len + 4; // skip data + CRC } if (bitDepth !== 8 || colorType !== 6) throw new Error(`unsupported PNG (depth ${bitDepth}, colorType ${colorType})`); const compressed = Buffer.concat(idat.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))); const raw = new Uint8Array(inflateSync(compressed)); const channels = 4; const stride = width * channels; const out = new Uint8Array(width * height * channels); const paeth = (a: number, b: number, c: number): number => { const p = a + b - c; const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }; let rp = 0; for (let y = 0; y < height; y += 1) { const filter = raw[rp]!; rp += 1; for (let x = 0; x < stride; x += 1) { const rawByte = raw[rp + x]!; const a = x >= channels ? out[y * stride + x - channels]! : 0; const b = y > 0 ? out[(y - 1) * stride + x]! : 0; const c = x >= channels && y > 0 ? out[(y - 1) * stride + x - channels]! : 0; let v: number; switch (filter) { case 0: v = rawByte; break; case 1: v = rawByte + a; break; case 2: v = rawByte + b; break; case 3: v = rawByte + ((a + b) >> 1); break; case 4: v = rawByte + paeth(a, b, c); break; default: throw new Error(`bad filter ${filter}`); } out[y * stride + x] = v & 0xff; } rp += stride; } return { width, height, data: out }; } interface GLBJson { skins?: { joints: number[]; inverseBindMatrices?: number }[]; animations?: { name?: string }[]; meshes?: { primitives: { targets?: unknown[]; attributes?: Record; material?: number }[]; extras?: { targetNames?: string[] }; weights?: number[]; }[]; images?: unknown[]; textures?: unknown[]; materials?: { pbrMetallicRoughness?: { baseColorTexture?: { index?: number } } }[]; } function parseGLBJson(bytes: Uint8Array): GLBJson { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (dv.getUint32(0, true) !== 0x46546c67) throw new Error("bad GLB magic"); if (dv.getUint32(4, true) !== 2) throw new Error("bad GLB version"); let offset = 12; while (offset < bytes.byteLength) { const len = dv.getUint32(offset, true); const type = dv.getUint32(offset + 4, true); const start = offset + 8; if (type === 0x4e4f534a) { const text = new TextDecoder().decode(bytes.subarray(start, start + len)).replace(/+$/u, "").trim(); return JSON.parse(text) as GLBJson; } offset = start + len; } throw new Error("no JSON chunk"); } async function main(): Promise { let allOk = true; for (const file of FILES) { const path = resolve(ASSET_DIR, file); console.log(`\n=== ${file} ===`); const bytes = new Uint8Array(readFileSync(path)); // ---- Stage 1: JSON chunk structural assertions ---- const json = parseGLBJson(bytes); const skin = json.skins?.[0]; const jointCount = skin?.joints.length ?? 0; const clipNames = (json.animations ?? []).map((a) => a.name ?? "(unnamed)"); const mesh = json.meshes?.[0]; const targetNames = mesh?.extras?.targetNames ?? []; const morphTargetCount = mesh?.primitives?.[0]?.targets?.length ?? 0; const hasMouthOpen = targetNames.includes("mouthOpen"); // Texture presence: images + textures + every primitive carries TEXCOORD_0 + every // material that drives a surface references a baseColorTexture (the new bitmap path). const imageCount = json.images?.length ?? 0; const textureCount = json.textures?.length ?? 0; const allPrimsHaveUV = (mesh?.primitives ?? []).every((p) => p.attributes?.TEXCOORD_0 !== undefined); const allMatsHaveTex = (json.materials ?? []).every((m) => m.pbrMetallicRoughness?.baseColorTexture?.index !== undefined); console.log(` [json] skins=${json.skins?.length ?? 0} joints=${jointCount} ibm=${skin?.inverseBindMatrices !== undefined}`); console.log(` [json] animations=${clipNames.length} -> ${clipNames.join(", ")}`); console.log(` [json] morphTargets=${morphTargetCount} targetNames=[${targetNames.join(",")}] mouthOpen=${hasMouthOpen}`); console.log(` [json] images=${imageCount} textures=${textureCount} allPrimsTEXCOORD_0=${allPrimsHaveUV} allMatsBaseColorTexture=${allMatsHaveTex}`); const stage1Ok = jointCount > 0 && skin?.inverseBindMatrices !== undefined && clipNames.length >= 3 && morphTargetCount >= 1 && hasMouthOpen && imageCount >= 1 && textureCount >= 1 && allPrimsHaveUV && allMatsHaveTex; if (!stage1Ok) { allOk = false; console.error(" [json] FAILED structural assertions"); continue; } // ---- Stage 2: real engine loader ---- // The loader fetches non-data URLs; in Node there is no http server, so feed // the exact same bytes through its `data:model/gltf-binary` path, which runs the // identical `parseGLB` + render-resource + animation-runtime code the browser // route uses via createTypedGLBActor. const url = `data:model/gltf-binary;base64,${Buffer.from(bytes).toString("base64")}`; let decodedTextureDim = 0; const pipeline = await loadProductionGLTFRenderPipeline({ url, assetId: file, assetName: file, width: 512, height: 512, // Node has no createImageBitmap; decode the embedded base-colour atlas PNG ourselves // so the REAL loader builds the texture binding (proves the bitmap is decodable). imageDecoder: (image: GLTFImageAsset) => { if (!image.data) throw new Error("image has no embedded data"); const png = decodePNG(new Uint8Array(image.data)); decodedTextureDim = Math.max(decodedTextureDim, png.width, png.height); return { width: png.width, height: png.height, colorSpace: "srgb", data: png.data }; } }); const meta = pipeline.metadata; console.log( ` [loader] meshes=${meta.meshCount} skins=${meta.skinCount} morphTargets=${meta.morphTargetCount} ` + `animations=${meta.animationCount} hasSkinning=${meta.hasSkinning} hasMorph=${meta.hasMorphTargets}` ); const runtime = createGLTFSceneAnimationRuntime({ scene: pipeline.resources.scene, clips: pipeline.asset.animations, asset: pipeline.asset }); const snapshot = runtime.snapshot(); console.log(` [runtime] clips=[${snapshot.clips.join(",")}] skinningBindingCount=${snapshot.skinningBindingCount}`); // Drive a clip and the morph weight; confirm tracks/palettes applied. const apply = runtime.applyClipByName("Walk", 0.5); console.log( ` [runtime] applyClip(Walk,0.5): tracksApplied=${apply.tracksApplied} ` + `transformTracks=${apply.transformTracksApplied} skinningPalettesUpdated=${apply.skinningPalettesUpdated} ` + `missingTargets=${apply.missingTargets.length}` ); // Confirm morph renderable exists and morphTargetLibrary has the deltas. const morphRenderables = pipeline.resources.scene .collectRenderables() .map((entry) => entry.renderable) .filter((r) => r.morphWeights.length > 0); const morphLibKeys = [...pipeline.resources.morphTargetLibrary.keys()]; // The mesh is split into one primitive per material, so the mouthOpen deltas are // only non-zero on the primitive that owns the mouth lobe (the dark material). // Scan EVERY morph-library entry, not just the first, to measure whether the // mouth morph moves any pixels anywhere on the mesh. let maxDelta = 0; for (const key of morphLibKeys) { const morphDeltas = pipeline.resources.morphTargetLibrary.get(key); if (!morphDeltas) continue; for (const target of morphDeltas) { for (const p of target.positions) { maxDelta = Math.max(maxDelta, Math.abs(p[0]), Math.abs(p[1]), Math.abs(p[2])); } } } console.log( ` [runtime] morphRenderables=${morphRenderables.length} morphLibKeys=[${morphLibKeys.join(",")}] ` + `mouthOpen maxAbsDelta=${maxDelta.toFixed(4)} (must be >0 to move pixels)` ); console.log( ` [loader] materials=${meta.materialCount} textures=${meta.textureCount} ` + `baseColorAtlas decoded=${decodedTextureDim}px (real UV-mapped bitmap, was vertex-colour only)` ); const stage2Ok = meta.skinCount > 0 && snapshot.skinningBindingCount >= 1 && snapshot.clips.length >= 3 && morphRenderables.length >= 1 && maxDelta > 0.01 && apply.skinningPalettesUpdated >= 1 && meta.textureCount >= 1 && decodedTextureDim >= 512; if (!stage2Ok) { allOk = false; console.error(" [loader] FAILED engine-load assertions"); } else { console.log(" OK — structurally valid AND engine loader accepts skin + morph"); } pipeline.dispose(); } console.log(`\n${allOk ? "ALL CHARACTERS VALID" : "VALIDATION FAILED"}`); if (!allOk) process.exitCode = 1; } main().catch((error: unknown) => { console.error("validate-characters failed:", error instanceof Error ? (error.stack ?? error.message) : error); process.exitCode = 1; });