@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,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,49 @@
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;
4
+ export type RejectionReason = 'excluded-clip' | 'gait' | 'context' | 'entry-window' | 'excluded-window' | 'velocity';
5
+ export interface CandidateCost extends Match {
6
+ channels: {
7
+ id: string;
8
+ cost: number;
9
+ }[];
10
+ velocityError: number | null;
11
+ }
12
+ export interface SearchReport {
13
+ best: Match | null;
14
+ candidates: CandidateCost[];
15
+ rejected: Record<RejectionReason, number>;
16
+ examples: {
17
+ index: number;
18
+ clip: number;
19
+ time: number;
20
+ reason: RejectionReason;
21
+ }[];
22
+ eligible: number;
23
+ total: number;
24
+ velocityLimit: number | null;
25
+ }
26
+ export declare function readCandidateTrajectory(database: Database, index: number): {
27
+ horizon: number;
28
+ position?: readonly [number, number];
29
+ facing?: readonly [number, number];
30
+ }[];
3
31
  export declare class PoseSearch {
4
32
  readonly database: Database;
5
33
  private normalized;
34
+ private dimensions;
35
+ private rootOffset?;
6
36
  readonly weights: Float32Array;
7
- constructor(database: Database, weights?: Float32Array<ArrayBufferLike>);
37
+ constructor(database: Database, weights?: Float32Array);
38
+ validateQuery(query: Query): void;
39
+ /** Context/gait validity is separate from eligibility to enter at a sample. */
40
+ allowsClip(clip: number, query: Query): boolean;
41
+ allowsFrame(index: number, query: Query): boolean;
8
42
  normalize(query: Query): void;
9
43
  cost(index: number): number;
44
+ /** Physical error, independent of normalization/pose weights. Query must have been validated. */
45
+ rootVelocityError(index: number, query: Query): number;
10
46
  search(query: Query): Match | null;
47
+ /** Opt-in diagnostic pass. Bounded output, exact ranking, no change to filters/weights. */
48
+ inspect(query: Query, limit?: number): SearchReport;
11
49
  }
@@ -1,43 +1,121 @@
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
+ 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');
6
10
  return weights;
7
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
+ }
8
27
  export class PoseSearch {
9
28
  database;
10
- normalized = new Float32Array(DIMENSIONS);
29
+ normalized;
30
+ dimensions;
31
+ rootOffset;
11
32
  weights;
12
- constructor(database, weights = defaultWeights()) {
33
+ constructor(database, weights) {
13
34
  this.database = database;
14
35
  validateDatabase(database);
15
- if (weights.length !== DIMENSIONS || !weights.every(v => Number.isFinite(v) && v >= 0))
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))
16
41
  throw new Error('Invalid feature weights');
17
42
  this.weights = weights.slice();
18
43
  }
19
- normalize(query) {
20
- if (query.features.length !== DIMENSIONS || !query.features.every(Number.isFinite))
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))
21
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);
22
68
  const { mean, std } = this.database.manifest;
23
- for (let j = 0; j < DIMENSIONS; j++)
69
+ for (let j = 0; j < this.dimensions; j++)
24
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');
25
73
  }
26
74
  cost(index) {
27
75
  const { features } = this.database;
28
76
  let sum = 0;
29
- for (let j = 0; j < DIMENSIONS; j++)
30
- sum += this.weights[j] * (this.normalized[j] - features[index * DIMENSIONS + j]) ** 2;
77
+ for (let j = 0; j < this.dimensions; j++)
78
+ sum += this.weights[j] * (this.normalized[j] - features[index * this.dimensions + j]) ** 2;
31
79
  return sum;
32
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
+ }
33
93
  search(query) {
34
94
  this.normalize(query);
35
95
  const { frames, clips } = this.database.manifest;
36
- let index = -1, cost = Infinity;
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 = [];
37
100
  for (let i = 0; i < frames.length; i++) {
38
- if (query.excludedClips?.has(frames[i].clip))
39
- continue;
40
- if (query.gaits && !query.gaits.has(clips[frames[i].clip].gait))
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)
41
119
  continue;
42
120
  const score = this.cost(i);
43
121
  if (score < cost) {
@@ -47,4 +125,57 @@ export class PoseSearch {
47
125
  }
48
126
  return index < 0 ? null : { ...frames[index], index, cost };
49
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
+ }
50
181
  }
