@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,123 @@
|
|
|
1
|
+
export const LOCOMOTION_SCHEMA = {
|
|
2
|
+
version: 1, id: 'biped-locomotion', channels: [
|
|
3
|
+
{ id: 'left-position', kind: 'bone-position', bone: 'leftFoot', weight: 1, normalization: 'standard' },
|
|
4
|
+
{ id: 'right-position', kind: 'bone-position', bone: 'rightFoot', weight: 1, normalization: 'standard' },
|
|
5
|
+
{ id: 'left-velocity', kind: 'bone-velocity', bone: 'leftFoot', weight: 1, normalization: 'standard' },
|
|
6
|
+
{ id: 'right-velocity', kind: 'bone-velocity', bone: 'rightFoot', weight: 1, normalization: 'standard' },
|
|
7
|
+
{ id: 'root-velocity', kind: 'root-velocity', weight: 1, normalization: 'standard' },
|
|
8
|
+
{ id: 'future-position', kind: 'trajectory-position', horizons: [0.2, 0.4, 0.6], weight: 3, normalization: 'standard' },
|
|
9
|
+
{ id: 'future-facing', kind: 'trajectory-facing', horizons: [0.2, 0.4, 0.6], weight: 3, normalization: 'standard' },
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
for (const channel of LOCOMOTION_SCHEMA.channels) {
|
|
13
|
+
if ('horizons' in channel)
|
|
14
|
+
Object.freeze(channel.horizons);
|
|
15
|
+
Object.freeze(channel);
|
|
16
|
+
}
|
|
17
|
+
Object.freeze(LOCOMOTION_SCHEMA.channels);
|
|
18
|
+
Object.freeze(LOCOMOTION_SCHEMA);
|
|
19
|
+
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
20
|
+
const name = (v) => typeof v === 'string' && /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(v);
|
|
21
|
+
export function validateHorizons(input) {
|
|
22
|
+
if (!Array.isArray(input) || input.length > 64 || [...input].some((v, i) => !Number.isFinite(v) || v <= 0 || v > 10 || (i > 0 && v <= input[i - 1])))
|
|
23
|
+
throw new Error('Schema: at most 64 horizons, increasing within (0, 10] seconds');
|
|
24
|
+
}
|
|
25
|
+
/** Strict JSON parsing: unsupported channels/options fail instead of being ignored. */
|
|
26
|
+
export function parseSchema(input) {
|
|
27
|
+
if (!record(input) || input.version !== 1 || !name(input.id) || Object.keys(input).some(k => !['version', 'id', 'channels'].includes(k))
|
|
28
|
+
|| !Array.isArray(input.channels) || !input.channels.length || input.channels.length > 128)
|
|
29
|
+
throw new Error('Schema: invalid version/id/channels');
|
|
30
|
+
const ids = new Set();
|
|
31
|
+
const channels = [...input.channels].map(value => {
|
|
32
|
+
if (!record(value) || !name(value.id) || ids.has(value.id) || typeof value.weight !== 'number' || !Number.isFinite(Math.fround(value.weight)) || value.weight < 0 || (value.weight > 0 && Math.fround(value.weight) === 0)
|
|
33
|
+
|| !['standard', 'none'].includes(String(value.normalization)))
|
|
34
|
+
throw new Error('Schema: invalid channel id/weight/normalization');
|
|
35
|
+
ids.add(value.id);
|
|
36
|
+
const common = { id: value.id, kind: value.kind, weight: value.weight, normalization: value.normalization };
|
|
37
|
+
let result;
|
|
38
|
+
if (value.kind === 'bone-position' || value.kind === 'bone-velocity') {
|
|
39
|
+
if (!name(value.bone) || value.bone === 'root')
|
|
40
|
+
throw new Error('Schema: invalid semantic bone role');
|
|
41
|
+
result = { ...common, bone: value.bone };
|
|
42
|
+
}
|
|
43
|
+
else if (value.kind === 'root-velocity')
|
|
44
|
+
result = common;
|
|
45
|
+
else if (value.kind === 'trajectory-position' || value.kind === 'trajectory-facing') {
|
|
46
|
+
if (!Array.isArray(value.horizons) || !value.horizons.length)
|
|
47
|
+
throw new Error('Schema: missing horizons');
|
|
48
|
+
validateHorizons(value.horizons);
|
|
49
|
+
result = { ...common, horizons: [...value.horizons] };
|
|
50
|
+
}
|
|
51
|
+
else
|
|
52
|
+
throw new Error('Schema: unsupported channel kind');
|
|
53
|
+
if (Object.keys(value).some(k => !Object.hasOwn(result, k)))
|
|
54
|
+
throw new Error('Schema: unsupported channel option');
|
|
55
|
+
return result;
|
|
56
|
+
});
|
|
57
|
+
if (!channels.some(c => c.weight > 0))
|
|
58
|
+
throw new Error('Schema: at least one positive weight required');
|
|
59
|
+
validateHorizons([...new Set(channels.flatMap(c => 'horizons' in c ? [...c.horizons] : []))].sort((a, b) => a - b));
|
|
60
|
+
return { version: 1, id: input.id, channels };
|
|
61
|
+
}
|
|
62
|
+
/** Canonical content identity, not a lossy hash. Order of channels is significant. */
|
|
63
|
+
export function compileSchema(input) {
|
|
64
|
+
const schema = parseSchema(input), bones = new Set(), horizons = new Set();
|
|
65
|
+
let dimensions = 0;
|
|
66
|
+
const channels = schema.channels.map(channel => {
|
|
67
|
+
if ('bone' in channel)
|
|
68
|
+
bones.add(channel.bone);
|
|
69
|
+
if ('horizons' in channel)
|
|
70
|
+
channel.horizons.forEach(h => horizons.add(h));
|
|
71
|
+
const width = 'horizons' in channel ? 2 * channel.horizons.length : 3;
|
|
72
|
+
const layout = Object.freeze({ channel, offset: dimensions, dimensions: width });
|
|
73
|
+
dimensions += width;
|
|
74
|
+
if ('horizons' in channel)
|
|
75
|
+
Object.freeze(channel.horizons);
|
|
76
|
+
Object.freeze(channel);
|
|
77
|
+
return layout;
|
|
78
|
+
});
|
|
79
|
+
Object.freeze(schema.channels);
|
|
80
|
+
Object.freeze(schema);
|
|
81
|
+
return Object.freeze({ schema, signature: 'pose-schema-v1:' + JSON.stringify(schema), dimensions,
|
|
82
|
+
channels: Object.freeze(channels), bones: Object.freeze([...bones]), horizons: Object.freeze([...horizons].sort((a, b) => a - b)) });
|
|
83
|
+
}
|
|
84
|
+
/** One encoder shared by bake and runtime. Trajectory points follow the sorted union of horizons. */
|
|
85
|
+
export class SchemaQueryBuilder {
|
|
86
|
+
layout;
|
|
87
|
+
horizonIndices;
|
|
88
|
+
constructor(schema = LOCOMOTION_SCHEMA) {
|
|
89
|
+
this.layout = compileSchema(schema);
|
|
90
|
+
this.horizonIndices = new Map(this.layout.horizons.map((h, i) => [h, i]));
|
|
91
|
+
}
|
|
92
|
+
features(pose, trajectory, out = new Float32Array(this.layout.dimensions)) {
|
|
93
|
+
if (trajectory.length !== this.layout.horizons.length || out.length !== this.layout.dimensions)
|
|
94
|
+
throw new Error('Invalid schema pose/trajectory dimensions');
|
|
95
|
+
const put = (values, offset, length) => {
|
|
96
|
+
if (!values || values.length !== length || !values.every(Number.isFinite))
|
|
97
|
+
throw new Error('Invalid schema feature values');
|
|
98
|
+
out.set(values, offset);
|
|
99
|
+
};
|
|
100
|
+
for (const { channel, offset } of this.layout.channels) {
|
|
101
|
+
if ('bone' in channel) {
|
|
102
|
+
const bone = Object.hasOwn(pose.bones, channel.bone) ? pose.bones[channel.bone] : undefined;
|
|
103
|
+
if (!bone)
|
|
104
|
+
throw new Error('Missing semantic bone: ' + channel.bone);
|
|
105
|
+
put(channel.kind === 'bone-position' ? bone.position : bone.velocity, offset, 3);
|
|
106
|
+
}
|
|
107
|
+
else if ('horizons' in channel) {
|
|
108
|
+
channel.horizons.forEach((h, i) => {
|
|
109
|
+
const point = trajectory[this.horizonIndices.get(h)];
|
|
110
|
+
put(channel.kind === 'trajectory-position' ? point?.position : point?.facing, offset + i * 2, 2);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else
|
|
114
|
+
put(pose.rootVelocity, offset, 3);
|
|
115
|
+
}
|
|
116
|
+
if (!out.every(Number.isFinite))
|
|
117
|
+
throw new Error('Invalid schema float32 feature values');
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
build(pose, trajectory) {
|
|
121
|
+
return { features: this.features(pose, trajectory), schemaSignature: this.layout.signature };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { validateDatabase } from './database.js';
|
|
2
|
+
import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
|
|
3
|
+
export function defaultWeights(schema = LOCOMOTION_SCHEMA) {
|
|
4
|
+
const layout = compileSchema(schema), weights = new Float32Array(layout.dimensions);
|
|
5
|
+
for (const { channel, offset, dimensions } of layout.channels)
|
|
6
|
+
weights.fill(channel.weight, offset, offset + dimensions);
|
|
7
|
+
if (!weights.every(Number.isFinite))
|
|
8
|
+
throw new Error('Invalid feature weights');
|
|
9
|
+
return weights;
|
|
10
|
+
}
|
|
11
|
+
const legacySignature = compileSchema(LOCOMOTION_SCHEMA).signature;
|
|
12
|
+
export class PoseSearch {
|
|
13
|
+
database;
|
|
14
|
+
normalized;
|
|
15
|
+
dimensions;
|
|
16
|
+
rootOffset;
|
|
17
|
+
weights;
|
|
18
|
+
constructor(database, weights) {
|
|
19
|
+
this.database = database;
|
|
20
|
+
validateDatabase(database);
|
|
21
|
+
this.dimensions = database.manifest.dimensions;
|
|
22
|
+
this.rootOffset = compileSchema(database.manifest.schema).channels.find(c => c.channel.kind === 'root-velocity')?.offset;
|
|
23
|
+
this.normalized = new Float32Array(this.dimensions);
|
|
24
|
+
weights ??= defaultWeights(database.manifest.schema);
|
|
25
|
+
if (weights.length !== this.dimensions || !weights.every(v => Number.isFinite(v) && v >= 0))
|
|
26
|
+
throw new Error('Invalid feature weights');
|
|
27
|
+
this.weights = weights.slice();
|
|
28
|
+
}
|
|
29
|
+
validateQuery(query) {
|
|
30
|
+
if (query.schemaSignature !== this.database.manifest.schemaSignature && !(query.schemaSignature === undefined && this.database.manifest.schemaSignature === legacySignature))
|
|
31
|
+
throw new Error('Incompatible query schema');
|
|
32
|
+
if (query.features.length !== this.dimensions || !query.features.every(Number.isFinite))
|
|
33
|
+
throw new Error('Invalid pose query');
|
|
34
|
+
if (query.allowTemporalReselection !== undefined && typeof query.allowTemporalReselection !== 'boolean')
|
|
35
|
+
throw new Error('Invalid temporal reselection option');
|
|
36
|
+
if (query.velocityTolerance !== undefined && (!Number.isFinite(query.velocityTolerance) || query.velocityTolerance < 0 || this.rootOffset === undefined))
|
|
37
|
+
throw new Error('Velocity tolerance requires a root-velocity channel and a finite nonnegative value');
|
|
38
|
+
}
|
|
39
|
+
normalize(query) {
|
|
40
|
+
this.validateQuery(query);
|
|
41
|
+
const { mean, std } = this.database.manifest;
|
|
42
|
+
for (let j = 0; j < this.dimensions; j++)
|
|
43
|
+
this.normalized[j] = (query.features[j] - mean[j]) / std[j];
|
|
44
|
+
if (!this.normalized.every(Number.isFinite))
|
|
45
|
+
throw new Error('Invalid normalized pose query');
|
|
46
|
+
}
|
|
47
|
+
cost(index) {
|
|
48
|
+
const { features } = this.database;
|
|
49
|
+
let sum = 0;
|
|
50
|
+
for (let j = 0; j < this.dimensions; j++)
|
|
51
|
+
sum += this.weights[j] * (this.normalized[j] - features[index * this.dimensions + j]) ** 2;
|
|
52
|
+
return sum;
|
|
53
|
+
}
|
|
54
|
+
/** Physical error, independent of normalization/pose weights. Query must have been validated. */
|
|
55
|
+
rootVelocityError(index, query) {
|
|
56
|
+
if (this.rootOffset === undefined || !Number.isInteger(index) || index < 0 || index >= this.database.manifest.frames.length)
|
|
57
|
+
return Infinity;
|
|
58
|
+
const { mean, std } = this.database.manifest;
|
|
59
|
+
let squared = 0;
|
|
60
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
61
|
+
const j = this.rootOffset + axis, value = this.database.features[index * this.dimensions + j] * std[j] + mean[j];
|
|
62
|
+
squared += (value - query.features[j]) ** 2;
|
|
63
|
+
}
|
|
64
|
+
return Math.sqrt(squared);
|
|
65
|
+
}
|
|
66
|
+
search(query) {
|
|
67
|
+
this.normalize(query);
|
|
68
|
+
const { frames, clips } = this.database.manifest;
|
|
69
|
+
let index = -1, cost = Infinity;
|
|
70
|
+
let velocityLimit = Infinity;
|
|
71
|
+
if (query.velocityTolerance !== undefined) {
|
|
72
|
+
let nearest = Infinity;
|
|
73
|
+
for (let i = 0; i < frames.length; i++) {
|
|
74
|
+
if (query.excludedClips?.has(frames[i].clip) || (query.gaits && !query.gaits.has(clips[frames[i].clip].gait)))
|
|
75
|
+
continue;
|
|
76
|
+
nearest = Math.min(nearest, this.rootVelocityError(i, query));
|
|
77
|
+
}
|
|
78
|
+
// Sparse datasets retain a deterministic nearest-velocity fallback. Never
|
|
79
|
+
// expand the pool to arbitrary stationary poses just because coverage is missing.
|
|
80
|
+
velocityLimit = Math.max(query.velocityTolerance, nearest + 1e-6);
|
|
81
|
+
}
|
|
82
|
+
for (let i = 0; i < frames.length; i++) {
|
|
83
|
+
if (query.excludedClips?.has(frames[i].clip))
|
|
84
|
+
continue;
|
|
85
|
+
if (query.gaits && !query.gaits.has(clips[frames[i].clip].gait))
|
|
86
|
+
continue;
|
|
87
|
+
if (query.velocityTolerance !== undefined && this.rootVelocityError(i, query) > velocityLimit)
|
|
88
|
+
continue;
|
|
89
|
+
const score = this.cost(i);
|
|
90
|
+
if (score < cost) {
|
|
91
|
+
index = i;
|
|
92
|
+
cost = score;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return index < 0 ? null : { ...frames[index], index, cost };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './core/types.js';
|
|
2
|
+
export * from './core/schema.js';
|
|
3
|
+
export * from './core/features.js';
|
|
4
|
+
export * from './core/database.js';
|
|
5
|
+
export * from './core/search.js';
|
|
6
|
+
export * from './core/controller.js';
|
|
7
|
+
export * from './core/root-motion.js';
|
|
8
|
+
export * from './core/profile.js';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { FreeCamera, TransformNode, Vector3 } from '@babylonjs/core';
|
|
3
|
+
import { hashProfileAssets, loadProfileAssets, profileArgument, publicPath, readProfile } from './profile-assets.js';
|
|
4
|
+
import { buildDatabase, encodeFeatures, SchemaQueryBuilder, rootMotionFeatures, cropRootMotion, validateRootMotion, profileSignature } from '../src/index.js';
|
|
5
|
+
import { BabylonMotionPlayer, BabylonSchemaPoseReader } from '../src/babylon.js';
|
|
6
|
+
const profile = await readProfile(profileArgument());
|
|
7
|
+
const builder = new SchemaQueryBuilder(profile.schemaDefinition), layout = builder.layout;
|
|
8
|
+
const assets = await loadProfileAssets(profile);
|
|
9
|
+
try {
|
|
10
|
+
const { scene, containers } = assets, body = containers.get(profile.model);
|
|
11
|
+
new FreeCamera('bake-camera', new Vector3(0, 2, -5), scene);
|
|
12
|
+
body.addAllToScene();
|
|
13
|
+
const controller = new TransformNode('bake-controller', scene);
|
|
14
|
+
body.rootNodes.forEach(n => n.parent = controller);
|
|
15
|
+
const nodes = new Map(body.transformNodes.map(n => [n.name, n])), targets = new Map(nodes);
|
|
16
|
+
for (const [source, target] of Object.entries(profile.boneMap ?? {}))
|
|
17
|
+
targets.set(source, nodes.get(target));
|
|
18
|
+
const reader = new BabylonSchemaPoseReader(controller, new Map(layout.bones.map(role => [role, nodes.get(profile.rig[role])])), layout.schema);
|
|
19
|
+
const clips = [], frames = [], raw = [], sampleRate = profile.sampleRate;
|
|
20
|
+
for (const spec of profile.clips) {
|
|
21
|
+
const { id, file, animation, gait, looping } = spec, nominalVelocity = spec.nominalVelocity ?? [0, 0, 0];
|
|
22
|
+
const source = containers.get(file).animationGroups.find(g => g.name === animation);
|
|
23
|
+
const fps = source.targetedAnimations[0].animation.framePerSecond, sourceDuration = (source.to - source.from) / fps;
|
|
24
|
+
const [start, end] = spec.range ?? [0, sourceDuration], duration = end - start;
|
|
25
|
+
let motion = spec.motionFile ? JSON.parse(await readFile(publicPath(profile.assetBase + spec.motionFile), 'utf8')) : undefined;
|
|
26
|
+
if (motion) {
|
|
27
|
+
validateRootMotion(motion);
|
|
28
|
+
motion.samples = motion.samples.map(sample => ({ ...sample, x: sample.x * profile.scale, z: sample.z * profile.scale }));
|
|
29
|
+
if (Math.abs(motion.duration - sourceDuration) > 1e-5)
|
|
30
|
+
throw new Error(`Motion/GLB time mismatch: ${id}`);
|
|
31
|
+
}
|
|
32
|
+
if (motion && spec.range)
|
|
33
|
+
motion = cropRootMotion(motion, start, end);
|
|
34
|
+
const descriptor = { id, file, animation, duration, looping, gait, ...(spec.range ? { sourceStart: start } : {}), ...(spec.rootOrientation ? { rootOrientation: spec.rootOrientation } : {}),
|
|
35
|
+
...(motion ? { motionSource: motion.source } : { nominalVelocity, motionSource: 'nominal' }) };
|
|
36
|
+
const player = new BabylonMotionPlayer(scene, [descriptor], new Map([[id, source]]), targets, 0, profile.stripRoot ?? '');
|
|
37
|
+
const clipTime = (t) => looping ? ((t % duration) + duration) % duration : Math.max(0, Math.min(t, duration));
|
|
38
|
+
// Sample the same target model/player/root stripping used at runtime.
|
|
39
|
+
const sample = (t) => {
|
|
40
|
+
player.update({ clip: 0, time: clipTime(t), switched: false, match: null }, 0);
|
|
41
|
+
scene.render();
|
|
42
|
+
return reader.read(0);
|
|
43
|
+
};
|
|
44
|
+
try {
|
|
45
|
+
const clip = clips.length;
|
|
46
|
+
clips.push(descriptor);
|
|
47
|
+
const average = [0, 0, 0], count = Math.ceil(duration * sampleRate);
|
|
48
|
+
for (let i = 0; i < Math.ceil(duration * sampleRate); i++) {
|
|
49
|
+
const time = i / sampleRate, h = 1 / sampleRate;
|
|
50
|
+
const before = sample(time - h), after = sample(time + h), now = sample(time);
|
|
51
|
+
const measured = motion ? rootMotionFeatures(motion, time, h, layout.horizons, looping) : undefined;
|
|
52
|
+
const rootVelocity = measured?.velocity ?? nominalVelocity;
|
|
53
|
+
if (measured)
|
|
54
|
+
for (let axis = 0; axis < 3; axis++)
|
|
55
|
+
average[axis] += rootVelocity[axis] / count;
|
|
56
|
+
const elapsed = looping ? 2 * h : clipTime(time + h) - clipTime(time - h);
|
|
57
|
+
const bones = Object.fromEntries(layout.bones.map(role => {
|
|
58
|
+
const position = now.bones[role].position;
|
|
59
|
+
const velocity = position.map((_, j) => (after.bones[role].position[j] - before.bones[role].position[j]) / elapsed + rootVelocity[j]);
|
|
60
|
+
velocity[0] += (measured?.angularVelocity ?? 0) * position[2];
|
|
61
|
+
velocity[2] -= (measured?.angularVelocity ?? 0) * position[0];
|
|
62
|
+
return [role, { position, velocity: velocity }];
|
|
63
|
+
}));
|
|
64
|
+
const pose = { bones, rootVelocity };
|
|
65
|
+
const future = measured?.trajectory ?? layout.horizons.map(t => ({ position: [nominalVelocity[0] * t, nominalVelocity[2] * t], facing: [0, 1] }));
|
|
66
|
+
raw.push(...builder.features(pose, future));
|
|
67
|
+
frames.push({ clip, time });
|
|
68
|
+
}
|
|
69
|
+
if (motion)
|
|
70
|
+
descriptor.averageVelocity = average;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
player.dispose();
|
|
74
|
+
}
|
|
75
|
+
console.log(`${id}: ${duration.toFixed(3)}s`);
|
|
76
|
+
}
|
|
77
|
+
const db = buildDatabase({ profileId: profile.id, profileSignature: profileSignature(profile), assetHashes: await hashProfileAssets(profile), sampleRate, clips, frames }, Float32Array.from(raw), layout.schema);
|
|
78
|
+
const output = publicPath(profile.database);
|
|
79
|
+
await mkdir(output, { recursive: true });
|
|
80
|
+
await writeFile(`${output}/manifest.json`, JSON.stringify(db.manifest, null, 2));
|
|
81
|
+
await writeFile(`${output}/poses.bin`, new Uint8Array(encodeFeatures(db)));
|
|
82
|
+
console.log(`Baked ${frames.length} poses × ${layout.dimensions} features (${layout.schema.id}); ${db.features.byteLength} bytes.`);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
assets.dispose();
|
|
86
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Node-only entry; paths are relative to the caller, never to this installation. */
|
|
3
|
+
const command = process.argv[2];
|
|
4
|
+
try {
|
|
5
|
+
if (command === 'bake')
|
|
6
|
+
await import('./bake.js');
|
|
7
|
+
else if (command === 'prepare') {
|
|
8
|
+
const at = process.argv.indexOf('--config');
|
|
9
|
+
if (at < 0 || !process.argv[at + 1])
|
|
10
|
+
throw new Error('Provide --config preparation.json');
|
|
11
|
+
const { readFile } = await import('node:fs/promises');
|
|
12
|
+
const { parsePreparation, prepareGLB } = await import('./prepare.js');
|
|
13
|
+
console.log(JSON.stringify(await prepareGLB(parsePreparation(JSON.parse(await readFile(process.argv[at + 1], 'utf8')))), null, 2));
|
|
14
|
+
}
|
|
15
|
+
else if (command === 'validate' || command === 'inspect') {
|
|
16
|
+
if (command === 'inspect')
|
|
17
|
+
process.argv.splice(3, 0, '--inspect');
|
|
18
|
+
await import('./validate-assets.js');
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
console.log('pose-flow bake|validate --profile public/profiles/rig.json [--public-dir public]\npose-flow inspect model.glb\npose-flow prepare --config preparation.json');
|
|
22
|
+
if (command && command !== '--help')
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function readGLB(bytes) {
|
|
2
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
3
|
+
if (bytes.length < 20 || view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== bytes.length)
|
|
4
|
+
throw new Error('Invalid GLB header');
|
|
5
|
+
let json, binary = new Uint8Array();
|
|
6
|
+
for (let offset = 12; offset < bytes.length;) {
|
|
7
|
+
if (offset + 8 > bytes.length)
|
|
8
|
+
throw new Error('Truncated GLB chunk');
|
|
9
|
+
const length = view.getUint32(offset, true), kind = view.getUint32(offset + 4, true);
|
|
10
|
+
offset += 8;
|
|
11
|
+
if (length % 4 || offset + length > bytes.length)
|
|
12
|
+
throw new Error('Invalid GLB chunk');
|
|
13
|
+
if (kind === 0x4e4f534a)
|
|
14
|
+
json = JSON.parse(new TextDecoder().decode(bytes.subarray(offset, offset + length)));
|
|
15
|
+
else if (kind === 0x004e4942)
|
|
16
|
+
binary = bytes.slice(offset, offset + length);
|
|
17
|
+
offset += length;
|
|
18
|
+
}
|
|
19
|
+
if (!json || json.buffers?.length !== 1 || json.buffers[0].uri || json.buffers[0].byteLength > binary.length || json.images?.some(i => i.uri && !i.uri.startsWith('data:')))
|
|
20
|
+
throw new Error('Preparation requires a self-contained GLB with one embedded buffer/images');
|
|
21
|
+
return { json, binary };
|
|
22
|
+
}
|
|
23
|
+
export function writeGLB(json, binary) {
|
|
24
|
+
const data = new TextEncoder().encode(JSON.stringify(json)), jsonLength = Math.ceil(data.length / 4) * 4, binLength = Math.ceil(binary.length / 4) * 4;
|
|
25
|
+
const output = new Uint8Array(28 + jsonLength + binLength), view = new DataView(output.buffer);
|
|
26
|
+
view.setUint32(0, 0x46546c67, true);
|
|
27
|
+
view.setUint32(4, 2, true);
|
|
28
|
+
view.setUint32(8, output.length, true);
|
|
29
|
+
view.setUint32(12, jsonLength, true);
|
|
30
|
+
view.setUint32(16, 0x4e4f534a, true);
|
|
31
|
+
output.fill(32, 20, 20 + jsonLength);
|
|
32
|
+
output.set(data, 20);
|
|
33
|
+
view.setUint32(20 + jsonLength, binLength, true);
|
|
34
|
+
view.setUint32(24 + jsonLength, 0x004e4942, true);
|
|
35
|
+
output.set(binary, 28 + jsonLength);
|
|
36
|
+
return output;
|
|
37
|
+
}
|
|
38
|
+
export function appendFloats(document, values, width) {
|
|
39
|
+
if (!values.length || values.length % width || !values.every(Number.isFinite))
|
|
40
|
+
throw new Error('Invalid prepared samples');
|
|
41
|
+
const { json } = document, offset = Math.ceil(document.binary.length / 4) * 4;
|
|
42
|
+
const binary = new Uint8Array(offset + values.length * 4);
|
|
43
|
+
binary.set(document.binary);
|
|
44
|
+
const view = new DataView(binary.buffer);
|
|
45
|
+
values.forEach((v, i) => view.setFloat32(offset + i * 4, v, true));
|
|
46
|
+
const bufferView = (json.bufferViews ??= []).length;
|
|
47
|
+
json.bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: values.length * 4 });
|
|
48
|
+
const accessor = (json.accessors ??= []).length;
|
|
49
|
+
json.accessors.push({ bufferView, componentType: 5126, count: values.length / width, type: width === 1 ? 'SCALAR' : 'VEC' + width,
|
|
50
|
+
...(width === 1 ? { min: [values.reduce((min, v) => Math.min(min, v), Infinity)], max: [values.reduce((max, v) => Math.max(max, v), -Infinity)] } : {}) });
|
|
51
|
+
json.buffers[0].byteLength = binary.length;
|
|
52
|
+
document.binary = binary;
|
|
53
|
+
return accessor;
|
|
54
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { FreeCamera, Matrix, NullEngine, Quaternion, Scene, SceneLoader, Vector3 } from '@babylonjs/core';
|
|
5
|
+
import { GLTFFileLoader } from '@babylonjs/loaders/glTF/index.js';
|
|
6
|
+
import { validateRootMotion } from '../src/index.js';
|
|
7
|
+
import { appendFloats, readGLB, writeGLB } from './glb.js';
|
|
8
|
+
export function parsePreparation(input) {
|
|
9
|
+
if (!input || typeof input !== 'object')
|
|
10
|
+
throw new Error('Invalid preparation config');
|
|
11
|
+
const c = input;
|
|
12
|
+
if (c.version !== 1 || ![c.input, c.output, c.animation, c.rootNode].every(v => typeof v === 'string' && v.trim())
|
|
13
|
+
|| !['root', 'hips'].includes(c.source) || !['horizontal', 'all'].includes(c.position) || typeof c.heading !== 'boolean'
|
|
14
|
+
|| !Number.isFinite(c.sampleRate) || c.sampleRate < 1 || c.sampleRate > 120
|
|
15
|
+
|| (c.unitScale !== undefined && (!Number.isFinite(c.unitScale) || c.unitScale <= 0))
|
|
16
|
+
|| (c.yawOffset !== undefined && !Number.isFinite(c.yawOffset)))
|
|
17
|
+
throw new Error('Invalid preparation config');
|
|
18
|
+
if (c.playbackRate !== undefined && (!Number.isFinite(c.playbackRate) || c.playbackRate < .25 || c.playbackRate > 4))
|
|
19
|
+
throw new Error('Preparation playbackRate must be between .25 and 4');
|
|
20
|
+
if (!c.input.endsWith('.glb') || !c.output.endsWith('.glb') || resolve(c.input) === resolve(c.output))
|
|
21
|
+
throw new Error('Preparation requires distinct input/output GLB paths');
|
|
22
|
+
return structuredClone(c);
|
|
23
|
+
}
|
|
24
|
+
/** Offline only. No character names; the prepared GLB and measured track are a pair. */
|
|
25
|
+
export async function prepareGLB(input) {
|
|
26
|
+
const config = parsePreparation(input), bytes = new Uint8Array(await readFile(config.input)), document = readGLB(bytes), { json } = document;
|
|
27
|
+
const matches = json.nodes.flatMap((node, index) => node.name === config.rootNode ? [index] : []);
|
|
28
|
+
if (matches.length !== 1)
|
|
29
|
+
throw new Error('Missing/ambiguous preparation root node');
|
|
30
|
+
const rootIndex = matches[0];
|
|
31
|
+
const animations = json.animations?.filter(a => a.name === config.animation);
|
|
32
|
+
if (animations?.length !== 1)
|
|
33
|
+
throw new Error('Missing/ambiguous preparation animation');
|
|
34
|
+
const selected = animations[0];
|
|
35
|
+
json.animations = [selected];
|
|
36
|
+
// Keep axis/unit conversion in the exported GLB, not only in the measured JSON.
|
|
37
|
+
const conversion = Matrix.Scaling(config.unitScale ?? 1, config.unitScale ?? 1, config.unitScale ?? 1).multiply(Matrix.RotationY(config.yawOffset ?? 0));
|
|
38
|
+
for (const index of json.scenes[json.scene ?? 0].nodes) {
|
|
39
|
+
const node = json.nodes[index];
|
|
40
|
+
const original = node.matrix ? Matrix.FromArray(node.matrix) : Matrix.Compose(Vector3.FromArray(node.scale ?? [1, 1, 1]), Quaternion.FromArray(node.rotation ?? [0, 0, 0, 1]), Vector3.FromArray(node.translation ?? [0, 0, 0]));
|
|
41
|
+
if (selected.channels.some(c => c.target.node === index))
|
|
42
|
+
throw new Error('Animated scene root requires offline axis/unit conversion before preparation');
|
|
43
|
+
node.matrix = original.multiply(conversion).asArray();
|
|
44
|
+
delete node.translation;
|
|
45
|
+
delete node.rotation;
|
|
46
|
+
delete node.scale;
|
|
47
|
+
}
|
|
48
|
+
const engine = new NullEngine(), scene = new Scene(engine);
|
|
49
|
+
scene.useRightHandedSystem = true;
|
|
50
|
+
new FreeCamera('preparation-camera', new Vector3(0, 2, -5), scene);
|
|
51
|
+
const hook = SceneLoader.OnPluginActivatedObservable.add(p => { if (p instanceof GLTFFileLoader)
|
|
52
|
+
p.skipMaterials = true; });
|
|
53
|
+
try {
|
|
54
|
+
const container = await SceneLoader.LoadAssetContainerAsync('', writeGLB(json, document.binary), scene, undefined, '.glb');
|
|
55
|
+
container.animationGroups.forEach(g => g.stop());
|
|
56
|
+
container.addAllToScene();
|
|
57
|
+
const roots = container.transformNodes.filter(n => n.name === config.rootNode);
|
|
58
|
+
if (roots.length !== 1)
|
|
59
|
+
throw new Error('Missing/ambiguous evaluated root');
|
|
60
|
+
const root = roots[0], group = container.animationGroups[0], fps = group.targetedAnimations[0]?.animation.framePerSecond;
|
|
61
|
+
if (!fps || group.targetedAnimations.some(t => t.animation.framePerSecond !== fps) || group.to <= group.from)
|
|
62
|
+
throw new Error('Invalid preparation clip/FPS');
|
|
63
|
+
const duration = (group.to - group.from) / fps, count = Math.ceil(duration * config.sampleRate);
|
|
64
|
+
if (count > 1_000_000)
|
|
65
|
+
throw new Error('Preparation clip too long');
|
|
66
|
+
const times = [], positions = [], rotations = [], motion = { version: 1, source: config.source, duration, samples: [] };
|
|
67
|
+
let initialPosition, initialRotation, previousYaw = 0, previousQ;
|
|
68
|
+
group.start(true, 0);
|
|
69
|
+
group.pause();
|
|
70
|
+
for (let i = 0; i <= count; i++) {
|
|
71
|
+
const time = duration * i / count;
|
|
72
|
+
group.goToFrame(group.from + time * fps, true);
|
|
73
|
+
scene.render();
|
|
74
|
+
root.computeWorldMatrix(true);
|
|
75
|
+
const scale = new Vector3(), rotation = new Quaternion(), position = new Vector3();
|
|
76
|
+
if (!root.getWorldMatrix().decompose(scale, rotation, position) || Math.min(scale.x, scale.y, scale.z) <= 0 || Math.max(scale.x, scale.y, scale.z) - Math.min(scale.x, scale.y, scale.z) > 1e-4)
|
|
77
|
+
throw new Error('Preparation requires positive uniform world scale without shear');
|
|
78
|
+
const recomposed = Matrix.Compose(scale, rotation, position).asArray();
|
|
79
|
+
if (root.getWorldMatrix().asArray().some((v, j) => Math.abs(v - recomposed[j]) > 1e-4))
|
|
80
|
+
throw new Error('Preparation does not support shear');
|
|
81
|
+
if (root.parent) {
|
|
82
|
+
const parentScale = new Vector3();
|
|
83
|
+
root.parent.getWorldMatrix().decompose(parentScale);
|
|
84
|
+
if (Math.min(parentScale.x, parentScale.y, parentScale.z) <= 0 || Math.max(parentScale.x, parentScale.y, parentScale.z) - Math.min(parentScale.x, parentScale.y, parentScale.z) > 1e-4)
|
|
85
|
+
throw new Error('Preparation requires a uniformly scaled root parent');
|
|
86
|
+
}
|
|
87
|
+
initialPosition ??= position.clone();
|
|
88
|
+
initialRotation ??= rotation.clone();
|
|
89
|
+
const relative = rotation.multiply(initialRotation.conjugate()), forward = new Vector3(0, 0, 1).applyRotationQuaternion(relative);
|
|
90
|
+
if (config.heading && Math.hypot(forward.x, forward.z) < 1e-5)
|
|
91
|
+
throw new Error('Undefined planar heading');
|
|
92
|
+
const angle = Math.atan2(forward.x, forward.z), yaw = config.heading ? previousYaw + Math.atan2(Math.sin(angle - previousYaw), Math.cos(angle - previousYaw)) : 0;
|
|
93
|
+
previousYaw = yaw;
|
|
94
|
+
motion.samples.push({ time, x: position.x - initialPosition.x, z: position.z - initialPosition.z, yaw });
|
|
95
|
+
position.x = initialPosition.x;
|
|
96
|
+
position.z = initialPosition.z;
|
|
97
|
+
if (config.position === 'all')
|
|
98
|
+
position.y = initialPosition.y;
|
|
99
|
+
const corrected = Matrix.Compose(scale, Quaternion.RotationAxis(Vector3.Up(), -yaw).multiply(rotation), position);
|
|
100
|
+
const local = root.parent ? corrected.multiply(Matrix.Invert(root.parent.getWorldMatrix())) : corrected;
|
|
101
|
+
const localScale = new Vector3(), q = new Quaternion(), p = new Vector3();
|
|
102
|
+
if (!local.decompose(localScale, q, p))
|
|
103
|
+
throw new Error('Cannot decompose prepared root');
|
|
104
|
+
if (i === 0) {
|
|
105
|
+
const node = json.nodes[rootIndex];
|
|
106
|
+
delete node.matrix;
|
|
107
|
+
node.translation = p.asArray();
|
|
108
|
+
node.rotation = q.asArray();
|
|
109
|
+
node.scale = localScale.asArray();
|
|
110
|
+
}
|
|
111
|
+
if (previousQ && Quaternion.Dot(previousQ, q) < 0)
|
|
112
|
+
q.scaleInPlace(-1);
|
|
113
|
+
previousQ = q;
|
|
114
|
+
times.push(time);
|
|
115
|
+
positions.push(p.x, p.y, p.z);
|
|
116
|
+
rotations.push(q.x, q.y, q.z, q.w);
|
|
117
|
+
}
|
|
118
|
+
const rate = config.playbackRate ?? 1;
|
|
119
|
+
motion.duration /= rate;
|
|
120
|
+
motion.samples.forEach(s => s.time /= rate);
|
|
121
|
+
validateRootMotion(motion);
|
|
122
|
+
const timeAccessor = appendFloats(document, times.map(t => t / rate), 1);
|
|
123
|
+
selected.channels = selected.channels.filter(c => c.target.node !== rootIndex || !['translation', 'rotation'].includes(c.target.path));
|
|
124
|
+
if (rate !== 1) {
|
|
125
|
+
const remapped = new Map();
|
|
126
|
+
for (const index of new Set(selected.channels.map(c => c.sampler))) {
|
|
127
|
+
const sampler = selected.samplers[index];
|
|
128
|
+
if (sampler.interpolation === 'CUBICSPLINE')
|
|
129
|
+
throw new Error('Time scaling requires LINEAR/STEP source channels; resample cubic channels offline first');
|
|
130
|
+
if (!remapped.has(sampler.input)) {
|
|
131
|
+
const accessor = json.accessors[sampler.input];
|
|
132
|
+
if (accessor.type !== 'SCALAR' || accessor.componentType !== 5126 || accessor.sparse)
|
|
133
|
+
throw new Error('Unsupported timeline accessor');
|
|
134
|
+
const view = json.bufferViews[accessor.bufferView];
|
|
135
|
+
const data = new DataView(document.binary.buffer, document.binary.byteOffset, document.binary.byteLength);
|
|
136
|
+
const values = Array.from({ length: accessor.count }, (_, i) => data.getFloat32((view.byteOffset ?? 0) + (accessor.byteOffset ?? 0) + i * (view.byteStride ?? 4), true) / rate);
|
|
137
|
+
remapped.set(sampler.input, appendFloats(document, values, 1));
|
|
138
|
+
}
|
|
139
|
+
sampler.input = remapped.get(sampler.input);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const [path, values, width] of [['translation', positions, 3], ['rotation', rotations, 4]]) {
|
|
143
|
+
const sampler = selected.samplers.length;
|
|
144
|
+
selected.samplers.push({ input: timeAccessor, output: appendFloats(document, [...values], width), interpolation: 'LINEAR' });
|
|
145
|
+
selected.channels.push({ sampler, target: { node: rootIndex, path } });
|
|
146
|
+
}
|
|
147
|
+
// Babylon GLTF frames are seconds × FPS; GLTF accessor times are seconds.
|
|
148
|
+
// Reject nonzero source origins rather than silently offsetting other channels.
|
|
149
|
+
if (Math.abs(group.from) > 1e-5)
|
|
150
|
+
throw new Error('Preparation requires source animation starting at zero; crop/convert offline first');
|
|
151
|
+
const output = resolve(config.output), motionFile = output.replace(/\.glb$/, '.motion.json');
|
|
152
|
+
await mkdir(dirname(output), { recursive: true });
|
|
153
|
+
await writeFile(output, writeGLB(json, document.binary));
|
|
154
|
+
await writeFile(motionFile, JSON.stringify(motion, null, 2));
|
|
155
|
+
const report = { config, inputSHA256: createHash('sha256').update(bytes).digest('hex'), duration: motion.duration, samples: times.length,
|
|
156
|
+
displacement: motion.samples.at(-1), note: 'Paired prepared GLB + measured planar trajectory. No retargeting; preserve source provenance/license. Horizontal policy preserves vertical motion.' };
|
|
157
|
+
await writeFile(output.replace(/\.glb$/, '.preparation.json'), JSON.stringify(report, null, 2));
|
|
158
|
+
return report;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
SceneLoader.OnPluginActivatedObservable.remove(hook);
|
|
162
|
+
scene.dispose();
|
|
163
|
+
engine.dispose();
|
|
164
|
+
}
|
|
165
|
+
}
|