cozyclay 1.0.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 +674 -0
- package/README.md +109 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/cozyclay.mjs +194 -0
- package/dist/ardy/cskel27-rest.json +557 -0
- package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
- package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
- package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
- package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
- package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
- package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
- package/dist/assets/index-BW_S1YRy.js +4395 -0
- package/dist/assets/index-nRmmJgap.css +1 -0
- package/dist/demo/walk-then-stop.npz +0 -0
- package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
- package/dist/fonts/instrument-serif-latin.woff2 +0 -0
- package/dist/fonts/inter-latin.woff2 +0 -0
- package/dist/index.html +13 -0
- package/dist/models/x-bot-tpose.fbx +0 -0
- package/dist/models/y-bot-tpose.fbx +0 -0
- package/package.json +80 -0
- package/src/App.jsx +3840 -0
- package/src/ardy/client.js +140 -0
- package/src/ardy/convert.js +313 -0
- package/src/ardy/cskel27-neutral.js +39 -0
- package/src/ardy/cskel27.js +68 -0
- package/src/ardy/export.js +157 -0
- package/src/ardy/ik.js +610 -0
- package/src/ardy/npz.js +520 -0
- package/src/ardy/playback.js +414 -0
- package/src/ardy/prompt-clips.js +16 -0
- package/src/ardy/timeline-coordinates.js +17 -0
- package/src/ardy/timeline-resize.js +11 -0
- package/src/ardy/timeline.jsx +723 -0
- package/src/ardy/to-cskel27.js +205 -0
- package/src/ardy/waypoints.js +364 -0
- package/src/camera-follow.js +366 -0
- package/src/camera-move.js +286 -0
- package/src/controls.jsx +245 -0
- package/src/dualview.jsx +305 -0
- package/src/hierarchy-model.js +77 -0
- package/src/hierarchy-panel.jsx +365 -0
- package/src/history.js +90 -0
- package/src/main.jsx +10 -0
- package/src/object-catalog.jsx +90 -0
- package/src/object-gizmo.jsx +755 -0
- package/src/planview.jsx +653 -0
- package/src/poses.js +424 -0
- package/src/posestudio.jsx +811 -0
- package/src/props.jsx +302 -0
- package/src/room.jsx +64 -0
- package/src/scene-history.js +125 -0
- package/src/scene-objects.js +420 -0
- package/src/shot-authoring.js +113 -0
- package/src/shot.js +239 -0
- package/src/styles.css +5639 -0
- package/src/ui.jsx +391 -0
- package/src/use-render-activity.js +96 -0
- package/tools/ardy/BRIDGE.md +255 -0
- package/tools/ardy/README.md +136 -0
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +1427 -0
- package/tools/ardy/cclay_motion_edit.py +445 -0
- package/tools/ardy/cclay_sequence_generate.py +595 -0
- package/tools/ardy/dump-npz.py +205 -0
- package/tools/ardy/extract-rest.mjs +299 -0
- package/tools/ardy/npz.mjs +335 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/ardy/pose-to-npz.mjs +106 -0
- package/tools/ardy/run-edit-on-box.sh +73 -0
- package/tools/ardy/run-on-box.sh +568 -0
- package/tools/ardy/run-sequence-on-box.sh +162 -0
- package/tools/ardy/visual-qa.mjs +188 -0
- package/tools/ardy/vq-car.mjs +52 -0
- package/tools/dev-full.mjs +29 -0
- package/tools/process-supervisor.mjs +63 -0
- package/tools/qa-browser.mjs +84 -0
- package/tools/qa-crop3.mjs +22 -0
- package/tools/qa-playview.mjs +49 -0
- package/tools/qa-screenshot.mjs +24 -0
- package/tools/qa-visual.mjs +51 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CozyClayPoseV1 -> the two npz arrays cclay_constrained_generate.load_poses
|
|
3
|
+
* reads (local_rot_mats 27x3x3, posed_joints 27x3; the generator then runs its
|
|
4
|
+
* own FK, consulting only posed_joints[0] as the root).
|
|
5
|
+
*
|
|
6
|
+
* The 19 joints CozyClay authors map through each bone's armature-space rest
|
|
7
|
+
* rotation Rb (cskel27-rest.json): L = Rb @ basis @ Rb^T. The other 8 joints
|
|
8
|
+
* get identity, i.e. "at ARDY rest" — CozyClay does not author them and must
|
|
9
|
+
* not invent them; `filled_identity` says which joints that happened for and
|
|
10
|
+
* why, so callers can report it rather than guess.
|
|
11
|
+
*
|
|
12
|
+
* Proportions are never hardcoded: bone offsets come from the reference frame
|
|
13
|
+
* of the same base clip the pose will constrain (mirroring
|
|
14
|
+
* motion_constraints.derive_bone_offsets), and the root rides on that frame's
|
|
15
|
+
* own root. If ARDY ever reproportions cskel27, the clip proportions stay
|
|
16
|
+
* correct by construction.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { COZYCLAY_BONES, CSKEL27_JOINTS } from "./cskel27.js";
|
|
20
|
+
import { CSKEL27_NEUTRAL } from "./cskel27-neutral.js";
|
|
21
|
+
import {
|
|
22
|
+
basisQuaternionToLocalRotation,
|
|
23
|
+
deriveBoneOffsets,
|
|
24
|
+
forwardKinematics,
|
|
25
|
+
} from "./convert.js";
|
|
26
|
+
|
|
27
|
+
// Tolerance on |norm - 1| for pose quaternions, and on orthonormality /
|
|
28
|
+
// determinant for emitted matrices. Float32-serialized source data is off by
|
|
29
|
+
// ~1e-7 at worst, so 1e-6 is comfortably above input noise while still
|
|
30
|
+
// catching real corruption.
|
|
31
|
+
const UNIT_TOLERANCE = 1e-6;
|
|
32
|
+
|
|
33
|
+
const IDENTITY = [
|
|
34
|
+
[1, 0, 0],
|
|
35
|
+
[0, 1, 0],
|
|
36
|
+
[0, 0, 1],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const CANONICAL_ROOT_Y = -Math.min(...CSKEL27_NEUTRAL.map((joint) => joint[1]));
|
|
40
|
+
|
|
41
|
+
/** Floor-aligned CoreSkeleton27 reference used when no clip reference is
|
|
42
|
+
* supplied. A generated motion is not a skeleton definition: borrowing its
|
|
43
|
+
* frame-zero root made a rolling clip lower every authored pose by 10 cm.
|
|
44
|
+
* The neutral skeleton is stable, deterministic, and keeps both toes at Y=0. */
|
|
45
|
+
export function canonicalCskel27Reference() {
|
|
46
|
+
return {
|
|
47
|
+
local_rot_mats: CSKEL27_JOINTS.map(() => IDENTITY.map((row) => row.slice())),
|
|
48
|
+
posed_joints: CSKEL27_NEUTRAL.map(([x, y, z]) => [x, y + CANONICAL_ROOT_Y, z]),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isFiniteNumber(value) {
|
|
53
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function determinant3(m) {
|
|
57
|
+
return (
|
|
58
|
+
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) -
|
|
59
|
+
m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) +
|
|
60
|
+
m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requireUnitQuaternion(q, bone) {
|
|
65
|
+
if (!Array.isArray(q) || q.length !== 4 || !q.every(isFiniteNumber)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`poseToCskel27: bone "${bone}" is not a [w, x, y, z] quaternion of four finite numbers`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const norm = Math.hypot(q[0], q[1], q[2], q[3]);
|
|
71
|
+
if (Math.abs(norm - 1) > UNIT_TOLERANCE) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`poseToCskel27: bone "${bone}" is not a unit quaternion (norm ${norm})`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A matrix must stay inside the rotation group: rows orthonormal (unit
|
|
79
|
+
// length, pairwise orthogonal) and right-handed.
|
|
80
|
+
function requireRotationMatrix(m, label) {
|
|
81
|
+
for (let i = 0; i < 3; i += 1) {
|
|
82
|
+
const row = m[i];
|
|
83
|
+
const normSq = row[0] * row[0] + row[1] * row[1] + row[2] * row[2];
|
|
84
|
+
if (Math.abs(normSq - 1) > UNIT_TOLERANCE) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`poseToCskel27: ${label} is not orthonormal (row ${i} has squared length ${normSq})`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
for (let j = i + 1; j < 3; j += 1) {
|
|
90
|
+
const other = m[j];
|
|
91
|
+
const dot = row[0] * other[0] + row[1] * other[1] + row[2] * other[2];
|
|
92
|
+
if (Math.abs(dot) > UNIT_TOLERANCE) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`poseToCskel27: ${label} is not orthonormal (rows ${i} and ${j} dot to ${dot})`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const det = determinant3(m);
|
|
100
|
+
if (Math.abs(det - 1) > UNIT_TOLERANCE) {
|
|
101
|
+
throw new Error(`poseToCskel27: ${label} has determinant ${det}, not +1`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Convert a CozyClayPoseV1 into the frame arrays the ARDY generator expects.
|
|
107
|
+
* `rest` is the parsed public/ardy/cskel27-rest.json, `reference` a parsed
|
|
108
|
+
* ardy.frame.v1 fixture from the base motion the pose will constrain.
|
|
109
|
+
* Returns { local_rot_mats, posed_joints, filled_identity }.
|
|
110
|
+
*/
|
|
111
|
+
export function poseToCskel27({ pose, rest, reference }) {
|
|
112
|
+
if (!pose || typeof pose !== "object") {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`poseToCskel27: pose is ${pose === null ? "null" : typeof pose}, expected an object`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (pose.schema !== "cozyclay.pose.v1") {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`poseToCskel27: unsupported pose schema ${JSON.stringify(pose.schema)} — expected "cozyclay.pose.v1"`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (!pose.bones || typeof pose.bones !== "object" || Array.isArray(pose.bones)) {
|
|
123
|
+
throw new Error("poseToCskel27: pose.bones must be an object keyed by mixamo bone name");
|
|
124
|
+
}
|
|
125
|
+
if (pose.root !== undefined) {
|
|
126
|
+
if (!Array.isArray(pose.root) || pose.root.length !== 3 || !pose.root.every(isFiniteNumber)) {
|
|
127
|
+
throw new Error("poseToCskel27: pose.root must be [x, y, z] finite metres when present");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Every joint CozyClay authors must be present and a unit quaternion.
|
|
132
|
+
for (const bone of COZYCLAY_BONES) {
|
|
133
|
+
if (!(bone in pose.bones)) {
|
|
134
|
+
throw new Error(`poseToCskel27: pose.bones is missing "${bone}"`);
|
|
135
|
+
}
|
|
136
|
+
requireUnitQuaternion(pose.bones[bone], bone);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!rest || !Array.isArray(rest.joints)) {
|
|
140
|
+
throw new Error("poseToCskel27: rest must be parsed cskel27-rest.json with a joints array");
|
|
141
|
+
}
|
|
142
|
+
const restByJoint = new Map();
|
|
143
|
+
for (const entry of rest.joints) {
|
|
144
|
+
if (!entry || typeof entry.name !== "string") {
|
|
145
|
+
throw new Error("poseToCskel27: rest.joints entries must carry a string name");
|
|
146
|
+
}
|
|
147
|
+
if (entry.rest !== null) {
|
|
148
|
+
if (
|
|
149
|
+
!Array.isArray(entry.rest) ||
|
|
150
|
+
entry.rest.length !== 3 ||
|
|
151
|
+
!entry.rest.every(
|
|
152
|
+
(row) => Array.isArray(row) && row.length === 3 && row.every(isFiniteNumber)
|
|
153
|
+
)
|
|
154
|
+
) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`poseToCskel27: rest for "${entry.name}" is neither null nor a 3x3 finite matrix`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
restByJoint.set(entry.name, entry.rest);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const authored = new Set(COZYCLAY_BONES);
|
|
164
|
+
const local_rot_mats = new Array(CSKEL27_JOINTS.length);
|
|
165
|
+
const filled_identity = [];
|
|
166
|
+
for (let index = 0; index < CSKEL27_JOINTS.length; index += 1) {
|
|
167
|
+
const name = CSKEL27_JOINTS[index];
|
|
168
|
+
const rb = restByJoint.get(name);
|
|
169
|
+
if (authored.has(name) && rb !== null && rb !== undefined) {
|
|
170
|
+
// L = Rb @ basis @ Rb^T; basisQuaternionToLocalRotation is the
|
|
171
|
+
// quatToMat + basisToLocal composition from convert.js.
|
|
172
|
+
local_rot_mats[index] = basisQuaternionToLocalRotation(pose.bones[name], rb);
|
|
173
|
+
} else {
|
|
174
|
+
// Fresh identity per joint so callers can never alias a shared
|
|
175
|
+
// constant by mutating one row.
|
|
176
|
+
local_rot_mats[index] = IDENTITY.map((row) => row.slice());
|
|
177
|
+
filled_identity.push({
|
|
178
|
+
joint: name,
|
|
179
|
+
reason: authored.has(name) ? "no rest rotation" : "not authored",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Every emitted matrix must be a real rotation.
|
|
185
|
+
for (let index = 0; index < CSKEL27_JOINTS.length; index += 1) {
|
|
186
|
+
requireRotationMatrix(
|
|
187
|
+
local_rot_mats[index],
|
|
188
|
+
`${CSKEL27_JOINTS[index]} (local_rot_mats[${index}])`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A reference may still supply different skeleton proportions, but its
|
|
193
|
+
// frame-zero root is never copied. Placement is authored explicitly in
|
|
194
|
+
// pose.root; otherwise the canonical floor-aligned neutral root is used.
|
|
195
|
+
// This decouples body shape from whichever motion happened to be selected.
|
|
196
|
+
const skeleton = reference ?? canonicalCskel27Reference();
|
|
197
|
+
if (!Array.isArray(skeleton.posed_joints) || !Array.isArray(skeleton.local_rot_mats)) {
|
|
198
|
+
throw new Error("poseToCskel27: reference must carry posed_joints and local_rot_mats");
|
|
199
|
+
}
|
|
200
|
+
const offsets = deriveBoneOffsets(skeleton.posed_joints, skeleton.local_rot_mats);
|
|
201
|
+
const canonicalRoot = canonicalCskel27Reference().posed_joints[0];
|
|
202
|
+
const posed_joints = forwardKinematics(local_rot_mats, offsets, pose.root ?? canonicalRoot);
|
|
203
|
+
|
|
204
|
+
return { local_rot_mats, posed_joints, filled_identity };
|
|
205
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
const cleanZero = (value) => (Object.is(value, -0) || Math.abs(value) < 1e-9 ? 0 : value);
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ARDY root constraints live in the actor's clip-local coordinates. CozyClay's
|
|
5
|
+
* plan uses scene-world coordinates and rotates Subject 1 as a scene object, so
|
|
6
|
+
* both translation and actor yaw must be removed before generation. Playback
|
|
7
|
+
* applies the exact inverse transform when it places the generated root.
|
|
8
|
+
*/
|
|
9
|
+
export function toArdyWaypoints(waypoints, actorRotationDeg = 0) {
|
|
10
|
+
if (!waypoints.length) return [];
|
|
11
|
+
const origin = waypoints[0];
|
|
12
|
+
const yaw = (actorRotationDeg * Math.PI) / 180;
|
|
13
|
+
const cos = Math.cos(yaw);
|
|
14
|
+
const sin = Math.sin(yaw);
|
|
15
|
+
return waypoints.map((waypoint) => {
|
|
16
|
+
const worldX = waypoint.x - origin.x;
|
|
17
|
+
const worldZ = waypoint.z - origin.z;
|
|
18
|
+
return {
|
|
19
|
+
frame: waypoint.frame,
|
|
20
|
+
x: cleanZero(cos * worldX - sin * worldZ),
|
|
21
|
+
z: cleanZero(sin * worldX + cos * worldZ),
|
|
22
|
+
heading: waypoint.heading ?? null,
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Densify an authored root path for ARDY motion generation: rebase to
|
|
28
|
+
* clip-local coordinates exactly like toArdyWaypoints, interpolate on the
|
|
29
|
+
* authored polyline, and emit one sample roughly every `spacing` frames
|
|
30
|
+
* (capped at maxPoints). Authored frames are always kept, the first
|
|
31
|
+
* sample is always frame 0, and frames are strictly ascending without
|
|
32
|
+
* duplicates. The spacing is deliberately loose: a sample every 2-3
|
|
33
|
+
* frames rails the root so hard the model gives up its gait cadence and
|
|
34
|
+
* ice-skates; ~0.4 s between position pins leaves room for real steps
|
|
35
|
+
* while the interpolated headings still steer the facing.
|
|
36
|
+
* Each sample heading is the path tangent (central difference between
|
|
37
|
+
* neighbors; adjacent-segment direction at the ends), carried forward
|
|
38
|
+
* through stalls shorter than 1e-6 and 0 when the whole path is
|
|
39
|
+
* stationary; headings are always finite numbers in [-2π, 2π].
|
|
40
|
+
*/
|
|
41
|
+
export function densifyArdyWaypoints(waypoints, actorRotationDeg = 0, maxPoints = 32, spacing = 8) {
|
|
42
|
+
if (waypoints.length < 2) return [];
|
|
43
|
+
const local = toArdyWaypoints(waypoints, actorRotationDeg);
|
|
44
|
+
|
|
45
|
+
// Authored frames and positions, deduplicated: these are the
|
|
46
|
+
// interpolation endpoints every sample set must contain.
|
|
47
|
+
const authored = [];
|
|
48
|
+
for (const point of local) {
|
|
49
|
+
if (authored.length === 0 || authored[authored.length - 1].frame !== point.frame) {
|
|
50
|
+
authored.push({ frame: point.frame, x: point.x, z: point.z });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (authored.length === 1) {
|
|
54
|
+
return [{ frame: authored[0].frame, x: authored[0].x, z: authored[0].z, heading: 0 }];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const denseCount = authored[authored.length - 1].frame - authored[0].frame + 1;
|
|
58
|
+
const limit = Math.max(0, Math.floor(maxPoints));
|
|
59
|
+
const gapSpacing = Math.max(1, Math.floor(spacing));
|
|
60
|
+
// One filler per started `spacing` chunk beyond the first, per gap.
|
|
61
|
+
let spacingExtra = 0;
|
|
62
|
+
for (let i = 0; i < authored.length - 1; i += 1) {
|
|
63
|
+
spacingExtra += Math.max(0, Math.ceil((authored[i + 1].frame - authored[i].frame) / gapSpacing) - 1);
|
|
64
|
+
}
|
|
65
|
+
const extra = Math.max(0, Math.min(limit, denseCount, authored.length + spacingExtra) - authored.length);
|
|
66
|
+
|
|
67
|
+
// Distribute the extra samples across the gaps between authored frames
|
|
68
|
+
// in proportion to gap length (largest remainder), so spacing stays
|
|
69
|
+
// uniform while authored frames are always kept.
|
|
70
|
+
const gaps = [];
|
|
71
|
+
for (let i = 0; i < authored.length - 1; i += 1) {
|
|
72
|
+
gaps.push(authored[i + 1].frame - authored[i].frame);
|
|
73
|
+
}
|
|
74
|
+
const gapSizes = gaps.map(() => 0);
|
|
75
|
+
if (extra > 0) {
|
|
76
|
+
const total = gaps.reduce((sum, size) => sum + size, 0);
|
|
77
|
+
const raw = gaps.map((size) => (extra * size) / total);
|
|
78
|
+
const base = raw.map((value) => Math.floor(value));
|
|
79
|
+
const order = raw
|
|
80
|
+
.map((value, i) => [value - base[i], i])
|
|
81
|
+
.sort((a, b) => b[0] - a[0]);
|
|
82
|
+
let used = base.reduce((sum, value) => sum + value, 0);
|
|
83
|
+
for (const [, i] of order) {
|
|
84
|
+
if (used >= extra) break;
|
|
85
|
+
const add = Math.min(extra - used, gaps[i] - 1 - base[i]);
|
|
86
|
+
base[i] += add;
|
|
87
|
+
used += add;
|
|
88
|
+
}
|
|
89
|
+
for (let i = 0; i < gaps.length; i += 1) gapSizes[i] = base[i];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Sample frames: authored frames plus evenly spaced integer fillers.
|
|
93
|
+
const frames = [];
|
|
94
|
+
for (let i = 0; i < authored.length; i += 1) {
|
|
95
|
+
if (i > 0) {
|
|
96
|
+
const count = gapSizes[i - 1];
|
|
97
|
+
if (count > 0) {
|
|
98
|
+
const step = gaps[i - 1] / (count + 1);
|
|
99
|
+
for (let j = 0; j < count; j += 1) {
|
|
100
|
+
frames.push(authored[i - 1].frame + Math.round((j + 1) * step));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
frames.push(authored[i].frame);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// C1 resampling: cubic Hermite through the authored pins, with each pin's
|
|
108
|
+
// velocity as the finite-difference average of its two segment velocities
|
|
109
|
+
// (one-sided at the ends). A C0 polyline pins an instantaneous direction
|
|
110
|
+
// flip at every interior waypoint — and, when authored frames imply
|
|
111
|
+
// different paces, an instantaneous speed jump — which the generator can
|
|
112
|
+
// only satisfy by braking and pivoting on the pin: the hitch you see as
|
|
113
|
+
// the root crosses waypoint 2. Hermite keeps authored pins exact, keeps a
|
|
114
|
+
// uniform straight path exactly linear, and turns corners into arcs where
|
|
115
|
+
// both travel direction and speed ramp continuously. (A near-180° reversal
|
|
116
|
+
// will swing wide of the pin — that wide swing is the C1 answer to an
|
|
117
|
+
// about-face, not an error.)
|
|
118
|
+
const segmentVelocity = (a, b) => ({
|
|
119
|
+
x: (b.x - a.x) / (b.frame - a.frame),
|
|
120
|
+
z: (b.z - a.z) / (b.frame - a.frame),
|
|
121
|
+
});
|
|
122
|
+
const stalledSegment = (a, b) => (b.x - a.x) * (b.x - a.x) + (b.z - a.z) * (b.z - a.z) < 1e-12;
|
|
123
|
+
const velocity = authored.map((point, i) => {
|
|
124
|
+
const prev = authored[i - 1];
|
|
125
|
+
const next = authored[i + 1];
|
|
126
|
+
// A knot beside a stall comes to a full stop: leaking a neighbour's
|
|
127
|
+
// velocity into a hold would make the "stationary" span drift, and a
|
|
128
|
+
// walker entering a hold decelerates to zero anyway.
|
|
129
|
+
if (!prev) return stalledSegment(point, next) ? { x: 0, z: 0 } : segmentVelocity(point, next);
|
|
130
|
+
if (!next) return stalledSegment(prev, point) ? { x: 0, z: 0 } : segmentVelocity(prev, point);
|
|
131
|
+
if (stalledSegment(prev, point) || stalledSegment(point, next)) return { x: 0, z: 0 };
|
|
132
|
+
const before = segmentVelocity(prev, point);
|
|
133
|
+
const after = segmentVelocity(point, next);
|
|
134
|
+
return { x: (before.x + after.x) / 2, z: (before.z + after.z) / 2 };
|
|
135
|
+
});
|
|
136
|
+
const samples = [];
|
|
137
|
+
let seg = 0;
|
|
138
|
+
for (const frame of frames) {
|
|
139
|
+
while (frame > authored[seg + 1].frame) seg += 1;
|
|
140
|
+
const start = authored[seg];
|
|
141
|
+
const end = authored[seg + 1];
|
|
142
|
+
const span = end.frame - start.frame;
|
|
143
|
+
const t = (frame - start.frame) / span;
|
|
144
|
+
const h00 = (1 + 2 * t) * (1 - t) * (1 - t);
|
|
145
|
+
const h10 = t * (1 - t) * (1 - t);
|
|
146
|
+
const h01 = t * t * (3 - 2 * t);
|
|
147
|
+
const h11 = t * t * (t - 1);
|
|
148
|
+
const v0 = velocity[seg];
|
|
149
|
+
const v1 = velocity[seg + 1];
|
|
150
|
+
samples.push({
|
|
151
|
+
frame,
|
|
152
|
+
x: cleanZero(h00 * start.x + h10 * span * v0.x + h01 * end.x + h11 * span * v1.x),
|
|
153
|
+
z: cleanZero(h00 * start.z + h10 * span * v0.z + h01 * end.z + h11 * span * v1.z),
|
|
154
|
+
heading: 0,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Path-tangent headings. Interior samples use the central difference
|
|
159
|
+
// between neighbors, the ends use their adjacent segment; displacements
|
|
160
|
+
// under 1e-6 (stalls) carry the previous heading, 0 when stationary.
|
|
161
|
+
// Sign convention measured against the deployed generator, not assumed.
|
|
162
|
+
// With the path aligned so heading[0] = 0 matches the model's forced
|
|
163
|
+
// frame-0 facing, the generated hips reproduce the SENT angle exactly
|
|
164
|
+
// (a probe sending mirrored headings walked the second segment facing
|
|
165
|
+
// +77.7° while travelling at −77.7°). Earlier mirror-looking results
|
|
166
|
+
// came from clips whose frame-0 facing conflicted with heading[0] and
|
|
167
|
+
// corrupted the whole track. Model heading = atan2(dx, dz) of the
|
|
168
|
+
// facing/travel direction; 0 faces +Z.
|
|
169
|
+
let previousHeading = 0;
|
|
170
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
171
|
+
let dx = 0;
|
|
172
|
+
let dz = 0;
|
|
173
|
+
if (samples.length > 1) {
|
|
174
|
+
if (i === 0) {
|
|
175
|
+
dx = samples[1].x - samples[0].x;
|
|
176
|
+
dz = samples[1].z - samples[0].z;
|
|
177
|
+
} else if (i === samples.length - 1) {
|
|
178
|
+
dx = samples[i].x - samples[i - 1].x;
|
|
179
|
+
dz = samples[i].z - samples[i - 1].z;
|
|
180
|
+
} else {
|
|
181
|
+
dx = samples[i + 1].x - samples[i - 1].x;
|
|
182
|
+
dz = samples[i + 1].z - samples[i - 1].z;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (dx * dx + dz * dz >= 1e-12) previousHeading = Math.atan2(dx, dz);
|
|
186
|
+
samples[i].heading = cleanZero(previousHeading);
|
|
187
|
+
}
|
|
188
|
+
return samples;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Align an authored root path for ARDY generation: compute the clip-local
|
|
192
|
+
* first travel tangent (first non-zero authored displacement, squared
|
|
193
|
+
* length >= 1e-12) and fold it into the total rotation so the densified
|
|
194
|
+
* path starts with heading 0, the model's forced frame-0 +Z facing. The
|
|
195
|
+
* returned rotationDeg is the scene->clip total rotation; handing it to
|
|
196
|
+
* toSceneRootOffset at playback restores scene coordinates exactly.
|
|
197
|
+
*/
|
|
198
|
+
export function alignArdyPath(waypoints, actorRotationDeg = 0, maxPoints = 32) {
|
|
199
|
+
if (waypoints.length < 2) return { waypoints: [], rotationDeg: actorRotationDeg };
|
|
200
|
+
// Fold the first *sampled* travel direction into the rotation, not the
|
|
201
|
+
// authored chord: the C1 resampler bends inside the first segment, so only
|
|
202
|
+
// the dense samples know the true first step. Rotation is rigid, so
|
|
203
|
+
// densifying again with the folded rotation rotates those samples exactly
|
|
204
|
+
// and heading[0] lands on 0 by construction.
|
|
205
|
+
const probe = densifyArdyWaypoints(waypoints, actorRotationDeg, maxPoints);
|
|
206
|
+
let phi = 0;
|
|
207
|
+
for (let i = 0; i < probe.length - 1; i += 1) {
|
|
208
|
+
const dx = probe[i + 1].x - probe[i].x;
|
|
209
|
+
const dz = probe[i + 1].z - probe[i].z;
|
|
210
|
+
if (dx * dx + dz * dz >= 1e-12) {
|
|
211
|
+
phi = Math.atan2(dx, dz);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const rotationDeg = actorRotationDeg + (phi * 180) / Math.PI;
|
|
216
|
+
return {
|
|
217
|
+
waypoints: densifyArdyWaypoints(waypoints, rotationDeg, maxPoints),
|
|
218
|
+
rotationDeg,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Rotate an ARDY clip-local root offset back into CozyClay scene space. */
|
|
223
|
+
export function toSceneRootOffset(x, z, actorRotationDeg = 0) {
|
|
224
|
+
const yaw = (actorRotationDeg * Math.PI) / 180;
|
|
225
|
+
const cos = Math.cos(yaw);
|
|
226
|
+
const sin = Math.sin(yaw);
|
|
227
|
+
return {
|
|
228
|
+
x: cleanZero(cos * x + sin * z),
|
|
229
|
+
z: cleanZero(-sin * x + cos * z),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A full-body pose also constrains the root. On a root path it must share the
|
|
235
|
+
* frame-zero origin; otherwise the two constraints can pull the clip apart.
|
|
236
|
+
*/
|
|
237
|
+
export function poseConstraintFrame(requestedFrame, clipFrames, hasRootPath) {
|
|
238
|
+
if (hasRootPath) return 0;
|
|
239
|
+
return Math.max(0, Math.min(Math.round(requestedFrame) || 0, clipFrames - 1));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* ---------------------------------------------------- path limits ------ */
|
|
243
|
+
|
|
244
|
+
// What the generator can actually express, learned the hard way: waypoints
|
|
245
|
+
// are inpainting observations ARDY cannot refuse, so a request outside the
|
|
246
|
+
// training distribution collapses into foot-sliding instead of erroring.
|
|
247
|
+
// These limits keep authored paths inside that distribution.
|
|
248
|
+
export const PATH_LIMITS = Object.freeze({
|
|
249
|
+
speedMinMps: 0.5, // below this, "walking" cannot cycle a gait — feet slide
|
|
250
|
+
speedMaxMps: 3.0, // above this is out of the locomotion band entirely
|
|
251
|
+
holdEpsM: 0.05, // a displacement this small is a deliberate hold, any duration
|
|
252
|
+
minPinGapFrames: 8, // authored pins closer than this over-rail the root
|
|
253
|
+
speedRatioWarn: 2, // adjacent legs differing more than 2x force a gait change
|
|
254
|
+
turnRateWarnDegPerS: 120, // sharper turns need wider arcs than the path gives
|
|
255
|
+
tailMaxS: 2, // unconstrained tail after the last pin inherits its history
|
|
256
|
+
clipMaxS: 10, // ARDY's trained window; one-shot constrained calls cannot exceed it
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const NATURAL_WALK_MPS = 1.4;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Judge the segment a candidate pin would create after `last`. Returns
|
|
263
|
+
* { ok, error?, warnings: [] } — `error` blocks the placement and always
|
|
264
|
+
* names the fix (a workable frame or distance), `warnings` accompany a
|
|
265
|
+
* placement that is legal but will read oddly.
|
|
266
|
+
* @param {{frame:number,x:number,z:number}} last the previous pin (or the frame-0 start)
|
|
267
|
+
* @param {{frame:number,x:number,z:number}} candidate the pin being authored
|
|
268
|
+
* @param {number} fps
|
|
269
|
+
* @param {{frame:number,x:number,z:number}|null} prev the pin before `last`, for ratio/turn warnings
|
|
270
|
+
*/
|
|
271
|
+
export function judgeNextWaypoint(last, candidate, fps, prev = null) {
|
|
272
|
+
const gap = candidate.frame - last.frame;
|
|
273
|
+
if (gap < PATH_LIMITS.minPinGapFrames) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
error: `pins need at least ${PATH_LIMITS.minPinGapFrames} frames between them (${(PATH_LIMITS.minPinGapFrames / fps).toFixed(1)} s) — scrub past frame ${last.frame + PATH_LIMITS.minPinGapFrames}`,
|
|
277
|
+
warnings: [],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const dist = Math.hypot(candidate.x - last.x, candidate.z - last.z);
|
|
281
|
+
if (dist <= PATH_LIMITS.holdEpsM) return { ok: true, warnings: [] }; // a hold: legal at any length
|
|
282
|
+
const dt = gap / fps;
|
|
283
|
+
const speed = dist / dt;
|
|
284
|
+
if (speed > PATH_LIMITS.speedMaxMps) {
|
|
285
|
+
const neededFrames = Math.ceil((dist / PATH_LIMITS.speedMaxMps) * fps);
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
error: `${speed.toFixed(1)} m/s is faster than anyone moves (max ${PATH_LIMITS.speedMaxMps}) — pin frame ${last.frame + neededFrames} or later, or click closer`,
|
|
289
|
+
warnings: [],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (speed < PATH_LIMITS.speedMinMps) {
|
|
293
|
+
const walkFrame = last.frame + Math.max(PATH_LIMITS.minPinGapFrames, Math.round((dist / NATURAL_WALK_MPS) * fps));
|
|
294
|
+
const neededDist = (PATH_LIMITS.speedMinMps * dt).toFixed(1);
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
error: `${speed.toFixed(2)} m/s is below a sustainable walk (min ${PATH_LIMITS.speedMinMps}) — pin frame ~${walkFrame} instead, or click at least ${neededDist} m away`,
|
|
298
|
+
warnings: [],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const warnings = [];
|
|
302
|
+
if (prev) {
|
|
303
|
+
const prevDist = Math.hypot(last.x - prev.x, last.z - prev.z);
|
|
304
|
+
if (prevDist > PATH_LIMITS.holdEpsM) {
|
|
305
|
+
const prevSpeed = prevDist / ((last.frame - prev.frame) / fps);
|
|
306
|
+
const ratio = Math.max(speed, prevSpeed) / Math.max(Math.min(speed, prevSpeed), 1e-6);
|
|
307
|
+
if (ratio > PATH_LIMITS.speedRatioWarn) {
|
|
308
|
+
warnings.push(`${ratio.toFixed(1)}x speed change from the previous leg — expect a visible gait shift`);
|
|
309
|
+
}
|
|
310
|
+
const before = Math.atan2(last.x - prev.x, last.z - prev.z);
|
|
311
|
+
const after = Math.atan2(candidate.x - last.x, candidate.z - last.z);
|
|
312
|
+
let turn = Math.abs(after - before);
|
|
313
|
+
if (turn > Math.PI) turn = 2 * Math.PI - turn;
|
|
314
|
+
const turnRate = (turn * 180) / Math.PI / dt;
|
|
315
|
+
if (turnRate > PATH_LIMITS.turnRateWarnDegPerS) {
|
|
316
|
+
warnings.push(`${Math.round((turn * 180) / Math.PI)}° turn in ${dt.toFixed(1)} s is sharper than a walking arc — give it more frames or distance`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { ok: true, warnings };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Re-judge a whole authored path (frame-0 start included) before generation:
|
|
325
|
+
* placement-time checks can be invalidated later by removing a middle pin.
|
|
326
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
327
|
+
*/
|
|
328
|
+
export function judgeAuthoredPath(rootPath, fps, clipFrames, { chained = false } = {}) {
|
|
329
|
+
const errors = [];
|
|
330
|
+
const warnings = [];
|
|
331
|
+
const ordered = [...rootPath].sort((a, b) => a.frame - b.frame);
|
|
332
|
+
for (let i = 1; i < ordered.length; i += 1) {
|
|
333
|
+
const verdict = judgeNextWaypoint(ordered[i - 1], ordered[i], fps, i >= 2 ? ordered[i - 2] : null);
|
|
334
|
+
if (!verdict.ok) errors.push(`leg ${i} (frame ${ordered[i - 1].frame}->${ordered[i].frame}): ${verdict.error}`);
|
|
335
|
+
else for (const warning of verdict.warnings) warnings.push(`leg ${i}: ${warning}`);
|
|
336
|
+
}
|
|
337
|
+
// The trained window binds ONE model call. A chained rollout (a prompt
|
|
338
|
+
// schedule) makes a call per block, so the whole clip may exceed it —
|
|
339
|
+
// each block is capped separately by the bridge and the box generator.
|
|
340
|
+
if (!chained && clipFrames / fps > PATH_LIMITS.clipMaxS) {
|
|
341
|
+
errors.push(
|
|
342
|
+
`a root path generates the whole clip in one model call, and ARDY's trained window is ${PATH_LIMITS.clipMaxS} s — shorten the clip to ${PATH_LIMITS.clipMaxS} s, or split the prompt into blocks so the path rides a chained rollout`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const tail = (clipFrames - 1 - (ordered[ordered.length - 1]?.frame ?? 0)) / fps;
|
|
346
|
+
if (ordered.length > 1 && tail > PATH_LIMITS.tailMaxS) {
|
|
347
|
+
warnings.push(
|
|
348
|
+
`${tail.toFixed(1)} s of clip remain after the last pin — the unconstrained tail continues whatever the path ends in`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return { errors, warnings };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Place a newly-authored keyframe where it is immediately visible and draggable. */
|
|
355
|
+
export function defaultWaypointPosition(waypoints, subject) {
|
|
356
|
+
if (!waypoints.length) return { x: subject.x, z: subject.z };
|
|
357
|
+
const last = waypoints[waypoints.length - 1];
|
|
358
|
+
if (waypoints.length > 1) {
|
|
359
|
+
const previous = waypoints[waypoints.length - 2];
|
|
360
|
+
return { x: last.x + (last.x - previous.x), z: last.z + (last.z - previous.z) };
|
|
361
|
+
}
|
|
362
|
+
const yaw = (subject.rot * Math.PI) / 180;
|
|
363
|
+
return { x: last.x + Math.sin(yaw), z: last.z + Math.cos(yaw) };
|
|
364
|
+
}
|