@@ -0,0 +1,24 @@
1
+ /** Exact, consumer-defined semantic values. Missing keys never act as wildcards. */
2
+ export type SelectionContext = Readonly<Record<string, string>>;
3
+ /** Closed intervals, in seconds relative to the cropped playback clip. */
4
+ export type TimeWindow = readonly [number, number];
5
+ export interface SelectionMetadata {
6
+ context?: SelectionContext;
7
+ entryWindows?: readonly TimeWindow[];
8
+ exitWindows?: readonly TimeWindow[];
9
+ excludedWindows?: readonly TimeWindow[];
10
+ }
11
+ export declare function validateContext(value: unknown): asserts value is SelectionContext;
12
+ export declare function matchesContext(metadata: SelectionContext | undefined, requested?: SelectionContext): boolean;
13
+ export declare function withinWindows(time: number, windows: readonly TimeWindow[] | undefined): boolean;
14
+ export declare function validateSelection(value: SelectionMetadata, duration?: number): void;
15
+ import type { Database } from './types.js';
16
+ export interface ContextBank {
17
+ id: string;
18
+ context: SelectionContext;
19
+ database: Database;
20
+ }
21
+ /** Route before searching; costs from independent normalizations are never compared.
22
+ * Caller must use the selected bank's schema, compatible rig/player and matcher.
23
+ */
24
+ export declare function selectContextBank(banks: readonly ContextBank[], context: SelectionContext): ContextBank | null;
@@ -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
+ }
@@ -1,8 +1,12 @@
1
+ import type { PoseSchema } from './schema.js';
2
+ import type { SelectionContext, SelectionMetadata } from './selection.js';
3
+ /** Legacy preset constants; dynamic consumers must use compileSchema. */
1
4
  export declare const DIMENSIONS = 27;
2
5
  export declare const HORIZONS: readonly [0.2, 0.4, 0.6];
3
6
  export type Vec3 = readonly [number, number, number];
7
+ export type Quat = readonly [number, number, number, number];
4
8
  export type Gait = 'idle' | 'walk' | 'run' | 'strafe' | 'turn' | 'stop';
