@pose_flow/babylon 0.1.0 → 0.2.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 (40) hide show
  1. package/README.md +123 -105
  2. package/dist/AI_USAGE.md +291 -0
  3. package/dist/PUBLISHING.md +108 -0
  4. package/dist/babylon.d.ts +13 -4
  5. package/dist/babylon.js +96 -21
  6. package/dist/core/controller.js +6 -1
  7. package/dist/core/database.d.ts +8 -2
  8. package/dist/core/database.js +60 -24
  9. package/dist/core/features.d.ts +1 -1
  10. package/dist/core/features.js +9 -11
  11. package/dist/core/profile.d.ts +23 -6
  12. package/dist/core/profile.js +35 -10
  13. package/dist/core/root-motion.d.ts +5 -3
  14. package/dist/core/root-motion.js +43 -7
  15. package/dist/core/schema.d.ts +56 -0
  16. package/dist/core/schema.js +123 -0
  17. package/dist/core/search.d.ts +8 -2
  18. package/dist/core/search.js +59 -12
  19. package/dist/core/types.d.ts +21 -3
  20. package/dist/core/types.js +1 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/offline/src/babylon.js +201 -0
  24. package/dist/offline/src/core/controller.js +97 -0
  25. package/dist/offline/src/core/database.js +95 -0
  26. package/dist/offline/src/core/features.js +41 -0
  27. package/dist/offline/src/core/profile.js +89 -0
  28. package/dist/offline/src/core/root-motion.js +78 -0
  29. package/dist/offline/src/core/schema.js +123 -0
  30. package/dist/offline/src/core/search.js +97 -0
  31. package/dist/offline/src/core/types.js +3 -0
  32. package/dist/offline/src/index.js +8 -0
  33. package/dist/offline/tools/bake.js +86 -0
  34. package/dist/offline/tools/cli.js +30 -0
  35. package/dist/offline/tools/glb.js +54 -0
  36. package/dist/offline/tools/prepare.js +165 -0
  37. package/dist/offline/tools/profile-assets.js +90 -0
  38. package/dist/offline/tools/rig.js +47 -0
  39. package/dist/offline/tools/validate-assets.js +43 -0
  40. package/package.json +48 -39
@@ -1,42 +1,67 @@
1
+ import { compileSchema, parseSchema } from './schema.js';
1
2
  const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
2
3
  const name = (v) => typeof v === 'string' && v.trim().length > 0;
3
4
  const asset = (v) => name(v) && !v.includes('\\') && !v.includes('..') && !/[?#:]/.test(v);
5
+ export function parseRootOrientation(value) {
6
+ if (!Array.isArray(value) || value.length !== 4 || ![...value].every(Number.isFinite) || Math.abs(Math.hypot(...value) - 1) > 1e-5)
7
+ throw new Error('Invalid rootOrientation: expected unit quaternion [x,y,z,w]');
8
+ return [...value];
9
+ }
4
10
  /** Read untrusted JSON as unknown; report a configuration error before loading assets. */
5
11
  export function parseProfile(input) {
6
- if (!record(input) || input.version !== 1 || !name(input.id) || !/^[a-z0-9][a-z0-9-]*$/.test(input.id) || !name(input.label))
7
- throw new Error('Profile: invalid version/id/label');
12
+ if (!record(input) || input.version !== 2 || !name(input.id) || !/^[a-z0-9][a-z0-9-]*$/.test(input.id) || !name(input.label))
13
+ throw new Error('Profile: expected version 2; split v1 clips into animationSet and declare schema');
14
+ if ('clips' in input || !asset(input.schema) || !input.schema.startsWith('/') || !input.schema.endsWith('.json')
15
+ || !asset(input.animationSet) || !input.animationSet.startsWith('/') || !input.animationSet.endsWith('.json'))
16
+ throw new Error('Profile: declare schema and animationSet JSON URLs, without inline clips');
8
17
  if (!asset(input.assetBase) || !input.assetBase.startsWith('/') || !input.assetBase.endsWith('/') || !asset(input.database) || !input.database.startsWith('/') || !input.database.endsWith('/')
9
18
  || !asset(input.model) || input.model.startsWith('/'))
10
19
  throw new Error('Profile: use public URL folders and relative model files');
11
20
  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)
12
21
  throw new Error('Profile: invalid scale/sampleRate');
13
- if (!record(input.rig) || !name(input.rig.root) || !name(input.rig.leftFoot) || !name(input.rig.rightFoot))
14
- throw new Error('Profile: root and both feet must be mapped');
15
- const rig = input.rig;
16
- if (new Set([rig.root, rig.leftFoot, rig.rightFoot]).size !== 3)
22
+ 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)))
23
+ throw new Error('Profile: semantic rig roles must be mapped');
24
+ if (new Set(Object.values(input.rig)).size !== Object.values(input.rig).length)
17
25
  throw new Error('Profile: required bones must be distinct');
