@pose_flow/babylon 0.1.0 → 0.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.
Files changed (53) hide show
  1. package/README.md +129 -105
  2. package/dist/AI_USAGE.md +386 -0
  3. package/dist/PUBLISHING.md +110 -0
  4. package/dist/babylon-inertialization.d.ts +29 -0
  5. package/dist/babylon-inertialization.js +88 -0
  6. package/dist/babylon.d.ts +27 -5
  7. package/dist/babylon.js +181 -26
  8. package/dist/core/controller.d.ts +26 -0
  9. package/dist/core/controller.js +78 -8
  10. package/dist/core/database.d.ts +8 -2
  11. package/dist/core/database.js +62 -24
  12. package/dist/core/features.d.ts +1 -1
  13. package/dist/core/features.js +9 -11
  14. package/dist/core/inertialization.d.ts +3 -0
  15. package/dist/core/inertialization.js +15 -0
  16. package/dist/core/profile.d.ts +34 -8
  17. package/dist/core/profile.js +49 -10
  18. package/dist/core/root-motion.d.ts +5 -3
  19. package/dist/core/root-motion.js +43 -7
  20. package/dist/core/scenario.d.ts +29 -0
  21. package/dist/core/scenario.js +57 -0
  22. package/dist/core/schema.d.ts +56 -0
  23. package/dist/core/schema.js +123 -0
  24. package/dist/core/search.d.ts +40 -2
  25. package/dist/core/search.js +147 -16
  26. package/dist/core/selection.d.ts +24 -0
  27. package/dist/core/selection.js +41 -0
  28. package/dist/core/types.d.ts +24 -4
  29. package/dist/core/types.js +1 -0
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.js +4 -0
  32. package/dist/offline/src/babylon-inertialization.js +88 -0
  33. package/dist/offline/src/babylon.js +281 -0
  34. package/dist/offline/src/core/controller.js +162 -0
  35. package/dist/offline/src/core/database.js +97 -0
  36. package/dist/offline/src/core/features.js +41 -0
  37. package/dist/offline/src/core/inertialization.js +15 -0
  38. package/dist/offline/src/core/profile.js +103 -0
  39. package/dist/offline/src/core/root-motion.js +78 -0
  40. package/dist/offline/src/core/scenario.js +57 -0
  41. package/dist/offline/src/core/schema.js +123 -0
  42. package/dist/offline/src/core/search.js +181 -0
  43. package/dist/offline/src/core/selection.js +41 -0
  44. package/dist/offline/src/core/types.js +3 -0
  45. package/dist/offline/src/index.js +11 -0
  46. package/dist/offline/tools/bake.js +88 -0
  47. package/dist/offline/tools/cli.js +30 -0
  48. package/dist/offline/tools/glb.js +54 -0
  49. package/dist/offline/tools/prepare.js +165 -0
  50. package/dist/offline/tools/profile-assets.js +90 -0
  51. package/dist/offline/tools/rig.js +47 -0
  52. package/dist/offline/tools/validate-assets.js +43 -0
  53. package/package.json +48 -39
