@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
@@ -1,59 +1,97 @@
1
- import { DIMENSIONS, HORIZONS } from './types.js';
1
+ import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
2
+ import { parseRootOrientation } from './profile.js';
3
+ import { validateSelection } from './selection.js';
4
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
2
5
  export function validateDatabase(db) {
3
- const m = db.manifest;
4
- if (m.version !== 1 || m.dimensions !== DIMENSIONS || m.coordinates !== 'meters-y-up-z-forward'
5
- || m.horizons.length !== 3 || m.horizons.some((v, i) => v !== HORIZONS[i]))
6
- throw new Error('Unsupported pose database schema');
7
- if (!Number.isFinite(m.sampleRate) || m.sampleRate <= 0 || !m.frames.length || !m.clips.length
8
- || db.features.length !== m.frames.length * DIMENSIONS)
6
+ const m = db?.manifest;
7
+ if (!record(m) || m.version !== 2)
8
+ throw new Error('Unsupported pose database version; v1 requires migrateLegacyDatabase or a new bake');
9
+ const layout = compileSchema(m.schema), dimensions = layout.dimensions;
10
+ if (m.dimensions !== dimensions || m.schemaSignature !== layout.signature || m.coordinates !== 'meters-y-up-z-forward'
11
+ || !Array.isArray(m.horizons) || m.horizons.length !== layout.horizons.length || m.horizons.some((v, i) => v !== layout.horizons[i]))
12
+ throw new Error('Incompatible pose database schema');
13
+ if (!Number.isFinite(m.sampleRate) || m.sampleRate <= 0 || !Array.isArray(m.frames) || !m.frames.length || !Array.isArray(m.clips) || !m.clips.length
14
+ || !(db.features instanceof Float32Array) || db.features.length !== m.frames.length * dimensions)
9
15
  throw new Error('Invalid database size/sample rate');
10
- if (m.mean.length !== DIMENSIONS || m.std.length !== DIMENSIONS || !m.mean.every(Number.isFinite)
11
- || !m.std.every(v => Number.isFinite(v) && v >= 1e-4) || !db.features.every(Number.isFinite))
16
+ if (!Array.isArray(m.mean) || !Array.isArray(m.std) || m.mean.length !== dimensions || m.std.length !== dimensions || ![...m.mean].every(Number.isFinite)
17
+ || ![...m.std].every(v => Number.isFinite(v) && v >= 1e-4) || !db.features.every(Number.isFinite))
12
18
  throw new Error('Invalid normalization/features');
19
+ for (const { channel, offset, dimensions: width } of layout.channels)
20
+ if (channel.normalization === 'none') {
21
+ for (let j = offset; j < offset + width; j++)
22
+ if (m.mean[j] !== 0 || m.std[j] !== 1)
23
+ throw new Error('Invalid unnormalized channel statistics');
24
+ }
25
+ if (m.assetHashes !== undefined && (!record(m.assetHashes) || !Object.entries(m.assetHashes).length || !Object.entries(m.assetHashes).every(([url, hash]) => url.startsWith('/') && typeof hash === 'string' && /^[a-f0-9]{64}$/.test(hash))))
26
+ throw new Error('Invalid asset hashes');
13
27
  const ids = new Set();
14
28
  for (const clip of m.clips) {
15
- if (ids.has(clip.id) || !clip.id || !clip.file || !clip.animation || !Number.isFinite(clip.duration) || clip.duration <= 0
29
+ if (!record(clip) || typeof clip.id !== 'string' || ids.has(clip.id) || !clip.id || typeof clip.file !== 'string' || !clip.file
30
+ || typeof clip.animation !== 'string' || !clip.animation || !Number.isFinite(clip.duration) || clip.duration <= 0 || typeof clip.looping !== 'boolean'
16
31
  || !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(clip.gait))
17
32
  throw new Error('Invalid clip');
18
33
  ids.add(clip.id);
34
+ validateSelection(clip, clip.duration);
35
+ if (clip.sourceStart !== undefined && (!Number.isFinite(clip.sourceStart) || clip.sourceStart < 0))
36
+ throw new Error('Invalid source start');
37
+ if (clip.averageVelocity !== undefined && (!Array.isArray(clip.averageVelocity) || clip.averageVelocity.length !== 3 || !clip.averageVelocity.every(Number.isFinite)))
38
+ throw new Error('Invalid measured average velocity');
39
+ if (clip.rootOrientation !== undefined)
40
+ parseRootOrientation(clip.rootOrientation);
19
41
  }
20
42
  for (const frame of m.frames) {
21
- if (!Number.isInteger(frame.clip) || !m.clips[frame.clip] || !Number.isFinite(frame.time)
43
+ if (!record(frame) || !Number.isInteger(frame.clip) || !m.clips[frame.clip] || !Number.isFinite(frame.time)
22
44
  || frame.time < 0 || frame.time > m.clips[frame.clip].duration + 1e-6)
23
45
  throw new Error('Invalid pose metadata');
24
46
  }
25
47
  }
26
- export function buildDatabase(manifest, raw) {
27
- const count = manifest.frames.length;
28
- if (!count || raw.length !== count * DIMENSIONS || !raw.every(Number.isFinite))
48
+ /** Omitting schema selects the legacy biped preset. New data is always v2. */
49
+ export function buildDatabase(manifest, raw, schema = LOCOMOTION_SCHEMA) {
50
+ const layout = compileSchema(schema), dimensions = layout.dimensions, count = manifest.frames.length;
51
+ if (!count || raw.length !== count * dimensions || !raw.every(Number.isFinite))
29
52
  throw new Error('Invalid raw samples');
30
- const mean = Array(DIMENSIONS).fill(0), std = Array(DIMENSIONS).fill(0);
53
+ const mean = Array(dimensions).fill(0), std = Array(dimensions).fill(0);
31
54
  for (let i = 0; i < raw.length; i++)
32
- mean[i % DIMENSIONS] += raw[i] / count;
55
+ mean[i % dimensions] += raw[i] / count;
33
56
  for (let i = 0; i < raw.length; i++)
34
- std[i % DIMENSIONS] += (raw[i] - mean[i % DIMENSIONS]) ** 2 / count;
35
- // Constant channels carry no measured variation. A unit scale avoids amplifying
36
- // unsupported trajectories (e.g. turns absent from the dataset) by 10,000×.
37
- for (let j = 0; j < DIMENSIONS; j++)
57
+ std[i % dimensions] += (raw[i] - mean[i % dimensions]) ** 2 / count;
58
+ for (let j = 0; j < dimensions; j++)
38
59
  std[j] = Math.sqrt(std[j]) < 1e-4 ? 1 : Math.sqrt(std[j]);
39
- const features = Float32Array.from(raw, (v, i) => (v - mean[i % DIMENSIONS]) / std[i % DIMENSIONS]);
40
- const db = { manifest: { ...manifest, version: 1, dimensions: DIMENSIONS, coordinates: 'meters-y-up-z-forward', horizons: HORIZONS, mean, std }, features };
60
+ for (const { channel, offset, dimensions: width } of layout.channels)
61
+ if (channel.normalization === 'none') {
62
+ mean.fill(0, offset, offset + width);
63
+ std.fill(1, offset, offset + width);
64
+ }
65
+ const features = Float32Array.from(raw, (v, i) => (v - mean[i % dimensions]) / std[i % dimensions]);
66
+ const db = { manifest: { ...manifest, version: 2, dimensions, schema: layout.schema, schemaSignature: layout.signature,
67
+ coordinates: 'meters-y-up-z-forward', horizons: layout.horizons, mean, std }, features };
41
68
  validateDatabase(db);
42
69
  return db;
43
70
  }
44
71
  /** Binary format is explicitly little endian float32; version/layout live in manifest. */
45
72
  export function encodeFeatures(db) {
73
+ validateDatabase(db);
46
74
  const bytes = new ArrayBuffer(db.features.length * 4), view = new DataView(bytes);
47
75
  db.features.forEach((v, i) => view.setFloat32(i * 4, v, true));
48
76
  return bytes;
49
77
  }
50
- export function decodeDatabase(manifest, bytes) {
78
+ export function decodeDatabase(manifest, bytes, expectedSchema) {
51
79
  if (bytes.byteLength % 4)
52
80
  throw new Error('Truncated feature binary');
53
81
  const view = new DataView(bytes), features = new Float32Array(bytes.byteLength / 4);
54
82
  for (let i = 0; i < features.length; i++)
55
83
  features[i] = view.getFloat32(i * 4, true);
56
- const db = { manifest, features };
84
+ const db = { manifest: manifest, features };
57
85
  validateDatabase(db);
86
+ if (expectedSchema && db.manifest.schemaSignature !== compileSchema(expectedSchema).signature)
87
+ throw new Error('Incompatible expected schema; regenerate database');
58
88
  return db;
59
89
  }
90
+ /** Explicit, lossless conversion ONLY for the original v1 27-dimensional preset. */
91
+ export function migrateLegacyDatabase(input, bytes) {
92
+ const layout = compileSchema(LOCOMOTION_SCHEMA);
93
+ if (!record(input) || input.version !== 1 || input.dimensions !== layout.dimensions || !Array.isArray(input.horizons)
94
+ || input.horizons.length !== layout.horizons.length || input.horizons.some((h, i) => h !== layout.horizons[i]))
95
+ throw new Error('Unsupported legacy database schema');
96
+ return decodeDatabase({ ...input, version: 2, schema: layout.schema, schemaSignature: layout.signature }, bytes);
97
+ }
@@ -12,4 +12,4 @@ export interface TrajectoryInput {
12
12
  turnSpeed: number;
13
13
  }
14
14
  /** Prediction only: never writes to the gameplay controller. Uses fixed substeps. */
15
- export declare function predictTrajectory(input: TrajectoryInput): TrajectoryPoint[];
15
+ export declare function predictTrajectory(input: TrajectoryInput, horizons?: readonly number[]): TrajectoryPoint[];
@@ -1,14 +1,11 @@
1
1
  import { DIMENSIONS, HORIZONS } from './types.js';
2
+ import { SchemaQueryBuilder, validateHorizons } from './schema.js';
3
+ const legacyBuilder = new SchemaQueryBuilder();
2
4
  export function featureVector(pose, trajectory, out = new Float32Array(DIMENSIONS)) {
3
- if (trajectory.length !== 3 || out.length !== DIMENSIONS)
4
- throw new Error('Expected 3 trajectory points and 27 features');
5
- out.set(pose.leftPosition, 0);
6
- out.set(pose.rightPosition, 3);
7
- out.set(pose.leftVelocity, 6);
8
- out.set(pose.rightVelocity, 9);
9
- out.set(pose.rootVelocity, 12);
10
- trajectory.forEach((point, i) => { out.set(point.position, 15 + i * 2); out.set(point.facing, 21 + i * 2); });
11
- return out;
5
+ return legacyBuilder.features({ rootVelocity: pose.rootVelocity, bones: {
6
+ leftFoot: { position: pose.leftPosition, velocity: pose.leftVelocity },
7
+ rightFoot: { position: pose.rightPosition, velocity: pose.rightVelocity },
8
+ } }, trajectory, out);
12
9
  }
13
10
  /** World delta to current character-local XZ (yaw zero looks along +Z). */
14
11
  export function toLocal(x, z, yaw) {
@@ -16,13 +13,14 @@ export function toLocal(x, z, yaw) {
16
13
  return [c * x - s * z, s * x + c * z];
17
14
  }
18
15
  /** Prediction only: never writes to the gameplay controller. Uses fixed substeps. */
19
- export function predictTrajectory(input) {
16
+ export function predictTrajectory(input, horizons = HORIZONS) {
17
+ validateHorizons(horizons);
20
18
  if (![...input.velocity, ...input.desiredVelocity, input.yaw, input.desiredYaw, input.acceleration, input.braking, input.turnSpeed].every(Number.isFinite)
21
19
  || Math.min(input.acceleration, input.braking, input.turnSpeed) < 0)
22
20
  throw new Error('Invalid trajectory input');
23
21
  let [vx, vz] = input.velocity, x = 0, z = 0, yaw = input.yaw, elapsed = 0;
24
22
  const points = [];
25
- for (const horizon of HORIZONS) {
23
+ for (const horizon of horizons) {
26
24
  while (elapsed < horizon - 1e-9) {
27
25
  const dt = Math.min(1 / 60, horizon - elapsed);
28
26
  const dx = input.desiredVelocity[0] - vx, dz = input.desiredVelocity[1] - vz, length = Math.hypot(dx, dz);
@@ -0,0 +1,3 @@
1
+ /** Critically damped residual. With zero initial velocity, offset halves at halfLife.
2
+ * Does not integrate gameplay/root displacement. */
3
+ export declare function decayInertialOffset(offset: number, velocity: number, elapsed: number, halfLife: number): number;
@@ -0,0 +1,15 @@
1
+ /** Critically damped residual. With zero initial velocity, offset halves at halfLife.
2
+ * Does not integrate gameplay/root displacement. */
3
+ export function decayInertialOffset(offset, velocity, elapsed, halfLife) {
4
+ if (![offset, velocity, elapsed, halfLife].every(Number.isFinite) || elapsed < 0 || halfLife <= 0)
5
+ throw new Error('Invalid inertial residual');
6
+ if (elapsed === 0)
7
+ return offset;
8
+ const exponent = (1.67834699001666 / halfLife) * elapsed;
9
+ if (exponent > 745)
10
+ return 0;
11
+ const decay = Math.exp(-exponent);
12
+ // Reassociate products: avoid Infinity * 0 after a long pause, and avoid
13
+ // overflowing omega*offset while the final damped value is representable.
14
+ return offset * ((1 + exponent) * decay) + velocity * (elapsed * decay);
15
+ }
@@ -1,6 +1,8 @@
1
- import type { Gait, Vec3 } from './types.js';
1
+ import type { Gait, Quat, Vec3 } from './types.js';
2
+ import { type PoseSchema } from './schema.js';
3
+ import { type SelectionMetadata, type SelectionContext } from './selection.js';
2
4
  export interface CharacterProfile {
3
- version: 1;
5
+ version: 2;
4
6
  id: string;
5
7
  label: string;
6
8
  assetBase: string;
@@ -10,14 +12,26 @@ export interface CharacterProfile {
10
12
  sampleRate: number;
11
13
  rig: {
12
14
  root: string;
13
- leftFoot: string;
14
- rightFoot: string;
15
- };
15
+ } & Record<string, string>;
16
+ schema: string;
17
+ animationSet: string;
16
18
  /** Source animation node -> model node; name mapping does not perform retargeting. */
17
19
  boneMap?: Record<string, string>;
18
20
  /** Model node whose position/heading tracks are excluded by the player. */
19
21
  stripRoot?: string;
20
- clips: {
22
+ /** Exact model node names for optional runtime layers. */
23
+ masks?: Record<string, readonly string[]>;
24
+ /** Lab-only presentation preview. Does not authorize a gameplay action. */
25
+ layerPreview?: {
26
+ baseMask: string;
27
+ overlayMask: string;
28
+ context: SelectionContext;
29
+ };
30
+ }
31
+ export interface AnimationSet {
32
+ version: 1;
33
+ id: string;
34
+ clips: (SelectionMetadata & {
21
35
  id: string;
22
36
  file: string;
23
37
  animation: string;
@@ -25,12 +39,24 @@ export interface CharacterProfile {
25
39
  looping: boolean;
26
40
  nominalVelocity?: Vec3;
27
41
  motionFile?: string;
28
- }[];
42
+ rootOrientation?: Quat;
43
+ /** Source interval in seconds. */
44
+ range?: readonly [number, number];
45
+ })[];
46
+ }
47
+ export interface ResolvedProfile extends CharacterProfile {
48
+ schemaDefinition: PoseSchema;
49
+ clips: AnimationSet['clips'];
50
+ setId: string;
29
51
  }
52
+ export declare function parseRootOrientation(value: unknown): Quat;
30
53
  /** Read untrusted JSON as unknown; report a configuration error before loading assets. */
31
54
  export declare function parseProfile(input: unknown): CharacterProfile;
55
+ export declare function parseAnimationSet(input: unknown): AnimationSet;
56
+ /** Resolve external configuration explicitly; core performs no I/O. */
57
+ export declare function resolveProfile(profile: CharacterProfile, schemaInput: unknown, setInput: unknown): ResolvedProfile;
32
58
  /** Deterministic configuration fingerprint; detects stale banks, not a security signature. */
33
- export declare function profileSignature(profile: CharacterProfile): string;
59
+ export declare function profileSignature(profile: ResolvedProfile): string;
34
60
  /** Suggestions only: ambiguity is returned to the caller, never silently resolved. */
35
61
  export declare function suggestRig(names: readonly string[]): Record<'root' | 'leftFoot' | 'rightFoot', string[]>;
36
62
  /** Semantic availability, independent of character/clip names. Preserve optional actions. */
@@ -1,42 +1,81 @@
1
+ import { compileSchema, parseSchema } from './schema.js';
2
+ import { validateContext, validateSelection } from './selection.js';
1
3
  const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
2
4
  const name = (v) => typeof v === 'string' && v.trim().length > 0;
3
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
+ }
4
11
  /** Read untrusted JSON as unknown; report a configuration error before loading assets. */
5
12
  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');
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');
8
18
  if (!asset(input.assetBase) || !input.assetBase.startsWith('/') || !input.assetBase.endsWith('/') || !asset(input.database) || !input.database.startsWith('/') || !input.database.endsWith('/')
9
19
  || !asset(input.model) || input.model.startsWith('/'))
10
20
  throw new Error('Profile: use public URL folders and relative model files');
11
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)
12
22
  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)
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)
17
26
  throw new Error('Profile: required bones must be distinct');
18
27
  if (input.stripRoot !== undefined && !name(input.stripRoot))
19
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
+ }
20
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))
21
42
  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');
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');
24
48
  const ids = new Set();
25
49
  for (const clip of input.clips) {
26
50
  if (!record(clip) || !name(clip.id) || ids.has(clip.id) || !asset(clip.file) || clip.file.startsWith('/') || !name(clip.animation) || typeof clip.looping !== 'boolean'
27
51
  || !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(String(clip.gait)))
28
52
  throw new Error('Profile: invalid/duplicate clip');
29
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);
30
59
  const nominal = clip.nominalVelocity !== undefined, measured = clip.motionFile !== undefined;
31
60
  if (nominal === measured)
32
61
  throw new Error(`Profile ${clip.id}: declare exactly one of nominalVelocity or motionFile`);
33
62
  if (nominal && (!Array.isArray(clip.nominalVelocity) || clip.nominalVelocity.length !== 3 || !clip.nominalVelocity.every(Number.isFinite)))
34
63
  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`);
64
+ if (measured && (!asset(clip.motionFile) || clip.motionFile.startsWith('/')))
65
+ throw new Error(`Profile ${clip.id}: measured motion requires a relative file`);
37
66
  }
38
67
  return structuredClone(input);
39
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
+ }
40
79
  /** Deterministic configuration fingerprint; detects stale banks, not a security signature. */
41
80
  export function profileSignature(profile) {
42
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;
@@ -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,29 @@
1
+ import { type MatcherOptions, type Playback } from './controller.js';
2
+ import type { Database, Gait, Query, Vec3 } from './types.js';
3
+ export interface RecordedQuery extends Omit<Query, 'features' | 'gaits' | 'excludedClips'> {
4
+ features: number[];
5
+ gaits?: Gait[];
6
+ excludedClips?: number[];
7
+ }
8
+ export interface ScenarioFrame {
9
+ dt: number;
10
+ query: RecordedQuery;
11
+ controller: {
12
+ position: Vec3;
13
+ yaw: number;
14
+ };
15
+ playback: Omit<Playback, 'match'>;
16
+ presentation?: Omit<Playback, 'match'>;
17
+ }
18
+ export interface MotionScenario {
19
+ version: 1;
20
+ databaseIdentity: string;
21
+ matcherOptions: Partial<MatcherOptions>;
22
+ frames: ScenarioFrame[];
23
+ }
24
+ /** Content identity for exact replay, not a cryptographic/security signature. */
25
+ export declare function scenarioDatabaseIdentity(db: Database): string;
26
+ export declare function restoreScenarioQuery(recorded: RecordedQuery): Query;
27
+ export declare function captureScenarioFrame(dt: number, query: Query, position: Vec3, yaw: number, playback: Playback, presentation?: Playback): ScenarioFrame;
28
+ /** Import only validated local data, never execute content from a scenario file. */
29
+ export declare function parseMotionScenario(input: unknown, db: Database): MotionScenario;
@@ -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,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 {};