5
- export interface Clip {
9
+ export interface Clip extends SelectionMetadata {
6
10
  id: string;
7
11
  file: string;
8
12
  animation: string;
@@ -12,7 +16,13 @@ export interface Clip {
12
16
  /** Explicit authoring velocity for in-place clips, never inferred root motion. */
13
17
  nominalVelocity?: Vec3;
14
18
  /** Hips is a measured pelvis proxy, not a dedicated animation root. */
15
- motionSource?: 'nominal' | 'hips';
19
+ motionSource?: 'nominal' | 'hips' | 'root';
20
+ /** Mean of measured local velocities; reference for demo controls, not gameplay authority. */
21
+ averageVelocity?: Vec3;
22
+ /** Source start in seconds; duration is the cropped duration. */
23
+ sourceStart?: number;
24
+ /** Optional absolute local orientation of the stripped rig root, as a static pose correction. */
25
+ rootOrientation?: Quat;
16
26
  }
17
27
  export interface Frame {
18
28
  clip: number;
@@ -21,8 +31,12 @@ export interface Frame {
21
31
  export interface DatabaseManifest {
22
32
  profileId?: string;
23
33
  profileSignature?: string;
24
- version: 1;
25
- dimensions: 27;
34
+ /** SHA-256 by public URL; verified by the loader. */
35
+ assetHashes?: Record<string, string>;
36
+ version: 2;
37
+ dimensions: number;
38
+ schema: PoseSchema;
39
+ schemaSignature: string;
26
40
  sampleRate: number;
27
41
  coordinates: 'meters-y-up-z-forward';
28
42
  horizons: readonly number[];
@@ -40,9 +54,15 @@ export interface Match extends Frame {
40
54
  cost: number;
41
55
  }
42
56
  export interface Query {
57
+ context?: SelectionContext;
43
58
  features: Float32Array;
59
+ schemaSignature?: string;
44
60
  gaits?: ReadonlySet<Gait>;
45
61
  excludedClips?: ReadonlySet<number>;
62
+ /** Optional root-velocity compatibility band in m/s. If uncovered, use nearest velocity before pose cost. */
63
+ velocityTolerance?: number;
64
+ /** False keeps the phase of the current loop; searches may still select another clip. Default true. */
65
+ allowTemporalReselection?: boolean;
46
66
  }
47
67
  export interface Pose {
48
68
  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,7 +1,11 @@
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';
5
6
  export * from './core/controller.js';
6
7
  export * from './core/root-motion.js';
7
8
  export * from './core/profile.js';
9
+ export * from './core/selection.js';
10
+ export * from './core/inertialization.js';
11
+ export * from './core/scenario.js';
package/dist/index.js CHANGED
@@ -1,7 +1,11 @@
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';
5
6
  export * from './core/controller.js';
6
7
  export * from './core/root-motion.js';
7
8
  export * from './core/profile.js';
9
+ export * from './core/selection.js';
10
+ export * from './core/inertialization.js';
11
+ export * from './core/scenario.js';
@@ -0,0 +1,88 @@
1
+ import { Quaternion, Vector3 } from '@babylonjs/core';
2
+ import { decayInertialOffset } from './core/inertialization.js';
3
+ export function captureLocal(node) {
4
+ return { position: node.position.clone(), rotation: node.rotationQuaternion?.clone() ?? Quaternion.FromEulerVector(node.rotation), scaling: node.scaling.clone() };
5
+ }
6
+ export function rotationVector(q) {
7
+ q = q.clone().normalize();
8
+ if (q.w < 0)
9
+ q.scaleInPlace(-1);
10
+ const length = Math.hypot(q.x, q.y, q.z);
11
+ return length < 1e-8 ? new Vector3(q.x * 2, q.y * 2, q.z * 2) : new Vector3(q.x, q.y, q.z).scale(2 * Math.atan2(length, q.w) / length);
12
+ }
13
+ function rotationFromVector(v) {
14
+ const angle = v.length();
15
+ return angle < 1e-8 ? new Quaternion(v.x / 2, v.y / 2, v.z / 2, 1).normalize() : Quaternion.RotationAxis(v.scale(1 / angle), angle);
16
+ }
17
+ export function poseVelocity(before, after, dt) {
18
+ return { position: after.position.subtract(before.position).scale(1 / dt), scaling: after.scaling.subtract(before.scaling).scale(1 / dt),
19
+ // rotation stores a tangent vector in XYZ; W is unused for velocities.
20
+ rotation: Quaternion.FromArray([...rotationVector(after.rotation.multiply(Quaternion.Inverse(before.rotation))).scale(1 / dt).asArray(), 0]) };
21
+ }
22
+ const zeroVelocity = () => ({ position: Vector3.Zero(), scaling: Vector3.Zero(), rotation: new Quaternion(0, 0, 0, 0) });
23
+ /** Owns local presentation residuals only. Evaluation happens before world matrices/rendering. */
24
+ export class BabylonInertializer {
25
+ scene;
26
+ halfLife;
27
+ observer;
28
+ last = new Map();
29
+ velocity = new Map();
30
+ residuals = new Map();
31
+ target = new Map();
32
+ dt = 0;
33
+ elapsed = 0;
34
+ revision = 0;
35
+ renderedRevision = -1;
36
+ constructor(scene, halfLife) {
37
+ this.scene = scene;
38
+ this.halfLife = halfLife;
39
+ this.observer = scene.onAfterAnimationsObservable.add(() => this.render());
40
+ }
41
+ update(target, targetVelocity, dt, switched) {
42
+ if (switched) {
43
+ this.residuals.clear();
44
+ this.elapsed = 0;
45
+ for (const [node, pose] of target) {
46
+ const before = this.last.get(node);
47
+ if (!before)
48
+ continue;
49
+ const v = this.velocity.get(node) ?? zeroVelocity(), incoming = targetVelocity.get(node) ?? zeroVelocity();
50
+ const angular = rotationVector(before.rotation.multiply(Quaternion.Inverse(pose.rotation)));
51
+ this.residuals.set(node, { offset: { position: before.position.subtract(pose.position), scaling: before.scaling.subtract(pose.scaling), rotation: new Quaternion(angular.x, angular.y, angular.z, 0) },
52
+ velocity: { position: v.position.subtract(incoming.position), scaling: v.scaling.subtract(incoming.scaling), rotation: v.rotation.subtract(incoming.rotation) } });
53
+ }
54
+ }
55
+ else
56
+ this.elapsed += dt;
57
+ this.target = target;
58
+ this.dt = dt;
59
+ this.revision++;
60
+ }
61
+ render() {
62
+ for (const [node, target] of this.target) {
63
+ const residual = this.residuals.get(node);
64
+ const decay = (offset, velocity) => new Vector3(...offset.asArray().map((v, i) => decayInertialOffset(v, velocity.asArray()[i], this.elapsed, this.halfLife)));
65
+ node.position.copyFrom(target.position);
66
+ node.scaling.copyFrom(target.scaling);
67
+ node.rotationQuaternion ??= Quaternion.Identity();
68
+ node.rotationQuaternion.copyFrom(target.rotation);
69
+ if (residual) {
70
+ node.position.addInPlace(decay(residual.offset.position, residual.velocity.position));
71
+ node.scaling.addInPlace(decay(residual.offset.scaling, residual.velocity.scaling));
72
+ const r = residual.offset.rotation, v = residual.velocity.rotation;
73
+ node.rotationQuaternion.copyFrom(rotationFromVector(decay(new Vector3(r.x, r.y, r.z), new Vector3(v.x, v.y, v.z))).multiply(target.rotation).normalize());
74
+ }
75
+ if (this.renderedRevision !== this.revision) {
76
+ const pose = captureLocal(node), previous = this.last.get(node);
77
+ if (previous && this.dt > 0)
78
+ this.velocity.set(node, poseVelocity(previous, pose, this.dt));
79
+ this.last.set(node, pose);
80
+ }
81
+ }
82
+ this.renderedRevision = this.revision;
83
+ if (this.elapsed > this.halfLife * 40)
84
+ this.residuals.clear();
85
+ }
86
+ reset() { this.last.clear(); this.velocity.clear(); this.residuals.clear(); this.target.clear(); this.elapsed = 0; this.renderedRevision = -1; }
87
+ dispose() { this.reset(); this.scene.onAfterAnimationsObservable.remove(this.observer); }
88
+ }