@pose_flow/babylon 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -3
- package/dist/AI_USAGE.md +291 -0
- package/dist/PUBLISHING.md +108 -0
- package/dist/babylon.d.ts +38 -0
- package/dist/babylon.js +201 -0
- package/dist/core/controller.d.ts +33 -0
- package/dist/core/controller.js +97 -0
- package/dist/core/database.d.ts +12 -0
- package/dist/core/database.js +95 -0
- package/dist/core/features.d.ts +15 -0
- package/dist/core/features.js +41 -0
- package/dist/core/profile.d.ts +54 -0
- package/dist/core/profile.js +89 -0
- package/dist/core/root-motion.d.ts +24 -0
- package/dist/core/root-motion.js +78 -0
- package/dist/core/schema.d.ts +56 -0
- package/dist/core/schema.js +123 -0
- package/dist/core/search.d.ts +17 -0
- package/dist/core/search.js +97 -0
- package/dist/core/types.d.ts +75 -0
- package/dist/core/types.js +3 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/offline/src/babylon.js +201 -0
- package/dist/offline/src/core/controller.js +97 -0
- package/dist/offline/src/core/database.js +95 -0
- package/dist/offline/src/core/features.js +41 -0
- package/dist/offline/src/core/profile.js +89 -0
- package/dist/offline/src/core/root-motion.js +78 -0
- package/dist/offline/src/core/schema.js +123 -0
- package/dist/offline/src/core/search.js +97 -0
- package/dist/offline/src/core/types.js +3 -0
- package/dist/offline/src/index.js +8 -0
- package/dist/offline/tools/bake.js +86 -0
- package/dist/offline/tools/cli.js +30 -0
- package/dist/offline/tools/glb.js +54 -0
- package/dist/offline/tools/prepare.js +165 -0
- package/dist/offline/tools/profile-assets.js +90 -0
- package/dist/offline/tools/rig.js +47 -0
- package/dist/offline/tools/validate-assets.js +43 -0
- package/package.json +48 -6
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Animation, AnimationGroup, Matrix, Quaternion, Vector3 } from '@babylonjs/core';
|
|
2
|
+
import { compileSchema, LOCOMOTION_SCHEMA } from './core/schema.js';
|
|
3
|
+
import { parseRootOrientation } from './core/profile.js';
|
|
4
|
+
/** Constant authoring orientation belongs to the pose; varying root heading belongs to gameplay. */
|
|
5
|
+
function constantRootRotation(animation) {
|
|
6
|
+
const keys = animation.getKeys();
|
|
7
|
+
if (!keys.length || keys.some(k => k.inTangent !== undefined || k.outTangent !== undefined))
|
|
8
|
+
return undefined;
|
|
9
|
+
const rotation = (value) => animation.targetProperty === 'rotationQuaternion' && value instanceof Quaternion ? value.clone()
|
|
10
|
+
: animation.targetProperty === 'rotation' && value instanceof Vector3 ? Quaternion.FromEulerVector(value) : undefined;
|
|
11
|
+
const first = rotation(keys[0].value);
|
|
12
|
+
if (!first || !first.asArray().every(Number.isFinite) || first.length() < 1e-8)
|
|
13
|
+
return undefined;
|
|
14
|
+
first.normalize();
|
|
15
|
+
for (const key of keys) {
|
|
16
|
+
const q = rotation(key.value);
|
|
17
|
+
if (!q || !q.asArray().every(Number.isFinite) || q.length() < 1e-8 || Math.abs(Quaternion.Dot(first, q.normalize())) < 1 - 1e-8)
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return first;
|
|
21
|
+
}
|
|
22
|
+
/** Clone onto a target rig. Two playback slots allow crossfade between times of the same clip. */
|
|
23
|
+
export class BabylonMotionPlayer {
|
|
24
|
+
scene;
|
|
25
|
+
clips;
|
|
26
|
+
sources;
|
|
27
|
+
nodes;
|
|
28
|
+
fadeSeconds;
|
|
29
|
+
rootName;
|
|
30
|
+
tracks = [];
|
|
31
|
+
active;
|
|
32
|
+
disposed = false;
|
|
33
|
+
strippedRoot;
|
|
34
|
+
rootRestRotation;
|
|
35
|
+
constructor(scene, clips, sources, nodes, fadeSeconds = 0.15, rootName = 'root') {
|
|
36
|
+
this.scene = scene;
|
|
37
|
+
this.clips = clips;
|
|
38
|
+
this.sources = sources;
|
|
39
|
+
this.nodes = nodes;
|
|
40
|
+
this.fadeSeconds = fadeSeconds;
|
|
41
|
+
this.rootName = rootName;
|
|
42
|
+
if (!Number.isFinite(fadeSeconds) || fadeSeconds < 0)
|
|
43
|
+
throw new Error('Invalid crossfade duration');
|
|
44
|
+
for (const clip of clips) {
|
|
45
|
+
const source = sources.get(clip.id);
|
|
46
|
+
if (!source)
|
|
47
|
+
throw new Error(`Missing animation source: ${clip.id}`);
|
|
48
|
+
const fps = source.targetedAnimations[0]?.animation.framePerSecond, start = clip.sourceStart ?? 0;
|
|
49
|
+
if (!Number.isFinite(start) || start < 0 || !Number.isFinite(clip.duration) || clip.duration <= 0 || !fps
|
|
50
|
+
|| start + clip.duration > (source.to - source.from) / fps + 1e-5)
|
|
51
|
+
throw new Error(`Invalid source range: ${clip.id}`);
|
|
52
|
+
}
|
|
53
|
+
this.strippedRoot = [...nodes.values()].find(n => n.name === rootName);
|
|
54
|
+
if (this.strippedRoot) {
|
|
55
|
+
this.strippedRoot.rotationQuaternion ??= Quaternion.FromEulerVector(this.strippedRoot.rotation);
|
|
56
|
+
this.rootRestRotation = this.strippedRoot.rotationQuaternion.clone();
|
|
57
|
+
}
|
|
58
|
+
for (const clip of clips)
|
|
59
|
+
if (clip.rootOrientation !== undefined) {
|
|
60
|
+
parseRootOrientation(clip.rootOrientation);
|
|
61
|
+
if (!this.strippedRoot)
|
|
62
|
+
throw new Error('rootOrientation requires a mapped stripped root');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
create(clip, time) {
|
|
66
|
+
const source = this.sources.get(this.clips[clip].id);
|
|
67
|
+
const group = new AnimationGroup(`mm-${this.clips[clip].id}-${this.tracks.length}`, this.scene);
|
|
68
|
+
let rootRotation = this.rootRestRotation?.clone();
|
|
69
|
+
for (const targeted of source.targetedAnimations) {
|
|
70
|
+
const node = this.nodes.get(targeted.target.name);
|
|
71
|
+
if (!node)
|
|
72
|
+
continue;
|
|
73
|
+
const property = targeted.animation.targetProperty;
|
|
74
|
+
if (node === this.strippedRoot) {
|
|
75
|
+
if (property === 'position' || property.startsWith('position.'))
|
|
76
|
+
continue;
|
|
77
|
+
if (property === 'rotationQuaternion' || property === 'rotation' || property.startsWith('rotation.') || property.startsWith('rotationQuaternion.')) {
|
|
78
|
+
rootRotation = constantRootRotation(targeted.animation) ?? rootRotation;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
group.addTargetedAnimation(targeted.animation, node);
|
|
83
|
+
}
|
|
84
|
+
if (!group.targetedAnimations.length) {
|
|
85
|
+
group.dispose();
|
|
86
|
+
throw new Error('No matching rig animation tracks');
|
|
87
|
+
}
|
|
88
|
+
const correction = this.clips[clip].rootOrientation;
|
|
89
|
+
if (correction)
|
|
90
|
+
rootRotation = Quaternion.FromArray([...correction]).normalize();
|
|
91
|
+
if (this.strippedRoot && rootRotation) {
|
|
92
|
+
// An explicit constant binding also restores the baseline after a clip with
|
|
93
|
+
// another orientation, and participates in the same weighted crossfade.
|
|
94
|
+
const rotation = new Animation('mm-root-pose', 'rotationQuaternion', source.targetedAnimations[0].animation.framePerSecond, Animation.ANIMATIONTYPE_QUATERNION);
|
|
95
|
+
rotation.setKeys([{ frame: source.from, value: rootRotation }, { frame: source.to, value: rootRotation.clone() }]);
|
|
96
|
+
group.addTargetedAnimation(rotation, this.strippedRoot);
|
|
97
|
+
}
|
|
98
|
+
// Paused groups still participate in Babylon's late weighted bindings when
|
|
99
|
+
// goToFrame(frame, true) is called. Speed zero alone resets on scene.render().
|
|
100
|
+
group.start(true, 0);
|
|
101
|
+
group.pause();
|
|
102
|
+
group.setWeightForAllAnimatables(0);
|
|
103
|
+
const track = { group, clip, time, weight: this.tracks.length === 0 ? 1 : 0 };
|
|
104
|
+
this.tracks.push(track);
|
|
105
|
+
return track;
|
|
106
|
+
}
|
|
107
|
+
update(state, dt) {
|
|
108
|
+
if (this.disposed)
|
|
109
|
+
throw new Error('Player already disposed');
|
|
110
|
+
if (!Number.isFinite(dt) || dt < 0)
|
|
111
|
+
throw new Error('Invalid player delta');
|
|
112
|
+
if (state.clip < 0) {
|
|
113
|
+
this.clear();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!this.clips[state.clip])
|
|
117
|
+
throw new Error('Unknown playback clip');
|
|
118
|
+
if (!this.active || state.switched || this.active.clip !== state.clip) {
|
|
119
|
+
// Bound simultaneous slots during rapid replanning; residual poses retain relative weights.
|
|
120
|
+
if (this.tracks.length >= 4) {
|
|
121
|
+
const oldest = this.tracks.shift();
|
|
122
|
+
oldest.group.dispose();
|
|
123
|
+
}
|
|
124
|
+
this.active = this.create(state.clip, state.time);
|
|
125
|
+
}
|
|
126
|
+
this.active.time = state.time;
|
|
127
|
+
const alpha = this.fadeSeconds === 0 ? 1 : 1 - Math.exp(-dt * 4 / this.fadeSeconds);
|
|
128
|
+
for (const track of this.tracks) {
|
|
129
|
+
if (track !== this.active)
|
|
130
|
+
track.time += dt;
|
|
131
|
+
track.weight += ((track === this.active ? 1 : 0) - track.weight) * alpha;
|
|
132
|
+
}
|
|
133
|
+
// First pose must be fully defined even at dt=0.
|
|
134
|
+
const total = this.tracks.reduce((sum, t) => sum + t.weight, 0);
|
|
135
|
+
for (const track of this.tracks) {
|
|
136
|
+
const descriptor = this.clips[track.clip], group = track.group;
|
|
137
|
+
const time = descriptor.looping ? ((track.time % descriptor.duration) + descriptor.duration) % descriptor.duration : Math.max(0, Math.min(track.time, descriptor.duration));
|
|
138
|
+
group.setWeightForAllAnimatables(total > 0 ? track.weight / total : (track === this.active ? 1 : 0));
|
|
139
|
+
const source = this.sources.get(descriptor.id), fps = source.targetedAnimations[0].animation.framePerSecond;
|
|
140
|
+
group.goToFrame(source.from + ((descriptor.sourceStart ?? 0) + time) * fps, true);
|
|
141
|
+
}
|
|
142
|
+
this.tracks = this.tracks.filter(track => {
|
|
143
|
+
if (track !== this.active && track.weight < 0.001) {
|
|
144
|
+
track.group.dispose();
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
clear() { for (const t of this.tracks)
|
|
151
|
+
t.group.dispose(); this.tracks = []; this.active = undefined; }
|
|
152
|
+
dispose() { this.clear(); this.disposed = true; }
|
|
153
|
+
}
|
|
154
|
+
/** Sample AFTER scene animation evaluation. Semantic roles are defined by the schema. */
|
|
155
|
+
export class BabylonSchemaPoseReader {
|
|
156
|
+
root;
|
|
157
|
+
previous;
|
|
158
|
+
nodes;
|
|
159
|
+
constructor(root, bones, schema) {
|
|
160
|
+
this.root = root;
|
|
161
|
+
const roles = compileSchema(schema).bones;
|
|
162
|
+
for (const role of roles)
|
|
163
|
+
if (!bones.has(role))
|
|
164
|
+
throw new Error('Missing schema bone node: ' + role);
|
|
165
|
+
this.nodes = new Map(roles.map(role => [role, bones.get(role)]));
|
|
166
|
+
}
|
|
167
|
+
reset() { this.previous = undefined; }
|
|
168
|
+
read(dt) {
|
|
169
|
+
if (!Number.isFinite(dt) || dt < 0)
|
|
170
|
+
throw new Error('Invalid pose delta');
|
|
171
|
+
this.root.computeWorldMatrix(true);
|
|
172
|
+
const root = this.root.getAbsolutePosition().clone(), positions = new Map();
|
|
173
|
+
for (const [role, node] of this.nodes) {
|
|
174
|
+
node.computeWorldMatrix(true);
|
|
175
|
+
positions.set(role, node.getAbsolutePosition().clone());
|
|
176
|
+
}
|
|
177
|
+
const inverse = Matrix.Invert(this.root.getWorldMatrix());
|
|
178
|
+
// Vector transform through inverse includes inverse model scale: consumers should keep root scale 1.
|
|
179
|
+
const tuple = (v) => [v.x, v.y, v.z];
|
|
180
|
+
const velocity = (value, previous) => previous && dt > 0 ? Vector3.TransformNormal(value.subtract(previous).scale(1 / dt), inverse) : Vector3.Zero();
|
|
181
|
+
const bones = Object.fromEntries([...positions].map(([role, position]) => [role, {
|
|
182
|
+
position: tuple(Vector3.TransformCoordinates(position, inverse)), velocity: tuple(velocity(position, this.previous?.bones.get(role))),
|
|
183
|
+
}]));
|
|
184
|
+
const pose = { bones, rootVelocity: tuple(velocity(root, this.previous?.root)) };
|
|
185
|
+
this.previous = { bones: positions, root };
|
|
186
|
+
return pose;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Compatibility reader for the original two-foot preset. */
|
|
190
|
+
export class BabylonPoseReader {
|
|
191
|
+
reader;
|
|
192
|
+
constructor(root, left, right) {
|
|
193
|
+
this.reader = new BabylonSchemaPoseReader(root, new Map([['leftFoot', left], ['rightFoot', right]]), LOCOMOTION_SCHEMA);
|
|
194
|
+
}
|
|
195
|
+
reset() { this.reader.reset(); }
|
|
196
|
+
read(dt) {
|
|
197
|
+
const pose = this.reader.read(dt);
|
|
198
|
+
return { leftPosition: pose.bones.leftFoot.position, rightPosition: pose.bones.rightFoot.position,
|
|
199
|
+
leftVelocity: pose.bones.leftFoot.velocity, rightVelocity: pose.bones.rightFoot.velocity, rootVelocity: pose.rootVelocity };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { PoseSearch } from './search.js';
|
|
2
|
+
/** Owns animation time only. Caller retains movement and action authority. */
|
|
3
|
+
export class MotionMatcher {
|
|
4
|
+
database;
|
|
5
|
+
search;
|
|
6
|
+
options;
|
|
7
|
+
clip = -1;
|
|
8
|
+
time = 0;
|
|
9
|
+
sinceSearch = Infinity;
|
|
10
|
+
sinceSwitch = Infinity;
|
|
11
|
+
last = null;
|
|
12
|
+
completed = new Map();
|
|
13
|
+
indices;
|
|
14
|
+
constructor(database, options = {}) {
|
|
15
|
+
this.database = database;
|
|
16
|
+
this.search = new PoseSearch(database);
|
|
17
|
+
this.options = { searchInterval: 0.1, minimumHold: 0.12, improvement: 0.1, jumpPenalty: 0.5, timeSeparation: 0.2, reselectDelay: 0.6, ...options };
|
|
18
|
+
if (!Object.values(this.options).every(v => Number.isFinite(v) && v >= 0) || this.options.searchInterval === 0 || this.options.improvement >= 1)
|
|
19
|
+
throw new Error('Invalid matcher options');
|
|
20
|
+
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
|
+
if (this.indices.some(i => !i.length))
|
|
22
|
+
throw new Error('Every clip must have indexed samples');
|
|
23
|
+
}
|
|
24
|
+
reset() { this.clip = -1; this.time = 0; this.sinceSearch = this.sinceSwitch = Infinity; this.last = null; this.completed.clear(); }
|
|
25
|
+
update(dt, query) {
|
|
26
|
+
if (!Number.isFinite(dt) || dt < 0)
|
|
27
|
+
throw new Error('Invalid delta time');
|
|
28
|
+
// Validate identity even while continuity skips a search this frame.
|
|
29
|
+
this.search.validateQuery(query);
|
|
30
|
+
let switched = false, ended = false;
|
|
31
|
+
for (const [clip, remaining] of this.completed) {
|
|
32
|
+
if (remaining <= dt)
|
|
33
|
+
this.completed.delete(clip);
|
|
34
|
+
else
|
|
35
|
+
this.completed.set(clip, remaining - dt);
|
|
36
|
+
}
|
|
37
|
+
if (this.clip >= 0) {
|
|
38
|
+
const clip = this.database.manifest.clips[this.clip];
|
|
39
|
+
this.time += dt;
|
|
40
|
+
ended = !clip.looping && this.time >= clip.duration;
|
|
41
|
+
this.time = clip.looping ? this.time % clip.duration : Math.min(this.time, clip.duration);
|
|
42
|
+
}
|
|
43
|
+
this.sinceSearch += dt;
|
|
44
|
+
this.sinceSwitch += dt;
|
|
45
|
+
const allowed = this.clip >= 0 && !query.excludedClips?.has(this.clip) && (!query.gaits || query.gaits.has(this.database.manifest.clips[this.clip].gait));
|
|
46
|
+
if (this.sinceSearch >= this.options.searchInterval || !allowed || ended) {
|
|
47
|
+
this.sinceSearch = 0;
|
|
48
|
+
// A finished one-shot must yield to another clip, rather than selecting
|
|
49
|
+
// its terminal pose and restarting forever. User exclusions still apply.
|
|
50
|
+
if (ended)
|
|
51
|
+
this.completed.set(this.clip, this.options.reselectDelay);
|
|
52
|
+
const excludedClips = new Set([...(query.excludedClips ?? []), ...this.completed.keys()]);
|
|
53
|
+
const best = this.search.search({ ...query, excludedClips });
|
|
54
|
+
this.last = best;
|
|
55
|
+
if (best) {
|
|
56
|
+
const current = this.clip >= 0 ? this.nearestFrame(this.clip, this.time) : -1;
|
|
57
|
+
const duration = this.database.manifest.clips[best.clip].duration;
|
|
58
|
+
let gap = Math.abs(best.time - this.time);
|
|
59
|
+
if (best.clip === this.clip && this.database.manifest.clips[best.clip].looping)
|
|
60
|
+
gap = Math.min(gap, duration - gap);
|
|
61
|
+
const distant = best.clip !== this.clip || gap > this.options.timeSeparation;
|
|
62
|
+
const better = current < 0 || best.cost + this.options.jumpPenalty < this.search.cost(current) * (1 - this.options.improvement);
|
|
63
|
+
const incompatibleVelocity = current >= 0 && distant && this.sinceSwitch >= this.options.minimumHold && query.velocityTolerance !== undefined
|
|
64
|
+
&& this.search.rootVelocityError(current, query) > Math.max(query.velocityTolerance, this.search.rootVelocityError(best.index, query) + 1e-6);
|
|
65
|
+
const mayReselectTime = query.allowTemporalReselection !== false || best.clip !== this.clip || !this.database.manifest.clips[best.clip].looping;
|
|
66
|
+
if (current < 0 || !allowed || ended || (mayReselectTime && (incompatibleVelocity || (distant && better && this.sinceSwitch >= this.options.minimumHold)))) {
|
|
67
|
+
this.clip = best.clip;
|
|
68
|
+
this.time = best.time;
|
|
69
|
+
this.sinceSwitch = 0;
|
|
70
|
+
switched = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (!allowed || ended) {
|
|
74
|
+
this.clip = -1;
|
|
75
|
+
this.time = 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { clip: this.clip, time: this.time, switched, match: this.last };
|
|
79
|
+
}
|
|
80
|
+
nearestFrame(clip, time) {
|
|
81
|
+
const indices = this.indices[clip];
|
|
82
|
+
if (!indices?.length)
|
|
83
|
+
return -1;
|
|
84
|
+
const { frames, clips } = this.database.manifest;
|
|
85
|
+
let best = indices[0], distance = Infinity;
|
|
86
|
+
for (const i of indices) {
|
|
87
|
+
let delta = Math.abs(frames[i].time - time);
|
|
88
|
+
if (clips[clip].looping)
|
|
89
|
+
delta = Math.min(delta, clips[clip].duration - delta);
|
|
90
|
+
if (delta < distance) {
|
|
91
|
+
best = i;
|
|
92
|
+
distance = delta;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return best;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
|
|
2
|
+
import { parseRootOrientation } from './profile.js';
|
|
3
|
+
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
4
|
+
export function validateDatabase(db) {
|
|
5
|
+
const m = db?.manifest;
|
|
6
|
+
if (!record(m) || m.version !== 2)
|
|
7
|
+
throw new Error('Unsupported pose database version; v1 requires migrateLegacyDatabase or a new bake');
|
|
8
|
+
const layout = compileSchema(m.schema), dimensions = layout.dimensions;
|
|
9
|
+
if (m.dimensions !== dimensions || m.schemaSignature !== layout.signature || m.coordinates !== 'meters-y-up-z-forward'
|
|
10
|
+
|| !Array.isArray(m.horizons) || m.horizons.length !== layout.horizons.length || m.horizons.some((v, i) => v !== layout.horizons[i]))
|
|
11
|
+
throw new Error('Incompatible pose database schema');
|
|
12
|
+
if (!Number.isFinite(m.sampleRate) || m.sampleRate <= 0 || !Array.isArray(m.frames) || !m.frames.length || !Array.isArray(m.clips) || !m.clips.length
|
|
13
|
+
|| !(db.features instanceof Float32Array) || db.features.length !== m.frames.length * dimensions)
|
|
14
|
+
throw new Error('Invalid database size/sample rate');
|
|
15
|
+
if (!Array.isArray(m.mean) || !Array.isArray(m.std) || m.mean.length !== dimensions || m.std.length !== dimensions || ![...m.mean].every(Number.isFinite)
|
|
16
|
+
|| ![...m.std].every(v => Number.isFinite(v) && v >= 1e-4) || !db.features.every(Number.isFinite))
|
|
17
|
+
throw new Error('Invalid normalization/features');
|
|
18
|
+
for (const { channel, offset, dimensions: width } of layout.channels)
|
|
19
|
+
if (channel.normalization === 'none') {
|
|
20
|
+
for (let j = offset; j < offset + width; j++)
|
|
21
|
+
if (m.mean[j] !== 0 || m.std[j] !== 1)
|
|
22
|
+
throw new Error('Invalid unnormalized channel statistics');
|
|
23
|
+
}
|
|
24
|
+
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))))
|
|
25
|
+
throw new Error('Invalid asset hashes');
|
|
26
|
+
const ids = new Set();
|
|
27
|
+
for (const clip of m.clips) {
|
|
28
|
+
if (!record(clip) || typeof clip.id !== 'string' || ids.has(clip.id) || !clip.id || typeof clip.file !== 'string' || !clip.file
|
|
29
|
+
|| typeof clip.animation !== 'string' || !clip.animation || !Number.isFinite(clip.duration) || clip.duration <= 0 || typeof clip.looping !== 'boolean'
|
|
30
|
+
|| !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(clip.gait))
|
|
31
|
+
throw new Error('Invalid clip');
|
|
32
|
+
ids.add(clip.id);
|
|
33
|
+
if (clip.sourceStart !== undefined && (!Number.isFinite(clip.sourceStart) || clip.sourceStart < 0))
|
|
34
|
+
throw new Error('Invalid source start');
|
|
35
|
+
if (clip.averageVelocity !== undefined && (!Array.isArray(clip.averageVelocity) || clip.averageVelocity.length !== 3 || !clip.averageVelocity.every(Number.isFinite)))
|
|
36
|
+
throw new Error('Invalid measured average velocity');
|
|
37
|
+
if (clip.rootOrientation !== undefined)
|
|
38
|
+
parseRootOrientation(clip.rootOrientation);
|
|
39
|
+
}
|
|
40
|
+
for (const frame of m.frames) {
|
|
41
|
+
if (!record(frame) || !Number.isInteger(frame.clip) || !m.clips[frame.clip] || !Number.isFinite(frame.time)
|
|
42
|
+
|| frame.time < 0 || frame.time > m.clips[frame.clip].duration + 1e-6)
|
|
43
|
+
throw new Error('Invalid pose metadata');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Omitting schema selects the legacy biped preset. New data is always v2. */
|
|
47
|
+
export function buildDatabase(manifest, raw, schema = LOCOMOTION_SCHEMA) {
|
|
48
|
+
const layout = compileSchema(schema), dimensions = layout.dimensions, count = manifest.frames.length;
|
|
49
|
+
if (!count || raw.length !== count * dimensions || !raw.every(Number.isFinite))
|
|
50
|
+
throw new Error('Invalid raw samples');
|
|
51
|
+
const mean = Array(dimensions).fill(0), std = Array(dimensions).fill(0);
|
|
52
|
+
for (let i = 0; i < raw.length; i++)
|
|
53
|
+
mean[i % dimensions] += raw[i] / count;
|
|
54
|
+
for (let i = 0; i < raw.length; i++)
|
|
55
|
+
std[i % dimensions] += (raw[i] - mean[i % dimensions]) ** 2 / count;
|
|
56
|
+
for (let j = 0; j < dimensions; j++)
|
|
57
|
+
std[j] = Math.sqrt(std[j]) < 1e-4 ? 1 : Math.sqrt(std[j]);
|
|
58
|
+
for (const { channel, offset, dimensions: width } of layout.channels)
|
|
59
|
+
if (channel.normalization === 'none') {
|
|
60
|
+
mean.fill(0, offset, offset + width);
|
|
61
|
+
std.fill(1, offset, offset + width);
|
|
62
|
+
}
|
|
63
|
+
const features = Float32Array.from(raw, (v, i) => (v - mean[i % dimensions]) / std[i % dimensions]);
|
|
64
|
+
const db = { manifest: { ...manifest, version: 2, dimensions, schema: layout.schema, schemaSignature: layout.signature,
|
|
65
|
+
coordinates: 'meters-y-up-z-forward', horizons: layout.horizons, mean, std }, features };
|
|
66
|
+
validateDatabase(db);
|
|
67
|
+
return db;
|
|
68
|
+
}
|
|
69
|
+
/** Binary format is explicitly little endian float32; version/layout live in manifest. */
|
|
70
|
+
export function encodeFeatures(db) {
|
|
71
|
+
validateDatabase(db);
|
|
72
|
+
const bytes = new ArrayBuffer(db.features.length * 4), view = new DataView(bytes);
|
|
73
|
+
db.features.forEach((v, i) => view.setFloat32(i * 4, v, true));
|
|
74
|
+
return bytes;
|
|
75
|
+
}
|
|
76
|
+
export function decodeDatabase(manifest, bytes, expectedSchema) {
|
|
77
|
+
if (bytes.byteLength % 4)
|
|
78
|
+
throw new Error('Truncated feature binary');
|
|
79
|
+
const view = new DataView(bytes), features = new Float32Array(bytes.byteLength / 4);
|
|
80
|
+
for (let i = 0; i < features.length; i++)
|
|
81
|
+
features[i] = view.getFloat32(i * 4, true);
|
|
82
|
+
const db = { manifest: manifest, features };
|
|
83
|
+
validateDatabase(db);
|
|
84
|
+
if (expectedSchema && db.manifest.schemaSignature !== compileSchema(expectedSchema).signature)
|
|
85
|
+
throw new Error('Incompatible expected schema; regenerate database');
|
|
86
|
+
return db;
|
|
87
|
+
}
|
|
88
|
+
/** Explicit, lossless conversion ONLY for the original v1 27-dimensional preset. */
|
|
89
|
+
export function migrateLegacyDatabase(input, bytes) {
|
|
90
|
+
const layout = compileSchema(LOCOMOTION_SCHEMA);
|
|
91
|
+
if (!record(input) || input.version !== 1 || input.dimensions !== layout.dimensions || !Array.isArray(input.horizons)
|
|
92
|
+
|| input.horizons.length !== layout.horizons.length || input.horizons.some((h, i) => h !== layout.horizons[i]))
|
|
93
|
+
throw new Error('Unsupported legacy database schema');
|
|
94
|
+
return decodeDatabase({ ...input, version: 2, schema: layout.schema, schemaSignature: layout.signature }, bytes);
|
|
95
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { DIMENSIONS, HORIZONS } from './types.js';
|
|
2
|
+
import { SchemaQueryBuilder, validateHorizons } from './schema.js';
|
|
3
|
+
const legacyBuilder = new SchemaQueryBuilder();
|
|
4
|
+
export function featureVector(pose, trajectory, out = new Float32Array(DIMENSIONS)) {
|
|
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);
|
|
9
|
+
}
|
|
10
|
+
/** World delta to current character-local XZ (yaw zero looks along +Z). */
|
|
11
|
+
export function toLocal(x, z, yaw) {
|
|
12
|
+
const c = Math.cos(yaw), s = Math.sin(yaw);
|
|
13
|
+
return [c * x - s * z, s * x + c * z];
|
|
14
|
+
}
|
|
15
|
+
/** Prediction only: never writes to the gameplay controller. Uses fixed substeps. */
|
|
16
|
+
export function predictTrajectory(input, horizons = HORIZONS) {
|
|
17
|
+
validateHorizons(horizons);
|
|
18
|
+
if (![...input.velocity, ...input.desiredVelocity, input.yaw, input.desiredYaw, input.acceleration, input.braking, input.turnSpeed].every(Number.isFinite)
|
|
19
|
+
|| Math.min(input.acceleration, input.braking, input.turnSpeed) < 0)
|
|
20
|
+
throw new Error('Invalid trajectory input');
|
|
21
|
+
let [vx, vz] = input.velocity, x = 0, z = 0, yaw = input.yaw, elapsed = 0;
|
|
22
|
+
const points = [];
|
|
23
|
+
for (const horizon of horizons) {
|
|
24
|
+
while (elapsed < horizon - 1e-9) {
|
|
25
|
+
const dt = Math.min(1 / 60, horizon - elapsed);
|
|
26
|
+
const dx = input.desiredVelocity[0] - vx, dz = input.desiredVelocity[1] - vz, length = Math.hypot(dx, dz);
|
|
27
|
+
const rate = Math.hypot(...input.desiredVelocity) < Math.hypot(vx, vz) ? input.braking : input.acceleration;
|
|
28
|
+
const amount = length > 0 ? Math.min(1, rate * dt / length) : 0;
|
|
29
|
+
const nx = vx + dx * amount, nz = vz + dz * amount;
|
|
30
|
+
x += (vx + nx) * 0.5 * dt;
|
|
31
|
+
z += (vz + nz) * 0.5 * dt;
|
|
32
|
+
vx = nx;
|
|
33
|
+
vz = nz;
|
|
34
|
+
const angle = Math.atan2(Math.sin(input.desiredYaw - yaw), Math.cos(input.desiredYaw - yaw));
|
|
35
|
+
yaw += Math.sign(angle) * Math.min(Math.abs(angle), input.turnSpeed * dt);
|
|
36
|
+
elapsed += dt;
|
|
37
|
+
}
|
|
38
|
+
points.push({ position: toLocal(x, z, input.yaw), facing: [Math.sin(yaw - input.yaw), Math.cos(yaw - input.yaw)] });
|
|
39
|
+
}
|
|
40
|
+
return points;
|
|
41
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { compileSchema, parseSchema } from './schema.js';
|
|
2
|
+
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
3
|
+
const name = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
4
|
+
const asset = (v) => name(v) && !v.includes('\\') && !v.includes('..') && !/[?#:]/.test(v);
|
|
5
|
+
export function parseRootOrientation(value) {
|
|
6
|
+
if (!Array.isArray(value) || value.length !== 4 || ![...value].every(Number.isFinite) || Math.abs(Math.hypot(...value) - 1) > 1e-5)
|
|
7
|
+
throw new Error('Invalid rootOrientation: expected unit quaternion [x,y,z,w]');
|
|
8
|
+
return [...value];
|
|
9
|
+
}
|
|
10
|
+
/** Read untrusted JSON as unknown; report a configuration error before loading assets. */
|
|
11
|
+
export function parseProfile(input) {
|
|
12
|
+
if (!record(input) || input.version !== 2 || !name(input.id) || !/^[a-z0-9][a-z0-9-]*$/.test(input.id) || !name(input.label))
|
|
13
|
+
throw new Error('Profile: expected version 2; split v1 clips into animationSet and declare schema');
|
|
14
|
+
if ('clips' in input || !asset(input.schema) || !input.schema.startsWith('/') || !input.schema.endsWith('.json')
|
|
15
|
+
|| !asset(input.animationSet) || !input.animationSet.startsWith('/') || !input.animationSet.endsWith('.json'))
|
|
16
|
+
throw new Error('Profile: declare schema and animationSet JSON URLs, without inline clips');
|
|
17
|
+
if (!asset(input.assetBase) || !input.assetBase.startsWith('/') || !input.assetBase.endsWith('/') || !asset(input.database) || !input.database.startsWith('/') || !input.database.endsWith('/')
|
|
18
|
+
|| !asset(input.model) || input.model.startsWith('/'))
|
|
19
|
+
throw new Error('Profile: use public URL folders and relative model files');
|
|
20
|
+
if (typeof input.scale !== 'number' || !Number.isFinite(input.scale) || input.scale <= 0 || typeof input.sampleRate !== 'number' || !Number.isFinite(input.sampleRate) || input.sampleRate < 1 || input.sampleRate > 120)
|
|
21
|
+
throw new Error('Profile: invalid scale/sampleRate');
|
|
22
|
+
if (!record(input.rig) || !name(input.rig.root) || !Object.entries(input.rig).every(([k, v]) => /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(k) && name(v)))
|
|
23
|
+
throw new Error('Profile: semantic rig roles must be mapped');
|
|
24
|
+
if (new Set(Object.values(input.rig)).size !== Object.values(input.rig).length)
|
|
25
|
+
throw new Error('Profile: required bones must be distinct');
|
|
26
|
+
if (input.stripRoot !== undefined && !name(input.stripRoot))
|
|
27
|
+
throw new Error('Profile: invalid stripRoot');
|
|
28
|
+
if (input.boneMap !== undefined && (!record(input.boneMap) || !Object.entries(input.boneMap).every(([k, v]) => name(k) && name(v)) || new Set(Object.values(input.boneMap)).size !== Object.values(input.boneMap).length))
|
|
29
|
+
throw new Error('Profile: boneMap must map distinct nonempty names');
|
|
30
|
+
return structuredClone(input);
|
|
31
|
+
}
|
|
32
|
+
export function parseAnimationSet(input) {
|
|
33
|
+
if (!record(input) || input.version !== 1 || !name(input.id) || !Array.isArray(input.clips) || !input.clips.length)
|
|
34
|
+
throw new Error('AnimationSet: invalid version/id/clips');
|
|
35
|
+
const ids = new Set();
|
|
36
|
+
for (const clip of input.clips) {
|
|
37
|
+
if (!record(clip) || !name(clip.id) || ids.has(clip.id) || !asset(clip.file) || clip.file.startsWith('/') || !name(clip.animation) || typeof clip.looping !== 'boolean'
|
|
38
|
+
|| !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(String(clip.gait)))
|
|
39
|
+
throw new Error('Profile: invalid/duplicate clip');
|
|
40
|
+
ids.add(clip.id);
|
|
41
|
+
if (clip.rootOrientation !== undefined)
|
|
42
|
+
parseRootOrientation(clip.rootOrientation);
|
|
43
|
+
if (clip.range !== undefined && (!Array.isArray(clip.range) || clip.range.length !== 2 || !clip.range.every(Number.isFinite) || clip.range[0] < 0 || clip.range[1] <= clip.range[0]))
|
|
44
|
+
throw new Error('Invalid clip range');
|
|
45
|
+
const nominal = clip.nominalVelocity !== undefined, measured = clip.motionFile !== undefined;
|
|
46
|
+
if (nominal === measured)
|
|
47
|
+
throw new Error(`Profile ${clip.id}: declare exactly one of nominalVelocity or motionFile`);
|
|
48
|
+
if (nominal && (!Array.isArray(clip.nominalVelocity) || clip.nominalVelocity.length !== 3 || !clip.nominalVelocity.every(Number.isFinite)))
|
|
49
|
+
throw new Error(`Profile ${clip.id}: invalid velocity`);
|
|
50
|
+
if (measured && (!asset(clip.motionFile) || clip.motionFile.startsWith('/')))
|
|
51
|
+
throw new Error(`Profile ${clip.id}: measured motion requires a relative file`);
|
|
52
|
+
}
|
|
53
|
+
return structuredClone(input);
|
|
54
|
+
}
|
|
55
|
+
/** Resolve external configuration explicitly; core performs no I/O. */
|
|
56
|
+
export function resolveProfile(profile, schemaInput, setInput) {
|
|
57
|
+
const config = parseProfile(profile), schemaDefinition = parseSchema(schemaInput), set = parseAnimationSet(setInput);
|
|
58
|
+
for (const role of compileSchema(schemaDefinition).bones)
|
|
59
|
+
if (!Object.hasOwn(config.rig, role))
|
|
60
|
+
throw new Error('Profile: missing schema bone role: ' + role);
|
|
61
|
+
if (!config.stripRoot && set.clips.some(c => c.rootOrientation !== undefined))
|
|
62
|
+
throw new Error('Profile: rootOrientation requires stripRoot');
|
|
63
|
+
return { ...config, schemaDefinition, clips: set.clips, setId: set.id };
|
|
64
|
+
}
|
|
65
|
+
/** Deterministic configuration fingerprint; detects stale banks, not a security signature. */
|
|
66
|
+
export function profileSignature(profile) {
|
|
67
|
+
const sorted = (value) => Array.isArray(value) ? value.map(sorted) : record(value) ? Object.fromEntries(Object.keys(value).sort().map(k => [k, sorted(value[k])])) : value;
|
|
68
|
+
const text = JSON.stringify(sorted(profile));
|
|
69
|
+
let hash = 2166136261;
|
|
70
|
+
for (let i = 0; i < text.length; i++)
|
|
71
|
+
hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
|
|
72
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
73
|
+
}
|
|
74
|
+
/** Suggestions only: ambiguity is returned to the caller, never silently resolved. */
|
|
75
|
+
export function suggestRig(names) {
|
|
76
|
+
const aliases = { root: ['root', 'hips', 'pelvis'], leftFoot: ['footl', 'leftfoot', 'lfoot'], rightFoot: ['footr', 'rightfoot', 'rfoot'] };
|
|
77
|
+
return Object.fromEntries(Object.entries(aliases).map(([role, values]) => [role, names.filter(n => values.includes(n.split(':').at(-1).toLowerCase().replace(/[^a-z]/g, '')))]));
|
|
78
|
+
}
|
|
79
|
+
/** Semantic availability, independent of character/clip names. Preserve optional actions. */
|
|
80
|
+
export function filterLocomotionGaits(available, speed) {
|
|
81
|
+
if (!Number.isFinite(speed) || speed < 0)
|
|
82
|
+
throw new Error('Invalid locomotion speed');
|
|
83
|
+
const result = new Set(available), movement = ['walk', 'run', 'strafe'];
|
|
84
|
+
if (speed > 0.2 && movement.some(g => result.has(g)))
|
|
85
|
+
result.delete('idle');
|
|
86
|
+
else if (speed < 0.05 && result.has('idle'))
|
|
87
|
+
movement.forEach(g => result.delete(g));
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { HORIZONS } from './types.js';
|
|
2
|
+
import { toLocal } from './features.js';
|
|
3
|
+
import { validateHorizons } from './schema.js';
|
|
4
|
+
export function validateRootMotion(track) {
|
|
5
|
+
if (!track || track.version !== 1 || !['hips', 'root'].includes(track.source) || !Number.isFinite(track.duration) || track.duration <= 0 || !Array.isArray(track.samples) || track.samples.length < 2)
|
|
6
|
+
throw new Error('Invalid root motion track');
|
|
7
|
+
let previous = -Infinity;
|
|
8
|
+
for (const sample of track.samples) {
|
|
9
|
+
if (!sample || ![sample.time, sample.x, sample.z, sample.yaw].every(Number.isFinite) || sample.time <= previous || sample.time < 0 || sample.time > track.duration + 1e-6)
|
|
10
|
+
throw new Error('Invalid root motion sample');
|
|
11
|
+
previous = sample.time;
|
|
12
|
+
}
|
|
13
|
+
if (track.samples[0].time !== 0 || Math.abs(track.samples.at(-1).time - track.duration) > 1e-6)
|
|
14
|
+
throw new Error('Incomplete root motion track');
|
|
15
|
+
}
|
|
16
|
+
/** Clamped sampling for non-looping clips; completed motion stays at its endpoint. */
|
|
17
|
+
export function sampleRootMotion(track, time, looping = false) {
|
|
18
|
+
if (!Number.isFinite(time))
|
|
19
|
+
throw new Error('Invalid root motion time');
|
|
20
|
+
if (looping) {
|
|
21
|
+
const cycle = Math.floor(time / track.duration), phase = time - cycle * track.duration;
|
|
22
|
+
if (!Number.isSafeInteger(cycle))
|
|
23
|
+
throw new Error('Root motion cycle out of range');
|
|
24
|
+
const first = track.samples[0], last = track.samples.at(-1);
|
|
25
|
+
const delta = toLocal(last.x - first.x, last.z - first.z, first.yaw), angle = last.yaw - first.yaw;
|
|
26
|
+
// Exponentiate planar rigid transforms, including negative cycles.
|
|
27
|
+
const compose = (a, b) => ({ time,
|
|
28
|
+
x: a.x + Math.cos(a.yaw) * b.x + Math.sin(a.yaw) * b.z,
|
|
29
|
+
z: a.z - Math.sin(a.yaw) * b.x + Math.cos(a.yaw) * b.z, yaw: a.yaw + b.yaw });
|
|
30
|
+
let power = { time, x: delta[0], z: delta[1], yaw: angle };
|
|
31
|
+
if (cycle < 0) {
|
|
32
|
+
const v = toLocal(-power.x, -power.z, angle);
|
|
33
|
+
power = { time, x: v[0], z: v[1], yaw: -angle };
|
|
34
|
+
}
|
|
35
|
+
let result = { time, x: 0, z: 0, yaw: 0 }, count = Math.abs(cycle);
|
|
36
|
+
while (count > 0) {
|
|
37
|
+
if (count % 2 === 1)
|
|
38
|
+
result = compose(result, power);
|
|
39
|
+
power = compose(power, power);
|
|
40
|
+
count = Math.floor(count / 2);
|
|
41
|
+
}
|
|
42
|
+
const sample = sampleRootMotion(track, phase), local = toLocal(sample.x - first.x, sample.z - first.z, first.yaw);
|
|
43
|
+
return compose(first, compose(result, { time, x: local[0], z: local[1], yaw: sample.yaw - first.yaw }));
|
|
44
|
+
}
|
|
45
|
+
const t = Math.max(0, Math.min(time, track.duration)), samples = track.samples;
|
|
46
|
+
let lo = 0, hi = samples.length - 1;
|
|
47
|
+
while (hi - lo > 1) {
|
|
48
|
+
const mid = (lo + hi) >>> 1;
|
|
49
|
+
if (samples[mid].time <= t)
|
|
50
|
+
lo = mid;
|
|
51
|
+
else
|
|
52
|
+
hi = mid;
|
|
53
|
+
}
|
|
54
|
+
const a = samples[lo], b = samples[hi], alpha = (t - a.time) / (b.time - a.time);
|
|
55
|
+
return { time: t, x: a.x + (b.x - a.x) * alpha, z: a.z + (b.z - a.z) * alpha, yaw: a.yaw + (b.yaw - a.yaw) * alpha };
|
|
56
|
+
}
|
|
57
|
+
export function rootMotionFeatures(track, time, derivativeStep = 1 / 30, horizons = HORIZONS, looping = false) {
|
|
58
|
+
validateHorizons(horizons);
|
|
59
|
+
if (!Number.isFinite(derivativeStep) || derivativeStep <= 0)
|
|
60
|
+
throw new Error('Invalid derivative step');
|
|
61
|
+
const now = sampleRootMotion(track, time, looping), a = sampleRootMotion(track, time - derivativeStep, looping), b = sampleRootMotion(track, time + derivativeStep, looping);
|
|
62
|
+
const elapsed = b.time - a.time;
|
|
63
|
+
const v = elapsed > 0 ? toLocal((b.x - a.x) / elapsed, (b.z - a.z) / elapsed, now.yaw) : [0, 0];
|
|
64
|
+
return { velocity: [v[0], 0, v[1]], angularVelocity: elapsed > 0 ? (b.yaw - a.yaw) / elapsed : 0,
|
|
65
|
+
trajectory: horizons.map(horizon => {
|
|
66
|
+
const future = sampleRootMotion(track, time + horizon, looping), yaw = future.yaw - now.yaw;
|
|
67
|
+
return { position: toLocal(future.x - now.x, future.z - now.z, now.yaw), facing: [Math.sin(yaw), Math.cos(yaw)] };
|
|
68
|
+
}) };
|
|
69
|
+
}
|
|
70
|
+
/** Crop to the same source interval as playback; preserve the source coordinate frame. */
|
|
71
|
+
export function cropRootMotion(track, start, end) {
|
|
72
|
+
validateRootMotion(track);
|
|
73
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start || end > track.duration)
|
|
74
|
+
throw new Error('Invalid root motion range');
|
|
75
|
+
const samples = [sampleRootMotion(track, start), ...track.samples.filter(s => s.time > start && s.time < end), sampleRootMotion(track, end)]
|
|
76
|
+
.map(s => ({ ...s, time: s.time - start }));
|
|
77
|
+
return { version: 1, source: track.source, duration: end - start, samples };
|
|
78
|
+
}
|