18
26
  if (input.stripRoot !== undefined && !name(input.stripRoot))
19
27
  throw new Error('Profile: invalid stripRoot');
20
28
  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))
21
29
  throw new Error('Profile: boneMap must map distinct nonempty names');
22
- if (!Array.isArray(input.clips) || !input.clips.length)
23
- throw new Error('Profile: no clips');
30
+ return structuredClone(input);
31
+ }
32
+ export function parseAnimationSet(input) {
33
+ if (!record(input) || input.version !== 1 || !name(input.id) || !Array.isArray(input.clips) || !input.clips.length)
34
+ throw new Error('AnimationSet: invalid version/id/clips');
24
35
  const ids = new Set();
25
36
  for (const clip of input.clips) {
26
37
  if (!record(clip) || !name(clip.id) || ids.has(clip.id) || !asset(clip.file) || clip.file.startsWith('/') || !name(clip.animation) || typeof clip.looping !== 'boolean'
27
38
  || !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(String(clip.gait)))
28
39
  throw new Error('Profile: invalid/duplicate clip');
29
40
  ids.add(clip.id);
41
+ if (clip.rootOrientation !== undefined)
42
+ parseRootOrientation(clip.rootOrientation);
43
+ 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]))
44
+ throw new Error('Invalid clip range');
30
45
  const nominal = clip.nominalVelocity !== undefined, measured = clip.motionFile !== undefined;
31
46
  if (nominal === measured)
32
47
  throw new Error(`Profile ${clip.id}: declare exactly one of nominalVelocity or motionFile`);
33
48
  if (nominal && (!Array.isArray(clip.nominalVelocity) || clip.nominalVelocity.length !== 3 || !clip.nominalVelocity.every(Number.isFinite)))
34
49
  throw new Error(`Profile ${clip.id}: invalid velocity`);
35
- if (measured && (!asset(clip.motionFile) || clip.motionFile.startsWith('/') || clip.looping))
36
- throw new Error(`Profile ${clip.id}: measured motion requires a relative file and non-looping clip`);
50
+ if (measured && (!asset(clip.motionFile) || clip.motionFile.startsWith('/')))
51
+ throw new Error(`Profile ${clip.id}: measured motion requires a relative file`);
37
52
  }
38
53
  return structuredClone(input);
39
54
  }
55
+ /** Resolve external configuration explicitly; core performs no I/O. */
56
+ export function resolveProfile(profile, schemaInput, setInput) {
57
+ const config = parseProfile(profile), schemaDefinition = parseSchema(schemaInput), set = parseAnimationSet(setInput);
58
+ for (const role of compileSchema(schemaDefinition).bones)
59
+ if (!Object.hasOwn(config.rig, role))
60
+ throw new Error('Profile: missing schema bone role: ' + role);
61
+ if (!config.stripRoot && set.clips.some(c => c.rootOrientation !== undefined))
62
+ throw new Error('Profile: rootOrientation requires stripRoot');
63
+ return { ...config, schemaDefinition, clips: set.clips, setId: set.id };
64
+ }
40
65
  /** Deterministic configuration fingerprint; detects stale banks, not a security signature. */
