@pose_flow/babylon 0.2.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.
- package/README.md +9 -3
- package/dist/AI_USAGE.md +107 -12
- package/dist/PUBLISHING.md +2 -0
- package/dist/babylon-inertialization.d.ts +29 -0
- package/dist/babylon-inertialization.js +88 -0
- package/dist/babylon.d.ts +14 -1
- package/dist/babylon.js +88 -8
- package/dist/core/controller.d.ts +26 -0
- package/dist/core/controller.js +73 -8
- package/dist/core/database.js +2 -0
- package/dist/core/inertialization.d.ts +3 -0
- package/dist/core/inertialization.js +15 -0
- package/dist/core/profile.d.ts +11 -2
- package/dist/core/profile.js +14 -0
- package/dist/core/scenario.d.ts +29 -0
- package/dist/core/scenario.js +57 -0
- package/dist/core/search.d.ts +32 -0
- package/dist/core/search.js +92 -8
- package/dist/core/selection.d.ts +24 -0
- package/dist/core/selection.js +41 -0
- package/dist/core/types.d.ts +3 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/offline/src/babylon-inertialization.js +88 -0
- package/dist/offline/src/babylon.js +88 -8
- package/dist/offline/src/core/controller.js +73 -8
- package/dist/offline/src/core/database.js +2 -0
- package/dist/offline/src/core/inertialization.js +15 -0
- package/dist/offline/src/core/profile.js +14 -0
- package/dist/offline/src/core/scenario.js +57 -0
- package/dist/offline/src/core/search.js +92 -8
- package/dist/offline/src/core/selection.js +41 -0
- package/dist/offline/src/index.js +3 -0
- package/dist/offline/tools/bake.js +3 -1
- package/package.json +4 -4
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { PoseSearch } from './search.js';
|
|
2
2
|
import type { Database, Match, Query } from './types.js';
|
|
3
|
+
import type { SearchReport } from './search.js';
|
|
3
4
|
export interface Playback {
|
|
4
5
|
clip: number;
|
|
5
6
|
time: number;
|
|
6
7
|
switched: boolean;
|
|
7
8
|
match: Match | null;
|
|
8
9
|
}
|
|
10
|
+
export interface MatcherDecision {
|
|
11
|
+
searched: boolean;
|
|
12
|
+
reason: string;
|
|
13
|
+
previousClip: number;
|
|
14
|
+
currentClip: number;
|
|
15
|
+
currentCost: number | null;
|
|
16
|
+
candidateCost: number | null;
|
|
17
|
+
sinceSwitch: number;
|
|
18
|
+
minimumHold: number;
|
|
19
|
+
jumpPenalty: number;
|
|
20
|
+
improvement: number;
|
|
21
|
+
}
|
|
9
22
|
export interface MatcherOptions {
|
|
10
23
|
searchInterval: number;
|
|
11
24
|
minimumHold: number;
|
|
@@ -13,6 +26,12 @@ export interface MatcherOptions {
|
|
|
13
26
|
jumpPenalty: number;
|
|
14
27
|
timeSeparation: number;
|
|
15
28
|
reselectDelay: number;
|
|
29
|
+
/** Active only when the query explicitly requests idle alone. */
|
|
30
|
+
idleVariation?: {
|
|
31
|
+
interval: number;
|
|
32
|
+
historySize: number;
|
|
33
|
+
};
|
|
34
|
+
diagnostics?: boolean;
|
|
16
35
|
}
|
|
17
36
|
/** Owns animation time only. Caller retains movement and action authority. */
|
|
18
37
|
export declare class MotionMatcher {
|
|
@@ -26,6 +45,13 @@ export declare class MotionMatcher {
|
|
|
26
45
|
private last;
|
|
27
46
|
private completed;
|
|
28
47
|
private indices;
|
|
48
|
+
private idleElapsed;
|
|
49
|
+
private idleHistory;
|
|
50
|
+
private effectiveQuery?;
|
|
51
|
+
private decisionState;
|
|
52
|
+
/** Snapshot of the last continuity decision. Query copying requires diagnostics=true. */
|
|
53
|
+
get decision(): MatcherDecision | null;
|
|
54
|
+
inspect(limit?: number): SearchReport | null;
|
|
29
55
|
constructor(database: Database, options?: Partial<MatcherOptions>);
|
|
30
56
|
reset(): void;
|
|
31
57
|
update(dt: number, query: Query): Playback;
|
package/dist/core/controller.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PoseSearch } from './search.js';
|
|
2
|
+
import { withinWindows } from './selection.js';
|
|
2
3
|
/** Owns animation time only. Caller retains movement and action authority. */
|
|
3
4
|
export class MotionMatcher {
|
|
4
5
|
database;
|
|
@@ -11,22 +12,36 @@ export class MotionMatcher {
|
|
|
11
12
|
last = null;
|
|
12
13
|
completed = new Map();
|
|
13
14
|
indices;
|
|
15
|
+
idleElapsed = 0;
|
|
16
|
+
idleHistory = [];
|
|
17
|
+
effectiveQuery;
|
|
18
|
+
decisionState = null;
|
|
19
|
+
/** Snapshot of the last continuity decision. Query copying requires diagnostics=true. */
|
|
20
|
+
get decision() { return this.decisionState ? { ...this.decisionState } : null; }
|
|
21
|
+
inspect(limit = 5) { return this.options.diagnostics && this.effectiveQuery ? this.search.inspect(this.effectiveQuery, limit) : null; }
|
|
14
22
|
constructor(database, options = {}) {
|
|
15
23
|
this.database = database;
|
|
16
24
|
this.search = new PoseSearch(database);
|
|
17
25
|
this.options = { searchInterval: 0.1, minimumHold: 0.12, improvement: 0.1, jumpPenalty: 0.5, timeSeparation: 0.2, reselectDelay: 0.6, ...options };
|
|
18
|
-
|
|
26
|
+
const { idleVariation, diagnostics, ...numeric } = this.options;
|
|
27
|
+
if (!Object.values(numeric).every(v => Number.isFinite(v) && v >= 0) || this.options.searchInterval === 0 || this.options.improvement >= 1)
|
|
19
28
|
throw new Error('Invalid matcher options');
|
|
29
|
+
if (idleVariation && (!Number.isFinite(idleVariation.interval) || idleVariation.interval <= 0 || !Number.isInteger(idleVariation.historySize) || idleVariation.historySize < 1))
|
|
30
|
+
throw new Error('Invalid idle variation policy');
|
|
31
|
+
if (diagnostics !== undefined && typeof diagnostics !== 'boolean')
|
|
32
|
+
throw new Error('Invalid diagnostics option');
|
|
20
33
|
this.indices = database.manifest.clips.map((_, clip) => database.manifest.frames.flatMap((f, i) => f.clip === clip ? [i] : []).sort((a, b) => database.manifest.frames[a].time - database.manifest.frames[b].time));
|
|
21
34
|
if (this.indices.some(i => !i.length))
|
|
22
35
|
throw new Error('Every clip must have indexed samples');
|
|
23
36
|
}
|
|
24
|
-
reset() { this.clip = -1; this.time = 0; this.sinceSearch = this.sinceSwitch = Infinity; this.last = null; this.completed.clear(); }
|
|
37
|
+
reset() { this.clip = -1; this.time = 0; this.sinceSearch = this.sinceSwitch = Infinity; this.last = null; this.completed.clear(); this.idleElapsed = 0; this.idleHistory = []; this.effectiveQuery = undefined; this.decisionState = null; }
|
|
25
38
|
update(dt, query) {
|
|
26
39
|
if (!Number.isFinite(dt) || dt < 0)
|
|
27
40
|
throw new Error('Invalid delta time');
|
|
28
41
|
// Validate identity even while continuity skips a search this frame.
|
|
29
42
|
this.search.validateQuery(query);
|
|
43
|
+
const previousClip = this.clip;
|
|
44
|
+
let reason = 'search-interval', searched = false, currentCost = null, candidateCost = null;
|
|
30
45
|
let switched = false, ended = false;
|
|
31
46
|
for (const [clip, remaining] of this.completed) {
|
|
32
47
|
if (remaining <= dt)
|
|
@@ -42,15 +57,47 @@ export class MotionMatcher {
|
|
|
42
57
|
}
|
|
43
58
|
this.sinceSearch += dt;
|
|
44
59
|
this.sinceSwitch += dt;
|
|
45
|
-
const allowed = this.
|
|
60
|
+
const allowed = this.search.allowsClip(this.clip, query);
|
|
61
|
+
const idleOnly = query.gaits?.size === 1 && query.gaits.has('idle') && !!this.options.idleVariation;
|
|
62
|
+
if (idleOnly && allowed)
|
|
63
|
+
this.idleElapsed += dt;
|
|
64
|
+
else {
|
|
65
|
+
this.idleElapsed = 0;
|
|
66
|
+
this.idleHistory = [];
|
|
67
|
+
}
|
|
46
68
|
if (this.sinceSearch >= this.options.searchInterval || !allowed || ended) {
|
|
69
|
+
searched = true;
|
|
47
70
|
this.sinceSearch = 0;
|
|
48
71
|
// A finished one-shot must yield to another clip, rather than selecting
|
|
49
72
|
// its terminal pose and restarting forever. User exclusions still apply.
|
|
50
73
|
if (ended)
|
|
51
74
|
this.completed.set(this.clip, this.options.reselectDelay);
|
|
52
75
|
const excludedClips = new Set([...(query.excludedClips ?? []), ...this.completed.keys()]);
|
|
53
|
-
|
|
76
|
+
let variation = false;
|
|
77
|
+
let best = null;
|
|
78
|
+
const runSearch = (effective) => {
|
|
79
|
+
// Keep the query that actually produced the candidate, including idle
|
|
80
|
+
// history/completion exclusions. Features are snapshotted for later UI.
|
|
81
|
+
if (this.options.diagnostics)
|
|
82
|
+
this.effectiveQuery = { ...effective, features: effective.features.slice(), context: effective.context ? { ...effective.context } : undefined,
|
|
83
|
+
gaits: effective.gaits ? new Set(effective.gaits) : undefined, excludedClips: effective.excludedClips ? new Set(effective.excludedClips) : undefined };
|
|
84
|
+
return this.search.search(effective);
|
|
85
|
+
};
|
|
86
|
+
if (idleOnly && allowed && !ended) {
|
|
87
|
+
if (this.idleElapsed >= this.options.idleVariation.interval) {
|
|
88
|
+
// Relax oldest history first when the pool is small, but never force
|
|
89
|
+
// a restart of the current clip or relax the consumer's context.
|
|
90
|
+
const history = [...this.idleHistory];
|
|
91
|
+
do {
|
|
92
|
+
best = runSearch({ ...query, excludedClips: new Set([...excludedClips, this.clip, ...history]) });
|
|
93
|
+
} while (!best && history.shift() !== undefined);
|
|
94
|
+
variation = best !== null;
|
|
95
|
+
}
|
|
96
|
+
if (!best)
|
|
97
|
+
best = runSearch({ ...query, excludedClips: new Set([...excludedClips, ...this.database.manifest.clips.flatMap((_, i) => i === this.clip ? [] : [i])]) });
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
best = runSearch({ ...query, excludedClips });
|
|
54
101
|
this.last = best;
|
|
55
102
|
if (best) {
|
|
56
103
|
const current = this.clip >= 0 ? this.nearestFrame(this.clip, this.time) : -1;
|
|
@@ -60,21 +107,39 @@ export class MotionMatcher {
|
|
|
60
107
|
gap = Math.min(gap, duration - gap);
|
|
61
108
|
const distant = best.clip !== this.clip || gap > this.options.timeSeparation;
|
|
62
109
|
const better = current < 0 || best.cost + this.options.jumpPenalty < this.search.cost(current) * (1 - this.options.improvement);
|
|
110
|
+
currentCost = current < 0 ? null : this.search.cost(current);
|
|
111
|
+
candidateCost = best.cost;
|
|
63
112
|
const incompatibleVelocity = current >= 0 && distant && this.sinceSwitch >= this.options.minimumHold && query.velocityTolerance !== undefined
|
|
64
113
|
&& this.search.rootVelocityError(current, query) > Math.max(query.velocityTolerance, this.search.rootVelocityError(best.index, query) + 1e-6);
|
|
65
114
|
const mayReselectTime = query.allowTemporalReselection !== false || best.clip !== this.clip || !this.database.manifest.clips[best.clip].looping;
|
|
66
|
-
|
|
115
|
+
const mayExit = this.clip < 0 || withinWindows(this.time, this.database.manifest.clips[this.clip].exitWindows);
|
|
116
|
+
const shouldSwitch = current < 0 || !allowed || ended || (mayExit && mayReselectTime && (incompatibleVelocity || (distant && (better || variation) && this.sinceSwitch >= this.options.minimumHold)));
|
|
117
|
+
reason = current < 0 ? 'initial' : !allowed ? 'incompatible-current' : ended ? 'clip-ended' : !mayExit ? 'exit-window' : !mayReselectTime ? 'phase-preserved'
|
|
118
|
+
: incompatibleVelocity ? 'velocity-incompatible' : !distant ? 'nearby-candidate' : this.sinceSwitch < this.options.minimumHold ? 'minimum-hold' : variation ? 'idle-variation' : better ? 'better-candidate' : 'insufficient-improvement';
|
|
119
|
+
if (shouldSwitch) {
|
|
67
120
|
this.clip = best.clip;
|
|
68
121
|
this.time = best.time;
|
|
69
122
|
this.sinceSwitch = 0;
|
|
70
123
|
switched = true;
|
|
124
|
+
this.idleElapsed = 0;
|
|
125
|
+
if (this.database.manifest.clips[this.clip].gait === 'idle') {
|
|
126
|
+
this.idleHistory.push(this.clip);
|
|
127
|
+
this.idleHistory = this.idleHistory.slice(-(this.options.idleVariation?.historySize ?? 1));
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
this.idleHistory = [];
|
|
71
131
|
}
|
|
72
132
|
}
|
|
73
|
-
else
|
|
74
|
-
|
|
75
|
-
|
|
133
|
+
else {
|
|
134
|
+
reason = !allowed || ended ? 'no-candidate-cleared' : 'no-entry-continue';
|
|
135
|
+
if (!allowed || ended) {
|
|
136
|
+
this.clip = -1;
|
|
137
|
+
this.time = 0;
|
|
138
|
+
}
|
|
76
139
|
}
|
|
77
140
|
}
|
|
141
|
+
this.decisionState = { searched, reason, previousClip, currentClip: this.clip, currentCost, candidateCost, sinceSwitch: this.sinceSwitch,
|
|
142
|
+
minimumHold: this.options.minimumHold, improvement: this.options.improvement, jumpPenalty: this.options.jumpPenalty };
|
|
78
143
|
return { clip: this.clip, time: this.time, switched, match: this.last };
|
|
79
144
|
}
|
|
80
145
|
nearestFrame(clip, time) {
|
package/dist/core/database.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
|
|
2
2
|
import { parseRootOrientation } from './profile.js';
|
|
3
|
+
import { validateSelection } from './selection.js';
|
|
3
4
|
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
4
5
|
export function validateDatabase(db) {
|
|
5
6
|
const m = db?.manifest;
|
|
@@ -30,6 +31,7 @@ export function validateDatabase(db) {
|
|
|
30
31
|
|| !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(clip.gait))
|
|
31
32
|
throw new Error('Invalid clip');
|
|
32
33
|
ids.add(clip.id);
|
|
34
|
+
validateSelection(clip, clip.duration);
|
|
33
35
|
if (clip.sourceStart !== undefined && (!Number.isFinite(clip.sourceStart) || clip.sourceStart < 0))
|
|
34
36
|
throw new Error('Invalid source start');
|
|
35
37
|
if (clip.averageVelocity !== undefined && (!Array.isArray(clip.averageVelocity) || clip.averageVelocity.length !== 3 || !clip.averageVelocity.every(Number.isFinite)))
|
|
@@ -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
|
+
}
|
package/dist/core/profile.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Gait, Quat, Vec3 } from './types.js';
|
|
2
2
|
import { type PoseSchema } from './schema.js';
|
|
3
|
+
import { type SelectionMetadata, type SelectionContext } from './selection.js';
|
|
3
4
|
export interface CharacterProfile {
|
|
4
5
|
version: 2;
|
|
5
6
|
id: string;
|
|
@@ -18,11 +19,19 @@ export interface CharacterProfile {
|
|
|
18
19
|
boneMap?: Record<string, string>;
|
|
19
20
|
/** Model node whose position/heading tracks are excluded by the player. */
|
|
20
21
|
stripRoot?: string;
|
|
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
|
+
};
|
|
21
30
|
}
|
|
22
31
|
export interface AnimationSet {
|
|
23
32
|
version: 1;
|
|
24
33
|
id: string;
|
|
25
|
-
clips: {
|
|
34
|
+
clips: (SelectionMetadata & {
|
|
26
35
|
id: string;
|
|
27
36
|
file: string;
|
|
28
37
|
animation: string;
|
|
@@ -33,7 +42,7 @@ export interface AnimationSet {
|
|
|
33
42
|
rootOrientation?: Quat;
|
|
34
43
|
/** Source interval in seconds. */
|
|
35
44
|
range?: readonly [number, number];
|
|
36
|
-
}[];
|
|
45
|
+
})[];
|
|
37
46
|
}
|
|
38
47
|
export interface ResolvedProfile extends CharacterProfile {
|
|
39
48
|
schemaDefinition: PoseSchema;
|
package/dist/core/profile.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { compileSchema, parseSchema } from './schema.js';
|
|
2
|
+
import { validateContext, validateSelection } from './selection.js';
|
|
2
3
|
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
3
4
|
const name = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
4
5
|
const asset = (v) => name(v) && !v.includes('\\') && !v.includes('..') && !/[?#:]/.test(v);
|
|
@@ -25,6 +26,18 @@ export function parseProfile(input) {
|
|
|
25
26
|
throw new Error('Profile: required bones must be distinct');
|
|
26
27
|
if (input.stripRoot !== undefined && !name(input.stripRoot))
|
|
27
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
|
+
}
|
|
28
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))
|
|
29
42
|
throw new Error('Profile: boneMap must map distinct nonempty names');
|
|
30
43
|
return structuredClone(input);
|
|
@@ -42,6 +55,7 @@ export function parseAnimationSet(input) {
|
|
|
42
55
|
parseRootOrientation(clip.rootOrientation);
|
|
43
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]))
|
|
44
57
|
throw new Error('Invalid clip range');
|
|
58
|
+
validateSelection(clip, Array.isArray(clip.range) ? clip.range[1] - clip.range[0] : Infinity);
|
|
45
59
|
const nominal = clip.nominalVelocity !== undefined, measured = clip.motionFile !== undefined;
|
|
46
60
|
if (nominal === measured)
|
|
47
61
|
throw new Error(`Profile ${clip.id}: declare exactly one of nominalVelocity or motionFile`);
|
|
@@ -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
|
+
}
|
package/dist/core/search.d.ts
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
import { type Database, type Match, type Query } from './types.js';
|
|
2
2
|
import { type PoseSchema } from './schema.js';
|
|
3
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
|
+
}[];
|
|
4
31
|
export declare class PoseSearch {
|
|
5
32
|
readonly database: Database;
|
|
6
33
|
private normalized;
|
|
@@ -9,9 +36,14 @@ export declare class PoseSearch {
|
|
|
9
36
|
readonly weights: Float32Array;
|
|
10
37
|
constructor(database: Database, weights?: Float32Array);
|
|
11
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;
|
|
12
42
|
normalize(query: Query): void;
|
|
13
43
|
cost(index: number): number;
|
|
14
44
|
/** Physical error, independent of normalization/pose weights. Query must have been validated. */
|
|
15
45
|
rootVelocityError(index: number, query: Query): number;
|
|
16
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;
|
|
17
49
|
}
|
package/dist/core/search.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { validateDatabase } from './database.js';
|
|
2
2
|
import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
|
|
3
|
+
import { matchesContext, validateContext, withinWindows } from './selection.js';
|
|
3
4
|
export function defaultWeights(schema = LOCOMOTION_SCHEMA) {
|
|
4
5
|
const layout = compileSchema(schema), weights = new Float32Array(layout.dimensions);
|
|
5
6
|
for (const { channel, offset, dimensions } of layout.channels)
|
|
@@ -9,6 +10,20 @@ export function defaultWeights(schema = LOCOMOTION_SCHEMA) {
|
|
|
9
10
|
return weights;
|
|
10
11
|
}
|
|
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
|
+
}
|
|
12
27
|
export class PoseSearch {
|
|
13
28
|
database;
|
|
14
29
|
normalized;
|
|
@@ -27,6 +42,8 @@ export class PoseSearch {
|
|
|
27
42
|
this.weights = weights.slice();
|
|
28
43
|
}
|
|
29
44
|
validateQuery(query) {
|
|
45
|
+
if (query.context !== undefined)
|
|
46
|
+
validateContext(query.context);
|
|
30
47
|
if (query.schemaSignature !== this.database.manifest.schemaSignature && !(query.schemaSignature === undefined && this.database.manifest.schemaSignature === legacySignature))
|
|
31
48
|
throw new Error('Incompatible query schema');
|
|
32
49
|
if (query.features.length !== this.dimensions || !query.features.every(Number.isFinite))
|
|
@@ -36,6 +53,16 @@ export class PoseSearch {
|
|
|
36
53
|
if (query.velocityTolerance !== undefined && (!Number.isFinite(query.velocityTolerance) || query.velocityTolerance < 0 || this.rootOffset === undefined))
|
|
37
54
|
throw new Error('Velocity tolerance requires a root-velocity channel and a finite nonnegative value');
|
|
38
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
|
+
}
|
|
39
66
|
normalize(query) {
|
|
40
67
|
this.validateQuery(query);
|
|
41
68
|
const { mean, std } = this.database.manifest;
|
|
@@ -66,24 +93,28 @@ export class PoseSearch {
|
|
|
66
93
|
search(query) {
|
|
67
94
|
this.normalize(query);
|
|
68
95
|
const { frames, clips } = this.database.manifest;
|
|
96
|
+
// Context is clip-level: resolve it once per clip, rather than once per
|
|
97
|
+
// sample in each of the velocity and ranking passes.
|
|
98
|
+
const eligibleClips = clips.map((_, clip) => this.allowsClip(clip, query));
|
|
99
|
+
const candidates = [];
|
|
100
|
+
for (let i = 0; i < frames.length; i++) {
|
|
101
|
+
const frame = frames[i], clip = clips[frame.clip];
|
|
102
|
+
if (eligibleClips[frame.clip] && withinWindows(frame.time, clip.entryWindows)
|
|
103
|
+
&& (clip.excludedWindows === undefined || !withinWindows(frame.time, clip.excludedWindows)))
|
|
104
|
+
candidates.push(i);
|
|
105
|
+
}
|
|
69
106
|
let index = -1, cost = Infinity;
|
|
70
107
|
let velocityLimit = Infinity;
|
|
71
108
|
if (query.velocityTolerance !== undefined) {
|
|
72
109
|
let nearest = Infinity;
|
|
73
|
-
for (
|
|
74
|
-
if (query.excludedClips?.has(frames[i].clip) || (query.gaits && !query.gaits.has(clips[frames[i].clip].gait)))
|
|
75
|
-
continue;
|
|
110
|
+
for (const i of candidates) {
|
|
76
111
|
nearest = Math.min(nearest, this.rootVelocityError(i, query));
|
|
77
112
|
}
|
|
78
113
|
// Sparse datasets retain a deterministic nearest-velocity fallback. Never
|
|
79
114
|
// expand the pool to arbitrary stationary poses just because coverage is missing.
|
|
80
115
|
velocityLimit = Math.max(query.velocityTolerance, nearest + 1e-6);
|
|
81
116
|
}
|
|
82
|
-
for (
|
|
83
|
-
if (query.excludedClips?.has(frames[i].clip))
|
|
84
|
-
continue;
|
|
85
|
-
if (query.gaits && !query.gaits.has(clips[frames[i].clip].gait))
|
|
86
|
-
continue;
|
|
117
|
+
for (const i of candidates) {
|
|
87
118
|
if (query.velocityTolerance !== undefined && this.rootVelocityError(i, query) > velocityLimit)
|
|
88
119
|
continue;
|
|
89
120
|
const score = this.cost(i);
|
|
@@ -94,4 +125,57 @@ export class PoseSearch {
|
|
|
94
125
|
}
|
|
95
126
|
return index < 0 ? null : { ...frames[index], index, cost };
|
|
96
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
|
+
}
|
|
97
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
|
+
}
|