@melite/formats 0.0.0-stage → 0.1.4-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.
Files changed (64) hide show
  1. package/LICENSE +557 -0
  2. package/lib/b64.d.ts +2 -0
  3. package/lib/b64.js +7 -0
  4. package/lib/bodyPresets.d.ts +40 -0
  5. package/lib/bodyPresets.js +77 -0
  6. package/lib/capabilityCards.d.ts +54 -0
  7. package/lib/capabilityCards.js +127 -0
  8. package/lib/captionSplitter.d.ts +49 -0
  9. package/lib/captionSplitter.js +172 -0
  10. package/lib/cardMutations.d.ts +52 -0
  11. package/lib/cardMutations.js +67 -0
  12. package/lib/downloads.d.ts +18 -0
  13. package/lib/downloads.js +64 -0
  14. package/lib/extractVideoFrame.d.ts +23 -0
  15. package/lib/extractVideoFrame.js +114 -0
  16. package/lib/glbLoad.d.ts +54 -0
  17. package/lib/glbLoad.js +96 -0
  18. package/lib/glbSkin.d.ts +14 -0
  19. package/lib/glbSkin.js +124 -0
  20. package/lib/handPose.d.ts +46 -0
  21. package/lib/handPose.js +122 -0
  22. package/lib/index.d.ts +26 -0
  23. package/lib/index.js +26 -0
  24. package/lib/keyframeExtract.d.ts +46 -0
  25. package/lib/keyframeExtract.js +89 -0
  26. package/lib/maskPrep.d.ts +88 -0
  27. package/lib/maskPrep.js +386 -0
  28. package/lib/motionConstraints.d.ts +237 -0
  29. package/lib/motionConstraints.js +369 -0
  30. package/lib/motionEnvelope.d.ts +56 -0
  31. package/lib/motionEnvelope.js +59 -0
  32. package/lib/motionNpz.d.ts +120 -0
  33. package/lib/motionNpz.js +497 -0
  34. package/lib/npz.d.ts +24 -0
  35. package/lib/npz.js +158 -0
  36. package/lib/openPoseCanvas.d.ts +17 -0
  37. package/lib/openPoseCanvas.js +90 -0
  38. package/lib/pngMeta.d.ts +42 -0
  39. package/lib/pngMeta.js +212 -0
  40. package/lib/pngMetaInfo.d.ts +14 -0
  41. package/lib/pngMetaInfo.js +127 -0
  42. package/lib/poseCodec.d.ts +20 -0
  43. package/lib/poseCodec.js +61 -0
  44. package/lib/poseTransforms.d.ts +73 -0
  45. package/lib/poseTransforms.js +237 -0
  46. package/lib/poserSkin.d.ts +75 -0
  47. package/lib/poserSkin.js +126 -0
  48. package/lib/prompt-tags.json +132 -0
  49. package/lib/promptTags.d.ts +42 -0
  50. package/lib/promptTags.js +116 -0
  51. package/lib/psdExport.d.ts +28 -0
  52. package/lib/psdExport.js +272 -0
  53. package/lib/readFile.d.ts +9 -0
  54. package/lib/readFile.js +16 -0
  55. package/lib/renderSnapshot.d.ts +34 -0
  56. package/lib/renderSnapshot.js +55 -0
  57. package/lib/somaSkin.d.ts +82 -0
  58. package/lib/somaSkin.js +267 -0
  59. package/lib/textToPose.d.ts +388 -0
  60. package/lib/textToPose.js +604 -0
  61. package/lib/uploadToEstate.d.ts +25 -0
  62. package/lib/uploadToEstate.js +97 -0
  63. package/package.json +148 -4
  64. package/README.md +0 -3
@@ -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