@melite/formats 0.0.0-stage → 0.1.5-pre-alpha
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 +557 -0
- package/README.md +37 -2
- package/lib/b64.d.ts +2 -0
- package/lib/b64.js +7 -0
- package/lib/bodyPresets.d.ts +40 -0
- package/lib/bodyPresets.js +77 -0
- package/lib/captionSplitter.d.ts +49 -0
- package/lib/captionSplitter.js +172 -0
- package/lib/cardMutations.d.ts +52 -0
- package/lib/cardMutations.js +67 -0
- package/lib/downloads.d.ts +18 -0
- package/lib/downloads.js +64 -0
- package/lib/extractVideoFrame.d.ts +23 -0
- package/lib/extractVideoFrame.js +114 -0
- package/lib/glbLoad.d.ts +54 -0
- package/lib/glbLoad.js +96 -0
- package/lib/glbSkin.d.ts +14 -0
- package/lib/glbSkin.js +124 -0
- package/lib/handPose.d.ts +46 -0
- package/lib/handPose.js +122 -0
- package/lib/index.d.ts +25 -0
- package/lib/index.js +25 -0
- package/lib/keyframeExtract.d.ts +46 -0
- package/lib/keyframeExtract.js +89 -0
- package/lib/maskPrep.d.ts +88 -0
- package/lib/maskPrep.js +386 -0
- package/lib/motionConstraints.d.ts +237 -0
- package/lib/motionConstraints.js +369 -0
- package/lib/motionEnvelope.d.ts +56 -0
- package/lib/motionEnvelope.js +59 -0
- package/lib/motionNpz.d.ts +120 -0
- package/lib/motionNpz.js +497 -0
- package/lib/npz.d.ts +24 -0
- package/lib/npz.js +158 -0
- package/lib/openPoseCanvas.d.ts +17 -0
- package/lib/openPoseCanvas.js +90 -0
- package/lib/pngMeta.d.ts +42 -0
- package/lib/pngMeta.js +212 -0
- package/lib/pngMetaInfo.d.ts +14 -0
- package/lib/pngMetaInfo.js +127 -0
- package/lib/poseCodec.d.ts +20 -0
- package/lib/poseCodec.js +61 -0
- package/lib/poseTransforms.d.ts +66 -0
- package/lib/poseTransforms.js +227 -0
- package/lib/poserSkin.d.ts +75 -0
- package/lib/poserSkin.js +141 -0
- package/lib/prompt-tags.json +132 -0
- package/lib/promptTags.d.ts +42 -0
- package/lib/promptTags.js +116 -0
- package/lib/psdExport.d.ts +28 -0
- package/lib/psdExport.js +272 -0
- package/lib/readFile.d.ts +9 -0
- package/lib/readFile.js +16 -0
- package/lib/renderSnapshot.d.ts +34 -0
- package/lib/renderSnapshot.js +55 -0
- package/lib/somaSkin.d.ts +82 -0
- package/lib/somaSkin.js +267 -0
- package/lib/textToPose.d.ts +394 -0
- package/lib/textToPose.js +607 -0
- package/lib/uploadToEstate.d.ts +25 -0
- package/lib/uploadToEstate.js +97 -0
- package/package.json +149 -4
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
export declare const SOMA_ROOT_IDX = 0;
|
|
2
|
+
export declare const SOMA_JOINT: {
|
|
3
|
+
readonly LeftHand: 14;
|
|
4
|
+
readonly RightHand: 42;
|
|
5
|
+
readonly LeftFoot: 69;
|
|
6
|
+
readonly RightFoot: 74;
|
|
7
|
+
};
|
|
8
|
+
export declare const TRACK_TO_EE_JOINT: Record<string, EEJointName>;
|
|
9
|
+
export type EEJointName = "LeftHand" | "RightHand" | "LeftFoot" | "RightFoot";
|
|
10
|
+
/** A single keyframe's constraint payload.
|
|
11
|
+
*
|
|
12
|
+
* jp/jr are base64-encoded little-endian float32 of the FULL 77-joint pose
|
|
13
|
+
* (jp: 77*3=231 floats=924 bytes; jr: 77*9=693 floats=2772 bytes). The full
|
|
14
|
+
* pose is stored even for end-effector lanes because kimodo's
|
|
15
|
+
* EndEffectorConstraintSet indexes into the FULL skeleton
|
|
16
|
+
* (constraints.py:351 — skeleton.bone_index[jname]) — passing a single
|
|
17
|
+
* joint's position would crash the index lookup. This mirrors the demo,
|
|
18
|
+
* which stores joints_pos/joints_rot (full pose) per keyframe
|
|
19
|
+
* (ui.py:2752-2755). */
|
|
20
|
+
export type MotionConstraint = {
|
|
21
|
+
kind: "fullbody";
|
|
22
|
+
frame: number;
|
|
23
|
+
jp: string;
|
|
24
|
+
jr: string;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "ee";
|
|
27
|
+
frame: number;
|
|
28
|
+
joint: EEJointName;
|
|
29
|
+
jp: string;
|
|
30
|
+
jr: string;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "root2d";
|
|
33
|
+
frame: number;
|
|
34
|
+
x: number;
|
|
35
|
+
z: number;
|
|
36
|
+
};
|
|
37
|
+
/** The transport + persistence shape (JSON in stage.params.motion_constraints).
|
|
38
|
+
*
|
|
39
|
+
* - keyframes: the viser timeline dot metadata (uuid/track/frame) so the bar
|
|
40
|
+
* reconstructs on reload. Backend ignores this field.
|
|
41
|
+
* - fullbody / end_effectors / root2d: per-track constraint arrays, sorted
|
|
42
|
+
* ascending by frame. These are what melite-kimodo-nodes deserializes into
|
|
43
|
+
* FullBodyConstraintSet / EndEffectorConstraintSet / Root2DConstraintSet. */
|
|
44
|
+
export interface MotionConstraintsJSON {
|
|
45
|
+
keyframes: {
|
|
46
|
+
uuid: string;
|
|
47
|
+
track_id: string;
|
|
48
|
+
frame: number;
|
|
49
|
+
}[];
|
|
50
|
+
fullbody?: {
|
|
51
|
+
frame: number;
|
|
52
|
+
jp: string;
|
|
53
|
+
jr: string;
|
|
54
|
+
}[];
|
|
55
|
+
end_effectors?: {
|
|
56
|
+
frame: number;
|
|
57
|
+
joint: EEJointName;
|
|
58
|
+
jp: string;
|
|
59
|
+
jr: string;
|
|
60
|
+
}[];
|
|
61
|
+
root2d?: {
|
|
62
|
+
frame: number;
|
|
63
|
+
x: number;
|
|
64
|
+
z: number;
|
|
65
|
+
}[];
|
|
66
|
+
}
|
|
67
|
+
export declare const MOTION_CONSTRAINTS_PARAM = "motion_constraints";
|
|
68
|
+
export declare const MOTION_PROMPTS_PARAM = "motion_prompts";
|
|
69
|
+
/** The runtime motion a snapshot reads from. Float32Array views into the NPZ
|
|
70
|
+
* (posedJoints: T*77*3 m, globalRotMats: T*77*9 row-major). */
|
|
71
|
+
export interface ConstraintSnapshotSource {
|
|
72
|
+
posedJoints: Float32Array;
|
|
73
|
+
globalRotMats: Float32Array;
|
|
74
|
+
T: number;
|
|
75
|
+
}
|
|
76
|
+
/** Encode a Float32Array to base64 (little-endian bytes). Round-trips with
|
|
77
|
+
* numpy: np.frombuffer(base64.b64decode(s), dtype='<f4'). */
|
|
78
|
+
export declare function encodeF32(arr: Float32Array): string;
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
export declare function decodeF32(b64: string): Float32Array;
|
|
83
|
+
/** AUTHOR a fullbody constraint from a user-edited pose (the WRITE path).
|
|
84
|
+
*
|
|
85
|
+
* This is the counterpart to snapshotConstraint: snapshotConstraint READS a
|
|
86
|
+
* pose from the motion NPZ; stampEditedPose WRITES a user-authored pose
|
|
87
|
+
* into a constraint payload. ``editedJointsM`` is the full 77-joint pose in
|
|
88
|
+
* meters (the output of the edit-mode gizmos — what the user dragged into
|
|
89
|
+
* being). ``patchedGlobalRotMats`` is the rotation field reconstructed by
|
|
90
|
+
* ``patchFrame`` (motionNpz.ts) so children follow the edit — pass either
|
|
91
|
+
* the full T·77·9 array (frameIdx selects the slice) or a single-frame
|
|
92
|
+
* 77·9 slice (frameIdx 0 is used).
|
|
93
|
+
*
|
|
94
|
+
* The returned payload is what a fullbody keyframe carries: when it reaches
|
|
95
|
+
* melite-kimodo-nodes, ``_build_user_constraints`` decodes jp/jr into a
|
|
96
|
+
* ``FullBodyConstraintSet`` that holds the AUTHORED pose at that frame,
|
|
97
|
+
* bending generation to match. This is the bar being WRITABLE — you drag a
|
|
98
|
+
* joint, the keyframe at that frame absorbs the new pose, Run feeds it as a
|
|
99
|
+
* constraint. Parity with the demo's edit-then-snap authoring flow
|
|
100
|
+
* (ui.py: ``update_pose_at_frame`` → constraint keyframes update). */
|
|
101
|
+
export declare function stampEditedPose(editedJointsM: Float32Array, patchedGlobalRotMats: Float32Array, frameIdx: number, totalFrames: number): MotionConstraint;
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
export declare function snapshotConstraint(trackId: string, frame: number, src: ConstraintSnapshotSource): MotionConstraint | null;
|
|
125
|
+
/** Gather the viser keyframe metadata + payloads into the transport JSON.
|
|
126
|
+
*
|
|
127
|
+
* Keyframes whose uuid has no payload (orphan viser dots from the old
|
|
128
|
+
* null-shell era) are dropped from the backend arrays but kept in
|
|
129
|
+
* `keyframes` only if they carry a payload — so stale dots don't leak
|
|
130
|
+
* broken constraints to the node. Sorted ascending by frame per track. */
|
|
131
|
+
export declare function serializeConstraints(keyframes: {
|
|
132
|
+
uuid: string;
|
|
133
|
+
track_id: string;
|
|
134
|
+
frame: number;
|
|
135
|
+
}[], payloads: Record<string, MotionConstraint>): MotionConstraintsJSON;
|
|
136
|
+
/** Reconstruct viser keyframes + payloads from a persisted/transported JSON.
|
|
137
|
+
*
|
|
138
|
+
* Tolerant of missing/malformed fields (a corrupt or partially-migrated
|
|
139
|
+
* stage.params.motion_constraints degrades to null → the card starts fresh
|
|
140
|
+
* with empty constraints, never crashes on hydrate). Returns null if the
|
|
141
|
+
* shape is unrecognized. */
|
|
142
|
+
export declare function hydrateConstraints(raw: unknown): {
|
|
143
|
+
keyframes: {
|
|
144
|
+
uuid: string;
|
|
145
|
+
track_id: string;
|
|
146
|
+
frame: number;
|
|
147
|
+
value: null;
|
|
148
|
+
opacity: number;
|
|
149
|
+
locked: boolean;
|
|
150
|
+
}[];
|
|
151
|
+
payloads: Record<string, MotionConstraint>;
|
|
152
|
+
} | null;
|
|
153
|
+
/** Carve a NEW empty prompt card out of the timeline's prompt lane so the
|
|
154
|
+
* user can type a distinct motion description for that segment — the
|
|
155
|
+
* demo's PRIMARY authoring surface (multi-prompt composition, one clip
|
|
156
|
+
* stitched from a sequence of text "cards").
|
|
157
|
+
*
|
|
158
|
+
* WHY THIS EXISTS: viser's built-in click-to-append path (Timeline.tsx)
|
|
159
|
+
* only fires when the click lands at or past the last prompt's end_frame.
|
|
160
|
+
* After MotionCardEditor seeds one prompt spanning ``[0, T-1]`` (the whole
|
|
161
|
+
* bar, edge-to-edge — the demo behavior), there is NO frame ≥ last.end_frame
|
|
162
|
+
* reachable inside the bar, so appending is impossible and the user "can
|
|
163
|
+
* only put in dots" (keyframe dots, on the constraint lanes). This helper
|
|
164
|
+
* is the explicit, always-reachable "add a card" affordance.
|
|
165
|
+
*
|
|
166
|
+
* Strategy — split the LAST prompt (highest end_frame) at its midpoint into
|
|
167
|
+
* two contiguous segments; the RIGHT half becomes a new EMPTY card
|
|
168
|
+
* (``text: ""``) the user types into, the LEFT half keeps the original
|
|
169
|
+
* text. The lane stays edge-to-edge (no gaps) and the new card is
|
|
170
|
+
* immediately editable. When there are no prompts, seeds one empty card
|
|
171
|
+
* spanning ``[0, T-1]``. When the last prompt is too narrow to split
|
|
172
|
+
* (≤1 frame wide), appends a 1-frame card after it.
|
|
173
|
+
*
|
|
174
|
+
* Pure: returns a NEW array, never mutates the input. ``newId`` is
|
|
175
|
+
* injectable for deterministic tests; defaults to a crypto-random UUID
|
|
176
|
+
* (same shape viser's makeTimelineId produces). */
|
|
177
|
+
export declare function carveNewPromptCard(prompts: {
|
|
178
|
+
uuid: string;
|
|
179
|
+
text: string;
|
|
180
|
+
start_frame: number;
|
|
181
|
+
end_frame: number;
|
|
182
|
+
color: [number, number, number] | null;
|
|
183
|
+
}[], totalFrames: number, newId?: () => string): {
|
|
184
|
+
uuid: string;
|
|
185
|
+
text: string;
|
|
186
|
+
start_frame: number;
|
|
187
|
+
end_frame: number;
|
|
188
|
+
color: [number, number, number] | null;
|
|
189
|
+
}[];
|
|
190
|
+
/** Serialize the timeline's PROMPT segments into the multi-prompt transport
|
|
191
|
+
* JSON that melite-kimodo-nodes consumes. This is the demo's PRIMARY authoring
|
|
192
|
+
* surface — each prompt segment is a motion "card" (a text description over
|
|
193
|
+
* a frame range), and the model composes a multi-segment motion that follows
|
|
194
|
+
* the sequence (kimodo_model.py _generate iterates the list with transition
|
|
195
|
+
* blending between segments).
|
|
196
|
+
*
|
|
197
|
+
* Mirrors demo/ui.py:1646-1659: segments are sorted ascending by start_frame;
|
|
198
|
+
* per-segment duration in seconds follows the demo's convention (non-last
|
|
199
|
+
* segments use ``[start, end)`` = ``end - start`` frames; the LAST segment
|
|
200
|
+
* uses ``[start, end]`` inclusive = ``end - start + 1`` frames). Returns the
|
|
201
|
+
* empty string when fewer than 2 non-empty segments survive — the single
|
|
202
|
+
* ``prompt`` param handles the 0/1-segment case, and the node's override
|
|
203
|
+
* threshold is >=2 segments (so an empty payload here is the honest "no
|
|
204
|
+
* multi-prompt authoring yet" signal, not silent data loss). */
|
|
205
|
+
export declare function serializeMotionPrompts(segments: {
|
|
206
|
+
text: string;
|
|
207
|
+
start_frame: number;
|
|
208
|
+
end_frame: number;
|
|
209
|
+
}[], fps: number): string;
|
|
210
|
+
/** Reverse of serializeMotionPrompts: rebuild timeline prompt cards from the
|
|
211
|
+
* persisted `motion_prompts` JSON (an array of `{ text, duration_sec }`).
|
|
212
|
+
* Returns null if the payload is missing/corrupt/empty — the caller seeds a
|
|
213
|
+
* blank card in that case. Frame boundaries are reconstructed by accumulating
|
|
214
|
+
* durations from frame 0 — the exact boundaries may differ slightly from the
|
|
215
|
+
* original (the serialization rounds), but the TEXT and the segment COUNT are
|
|
216
|
+
* preserved, which is what matters for state sync across reopens. */
|
|
217
|
+
export declare function hydrateMotionPrompts(raw: unknown, fps: number, totalFrames: number): {
|
|
218
|
+
text: string;
|
|
219
|
+
start_frame: number;
|
|
220
|
+
end_frame: number;
|
|
221
|
+
}[] | null;
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
export declare function untanglePrompts<T extends {
|
|
234
|
+
start_frame: number;
|
|
235
|
+
end_frame: number;
|
|
236
|
+
}>(segments: T[]): T[];
|
|
237
|
+
//# sourceMappingURL=motionConstraints.d.ts.map
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// Motion constraint snapshot + serialization — the FUNCTIONAL CORE of the
|
|
5
|
+
// timeline bar. Each keyframe on the 6 lanes (Full-Body / 2D Root / L/R
|
|
6
|
+
// Hand / L/R Foot) carries a REAL pose constraint snapshotted from the
|
|
7
|
+
// current motion NPZ, in the exact format kimodo's constraint classes
|
|
8
|
+
// consume (SOMA-77 order, meters). serializeConstraints() produces the
|
|
9
|
+
// JSON payload that:
|
|
10
|
+
// 1. PERSISTS in stage.params.motion_constraints (survives reloads — the
|
|
11
|
+
// flow UX "persist always" hard rule), AND
|
|
12
|
+
// 2. TRANSPORTS to melite-kimodo-nodes via the motion_constraints flow
|
|
13
|
+
// param, where the node deserializes it into FullBodyConstraintSet /
|
|
14
|
+
// EndEffectorConstraintSet / Root2DConstraintSet and feeds raw()'s
|
|
15
|
+
// constraint_lst.
|
|
16
|
+
//
|
|
17
|
+
// This is what makes the bar DRIVE generation — matching the Kimodo
|
|
18
|
+
// demo's session.constraints → compute_model_constraints_lst (demo/ui.py:994
|
|
19
|
+
// → generation.py:24-111). The demo builds constraint objects in-process;
|
|
20
|
+
// we serialize → transport → deserialize because the editor is remote from
|
|
21
|
+
// the kimodo library. The byte contract is identical: jp/jr are base64
|
|
22
|
+
// little-endian float32, SOMA-77 joint order, meters — the same arrays
|
|
23
|
+
// vendor/kimodo passes to FullBodyConstraintSet(model_skel, frame_idx,
|
|
24
|
+
// pos, rot, root2d).
|
|
25
|
+
import { encodeBufferToB64 } from "./poseCodec.js";
|
|
26
|
+
// SOMA-77 joint indices (somaskel77_standard_tpose.bvh declaration order).
|
|
27
|
+
// Verified against lib/motionNpz.ts SOMA_TO_COCO (LeftHand=14, RightHand=42,
|
|
28
|
+
// LeftFoot=69, RightFoot=74) + PoseEditMode.END_EFFECTOR_JOINTS +
|
|
29
|
+
// PoseViewer3D.tsx:502 (LeftFoot=69, RightFoot=74). Hips (root) is index 0.
|
|
30
|
+
export const SOMA_ROOT_IDX = 0;
|
|
31
|
+
export const SOMA_JOINT = {
|
|
32
|
+
LeftHand: 14,
|
|
33
|
+
RightHand: 42,
|
|
34
|
+
LeftFoot: 69,
|
|
35
|
+
RightFoot: 74,
|
|
36
|
+
};
|
|
37
|
+
// Timeline lane UUID → kimodo end-effector joint name.
|
|
38
|
+
// Keys match MOTION_TIMELINE_TRACKS uuids (motionTimelineState.ts).
|
|
39
|
+
export const TRACK_TO_EE_JOINT = {
|
|
40
|
+
"left-hand": "LeftHand",
|
|
41
|
+
"right-hand": "RightHand",
|
|
42
|
+
"left-foot": "LeftFoot",
|
|
43
|
+
"right-foot": "RightFoot",
|
|
44
|
+
};
|
|
45
|
+
export const MOTION_CONSTRAINTS_PARAM = "motion_constraints";
|
|
46
|
+
export const MOTION_PROMPTS_PARAM = "motion_prompts";
|
|
47
|
+
/** Encode a Float32Array to base64 (little-endian bytes). Round-trips with
|
|
48
|
+
* numpy: np.frombuffer(base64.b64decode(s), dtype='<f4'). */
|
|
49
|
+
export function encodeF32(arr) {
|
|
50
|
+
return encodeBufferToB64(new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
export function decodeF32(b64) {
|
|
56
|
+
const bin = typeof atob !== "undefined" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
57
|
+
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
58
|
+
return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4);
|
|
59
|
+
}
|
|
60
|
+
function frameSlice(src, frame) {
|
|
61
|
+
const T = Math.max(1, src.T);
|
|
62
|
+
const f = Math.max(0, Math.min(T - 1, Math.floor(frame)));
|
|
63
|
+
return {
|
|
64
|
+
jp: src.posedJoints.subarray(f * 77 * 3, (f + 1) * 77 * 3),
|
|
65
|
+
jr: src.globalRotMats.subarray(f * 77 * 9, (f + 1) * 77 * 9),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** AUTHOR a fullbody constraint from a user-edited pose (the WRITE path).
|
|
69
|
+
*
|
|
70
|
+
* This is the counterpart to snapshotConstraint: snapshotConstraint READS a
|
|
71
|
+
* pose from the motion NPZ; stampEditedPose WRITES a user-authored pose
|
|
72
|
+
* into a constraint payload. ``editedJointsM`` is the full 77-joint pose in
|
|
73
|
+
* meters (the output of the edit-mode gizmos — what the user dragged into
|
|
74
|
+
* being). ``patchedGlobalRotMats`` is the rotation field reconstructed by
|
|
75
|
+
* ``patchFrame`` (motionNpz.ts) so children follow the edit — pass either
|
|
76
|
+
* the full T·77·9 array (frameIdx selects the slice) or a single-frame
|
|
77
|
+
* 77·9 slice (frameIdx 0 is used).
|
|
78
|
+
*
|
|
79
|
+
* The returned payload is what a fullbody keyframe carries: when it reaches
|
|
80
|
+
* melite-kimodo-nodes, ``_build_user_constraints`` decodes jp/jr into a
|
|
81
|
+
* ``FullBodyConstraintSet`` that holds the AUTHORED pose at that frame,
|
|
82
|
+
* bending generation to match. This is the bar being WRITABLE — you drag a
|
|
83
|
+
* joint, the keyframe at that frame absorbs the new pose, Run feeds it as a
|
|
84
|
+
* constraint. Parity with the demo's edit-then-snap authoring flow
|
|
85
|
+
* (ui.py: ``update_pose_at_frame`` → constraint keyframes update). */
|
|
86
|
+
export function stampEditedPose(editedJointsM, patchedGlobalRotMats, frameIdx, totalFrames) {
|
|
87
|
+
const f = Math.max(0, Math.min(Math.max(0, totalFrames - 1), Math.floor(frameIdx)));
|
|
88
|
+
// Accept either a full T·77·9 array (patchFrame's raw output) or a single
|
|
89
|
+
// 77·9 slice. The single-frame shape lets callers subarray before passing.
|
|
90
|
+
const jrSlice = patchedGlobalRotMats.length >= 77 * 9 * 2
|
|
91
|
+
? patchedGlobalRotMats.subarray(f * 77 * 9, (f + 1) * 77 * 9)
|
|
92
|
+
: patchedGlobalRotMats;
|
|
93
|
+
return { kind: "fullbody", frame: f, jp: encodeF32(editedJointsM), jr: encodeF32(jrSlice) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
export function snapshotConstraint(trackId, frame, src) {
|
|
118
|
+
const f = Math.max(0, Math.floor(frame));
|
|
119
|
+
if (trackId === "fullbody") {
|
|
120
|
+
const { jp, jr } = frameSlice(src, f);
|
|
121
|
+
return { kind: "fullbody", frame: f, jp: encodeF32(jp), jr: encodeF32(jr) };
|
|
122
|
+
}
|
|
123
|
+
if (trackId === "root2d") {
|
|
124
|
+
const { jp } = frameSlice(src, f);
|
|
125
|
+
const rx = SOMA_ROOT_IDX * 3;
|
|
126
|
+
// Root XZ — joint 0 (Hips), components x and z. Matches the demo's
|
|
127
|
+
// smooth_root_pos_2d = root_pos[:, [0, 2]] (generation.py:61).
|
|
128
|
+
return { kind: "root2d", frame: f, x: jp[rx], z: jp[rx + 2] };
|
|
129
|
+
}
|
|
130
|
+
const joint = TRACK_TO_EE_JOINT[trackId];
|
|
131
|
+
if (joint) {
|
|
132
|
+
const { jp, jr } = frameSlice(src, f);
|
|
133
|
+
return { kind: "ee", frame: f, joint, jp: encodeF32(jp), jr: encodeF32(jr) };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
function sortByFrame(xs) {
|
|
138
|
+
return xs.slice().sort((a, b) => a.frame - b.frame);
|
|
139
|
+
}
|
|
140
|
+
/** Gather the viser keyframe metadata + payloads into the transport JSON.
|
|
141
|
+
*
|
|
142
|
+
* Keyframes whose uuid has no payload (orphan viser dots from the old
|
|
143
|
+
* null-shell era) are dropped from the backend arrays but kept in
|
|
144
|
+
* `keyframes` only if they carry a payload — so stale dots don't leak
|
|
145
|
+
* broken constraints to the node. Sorted ascending by frame per track. */
|
|
146
|
+
export function serializeConstraints(keyframes, payloads) {
|
|
147
|
+
// Only keyframes with a real payload survive into the transport.
|
|
148
|
+
const live = keyframes.filter((k) => payloads[k.uuid]);
|
|
149
|
+
const out = { keyframes: live.map(({ uuid, track_id, frame }) => ({ uuid, track_id, frame })) };
|
|
150
|
+
const all = live.map((k) => payloads[k.uuid]);
|
|
151
|
+
const fb = sortByFrame(all
|
|
152
|
+
.filter((p) => p.kind === "fullbody")
|
|
153
|
+
.map(({ frame, jp, jr }) => ({ frame, jp, jr })));
|
|
154
|
+
if (fb.length)
|
|
155
|
+
out.fullbody = fb;
|
|
156
|
+
const ee = sortByFrame(all
|
|
157
|
+
.filter((p) => p.kind === "ee")
|
|
158
|
+
.map(({ frame, joint, jp, jr }) => ({ frame, joint, jp, jr })));
|
|
159
|
+
if (ee.length)
|
|
160
|
+
out.end_effectors = ee;
|
|
161
|
+
const r2 = sortByFrame(all
|
|
162
|
+
.filter((p) => p.kind === "root2d")
|
|
163
|
+
.map(({ frame, x, z }) => ({ frame, x, z })));
|
|
164
|
+
if (r2.length)
|
|
165
|
+
out.root2d = r2;
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/** Reconstruct viser keyframes + payloads from a persisted/transported JSON.
|
|
169
|
+
*
|
|
170
|
+
* Tolerant of missing/malformed fields (a corrupt or partially-migrated
|
|
171
|
+
* stage.params.motion_constraints degrades to null → the card starts fresh
|
|
172
|
+
* with empty constraints, never crashes on hydrate). Returns null if the
|
|
173
|
+
* shape is unrecognized. */
|
|
174
|
+
export function hydrateConstraints(raw) {
|
|
175
|
+
if (!raw || typeof raw !== "object")
|
|
176
|
+
return null;
|
|
177
|
+
const json = raw;
|
|
178
|
+
if (!Array.isArray(json.keyframes))
|
|
179
|
+
return null;
|
|
180
|
+
const payloads = {};
|
|
181
|
+
const dots = [];
|
|
182
|
+
// Rebuild payloads from the per-track arrays (the source of truth), then
|
|
183
|
+
// cross-link viser dots by uuid. A dot whose uuid has no payload is
|
|
184
|
+
// dropped (stale null-shell leftover).
|
|
185
|
+
const byFrame = (xs) => xs ?? [];
|
|
186
|
+
for (const e of byFrame(json.fullbody)) {
|
|
187
|
+
const uuid = json.keyframes.find((k) => k.track_id === "fullbody" && k.frame === e.frame)?.uuid;
|
|
188
|
+
if (!uuid)
|
|
189
|
+
continue;
|
|
190
|
+
payloads[uuid] = { kind: "fullbody", frame: e.frame, jp: e.jp, jr: e.jr };
|
|
191
|
+
}
|
|
192
|
+
for (const e of byFrame(json.end_effectors)) {
|
|
193
|
+
const uuid = json.keyframes.find((k) => k.track_id && TRACK_TO_EE_JOINT[k.track_id] === e.joint && k.frame === e.frame)?.uuid;
|
|
194
|
+
if (!uuid)
|
|
195
|
+
continue;
|
|
196
|
+
payloads[uuid] = { kind: "ee", frame: e.frame, joint: e.joint, jp: e.jp, jr: e.jr };
|
|
197
|
+
}
|
|
198
|
+
for (const e of byFrame(json.root2d)) {
|
|
199
|
+
const uuid = json.keyframes.find((k) => k.track_id === "root2d" && k.frame === e.frame)?.uuid;
|
|
200
|
+
if (!uuid)
|
|
201
|
+
continue;
|
|
202
|
+
payloads[uuid] = { kind: "root2d", frame: e.frame, x: e.x, z: e.z };
|
|
203
|
+
}
|
|
204
|
+
for (const k of json.keyframes) {
|
|
205
|
+
if (!payloads[k.uuid])
|
|
206
|
+
continue;
|
|
207
|
+
dots.push({ uuid: k.uuid, track_id: k.track_id, frame: k.frame, value: null, opacity: 1, locked: false });
|
|
208
|
+
}
|
|
209
|
+
return { keyframes: dots, payloads };
|
|
210
|
+
}
|
|
211
|
+
/** Carve a NEW empty prompt card out of the timeline's prompt lane so the
|
|
212
|
+
* user can type a distinct motion description for that segment — the
|
|
213
|
+
* demo's PRIMARY authoring surface (multi-prompt composition, one clip
|
|
214
|
+
* stitched from a sequence of text "cards").
|
|
215
|
+
*
|
|
216
|
+
* WHY THIS EXISTS: viser's built-in click-to-append path (Timeline.tsx)
|
|
217
|
+
* only fires when the click lands at or past the last prompt's end_frame.
|
|
218
|
+
* After MotionCardEditor seeds one prompt spanning ``[0, T-1]`` (the whole
|
|
219
|
+
* bar, edge-to-edge — the demo behavior), there is NO frame ≥ last.end_frame
|
|
220
|
+
* reachable inside the bar, so appending is impossible and the user "can
|
|
221
|
+
* only put in dots" (keyframe dots, on the constraint lanes). This helper
|
|
222
|
+
* is the explicit, always-reachable "add a card" affordance.
|
|
223
|
+
*
|
|
224
|
+
* Strategy — split the LAST prompt (highest end_frame) at its midpoint into
|
|
225
|
+
* two contiguous segments; the RIGHT half becomes a new EMPTY card
|
|
226
|
+
* (``text: ""``) the user types into, the LEFT half keeps the original
|
|
227
|
+
* text. The lane stays edge-to-edge (no gaps) and the new card is
|
|
228
|
+
* immediately editable. When there are no prompts, seeds one empty card
|
|
229
|
+
* spanning ``[0, T-1]``. When the last prompt is too narrow to split
|
|
230
|
+
* (≤1 frame wide), appends a 1-frame card after it.
|
|
231
|
+
*
|
|
232
|
+
* Pure: returns a NEW array, never mutates the input. ``newId`` is
|
|
233
|
+
* injectable for deterministic tests; defaults to a crypto-random UUID
|
|
234
|
+
* (same shape viser's makeTimelineId produces). */
|
|
235
|
+
export function carveNewPromptCard(prompts, totalFrames, newId = defaultNewId) {
|
|
236
|
+
const T = Math.max(1, Math.floor(totalFrames));
|
|
237
|
+
if (prompts.length === 0) {
|
|
238
|
+
return [{ uuid: newId(), text: "", start_frame: 0, end_frame: T - 1, color: null }];
|
|
239
|
+
}
|
|
240
|
+
const sorted = [...prompts].sort((a, b) => a.start_frame - b.start_frame);
|
|
241
|
+
const last = sorted[sorted.length - 1];
|
|
242
|
+
const mid = Math.floor((last.start_frame + last.end_frame) / 2);
|
|
243
|
+
// Too narrow to split (≤1 frame wide) — append a minimum card instead.
|
|
244
|
+
// Keeps the "add always succeeds" contract; the serializer clamps the
|
|
245
|
+
// duration to ≥1 frame so a 1-frame card is valid input to the model.
|
|
246
|
+
if (mid <= last.start_frame || mid >= last.end_frame) {
|
|
247
|
+
const newStart = last.end_frame + 1;
|
|
248
|
+
const appended = { uuid: newId(), text: "", start_frame: newStart, end_frame: newStart, color: null };
|
|
249
|
+
return [...prompts, appended];
|
|
250
|
+
}
|
|
251
|
+
const left = { ...last, end_frame: mid };
|
|
252
|
+
const right = { uuid: newId(), text: "", start_frame: mid, end_frame: last.end_frame, color: null };
|
|
253
|
+
return prompts.flatMap((p) => (p.uuid === last.uuid ? [left, right] : [p]));
|
|
254
|
+
}
|
|
255
|
+
function defaultNewId() {
|
|
256
|
+
const c = typeof crypto !== "undefined" ? crypto : undefined;
|
|
257
|
+
if (c && "randomUUID" in c)
|
|
258
|
+
return c.randomUUID();
|
|
259
|
+
return "tl-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
260
|
+
}
|
|
261
|
+
/** Serialize the timeline's PROMPT segments into the multi-prompt transport
|
|
262
|
+
* JSON that melite-kimodo-nodes consumes. This is the demo's PRIMARY authoring
|
|
263
|
+
* surface — each prompt segment is a motion "card" (a text description over
|
|
264
|
+
* a frame range), and the model composes a multi-segment motion that follows
|
|
265
|
+
* the sequence (kimodo_model.py _generate iterates the list with transition
|
|
266
|
+
* blending between segments).
|
|
267
|
+
*
|
|
268
|
+
* Mirrors demo/ui.py:1646-1659: segments are sorted ascending by start_frame;
|
|
269
|
+
* per-segment duration in seconds follows the demo's convention (non-last
|
|
270
|
+
* segments use ``[start, end)`` = ``end - start`` frames; the LAST segment
|
|
271
|
+
* uses ``[start, end]`` inclusive = ``end - start + 1`` frames). Returns the
|
|
272
|
+
* empty string when fewer than 2 non-empty segments survive — the single
|
|
273
|
+
* ``prompt`` param handles the 0/1-segment case, and the node's override
|
|
274
|
+
* threshold is >=2 segments (so an empty payload here is the honest "no
|
|
275
|
+
* multi-prompt authoring yet" signal, not silent data loss). */
|
|
276
|
+
export function serializeMotionPrompts(segments, fps) {
|
|
277
|
+
const valid = segments
|
|
278
|
+
.filter((s) => s && typeof s.text === "string" && s.text.trim() && Number.isFinite(s.start_frame) && Number.isFinite(s.end_frame))
|
|
279
|
+
.slice()
|
|
280
|
+
.sort((a, b) => a.start_frame - b.start_frame);
|
|
281
|
+
// The node overrides single-prompt mode only at >=2 segments. Serialize
|
|
282
|
+
// nothing below that threshold so the payload isn't misleading.
|
|
283
|
+
if (valid.length < 2)
|
|
284
|
+
return "";
|
|
285
|
+
const safeFps = fps > 0 ? fps : 30;
|
|
286
|
+
const n = valid.length;
|
|
287
|
+
const out = valid.map((s, i) => {
|
|
288
|
+
const frames = s.end_frame - s.start_frame + (i === n - 1 ? 1 : 0);
|
|
289
|
+
return { text: s.text.trim(), duration_sec: Math.max(1, frames) / safeFps };
|
|
290
|
+
});
|
|
291
|
+
return JSON.stringify(out);
|
|
292
|
+
}
|
|
293
|
+
/** Reverse of serializeMotionPrompts: rebuild timeline prompt cards from the
|
|
294
|
+
* persisted `motion_prompts` JSON (an array of `{ text, duration_sec }`).
|
|
295
|
+
* Returns null if the payload is missing/corrupt/empty — the caller seeds a
|
|
296
|
+
* blank card in that case. Frame boundaries are reconstructed by accumulating
|
|
297
|
+
* durations from frame 0 — the exact boundaries may differ slightly from the
|
|
298
|
+
* original (the serialization rounds), but the TEXT and the segment COUNT are
|
|
299
|
+
* preserved, which is what matters for state sync across reopens. */
|
|
300
|
+
export function hydrateMotionPrompts(raw, fps, totalFrames) {
|
|
301
|
+
if (!raw || typeof raw !== "string")
|
|
302
|
+
return null;
|
|
303
|
+
const trimmed = raw.trim();
|
|
304
|
+
if (!trimmed)
|
|
305
|
+
return null;
|
|
306
|
+
let parsed;
|
|
307
|
+
try {
|
|
308
|
+
parsed = JSON.parse(trimmed);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
314
|
+
return null;
|
|
315
|
+
const safeFps = fps > 0 ? fps : 30;
|
|
316
|
+
const segments = [];
|
|
317
|
+
let cursor = 0;
|
|
318
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
319
|
+
const seg = parsed[i];
|
|
320
|
+
if (!seg || typeof seg.text !== "string")
|
|
321
|
+
return null;
|
|
322
|
+
const dur = typeof seg.duration_sec === "number" ? seg.duration_sec : 1;
|
|
323
|
+
const frames = Math.max(1, Math.round(dur * safeFps));
|
|
324
|
+
const start = cursor;
|
|
325
|
+
// Non-last segments: [start, start+frames-1]. Last segment extends to
|
|
326
|
+
// totalFrames-1 so the cards fill the full timeline.
|
|
327
|
+
const isLast = i === parsed.length - 1;
|
|
328
|
+
const end = isLast ? Math.max(start + frames - 1, totalFrames - 1) : start + frames - 1;
|
|
329
|
+
segments.push({ text: seg.text, start_frame: start, end_frame: end });
|
|
330
|
+
cursor = end + 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// iteration either returns null or pushes exactly one segment, so
|
|
334
|
+
// `segments` is always non-empty at this point.
|
|
335
|
+
return segments;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
export function untanglePrompts(segments) {
|
|
349
|
+
if (segments.length < 2)
|
|
350
|
+
return segments;
|
|
351
|
+
const sorted = [...segments].sort((a, b) => a.start_frame - b.start_frame || a.end_frame - b.end_frame);
|
|
352
|
+
let cursor = -Infinity;
|
|
353
|
+
let changed = false;
|
|
354
|
+
const healed = new Map(); // original index → healed segment
|
|
355
|
+
const indexByRef = new Map();
|
|
356
|
+
segments.forEach((s, i) => indexByRef.set(s, i));
|
|
357
|
+
for (const p of sorted) {
|
|
358
|
+
const dur = Math.max(1, p.end_frame - p.start_frame);
|
|
359
|
+
const s = Math.max(cursor + 1, p.start_frame);
|
|
360
|
+
const e = s + dur;
|
|
361
|
+
if (s !== p.start_frame || e !== p.end_frame) {
|
|
362
|
+
changed = true;
|
|
363
|
+
healed.set(indexByRef.get(p), { ...p, start_frame: s, end_frame: e });
|
|
364
|
+
}
|
|
365
|
+
cursor = e;
|
|
366
|
+
}
|
|
367
|
+
return changed ? segments.map((p, i) => healed.get(i) ?? p) : segments;
|
|
368
|
+
}
|
|
369
|
+
//# sourceMappingURL=motionConstraints.js.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E3.6 — Audio-reactive motion amplitude envelope sampler.
|
|
3
|
+
*
|
|
4
|
+
* Given a segment's timing + frame count and a set of audio cues, this
|
|
5
|
+
* samples the cues' waveform peaks at the segment's frame rate to
|
|
6
|
+
* produce a per-frame amplitude envelope. The envelope is emitted in the
|
|
7
|
+
* diffusers payload as `motion_amplitude_envelope: number[]` so the
|
|
8
|
+
* backend can modulate motion intensity per-frame (audio-reactive
|
|
9
|
+
* motion) instead of applying a single scalar `motion_amplitude`.
|
|
10
|
+
*
|
|
11
|
+
* Algorithm:
|
|
12
|
+
* For each video frame i in [0, frames):
|
|
13
|
+
* frameTime = segStart + (i + 0.5) / frames * segDuration
|
|
14
|
+
* (sample at the frame's midpoint, not its edge — avoids a
|
|
15
|
+
* one-frame bias at the segment boundary)
|
|
16
|
+
* For each cue that overlaps frameTime:
|
|
17
|
+
* relT = frameTime - cue.start (seconds into the cue)
|
|
18
|
+
* idx = floor(relT / cue.duration * peaks.length)
|
|
19
|
+
* contribution = peaks[clamp(idx)] * cue.volume
|
|
20
|
+
* envelope[i] = sum of contributions (clamped to [0, 1])
|
|
21
|
+
*
|
|
22
|
+
* The envelope is normalized to [0, 1] where 0 = silence and 1 = peak
|
|
23
|
+
* loudness. The backend maps this to its amplitude range (e.g. via the
|
|
24
|
+
* same 0.7 + 0.3*scale formula used for the scalar).
|
|
25
|
+
*
|
|
26
|
+
* Overlapping cues SUM (not max) — two simultaneous audio sources should
|
|
27
|
+
* produce more motion than one. The sum is clamped to [0, 1] so the
|
|
28
|
+
* backend never sees out-of-range values.
|
|
29
|
+
*
|
|
30
|
+
* Cues without waveformPeaks are skipped (not treated as silence — we
|
|
31
|
+
* simply have no data for them, so we don't dampen the envelope).
|
|
32
|
+
*/
|
|
33
|
+
import type { AudioCue } from "@melite/contracts";
|
|
34
|
+
export interface EnvelopeSampleArgs {
|
|
35
|
+
/** Segment start time on the timeline (seconds). */
|
|
36
|
+
segmentStart: number;
|
|
37
|
+
/** Segment duration (seconds). */
|
|
38
|
+
segmentDuration: number;
|
|
39
|
+
/** Number of video frames in the segment. */
|
|
40
|
+
frames: number;
|
|
41
|
+
/** Audio cues to sample (those overlapping the segment). */
|
|
42
|
+
cues: AudioCue[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Sample a per-frame motion-amplitude envelope from overlapping audio
|
|
46
|
+
* cues. Returns a `number[]` of length `frames`, each value in [0, 1].
|
|
47
|
+
* Returns an empty array if `frames <= 0` or no cues have peaks.
|
|
48
|
+
*/
|
|
49
|
+
export declare function sampleMotionEnvelope(args: EnvelopeSampleArgs): number[];
|
|
50
|
+
/**
|
|
51
|
+
* Normalize an envelope so its peak is exactly 1.0 (if it has any
|
|
52
|
+
* non-zero values). Useful when the raw peaks aren't normalized to
|
|
53
|
+
* full-scale. Returns the input unchanged if all values are 0.
|
|
54
|
+
*/
|
|
55
|
+
export declare function normalizeEnvelope(env: number[]): number[];
|
|
56
|
+
//# sourceMappingURL=motionEnvelope.d.ts.map
|