// PROCITY Lane D — R41 §41.3: THE CLIP BANK. Lane E's 46-clip motion library, loaded by group, // lazily, fail-soft, and byte-shaped exactly like the eight clips `loadPedFleet` already loads. // // The contract (Lane E, `web/assets/motion_manifest.json`, E-progress §41.1 ¶4): // groups[] one GLB under web/models/clips/ = ONE fetch, N named animations // clips[] { group, category, duration, loopable, loopSeamDeg, source, mixamo, … } // a group GLB's gltf.animations[] are NAMED BY clipId ⇒ `animations.find(a => a.name === id)` // // Verified before wiring, not assumed: `python3 pipeline/clips_verify.py` → GREEN 0/0 on today's // tree, 46 clips / 6 groups / 3 498 124 B, every group 0 meshes / 0 materials / 0 images / 66 nodes. // // This module is DYNAMICALLY imported by rigs.js, and only when the clip gate is on. That is not // cosmetic: under `?classic=1` / `?noassets=1` the browser never fetches clipbank.js itself, so the // zero-fetch-delta covenant holds for the JS surface as well as the asset surface. // // Loading is per GROUP and lazy by design (the fetch ledger is in LANE_D_NOTES §41): // boot idles.glb + locomotion.glb + the manifest (3 fetches, 1 258 784 B) // lazy sitlean.glb on the first sit/lean intent · browse.glb on the first interior browser · // venue.glb on the first gig / pub / record-shop keeper // never social.glb — no two-person state machine yet (R42) // A clip that has not landed yet is simply absent: every consumer falls back to the pre-R41 8-clip // action, so lazy loading can never change WHICH posture a citizen was assigned (postures.js), only // how soon it looks like it. import { loadGLB } from '../core/loaders.js'; import { canonName, rotOnlyClip } from './rigs.js'; export const CLIP_BASE = 'models/clips/'; export const MANIFEST_URL = 'assets/motion_manifest.json'; // Above this measured first-key→last-key seam a clip does not close, so repeating it pops. E measures // `loopSeamDeg` per clip; we ping-pong those instead of dropping them — which is why `browse_pick_up` // (92.15°) reads as crate-digging (down, up, down) rather than a teleport back to the start. export const LOOP_SEAM_DEG = 25; export class ClipBank { constructor({ clipBase = CLIP_BASE, manifestUrl = MANIFEST_URL } = {}) { this.clipBase = clipBase; this.manifestUrl = manifestUrl; this.manifest = null; this.clips = new Map(); // clipId → AnimationClip, already _canon'd + _rotOnly'd this.groups = new Map(); // group file → Promise (promise-cached: one fetch each) this.loaded = new Set(); // group files whose animations are indexed this.bytes = 0; // manifest-declared bytes of the groups actually resident this._mp = null; } // boot(groups) — the manifest and the eager groups IN PARALLEL. Deliberately not serialised: // the group filenames are static (postures.js BOOT_GROUPS), so waiting for a 16 KB JSON before // starting a 1 MB GLB would add a round trip to the boot for nothing. boot(groups = []) { return Promise.all([this.manifestP(), ...groups.map((g) => this.ensureGroup(g))]).then(() => this); } manifestP() { return (this._mp ||= fetch(this.manifestUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null) .then((m) => { this.manifest = m || null; return this.manifest; })); } // ensureGroup(file) → Promise. Promise-cached per file (and loadGLB is URL-cached under // that), so N callers racing on the first interior cost exactly one fetch. Fail-soft: a missing or // broken group resolves false and every consumer keeps its fallback clip. ensureGroup(file) { if (!file) return Promise.resolve(false); let p = this.groups.get(file); if (p) return p; p = loadGLB(this.clipBase + file).then((g) => { const anims = (g && g.animations) || []; if (!anims.length) return false; for (const a of anims) { if (this.clips.has(a.name)) continue; // the same two-step every shared clip in this project rides: fold mixamorigN: → mixamorig: // so any clip binds to any character, then keep rotations only (rigs.js `_rotOnly` — drops // every position/scale track AND Hips.quaternion). E measured 65 raw tracks → 64 after, on // all 46; asserted again below so a future re-pack that breaks it fails loudly here. a.tracks.forEach((t) => { t.name = canonName(t.name); }); const c = rotOnlyClip(a); c.name = a.name; if (!c.tracks.length) { console.warn('[clipbank] clip empty after _rotOnly, skipped:', a.name); continue; } this.clips.set(a.name, c); } this.loaded.add(file); const gm = this.manifest && this.manifest.groups && this.manifest.groups[file]; if (gm && gm.bytes) this.bytes += gm.bytes; return true; }); this.groups.set(file, p); return p; } // get(id) → AnimationClip | null. Null is a first-class answer: "assigned, not resident yet". get(id) { if (!id || id[0] === '@') return null; // '@…' are the pre-R41 base-asset sentinels const c = this.clips.get(id); if (!c) return null; // loop mode is decided ONCE, and only when the manifest is actually there to decide it from — // otherwise a clip that resolved before the JSON would be frozen on the default forever. if (c._pcLoop === undefined && this.manifest && this.manifest.clips) { const m = this.manifest.clips[id]; c._pcLoop = (m && !m.loopable && (m.loopSeamDeg || 0) > LOOP_SEAM_DEG) ? 'pingpong' : 'repeat'; } return c; } has(id) { return !!(id && id[0] !== '@' && this.clips.has(id)); } meta(id) { return (this.manifest && this.manifest.clips && this.manifest.clips[id]) || null; } // resolve a whole posture set at once; missing entries come back null (⇒ base-clip fallback) resolve(p) { return p ? { idle: this.get(p.idle), walk: this.get(p.walk), sit: this.get(p.sit), lean: this.get(p.lean) } : null; } stats() { return { groups: this.loaded.size, groupList: [...this.loaded].sort(), clips: this.clips.size, bytes: this.bytes, manifest: !!this.manifest, catalogue: this.manifest ? this.manifest.clipCount : 0, }; } // AnimationClips hold NO GPU resources (these groups are 0 meshes / 0 materials / 0 images — E's // glb_stat + clips_verify both assert it), so there is nothing to free but the references. The // parsed gltf stays in core/loaders' URL cache exactly like every other GLB in the game. dispose() { this.clips.clear(); this.groups.clear(); this.loaded.clear(); this.bytes = 0; } } // NOTE on imports, on purpose: nothing outside rigs.js imports this module STATICALLY. rigs.js // reaches it with a dynamic `import()` behind the clip gate, and every other consumer (sim, keepers, // band) touches it only through the live `fleet.bank` object. So `?classic=1` / `?noassets=1` fetch // neither the clip GLBs nor this file — the zero-fetch-delta covenant holds on the JS surface too.