@@ -0,0 +1,103 @@
1
+ import { compileSchema, parseSchema } from './schema.js';
2
+ import { validateContext, validateSelection } from './selection.js';
3
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
4
+ const name = (v) => typeof v === 'string' && v.trim().length > 0;
5
+ const asset = (v) => name(v) && !v.includes('\\') && !v.includes('..') && !/[?#:]/.test(v);
6
+ export function parseRootOrientation(value) {
7
+ if (!Array.isArray(value) || value.length !== 4 || ![...value].every(Number.isFinite) || Math.abs(Math.hypot(...value) - 1) > 1e-5)
8
+ throw new Error('Invalid rootOrientation: expected unit quaternion [x,y,z,w]');
9
+ return [...value];
10
+ }
11
+ /** Read untrusted JSON as unknown; report a configuration error before loading assets. */
12
+ export function parseProfile(input) {
13
+ if (!record(input) || input.version !== 2 || !name(input.id) || !/^[a-z0-9][a-z0-9-]*$/.test(input.id) || !name(input.label))
14
+ throw new Error('Profile: expected version 2; split v1 clips into animationSet and declare schema');
15
+ if ('clips' in input || !asset(input.schema) || !input.schema.startsWith('/') || !input.schema.endsWith('.json')
16
+ || !asset(input.animationSet) || !input.animationSet.startsWith('/') || !input.animationSet.endsWith('.json'))
17
+ throw new Error('Profile: declare schema and animationSet JSON URLs, without inline clips');
18
+ if (!asset(input.assetBase) || !input.assetBase.startsWith('/') || !input.assetBase.endsWith('/') || !asset(input.database) || !input.database.startsWith('/') || !input.database.endsWith('/')
19
+ || !asset(input.model) || input.model.startsWith('/'))
20
+ throw new Error('Profile: use public URL folders and relative model files');
21
+ if (typeof input.scale !== 'number' || !Number.isFinite(input.scale) || input.scale <= 0 || typeof input.sampleRate !== 'number' || !Number.isFinite(input.sampleRate) || input.sampleRate < 1 || input.sampleRate > 120)
22
+ throw new Error('Profile: invalid scale/sampleRate');
23
+ if (!record(input.rig) || !name(input.rig.root) || !Object.entries(input.rig).every(([k, v]) => /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(k) && name(v)))
24
+ throw new Error('Profile: semantic rig roles must be mapped');
25
+ if (new Set(Object.values(input.rig)).size !== Object.values(input.rig).length)
26
+ throw new Error('Profile: required bones must be distinct');
27
+ if (input.stripRoot !== undefined && !name(input.stripRoot))
28
+ throw new Error('Profile: invalid stripRoot');
29
+ if (input.masks !== undefined && (!record(input.masks) || !Object.entries(input.masks).length || !Object.entries(input.masks).every(([key, values]) => name(key)
30
+ && Array.isArray(values) && values.length > 0 && values.every(name) && new Set(values).size === values.length)))
31
+ throw new Error('Profile: invalid masks');
32
+ if (input.layerPreview !== undefined) {
33
+ const preview = input.layerPreview, masks = input.masks;
34
+ if (!record(preview) || !name(preview.baseMask) || !name(preview.overlayMask) || !masks || !Object.hasOwn(masks, preview.baseMask) || !Object.hasOwn(masks, preview.overlayMask)
35
+ || masks[preview.baseMask].some(node => masks[preview.overlayMask].includes(node)))
36
+ throw new Error('Profile: layer preview requires two disjoint masks');
37
+ validateContext(preview.context);
38
+ if (!Object.keys(preview.context).length)
39
+ throw new Error('Profile: layer preview requires explicit context');
40
+ }
41
+ if (input.boneMap !== undefined && (!record(input.boneMap) || !Object.entries(input.boneMap).every(([k, v]) => name(k) && name(v)) || new Set(Object.values(input.boneMap)).size !== Object.values(input.boneMap).length))
42
+ throw new Error('Profile: boneMap must map distinct nonempty names');
43
+ return structuredClone(input);
44
+ }
45
+ export function parseAnimationSet(input) {
46
+ if (!record(input) || input.version !== 1 || !name(input.id) || !Array.isArray(input.clips) || !input.clips.length)
47
+ throw new Error('AnimationSet: invalid version/id/clips');
48
+ const ids = new Set();
49
+ for (const clip of input.clips) {
50
+ if (!record(clip) || !name(clip.id) || ids.has(clip.id) || !asset(clip.file) || clip.file.startsWith('/') || !name(clip.animation) || typeof clip.looping !== 'boolean'
51
+ || !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(String(clip.gait)))
52
+ throw new Error('Profile: invalid/duplicate clip');
53
+ ids.add(clip.id);
54
+ if (clip.rootOrientation !== undefined)
55
+ parseRootOrientation(clip.rootOrientation);
56
+ if (clip.range !== undefined && (!Array.isArray(clip.range) || clip.range.length !== 2 || !clip.range.every(Number.isFinite) || clip.range[0] < 0 || clip.range[1] <= clip.range[0]))
57
+ throw new Error('Invalid clip range');
58
+ validateSelection(clip, Array.isArray(clip.range) ? clip.range[1] - clip.range[0] : Infinity);
59
+ const nominal = clip.nominalVelocity !== undefined, measured = clip.motionFile !== undefined;
60
+ if (nominal === measured)
61
+ throw new Error(`Profile ${clip.id}: declare exactly one of nominalVelocity or motionFile`);
62
+ if (nominal && (!Array.isArray(clip.nominalVelocity) || clip.nominalVelocity.length !== 3 || !clip.nominalVelocity.every(Number.isFinite)))
63
+ throw new Error(`Profile ${clip.id}: invalid velocity`);
64
+ if (measured && (!asset(clip.motionFile) || clip.motionFile.startsWith('/')))
65
+ throw new Error(`Profile ${clip.id}: measured motion requires a relative file`);
66
+ }
67
+ return structuredClone(input);
68
+ }
69
+ /** Resolve external configuration explicitly; core performs no I/O. */
70
+ export function resolveProfile(profile, schemaInput, setInput) {
71
+ const config = parseProfile(profile), schemaDefinition = parseSchema(schemaInput), set = parseAnimationSet(setInput);
72
+ for (const role of compileSchema(schemaDefinition).bones)
73
+ if (!Object.hasOwn(config.rig, role))
74
+ throw new Error('Profile: missing schema bone role: ' + role);
75
+ if (!config.stripRoot && set.clips.some(c => c.rootOrientation !== undefined))
76
+ throw new Error('Profile: rootOrientation requires stripRoot');
77
+ return { ...config, schemaDefinition, clips: set.clips, setId: set.id };
78
+ }
79
+ /** Deterministic configuration fingerprint; detects stale banks, not a security signature. */
80
+ export function profileSignature(profile) {
81
+ const sorted = (value) => Array.isArray(value) ? value.map(sorted) : record(value) ? Object.fromEntries(Object.keys(value).sort().map(k => [k, sorted(value[k])])) : value;
82
+ const text = JSON.stringify(sorted(profile));
83
+ let hash = 2166136261;
84
+ for (let i = 0; i < text.length; i++)
85
+ hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
86
+ return (hash >>> 0).toString(16).padStart(8, '0');
87
+ }
88
+ /** Suggestions only: ambiguity is returned to the caller, never silently resolved. */
89
+ export function suggestRig(names) {
90
+ const aliases = { root: ['root', 'hips', 'pelvis'], leftFoot: ['footl', 'leftfoot', 'lfoot'], rightFoot: ['footr', 'rightfoot', 'rfoot'] };
91
+ return Object.fromEntries(Object.entries(aliases).map(([role, values]) => [role, names.filter(n => values.includes(n.split(':').at(-1).toLowerCase().replace(/[^a-z]/g, '')))]));
92
+ }
93
+ /** Semantic availability, independent of character/clip names. Preserve optional actions. */
94
+ export function filterLocomotionGaits(available, speed) {
95
+ if (!Number.isFinite(speed) || speed < 0)
96
+ throw new Error('Invalid locomotion speed');
97
+ const result = new Set(available), movement = ['walk', 'run', 'strafe'];
98
+ if (speed > 0.2 && movement.some(g => result.has(g)))
99
+ result.delete('idle');
100
+ else if (speed < 0.05 && result.has('idle'))
101
+ movement.forEach(g => result.delete(g));
102
+ return result;
103
+ }
@@ -0,0 +1,78 @@
1
+ import { HORIZONS } from './types.js';
2
+ import { toLocal } from './features.js';
3
+ import { validateHorizons } from './schema.js';
4
+ export function validateRootMotion(track) {
5
+ if (!track || track.version !== 1 || !['hips', 'root'].includes(track.source) || !Number.isFinite(track.duration) || track.duration <= 0 || !Array.isArray(track.samples) || track.samples.length < 2)
6
+ throw new Error('Invalid root motion track');
7
+ let previous = -Infinity;
8
+ for (const sample of track.samples) {
9
+ if (!sample || ![sample.time, sample.x, sample.z, sample.yaw].every(Number.isFinite) || sample.time <= previous || sample.time < 0 || sample.time > track.duration + 1e-6)
10
+ throw new Error('Invalid root motion sample');
11
+ previous = sample.time;
12
+ }
13
+ if (track.samples[0].time !== 0 || Math.abs(track.samples.at(-1).time - track.duration) > 1e-6)
14
+ throw new Error('Incomplete root motion track');
15
+ }
16
+ /** Clamped sampling for non-looping clips; completed motion stays at its endpoint. */
17
+ export function sampleRootMotion(track, time, looping = false) {
18
+ if (!Number.isFinite(time))
19
+ throw new Error('Invalid root motion time');
20
+ if (looping) {
21
+ const cycle = Math.floor(time / track.duration), phase = time - cycle * track.duration;
22
+ if (!Number.isSafeInteger(cycle))
23
+ throw new Error('Root motion cycle out of range');
24
+ const first = track.samples[0], last = track.samples.at(-1);
25
+ const delta = toLocal(last.x - first.x, last.z - first.z, first.yaw), angle = last.yaw - first.yaw;
26
+ // Exponentiate planar rigid transforms, including negative cycles.
27
+ const compose = (a, b) => ({ time,
28
+ x: a.x + Math.cos(a.yaw) * b.x + Math.sin(a.yaw) * b.z,
29
+ z: a.z - Math.sin(a.yaw) * b.x + Math.cos(a.yaw) * b.z, yaw: a.yaw + b.yaw });
30
+ let power = { time, x: delta[0], z: delta[1], yaw: angle };
31
+ if (cycle < 0) {
32
+ const v = toLocal(-power.x, -power.z, angle);
33
+ power = { time, x: v[0], z: v[1], yaw: -angle };
34
+ }
35
+ let result = { time, x: 0, z: 0, yaw: 0 }, count = Math.abs(cycle);
36
+ while (count > 0) {
37
+ if (count % 2 === 1)
38
+ result = compose(result, power);
39
+ power = compose(power, power);
40
+ count = Math.floor(count / 2);
41
+ }
42
+ const sample = sampleRootMotion(track, phase), local = toLocal(sample.x - first.x, sample.z - first.z, first.yaw);
43
+ return compose(first, compose(result, { time, x: local[0], z: local[1], yaw: sample.yaw - first.yaw }));
44
+ }
45
+ const t = Math.max(0, Math.min(time, track.duration)), samples = track.samples;
46
+ let lo = 0, hi = samples.length - 1;
47
+ while (hi - lo > 1) {
48
+ const mid = (lo + hi) >>> 1;
49
+ if (samples[mid].time <= t)
50
+ lo = mid;
51
+ else
52
+ hi = mid;
53
+ }
54
+ const a = samples[lo], b = samples[hi], alpha = (t - a.time) / (b.time - a.time);
55
+ return { time: t, x: a.x + (b.x - a.x) * alpha, z: a.z + (b.z - a.z) * alpha, yaw: a.yaw + (b.yaw - a.yaw) * alpha };
56
+ }
57
+ export function rootMotionFeatures(track, time, derivativeStep = 1 / 30, horizons = HORIZONS, looping = false) {
58
+ validateHorizons(horizons);
59
+ if (!Number.isFinite(derivativeStep) || derivativeStep <= 0)
60
+ throw new Error('Invalid derivative step');
61
+ const now = sampleRootMotion(track, time, looping), a = sampleRootMotion(track, time - derivativeStep, looping), b = sampleRootMotion(track, time + derivativeStep, looping);
62
+ const elapsed = b.time - a.time;
63
+ const v = elapsed > 0 ? toLocal((b.x - a.x) / elapsed, (b.z - a.z) / elapsed, now.yaw) : [0, 0];
64
+ return { velocity: [v[0], 0, v[1]], angularVelocity: elapsed > 0 ? (b.yaw - a.yaw) / elapsed : 0,
65
+ trajectory: horizons.map(horizon => {
66
+ const future = sampleRootMotion(track, time + horizon, looping), yaw = future.yaw - now.yaw;
67
+ return { position: toLocal(future.x - now.x, future.z - now.z, now.yaw), facing: [Math.sin(yaw), Math.cos(yaw)] };
68
+ }) };
69
+ }
70
+ /** Crop to the same source interval as playback; preserve the source coordinate frame. */
71
+ export function cropRootMotion(track, start, end) {
72
+ validateRootMotion(track);
73
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start || end > track.duration)
74
+ throw new Error('Invalid root motion range');
75
+ const samples = [sampleRootMotion(track, start), ...track.samples.filter(s => s.time > start && s.time < end), sampleRootMotion(track, end)]
76
+ .map(s => ({ ...s, time: s.time - start }));
77
+ return { version: 1, source: track.source, duration: end - start, samples };
78
+ }
@@ -0,0 +1,57 @@
1
+ import { encodeFeatures } from './database.js';
2
+ import { MotionMatcher } from './controller.js';
3
+ import { PoseSearch } from './search.js';
4
+ /** Content identity for exact replay, not a cryptographic/security signature. */
5
+ export function scenarioDatabaseIdentity(db) {
6
+ let hash = 2166136261;
7
+ for (const char of JSON.stringify(db.manifest))
8
+ hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
9
+ for (const byte of new Uint8Array(encodeFeatures(db)))
10
+ hash = Math.imul(hash ^ byte, 16777619);
11
+ return 'scenario-db-v1:' + (hash >>> 0).toString(16).padStart(8, '0');
12
+ }
13
+ export function restoreScenarioQuery(recorded) {
14
+ return { ...recorded, features: Float32Array.from(recorded.features), gaits: recorded.gaits ? new Set(recorded.gaits) : undefined,
15
+ excludedClips: recorded.excludedClips ? new Set(recorded.excludedClips) : undefined };
16
+ }
17
+ export function captureScenarioFrame(dt, query, position, yaw, playback, presentation) {
18
+ return { dt, query: { ...query, features: [...query.features], ...(query.context ? { context: { ...query.context } } : {}),
19
+ gaits: query.gaits ? [...query.gaits] : undefined, excludedClips: query.excludedClips ? [...query.excludedClips] : undefined },
20
+ controller: { position: [...position], yaw }, playback: { clip: playback.clip, time: playback.time, switched: playback.switched },
21
+ ...(presentation ? { presentation: { clip: presentation.clip, time: presentation.time, switched: presentation.switched } } : {}) };
22
+ }
23
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
24
+ /** Import only validated local data, never execute content from a scenario file. */
25
+ export function parseMotionScenario(input, db) {
26
+ if (!record(input) || input.version !== 1 || input.databaseIdentity !== scenarioDatabaseIdentity(db) || !Array.isArray(input.frames)
27
+ || !input.frames.length || input.frames.length > 6000 || input.frames.length * db.manifest.dimensions > 2_000_000 || !record(input.matcherOptions))
28
+ throw new Error('Invalid/incompatible motion scenario');
29
+ const options = input.matcherOptions;
30
+ if (Object.keys(options).some(k => !['searchInterval', 'minimumHold', 'improvement', 'jumpPenalty', 'timeSeparation', 'reselectDelay', 'idleVariation', 'diagnostics'].includes(k)))
31
+ throw new Error('Invalid scenario matcher options');
32
+ new MotionMatcher(db, options);
33
+ const search = new PoseSearch(db);
34
+ for (const frame of input.frames) {
35
+ if (!record(frame) || typeof frame.dt !== 'number' || !Number.isFinite(frame.dt) || frame.dt < 0 || frame.dt > 10 || !record(frame.query)
36
+ || !record(frame.controller) || !record(frame.playback))
37
+ throw new Error('Invalid scenario frame');
38
+ const q = frame.query, p = frame.playback, controller = frame.controller;
39
+ if (Object.keys(q).some(k => !['features', 'schemaSignature', 'gaits', 'excludedClips', 'velocityTolerance', 'allowTemporalReselection', 'context'].includes(k))
40
+ || !Array.isArray(q.features) || q.features.length !== db.manifest.dimensions || !q.features.every(Number.isFinite)
41
+ || (q.gaits !== undefined && (!Array.isArray(q.gaits) || !q.gaits.every(g => ['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(g))))
42
+ || (q.excludedClips !== undefined && (!Array.isArray(q.excludedClips) || !q.excludedClips.every(c => Number.isInteger(c) && c >= 0 && c < db.manifest.clips.length))))
43
+ throw new Error('Invalid scenario query');
44
+ search.validateQuery(restoreScenarioQuery(q));
45
+ if (!Array.isArray(controller.position) || controller.position.length !== 3 || !controller.position.every(Number.isFinite) || typeof controller.yaw !== 'number' || !Number.isFinite(controller.yaw)
46
+ || !Number.isInteger(p.clip) || p.clip < -1 || p.clip >= db.manifest.clips.length || typeof p.time !== 'number' || !Number.isFinite(p.time) || p.time < 0
47
+ || (p.clip >= 0 && p.time > db.manifest.clips[p.clip].duration + 1e-6) || typeof p.switched !== 'boolean')
48
+ throw new Error('Invalid scenario playback/controller');
49
+ if (frame.presentation !== undefined) {
50
+ const p = frame.presentation;
51
+ if (!record(p) || !Number.isInteger(p.clip) || p.clip < -1 || p.clip >= db.manifest.clips.length || typeof p.time !== 'number' || !Number.isFinite(p.time) || p.time < 0
52
+ || (p.clip >= 0 && p.time > db.manifest.clips[p.clip].duration + 1e-6) || typeof p.switched !== 'boolean')
53
+ throw new Error('Invalid scenario presentation');
54
+ }
55
+ }
56
+ return structuredClone(input);
57
+ }
@@ -0,0 +1,123 @@
1
+ export const LOCOMOTION_SCHEMA = {
2
+ version: 1, id: 'biped-locomotion', channels: [
3
+ { id: 'left-position', kind: 'bone-position', bone: 'leftFoot', weight: 1, normalization: 'standard' },
4
+ { id: 'right-position', kind: 'bone-position', bone: 'rightFoot', weight: 1, normalization: 'standard' },
5
+ { id: 'left-velocity', kind: 'bone-velocity', bone: 'leftFoot', weight: 1, normalization: 'standard' },
6
+ { id: 'right-velocity', kind: 'bone-velocity', bone: 'rightFoot', weight: 1, normalization: 'standard' },
7
+ { id: 'root-velocity', kind: 'root-velocity', weight: 1, normalization: 'standard' },
8
+ { id: 'future-position', kind: 'trajectory-position', horizons: [0.2, 0.4, 0.6], weight: 3, normalization: 'standard' },
9
+ { id: 'future-facing', kind: 'trajectory-facing', horizons: [0.2, 0.4, 0.6], weight: 3, normalization: 'standard' },
10
+ ],
11
+ };
12
+ for (const channel of LOCOMOTION_SCHEMA.channels) {
13
+ if ('horizons' in channel)
14
+ Object.freeze(channel.horizons);
15
+ Object.freeze(channel);
16
+ }
17
+ Object.freeze(LOCOMOTION_SCHEMA.channels);
18
+ Object.freeze(LOCOMOTION_SCHEMA);
19
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
20
+ const name = (v) => typeof v === 'string' && /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(v);
21
+ export function validateHorizons(input) {
22
+ if (!Array.isArray(input) || input.length > 64 || [...input].some((v, i) => !Number.isFinite(v) || v <= 0 || v > 10 || (i > 0 && v <= input[i - 1])))
23
+ throw new Error('Schema: at most 64 horizons, increasing within (0, 10] seconds');
24
+ }
25
+ /** Strict JSON parsing: unsupported channels/options fail instead of being ignored. */
26
+ export function parseSchema(input) {
27
+ if (!record(input) || input.version !== 1 || !name(input.id) || Object.keys(input).some(k => !['version', 'id', 'channels'].includes(k))
28
+ || !Array.isArray(input.channels) || !input.channels.length || input.channels.length > 128)
29
+ throw new Error('Schema: invalid version/id/channels');
30
+ const ids = new Set();
31
+ const channels = [...input.channels].map(value => {
32
+ if (!record(value) || !name(value.id) || ids.has(value.id) || typeof value.weight !== 'number' || !Number.isFinite(Math.fround(value.weight)) || value.weight < 0 || (value.weight > 0 && Math.fround(value.weight) === 0)
33
+ || !['standard', 'none'].includes(String(value.normalization)))
34
+ throw new Error('Schema: invalid channel id/weight/normalization');
35
+ ids.add(value.id);
36
+ const common = { id: value.id, kind: value.kind, weight: value.weight, normalization: value.normalization };
37
+ let result;
38
+ if (value.kind === 'bone-position' || value.kind === 'bone-velocity') {
39
+ if (!name(value.bone) || value.bone === 'root')
40
+ throw new Error('Schema: invalid semantic bone role');
41
+ result = { ...common, bone: value.bone };
42
+ }
43
+ else if (value.kind === 'root-velocity')
44
+ result = common;
45
+ else if (value.kind === 'trajectory-position' || value.kind === 'trajectory-facing') {
46
+ if (!Array.isArray(value.horizons) || !value.horizons.length)
47
+ throw new Error('Schema: missing horizons');
48
+ validateHorizons(value.horizons);
49
+ result = { ...common, horizons: [...value.horizons] };
50
+ }
51
+ else
52
+ throw new Error('Schema: unsupported channel kind');
53
+ if (Object.keys(value).some(k => !Object.hasOwn(result, k)))
54
+ throw new Error('Schema: unsupported channel option');
55
+ return result;
56
+ });
57
+ if (!channels.some(c => c.weight > 0))
58
+ throw new Error('Schema: at least one positive weight required');
59
+ validateHorizons([...new Set(channels.flatMap(c => 'horizons' in c ? [...c.horizons] : []))].sort((a, b) => a - b));
60
+ return { version: 1, id: input.id, channels };
61
+ }
62
+ /** Canonical content identity, not a lossy hash. Order of channels is significant. */
63
+ export function compileSchema(input) {
64
+ const schema = parseSchema(input), bones = new Set(), horizons = new Set();
65
+ let dimensions = 0;
66
+ const channels = schema.channels.map(channel => {
67
+ if ('bone' in channel)
68
+ bones.add(channel.bone);
69
+ if ('horizons' in channel)
70
+ channel.horizons.forEach(h => horizons.add(h));
71
+ const width = 'horizons' in channel ? 2 * channel.horizons.length : 3;
72
+ const layout = Object.freeze({ channel, offset: dimensions, dimensions: width });
73
+ dimensions += width;
74
+ if ('horizons' in channel)
75
+ Object.freeze(channel.horizons);
76
+ Object.freeze(channel);
77
+ return layout;
78
+ });
79
+ Object.freeze(schema.channels);
80
+ Object.freeze(schema);
81
+ return Object.freeze({ schema, signature: 'pose-schema-v1:' + JSON.stringify(schema), dimensions,
82
+ channels: Object.freeze(channels), bones: Object.freeze([...bones]), horizons: Object.freeze([...horizons].sort((a, b) => a - b)) });
83
+ }
84
+ /** One encoder shared by bake and runtime. Trajectory points follow the sorted union of horizons. */
85
+ export class SchemaQueryBuilder {
86
+ layout;
87
+ horizonIndices;
88
+ constructor(schema = LOCOMOTION_SCHEMA) {
89
+ this.layout = compileSchema(schema);
90
+ this.horizonIndices = new Map(this.layout.horizons.map((h, i) => [h, i]));
91
+ }
92
+ features(pose, trajectory, out = new Float32Array(this.layout.dimensions)) {
93
+ if (trajectory.length !== this.layout.horizons.length || out.length !== this.layout.dimensions)
94
+ throw new Error('Invalid schema pose/trajectory dimensions');
95
+ const put = (values, offset, length) => {
96
+ if (!values || values.length !== length || !values.every(Number.isFinite))
97
+ throw new Error('Invalid schema feature values');
98
+ out.set(values, offset);
99
+ };
100
+ for (const { channel, offset } of this.layout.channels) {
101
+ if ('bone' in channel) {
102
+ const bone = Object.hasOwn(pose.bones, channel.bone) ? pose.bones[channel.bone] : undefined;
103
+ if (!bone)
104
+ throw new Error('Missing semantic bone: ' + channel.bone);
105
+ put(channel.kind === 'bone-position' ? bone.position : bone.velocity, offset, 3);
106
+ }
107
+ else if ('horizons' in channel) {
108
+ channel.horizons.forEach((h, i) => {
109
+ const point = trajectory[this.horizonIndices.get(h)];
110
+ put(channel.kind === 'trajectory-position' ? point?.position : point?.facing, offset + i * 2, 2);
111
+ });
112
+ }
113
+ else
114
+ put(pose.rootVelocity, offset, 3);
115
+ }
116
+ if (!out.every(Number.isFinite))
117
+ throw new Error('Invalid schema float32 feature values');
118
+ return out;
119
+ }
120
+ build(pose, trajectory) {
121
+ return { features: this.features(pose, trajectory), schemaSignature: this.layout.signature };
122
+ }
123
+ }
@@ -0,0 +1,181 @@
1
+ import { validateDatabase } from './database.js';
2
+ import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
3
+ import { matchesContext, validateContext, withinWindows } from './selection.js';
4
+ export function defaultWeights(schema = LOCOMOTION_SCHEMA) {
5
+ const layout = compileSchema(schema), weights = new Float32Array(layout.dimensions);
6
+ for (const { channel, offset, dimensions } of layout.channels)
7
+ weights.fill(channel.weight, offset, offset + dimensions);
8
+ if (!weights.every(Number.isFinite))
9
+ throw new Error('Invalid feature weights');
10
+ return weights;
11
+ }
12
+ const legacySignature = compileSchema(LOCOMOTION_SCHEMA).signature;
13
+ export function readCandidateTrajectory(database, index) {
14
+ if (!Number.isInteger(index) || index < 0 || index >= database.manifest.frames.length)
15
+ throw new Error('Invalid candidate frame');
16
+ const { dimensions, mean, std } = database.manifest, layout = compileSchema(database.manifest.schema);
17
+ const points = new Map(layout.horizons.map(horizon => [horizon, { horizon }]));
18
+ for (const { channel, offset } of layout.channels)
19
+ if (channel.kind === 'trajectory-position' || channel.kind === 'trajectory-facing') {
20
+ channel.horizons.forEach((horizon, h) => {
21
+ const j = offset + h * 2, value = [database.features[index * dimensions + j] * std[j] + mean[j], database.features[index * dimensions + j + 1] * std[j + 1] + mean[j + 1]];
22
+ points.get(horizon)[channel.kind === 'trajectory-position' ? 'position' : 'facing'] = value;
23
+ });
24
+ }
25
+ return [...points.values()];
26
+ }
27
+ export class PoseSearch {
28
+ database;
29
+ normalized;
30
+ dimensions;
31
+ rootOffset;
32
+ weights;
33
+ constructor(database, weights) {
34
+ this.database = database;
35
+ validateDatabase(database);
36
+ this.dimensions = database.manifest.dimensions;
37
+ this.rootOffset = compileSchema(database.manifest.schema).channels.find(c => c.channel.kind === 'root-velocity')?.offset;
38
+ this.normalized = new Float32Array(this.dimensions);
39
+ weights ??= defaultWeights(database.manifest.schema);
40
+ if (weights.length !== this.dimensions || !weights.every(v => Number.isFinite(v) && v >= 0))
41
+ throw new Error('Invalid feature weights');
42
+ this.weights = weights.slice();
43
+ }
44
+ validateQuery(query) {
45
+ if (query.context !== undefined)
46
+ validateContext(query.context);
47
+ if (query.schemaSignature !== this.database.manifest.schemaSignature && !(query.schemaSignature === undefined && this.database.manifest.schemaSignature === legacySignature))
48
+ throw new Error('Incompatible query schema');
49
+ if (query.features.length !== this.dimensions || !query.features.every(Number.isFinite))
50
+ throw new Error('Invalid pose query');
51
+ if (query.allowTemporalReselection !== undefined && typeof query.allowTemporalReselection !== 'boolean')
52
+ throw new Error('Invalid temporal reselection option');
53
+ if (query.velocityTolerance !== undefined && (!Number.isFinite(query.velocityTolerance) || query.velocityTolerance < 0 || this.rootOffset === undefined))
54
+ throw new Error('Velocity tolerance requires a root-velocity channel and a finite nonnegative value');
55
+ }
56
+ /** Context/gait validity is separate from eligibility to enter at a sample. */
57
+ allowsClip(clip, query) {
58
+ const descriptor = this.database.manifest.clips[clip];
59
+ return !!descriptor && !query.excludedClips?.has(clip) && (!query.gaits || query.gaits.has(descriptor.gait)) && (!query.context || matchesContext(descriptor.context, query.context));
60
+ }
61
+ allowsFrame(index, query) {
62
+ const frame = this.database.manifest.frames[index], clip = this.database.manifest.clips[frame.clip];
63
+ return this.allowsClip(frame.clip, query) && withinWindows(frame.time, clip.entryWindows)
64
+ && (clip.excludedWindows === undefined || !withinWindows(frame.time, clip.excludedWindows));
65
+ }
66
+ normalize(query) {
67
+ this.validateQuery(query);
68
+ const { mean, std } = this.database.manifest;
69
+ for (let j = 0; j < this.dimensions; j++)
70
+ this.normalized[j] = (query.features[j] - mean[j]) / std[j];
71
+ if (!this.normalized.every(Number.isFinite))
72
+ throw new Error('Invalid normalized pose query');
73
+ }
74
+ cost(index) {
75
+ const { features } = this.database;
76
+ let sum = 0;
77
+ for (let j = 0; j < this.dimensions; j++)
78
+ sum += this.weights[j] * (this.normalized[j] - features[index * this.dimensions + j]) ** 2;
79
+ return sum;
80
+ }
81
+ /** Physical error, independent of normalization/pose weights. Query must have been validated. */
82
+ rootVelocityError(index, query) {
83
+ if (this.rootOffset === undefined || !Number.isInteger(index) || index < 0 || index >= this.database.manifest.frames.length)
84
+ return Infinity;
85
+ const { mean, std } = this.database.manifest;
86
+ let squared = 0;
87
+ for (let axis = 0; axis < 3; axis++) {
88
+ const j = this.rootOffset + axis, value = this.database.features[index * this.dimensions + j] * std[j] + mean[j];
89
+ squared += (value - query.features[j]) ** 2;
90
+ }
91
+ return Math.sqrt(squared);
92
+ }
93
+ search(query) {
94
+ this.normalize(query);
95
+ const { frames, clips } = this.database.manifest;
96
+ // Context is clip-level: resolve it once per clip, rather than once per
97
+ // sample in each of the velocity and ranking passes.
98
+ const eligibleClips = clips.map((_, clip) => this.allowsClip(clip, query));
99
+ const candidates = [];
100
+ for (let i = 0; i < frames.length; i++) {
101
+ const frame = frames[i], clip = clips[frame.clip];
102
+ if (eligibleClips[frame.clip] && withinWindows(frame.time, clip.entryWindows)
103
+ && (clip.excludedWindows === undefined || !withinWindows(frame.time, clip.excludedWindows)))
104
+ candidates.push(i);
105
+ }
106
+ let index = -1, cost = Infinity;
107
+ let velocityLimit = Infinity;
108
+ if (query.velocityTolerance !== undefined) {
109
+ let nearest = Infinity;
110
+ for (const i of candidates) {
111
+ nearest = Math.min(nearest, this.rootVelocityError(i, query));
112
+ }
113
+ // Sparse datasets retain a deterministic nearest-velocity fallback. Never
114
+ // expand the pool to arbitrary stationary poses just because coverage is missing.
115
+ velocityLimit = Math.max(query.velocityTolerance, nearest + 1e-6);
116
+ }
117
+ for (const i of candidates) {
118
+ if (query.velocityTolerance !== undefined && this.rootVelocityError(i, query) > velocityLimit)
119
+ continue;
120
+ const score = this.cost(i);
121
+ if (score < cost) {
122
+ index = i;
123
+ cost = score;
124
+ }
125
+ }
126
+ return index < 0 ? null : { ...frames[index], index, cost };
127
+ }
128
+ /** Opt-in diagnostic pass. Bounded output, exact ranking, no change to filters/weights. */
129
+ inspect(query, limit = 5) {
130
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50)
131
+ throw new Error('Invalid diagnostic limit');
132
+ const best = this.search(query), { frames, clips } = this.database.manifest;
133
+ const rejected = { 'excluded-clip': 0, gait: 0, context: 0, 'entry-window': 0, 'excluded-window': 0, velocity: 0 };
134
+ const examples = [], candidates = [];
135
+ const layout = compileSchema(this.database.manifest.schema);
136
+ let nearest = Infinity, eligible = 0;
137
+ if (query.velocityTolerance !== undefined)
138
+ for (let i = 0; i < frames.length; i++)
139
+ if (this.allowsFrame(i, query))
140
+ nearest = Math.min(nearest, this.rootVelocityError(i, query));
141
+ const velocityLimit = query.velocityTolerance === undefined ? null : Math.max(query.velocityTolerance, nearest + 1e-6);
142
+ for (let index = 0; index < frames.length; index++) {
143
+ const frame = frames[index], clip = clips[frame.clip];
144
+ let reason;
145
+ if (query.excludedClips?.has(frame.clip))
146
+ reason = 'excluded-clip';
147
+ else if (query.gaits && !query.gaits.has(clip.gait))
148
+ reason = 'gait';
149
+ else if (!matchesContext(clip.context, query.context))
150
+ reason = 'context';
151
+ else if (!withinWindows(frame.time, clip.entryWindows))
152
+ reason = 'entry-window';
153
+ else if (clip.excludedWindows !== undefined && withinWindows(frame.time, clip.excludedWindows))
154
+ reason = 'excluded-window';
155
+ else if (velocityLimit !== null && this.rootVelocityError(index, query) > velocityLimit)
156
+ reason = 'velocity';
157
+ if (reason) {
158
+ rejected[reason]++;
159
+ if (examples.length < limit)
160
+ examples.push({ index, ...frame, reason });
161
+ continue;
162
+ }
163
+ eligible++;
164
+ const cost = this.cost(index);
165
+ if (candidates.length >= limit && cost >= candidates[candidates.length - 1].cost)
166
+ continue;
167
+ const channels = layout.channels.map(({ channel, offset, dimensions }) => {
168
+ let cost = 0;
169
+ for (let j = offset; j < offset + dimensions; j++)
170
+ cost += this.weights[j] * (this.normalized[j] - this.database.features[index * this.dimensions + j]) ** 2;
171
+ return { id: channel.id, cost };
172
+ });
173
+ const candidate = { index, ...frame, cost, channels, velocityError: this.rootOffset === undefined ? null : this.rootVelocityError(index, query) };
174
+ const at = candidates.findIndex(c => c.cost > cost);
175
+ candidates.splice(at < 0 ? candidates.length : at, 0, candidate);
176
+ if (candidates.length > limit)
177
+ candidates.pop();
178
+ }
179
+ return { best, candidates, rejected, examples, eligible, total: frames.length, velocityLimit: Number.isFinite(velocityLimit) ? velocityLimit : null };
180
+ }
181
+ }
@@ -0,0 +1,41 @@
1
+ export function validateContext(value) {
2
+ if (!value || typeof value !== 'object' || Array.isArray(value)
3
+ || !Object.entries(value).every(([k, v]) => k.trim().length > 0 && typeof v === 'string' && v.trim().length > 0))
4
+ throw new Error('Invalid selection context');
5
+ }
6
+ export function matchesContext(metadata, requested = {}) {
7
+ return Object.entries(requested).every(([key, value]) => metadata !== undefined && Object.hasOwn(metadata, key) && metadata[key] === value);
8
+ }
9
+ export function withinWindows(time, windows) {
10
+ return windows === undefined || windows.some(([start, end]) => time >= start && time <= end);
11
+ }
12
+ export function validateSelection(value, duration = Infinity) {
13
+ if (value.context !== undefined)
14
+ validateContext(value.context);
15
+ for (const key of ['entryWindows', 'exitWindows', 'excludedWindows']) {
16
+ const windows = value[key];
17
+ if (windows !== undefined && (!Array.isArray(windows) || !windows.every(w => Array.isArray(w) && w.length === 2 && w.every(Number.isFinite)
18
+ && w[0] >= 0 && w[1] >= w[0] && w[1] <= duration)))
19
+ throw new Error('Invalid selection windows: ' + key);
20
+ }
21
+ }
22
+ /** Route before searching; costs from independent normalizations are never compared.
23
+ * Caller must use the selected bank's schema, compatible rig/player and matcher.
24
+ */
25
+ export function selectContextBank(banks, context) {
26
+ validateContext(context);
27
+ const ids = new Set();
28
+ let selected = null;
29
+ for (const bank of banks) {
30
+ if (!bank.id.trim() || ids.has(bank.id))
31
+ throw new Error('Invalid/duplicate context bank id');
32
+ ids.add(bank.id);
33
+ validateContext(bank.context);
34
+ if (!matchesContext(bank.context, context))
35
+ continue;
36
+ if (selected)
37
+ throw new Error('Ambiguous context bank selection');
38
+ selected = bank;
39
+ }
40
+ return selected;
41
+ }
@@ -0,0 +1,3 @@
1
+ /** Legacy preset constants; dynamic consumers must use compileSchema. */
2
+ export const DIMENSIONS = 27;
3
+ export const HORIZONS = [0.2, 0.4, 0.6];
@@ -0,0 +1,11 @@
1
+ export * from './core/types.js';
2
+ export * from './core/schema.js';
3
+ export * from './core/features.js';
4
+ export * from './core/database.js';
5
+ export * from './core/search.js';
6
+ export * from './core/controller.js';
7
+ export * from './core/root-motion.js';
8
+ export * from './core/profile.js';
9
+ export * from './core/selection.js';
10
+ export * from './core/inertialization.js';
11
+ export * from './core/scenario.js';