// Build a multi-clip GLB from the pack's single baked Take: extract a chosen set of // clips (by frame range from angelfish-clips.json) into SEPARATE named animations, each // rebased to start at 0, then drop the original 75s Take. This is what the Zen Den's // behavior engine crossfades between. Usage: node build-clips.mjs in.glb out.glb import { NodeIO } from '@gltf-transform/core'; import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; import { prune } from '@gltf-transform/functions'; import fs from 'node:fs'; const [, , inPath, outPath] = process.argv; const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); const doc = await io.read(inPath); const root = doc.getRoot(); const buffer = root.listBuffers()[0]; const meta = JSON.parse(fs.readFileSync(new URL('./angelfish-clips.json', import.meta.url))).clips; // output-clip-name -> source clip in the pack. Full turn SEQUENCES (in→loop→out) so the // controller can play a real turn; swim intensities for cadence/zoomies; eats for forage. const MAP = { idle: 'Idle', cruise: 'Swim1_norm', fast: 'Swim2_Fast', turnLin: 'Turn_L_in', turnLloop: 'Turn_L_loop', turnLout: 'Turn_L_out', turnRin: 'Turn_R_in', turnRloop: 'Turn_R_loop', turnRout: 'Turn_R_out', eatswim: 'Eat_Swim', eatground: 'Eat_Ground', }; const src = root.listAnimations()[0]; const srcChannels = src.listChannels(); const EPS = 1e-4; // The original Take's time/value accessors — captured now, disposed after we've copied // the windows we need (new clips create their own fresh accessors, so these become dead). const oldAccessors = new Set(); for (const sm of src.listSamplers()) { oldAccessors.add(sm.getInput()); oldAccessors.add(sm.getOutput()); } for (const [outName, srcName] of Object.entries(MAP)) { const [s0, s1] = meta[srcName].seconds; const anim = doc.createAnimation(outName); for (const ch of srcChannels) { const sm = ch.getSampler(); const times = sm.getInput().getArray(); let a = 0; while (a < times.length - 1 && times[a] < s0 - EPS) a++; let b = times.length - 1; while (b > a && times[b] > s1 + EPS) b--; const base = times[a]; const nTimes = Float32Array.from(times.slice(a, b + 1), (x) => x - base); const out = sm.getOutput(); const c = out.getElementSize(); const nOut = out.getArray().slice(a * c, (b + 1) * c); const inAcc = doc.createAccessor().setType('SCALAR').setArray(nTimes).setBuffer(buffer); const outAcc = doc.createAccessor().setType(out.getType()).setArray(nOut).setBuffer(buffer); const ns = doc.createAnimationSampler().setInput(inAcc).setOutput(outAcc).setInterpolation(sm.getInterpolation()); anim.addSampler(ns); anim.addChannel(doc.createAnimationChannel().setTargetNode(ch.getTargetNode()).setTargetPath(ch.getTargetPath()).setSampler(ns)); } } src.dispose(); // remove the original 75s Take for (const acc of oldAccessors) acc.dispose(); // free its ~10MB of keyframe data await doc.transform(prune()); await io.write(outPath, doc); console.log('clips:', root.listAnimations().map((a) => `${a.getName()}(${a.listChannels().length}ch)`).join(', '));