41
66
  export function profileSignature(profile) {
42
67
  const sorted = (value) => Array.isArray(value) ? value.map(sorted) : record(value) ? Object.fromEntries(Object.keys(value).sort().map(k => [k, sorted(value[k])])) : value;
@@ -9,14 +9,16 @@ export interface RootMotionSample {
9
9
  export interface RootMotionTrack {
10
10
  version: 1;
11
11
  duration: number;
12
- source: 'hips';
12
+ source: 'hips' | 'root';
13
13
  samples: RootMotionSample[];
14
14
  }
15
15
  export declare function validateRootMotion(track: RootMotionTrack): void;
16
16
  /** Clamped sampling for non-looping clips; completed motion stays at its endpoint. */
17
- export declare function sampleRootMotion(track: RootMotionTrack, time: number): RootMotionSample;
18
- export declare function rootMotionFeatures(track: RootMotionTrack, time: number, derivativeStep?: number): {
17
+ export declare function sampleRootMotion(track: RootMotionTrack, time: number, looping?: boolean): RootMotionSample;
18
+ export declare function rootMotionFeatures(track: RootMotionTrack, time: number, derivativeStep?: number, horizons?: readonly number[], looping?: boolean): {
19
19
  velocity: readonly [number, number, number];
20
20
  angularVelocity: number;
21
21
  trajectory: TrajectoryPoint[];
22
22
  };
23
+ /** Crop to the same source interval as playback; preserve the source coordinate frame. */
24
+ export declare function cropRootMotion(track: RootMotionTrack, start: number, end: number): RootMotionTrack;
@@ -1,11 +1,12 @@
1
1
  import { HORIZONS } from './types.js';
2
2
  import { toLocal } from './features.js';
3
+ import { validateHorizons } from './schema.js';
3
4
  export function validateRootMotion(track) {
4
- if (track.version !== 1 || track.source !== 'hips' || !Number.isFinite(track.duration) || track.duration <= 0 || track.samples.length < 2)
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)
5
6
  throw new Error('Invalid root motion track');
6
7
  let previous = -Infinity;
7
8
  for (const sample of track.samples) {
8
- if (!Object.values(sample).every(Number.isFinite) || sample.time <= previous || sample.time < 0 || sample.time > track.duration + 1e-6)
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)
9
10
  throw new Error('Invalid root motion sample');
10
11
  previous = sample.time;
11
12
  }
@@ -13,9 +14,34 @@ export function validateRootMotion(track) {
13
14
  throw new Error('Incomplete root motion track');
14
15
  }
15
16
  /** Clamped sampling for non-looping clips; completed motion stays at its endpoint. */
16
- export function sampleRootMotion(track, time) {
17
+ export function sampleRootMotion(track, time, looping = false) {
17
18
  if (!Number.isFinite(time))
18
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
+ }
19
45
  const t = Math.max(0, Math.min(time, track.duration)), samples = track.samples;
20
46
  let lo = 0, hi = samples.length - 1;
21
47
  while (hi - lo > 1) {
@@ -28,15 +54,25 @@ export function sampleRootMotion(track, time) {
28
54
  const a = samples[lo], b = samples[hi], alpha = (t - a.time) / (b.time - a.time);
29
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 };
30
56
  }
31
- export function rootMotionFeatures(track, time, derivativeStep = 1 / 30) {
57
+ export function rootMotionFeatures(track, time, derivativeStep = 1 / 30, horizons = HORIZONS, looping = false) {
58
+ validateHorizons(horizons);
32
59
  if (!Number.isFinite(derivativeStep) || derivativeStep <= 0)
33
60
  throw new Error('Invalid derivative step');
34
- const now = sampleRootMotion(track, time), a = sampleRootMotion(track, time - derivativeStep), b = sampleRootMotion(track, time + derivativeStep);
61
+ const now = sampleRootMotion(track, time, looping), a = sampleRootMotion(track, time - derivativeStep, looping), b = sampleRootMotion(track, time + derivativeStep, looping);
35
62
  const elapsed = b.time - a.time;
36
63
  const v = elapsed > 0 ? toLocal((b.x - a.x) / elapsed, (b.z - a.z) / elapsed, now.yaw) : [0, 0];
37
64
  return { velocity: [v[0], 0, v[1]], angularVelocity: elapsed > 0 ? (b.yaw - a.yaw) / elapsed : 0,
38
- trajectory: HORIZONS.map(horizon => {
39
- const future = sampleRootMotion(track, time + horizon), yaw = future.yaw - now.yaw;
65
+ trajectory: horizons.map(horizon => {
66
+ const future = sampleRootMotion(track, time + horizon, looping), yaw = future.yaw - now.yaw;
40
67
  return { position: toLocal(future.x - now.x, future.z - now.z, now.yaw), facing: [Math.sin(yaw), Math.cos(yaw)] };
41
68
  }) };
42
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,56 @@
1
+ import type { Query, Vec3, TrajectoryPoint } from './types.js';
2
+ export type Normalization = 'standard' | 'none';
3
+ interface ChannelBase {
4
+ id: string;
5
+ weight: number;
6
+ normalization: Normalization;
7
+ }
8
+ export type FeatureChannel = ChannelBase & ({
9
+ kind: 'bone-position' | 'bone-velocity';
10
+ bone: string;
11
+ } | {
12
+ kind: 'root-velocity';
13
+ } | {
14
+ kind: 'trajectory-position' | 'trajectory-facing';
15
+ horizons: readonly number[];
16
+ });
17
+ export interface PoseSchema {
18
+ version: 1;
19
+ id: string;
20
+ channels: readonly FeatureChannel[];
21
+ }
22
+ export interface SchemaPose {
23
+ bones: Readonly<Record<string, {
24
+ position: Vec3;
25
+ velocity: Vec3;
26
+ }>>;
27
+ rootVelocity: Vec3;
28
+ }
29
+ export interface ChannelLayout {
30
+ channel: FeatureChannel;
31
+ offset: number;
32
+ dimensions: number;
33
+ }
34
+ export interface CompiledSchema {
35
+ schema: PoseSchema;
36
+ signature: string;
37
+ dimensions: number;
38
+ channels: readonly ChannelLayout[];
39
+ bones: readonly string[];
40
+ horizons: readonly number[];
41
+ }
42
+ export declare const LOCOMOTION_SCHEMA: PoseSchema;
43
+ export declare function validateHorizons(input: readonly number[]): void;
44
+ /** Strict JSON parsing: unsupported channels/options fail instead of being ignored. */
45
+ export declare function parseSchema(input: unknown): PoseSchema;
46
+ /** Canonical content identity, not a lossy hash. Order of channels is significant. */
47
+ export declare function compileSchema(input: PoseSchema): CompiledSchema;
48
+ /** One encoder shared by bake and runtime. Trajectory points follow the sorted union of horizons. */
49
+ export declare class SchemaQueryBuilder {
50
+ readonly layout: CompiledSchema;
51
+ private horizonIndices;
52
+ constructor(schema?: PoseSchema);
53
+ features(pose: SchemaPose, trajectory: readonly TrajectoryPoint[], out?: Float32Array<ArrayBuffer>): Float32Array;
54
+ build(pose: SchemaPose, trajectory: readonly TrajectoryPoint[]): Query;
55
+ }
56
+ export {};
@@ -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
+ }
@@ -1,11 +1,17 @@
1
1
  import { type Database, type Match, type Query } from './types.js';
2
- export declare function defaultWeights(): Float32Array;
2
+ import { type PoseSchema } from './schema.js';
3
+ export declare function defaultWeights(schema?: PoseSchema): Float32Array;
3
4
  export declare class PoseSearch {
4
5
  readonly database: Database;
5
6
  private normalized;
7
+ private dimensions;
8
+ private rootOffset?;
6
9
  readonly weights: Float32Array;
7
- constructor(database: Database, weights?: Float32Array<ArrayBufferLike>);
10
+ constructor(database: Database, weights?: Float32Array);
11
+ validateQuery(query: Query): void;
8
12
  normalize(query: Query): void;
9
13
  cost(index: number): number;
14
+ /** Physical error, independent of normalization/pose weights. Query must have been validated. */
15
+ rootVelocityError(index: number, query: Query): number;
10
16
  search(query: Query): Match | null;
11
17
  }
@@ -1,44 +1,91 @@
1
1
  import { validateDatabase } from './database.js';
2
- import { DIMENSIONS } from './types.js';
3
- export function defaultWeights() {
4
- const weights = new Float32Array(DIMENSIONS).fill(1);
5
- weights.fill(3, 15);
2
+ import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
3
+ export function defaultWeights(schema = LOCOMOTION_SCHEMA) {
4
+ const layout = compileSchema(schema), weights = new Float32Array(layout.dimensions);
5
+ for (const { channel, offset, dimensions } of layout.channels)
6
+ weights.fill(channel.weight, offset, offset + dimensions);
7
+ if (!weights.every(Number.isFinite))
8
+ throw new Error('Invalid feature weights');
6
9
  return weights;
7
10
  }
11
+ const legacySignature = compileSchema(LOCOMOTION_SCHEMA).signature;
8
12
  export class PoseSearch {
9
13
  database;
10
- normalized = new Float32Array(DIMENSIONS);
14
+ normalized;
15
+ dimensions;
16
+ rootOffset;
11
17
  weights;
12
- constructor(database, weights = defaultWeights()) {
18
+ constructor(database, weights) {
13
19
  this.database = database;
14
20
  validateDatabase(database);
15
- if (weights.length !== DIMENSIONS || !weights.every(v => Number.isFinite(v) && v >= 0))
21
+ this.dimensions = database.manifest.dimensions;
22
+ this.rootOffset = compileSchema(database.manifest.schema).channels.find(c => c.channel.kind === 'root-velocity')?.offset;
23
+ this.normalized = new Float32Array(this.dimensions);
24
+ weights ??= defaultWeights(database.manifest.schema);
25
+ if (weights.length !== this.dimensions || !weights.every(v => Number.isFinite(v) && v >= 0))
16
26
  throw new Error('Invalid feature weights');
17
27
  this.weights = weights.slice();
18
28
  }
19
- normalize(query) {
20
- if (query.features.length !== DIMENSIONS || !query.features.every(Number.isFinite))
29
+ validateQuery(query) {
30
+ if (query.schemaSignature !== this.database.manifest.schemaSignature && !(query.schemaSignature === undefined && this.database.manifest.schemaSignature === legacySignature))
31
+ throw new Error('Incompatible query schema');
32
+ if (query.features.length !== this.dimensions || !query.features.every(Number.isFinite))
21
33
  throw new Error('Invalid pose query');
34
+ if (query.allowTemporalReselection !== undefined && typeof query.allowTemporalReselection !== 'boolean')
35
+ throw new Error('Invalid temporal reselection option');
36
+ if (query.velocityTolerance !== undefined && (!Number.isFinite(query.velocityTolerance) || query.velocityTolerance < 0 || this.rootOffset === undefined))
37
+ throw new Error('Velocity tolerance requires a root-velocity channel and a finite nonnegative value');
38
+ }
39
+ normalize(query) {
40
+ this.validateQuery(query);
22
41
  const { mean, std } = this.database.manifest;
23
- for (let j = 0; j < DIMENSIONS; j++)
42
+ for (let j = 0; j < this.dimensions; j++)
24
43
  this.normalized[j] = (query.features[j] - mean[j]) / std[j];
44
+ if (!this.normalized.every(Number.isFinite))
45
+ throw new Error('Invalid normalized pose query');
25
46
  }
26
47
  cost(index) {
27
48
  const { features } = this.database;
28
49
  let sum = 0;
29
- for (let j = 0; j < DIMENSIONS; j++)
30
- sum += this.weights[j] * (this.normalized[j] - features[index * DIMENSIONS + j]) ** 2;
50
+ for (let j = 0; j < this.dimensions; j++)
51
+ sum += this.weights[j] * (this.normalized[j] - features[index * this.dimensions + j]) ** 2;
31
52
  return sum;
32
53
  }
54
+ /** Physical error, independent of normalization/pose weights. Query must have been validated. */
55
+ rootVelocityError(index, query) {
56
+ if (this.rootOffset === undefined || !Number.isInteger(index) || index < 0 || index >= this.database.manifest.frames.length)
57
+ return Infinity;
58
+ const { mean, std } = this.database.manifest;
59
+ let squared = 0;
60
+ for (let axis = 0; axis < 3; axis++) {
61
+ const j = this.rootOffset + axis, value = this.database.features[index * this.dimensions + j] * std[j] + mean[j];
62
+ squared += (value - query.features[j]) ** 2;
63
+ }
64
+ return Math.sqrt(squared);
65
+ }
33
66
  search(query) {
34
67
  this.normalize(query);
35
68
  const { frames, clips } = this.database.manifest;
36
69
  let index = -1, cost = Infinity;
70
+ let velocityLimit = Infinity;
71
+ if (query.velocityTolerance !== undefined) {
72
+ let nearest = Infinity;
73
+ for (let i = 0; i < frames.length; i++) {
74
+ if (query.excludedClips?.has(frames[i].clip) || (query.gaits && !query.gaits.has(clips[frames[i].clip].gait)))
75
+ continue;
76
+ nearest = Math.min(nearest, this.rootVelocityError(i, query));
77
+ }
78
+ // Sparse datasets retain a deterministic nearest-velocity fallback. Never
79
+ // expand the pool to arbitrary stationary poses just because coverage is missing.
80
+ velocityLimit = Math.max(query.velocityTolerance, nearest + 1e-6);
81
+ }
37
82
  for (let i = 0; i < frames.length; i++) {
38
83
  if (query.excludedClips?.has(frames[i].clip))
39
84
  continue;
40
85
  if (query.gaits && !query.gaits.has(clips[frames[i].clip].gait))
41
86
  continue;
87
+ if (query.velocityTolerance !== undefined && this.rootVelocityError(i, query) > velocityLimit)
88
+ continue;
42
89
  const score = this.cost(i);
43
90
  if (score < cost) {
44
91
  index = i;
@@ -1,6 +1,9 @@
1
+ import type { PoseSchema } from './schema.js';
2
+ /** Legacy preset constants; dynamic consumers must use compileSchema. */
1
3
  export declare const DIMENSIONS = 27;
2
4
  export declare const HORIZONS: readonly [0.2, 0.4, 0.6];
3
5
  export type Vec3 = readonly [number, number, number];
6
+ export type Quat = readonly [number, number, number, number];
4
7
  export type Gait = 'idle' | 'walk' | 'run' | 'strafe' | 'turn' | 'stop';
5
8
  export interface Clip {
6
9
  id: string;
@@ -12,7 +15,13 @@ export interface Clip {
12
15
  /** Explicit authoring velocity for in-place clips, never inferred root motion. */
13
16
  nominalVelocity?: Vec3;
14
17
  /** Hips is a measured pelvis proxy, not a dedicated animation root. */
15
- motionSource?: 'nominal' | 'hips';
18
+ motionSource?: 'nominal' | 'hips' | 'root';
19
+ /** Mean of measured local velocities; reference for demo controls, not gameplay authority. */
20
+ averageVelocity?: Vec3;
21
+ /** Source start in seconds; duration is the cropped duration. */
22
+ sourceStart?: number;
23
+ /** Optional absolute local orientation of the stripped rig root, as a static pose correction. */
24
+ rootOrientation?: Quat;
16
25
  }
17
26
  export interface Frame {
18
27
  clip: number;
@@ -21,8 +30,12 @@ export interface Frame {
21
30
  export interface DatabaseManifest {
22
31
  profileId?: string;
23
32
  profileSignature?: string;
24
- version: 1;
25
- dimensions: 27;
33
+ /** SHA-256 by public URL; verified by the loader. */
34
+ assetHashes?: Record<string, string>;
35
+ version: 2;
36
+ dimensions: number;
37
+ schema: PoseSchema;
38
+ schemaSignature: string;
26
39
  sampleRate: number;
27
40
  coordinates: 'meters-y-up-z-forward';
28
41
  horizons: readonly number[];
@@ -41,8 +54,13 @@ export interface Match extends Frame {
41
54
  }
42
55
  export interface Query {
43
56
  features: Float32Array;
57
+ schemaSignature?: string;
44
58
  gaits?: ReadonlySet<Gait>;
45
59
  excludedClips?: ReadonlySet<number>;
60
+ /** Optional root-velocity compatibility band in m/s. If uncovered, use nearest velocity before pose cost. */
61
+ velocityTolerance?: number;
62
+ /** False keeps the phase of the current loop; searches may still select another clip. Default true. */
63
+ allowTemporalReselection?: boolean;
46
64
  }
47
65
  export interface Pose {
48
66
  leftPosition: Vec3;
@@ -1,2 +1,3 @@
1
+ /** Legacy preset constants; dynamic consumers must use compileSchema. */
1
2
  export const DIMENSIONS = 27;
2
3
  export const HORIZONS = [0.2, 0.4, 0.6];
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './core/types.js';
2
+ export * from './core/schema.js';
2
3
  export * from './core/features.js';
3
4
  export * from './core/database.js';
4
5
  export * from './core/search.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './core/types.js';
2
+ export * from './core/schema.js';
2
3
  export * from './core/features.js';
3
4
  export * from './core/database.js';
4
5
  export * from './core/search.js';