cozyclay 1.2.0 → 1.3.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/CHANGELOG.md +85 -0
- package/README.md +31 -0
- package/THIRD_PARTY_NOTICES.md +37 -1
- package/bin/cozyclay.mjs +51 -2
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/index.html +60 -16
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -90
- package/src/App.jsx +2957 -514
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline.jsx +124 -5
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/asset-pane.jsx +36 -0
- package/src/dualview.jsx +14 -8
- package/src/hierarchy-model.js +95 -13
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +122 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +43 -32
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/index.js +3 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/posestudio.jsx +361 -11
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +207 -26
- package/src/shot-authoring.js +55 -13
- package/src/styles.css +1296 -129
- package/tools/ardy/BRIDGE.md +3 -2
- package/tools/ardy/README.md +9 -5
- package/tools/ardy/bridge.mjs +57 -1
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +123 -11
- package/tools/ardy/cclay_sequence_generate.py +49 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/remote.mjs +8 -2
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- package/dist/assets/app-DgZvaAE1.css +0 -1
|
@@ -11,10 +11,11 @@ One full ``model(...)`` sampling call over the whole clip, optionally
|
|
|
11
11
|
conditioned on:
|
|
12
12
|
|
|
13
13
|
* ``--pose-from <npz> <src-frame> <dst-frame>`` (repeatable): pins the FULL
|
|
14
|
-
BODY pose stored in an ARDY motion npz (``local_rot_mats`` +
|
|
15
|
-
``posed_joints``) onto a clip frame
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
* BODY pose stored in an ARDY motion npz (``local_rot_mats`` +
|
|
15
|
+
* ``posed_joints``) onto a clip frame — joint positions, authored joint
|
|
16
|
+
* rotations, root X/Y/Z and heading. CozyClay pose npz files carry an
|
|
17
|
+
* explicit rotation-index mask; ordinary motion npz files constrain all
|
|
18
|
+
* rotations.
|
|
18
19
|
* ``--root-2d FRAME X Z HEADING`` (repeatable): sparse root waypoints on the
|
|
19
20
|
X/Z ground plane via ``Root2DConstraintSet`` (HEADING in radians or the
|
|
20
21
|
literal ``none``), the same contract as cclay_sequence_generate.py.
|
|
@@ -42,6 +43,7 @@ import numpy as np
|
|
|
42
43
|
import torch
|
|
43
44
|
|
|
44
45
|
from ardy.model import DEFAULT_MODEL, load_model
|
|
46
|
+
from ardy.constraints import FullBodyConstraintSet
|
|
45
47
|
from ardy.model.loading import get_env_var
|
|
46
48
|
from ardy.model.registry import resolve_model_name
|
|
47
49
|
from ardy.motion_rep.tools import length_to_mask
|
|
@@ -109,6 +111,28 @@ def parse_args(argv=None):
|
|
|
109
111
|
action="store_true",
|
|
110
112
|
help="Don't apply motion post-processing (foot-skate reduction).",
|
|
111
113
|
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--history_frames",
|
|
116
|
+
type=int,
|
|
117
|
+
default=None,
|
|
118
|
+
help=(
|
|
119
|
+
"History crop budget per autoregressive step (multiple of the model's "
|
|
120
|
+
"token size). Default: the longest history fitting the trained 10 s "
|
|
121
|
+
"window together with the generation horizon."
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--root-margin",
|
|
126
|
+
type=float,
|
|
127
|
+
default=None,
|
|
128
|
+
help="Root position correction margin for post-processing (default: ARDY's 0.04 m). Larger = looser pin grip, less foot skate.",
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--contact-threshold",
|
|
132
|
+
type=float,
|
|
133
|
+
default=None,
|
|
134
|
+
help="Foot contact detection threshold for post-processing (default: ARDY's 0.5).",
|
|
135
|
+
)
|
|
112
136
|
parser.add_argument(
|
|
113
137
|
"--checkpoints_dir",
|
|
114
138
|
type=str,
|
|
@@ -234,7 +258,7 @@ def parse_root_waypoints(raw_waypoints, num_frames: int) -> list:
|
|
|
234
258
|
|
|
235
259
|
|
|
236
260
|
def load_pose(path, skeleton, device):
|
|
237
|
-
"""Pose npz ->
|
|
261
|
+
"""Pose npz -> local rotations, positions, and rotation constraint indices.
|
|
238
262
|
|
|
239
263
|
The npz stores per-joint LOCAL rotations; FullBodyConstraintSet wants
|
|
240
264
|
GLOBAL rotations and posed positions, so the caller runs skeleton.fk on
|
|
@@ -243,7 +267,38 @@ def load_pose(path, skeleton, device):
|
|
|
243
267
|
with np.load(path, allow_pickle=False) as data:
|
|
244
268
|
local = torch.from_numpy(np.asarray(data["local_rot_mats"])).float().to(device)
|
|
245
269
|
posed = torch.from_numpy(np.asarray(data["posed_joints"])).float().to(device)
|
|
246
|
-
|
|
270
|
+
if "rotation_constraint_indices" in data:
|
|
271
|
+
rotation_indices = torch.from_numpy(
|
|
272
|
+
np.asarray(data["rotation_constraint_indices"], dtype=np.int64)
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
rotation_indices = torch.arange(local.shape[1], dtype=torch.long)
|
|
276
|
+
return local, posed, rotation_indices
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class CclayFullBodyConstraintSet(FullBodyConstraintSet):
|
|
280
|
+
"""Upstream full-body positions plus explicit global-rotation observations.
|
|
281
|
+
|
|
282
|
+
ARDY's FullBodyConstraintSet accepts rotations but deliberately omits them
|
|
283
|
+
from update_constraints. CozyClay poses need shoulder, elbow and wrist
|
|
284
|
+
orientation as well as joint centers, so this subclass fills the motion
|
|
285
|
+
representation's supported global_joints_rots channel for authored joints.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
name = "cclay-fullbody-position-rotation"
|
|
289
|
+
|
|
290
|
+
def __init__(self, *args, rotation_indices, **kwargs):
|
|
291
|
+
super().__init__(*args, **kwargs)
|
|
292
|
+
self.rotation_indices = rotation_indices.long().cpu()
|
|
293
|
+
|
|
294
|
+
def update_constraints(self, data_dict, index_dict):
|
|
295
|
+
super().update_constraints(data_dict, index_dict)
|
|
296
|
+
indices = torch.cartesian_prod(self.frame_indices.cpu(), self.rotation_indices)
|
|
297
|
+
device_indices = self.rotation_indices.to(self.global_joints_rots.device)
|
|
298
|
+
data_dict["global_joints_rots"].append(
|
|
299
|
+
self.global_joints_rots[:, device_indices].reshape(-1, 3, 3)
|
|
300
|
+
)
|
|
301
|
+
index_dict["global_joints_rots"].append(indices)
|
|
247
302
|
|
|
248
303
|
|
|
249
304
|
def measure_waypoints(waypoints: list, generated_joints, skeleton) -> list:
|
|
@@ -306,7 +361,14 @@ def main(argv=None, preloaded_model=None):
|
|
|
306
361
|
raise ValueError(
|
|
307
362
|
f"--diffusion_steps must be between 1 and {num_base_steps}; got {diffusion_steps}."
|
|
308
363
|
)
|
|
309
|
-
history_frames
|
|
364
|
+
if args.history_frames is not None:
|
|
365
|
+
if args.history_frames < patch or args.history_frames % patch != 0:
|
|
366
|
+
raise ValueError(
|
|
367
|
+
f"--history_frames must be a positive multiple of {patch} (this model's token size)."
|
|
368
|
+
)
|
|
369
|
+
history_frames = args.history_frames
|
|
370
|
+
else:
|
|
371
|
+
history_frames = _default_history_frames(fps, model.gen_horizon_len, patch)
|
|
310
372
|
|
|
311
373
|
# --- constraints --------------------------------------------------------
|
|
312
374
|
constraint_lst = []
|
|
@@ -318,21 +380,20 @@ def main(argv=None, preloaded_model=None):
|
|
|
318
380
|
raise ValueError(f"--pose-from frames must be integers, got {(raw_src, raw_dst)!r}.")
|
|
319
381
|
if not 0 <= dst_frame < num_frames:
|
|
320
382
|
raise ValueError(f"--pose-from dst-frame {dst_frame} is outside the clip (0..{num_frames - 1}).")
|
|
321
|
-
local, posed = load_pose(npz_path, skeleton, device)
|
|
383
|
+
local, posed, rotation_indices = load_pose(npz_path, skeleton, device)
|
|
322
384
|
if not 0 <= src_frame < local.shape[0]:
|
|
323
385
|
raise ValueError(
|
|
324
386
|
f"--pose-from src-frame {src_frame} is outside {npz_path} (0..{local.shape[0] - 1})."
|
|
325
387
|
)
|
|
326
|
-
from ardy.constraints import FullBodyConstraintSet
|
|
327
|
-
|
|
328
388
|
root = posed[src_frame : src_frame + 1, skeleton.root_idx]
|
|
329
389
|
rotations, positions, _ = skeleton.fk(local[src_frame : src_frame + 1], root)
|
|
330
390
|
constraint_lst.append(
|
|
331
|
-
|
|
391
|
+
CclayFullBodyConstraintSet(
|
|
332
392
|
skeleton,
|
|
333
393
|
torch.tensor([dst_frame]),
|
|
334
394
|
positions,
|
|
335
395
|
rotations,
|
|
396
|
+
rotation_indices=rotation_indices,
|
|
336
397
|
)
|
|
337
398
|
)
|
|
338
399
|
pose_targets.append((dst_frame, positions[0].cpu().numpy()))
|
|
@@ -389,14 +450,20 @@ def main(argv=None, preloaded_model=None):
|
|
|
389
450
|
)
|
|
390
451
|
output = model.motion_rep.inverse(motion, is_normalized=True)
|
|
391
452
|
|
|
453
|
+
postprocess_kwargs = {}
|
|
392
454
|
use_postprocess = "g1" not in resolved_model.lower() and not args.no_postprocess
|
|
393
455
|
if use_postprocess:
|
|
456
|
+
if args.root_margin is not None:
|
|
457
|
+
postprocess_kwargs["root_margin"] = args.root_margin
|
|
458
|
+
if args.contact_threshold is not None:
|
|
459
|
+
postprocess_kwargs["contact_threshold"] = args.contact_threshold
|
|
394
460
|
corrected = post_process_motion(
|
|
395
461
|
output["local_rot_mats"],
|
|
396
462
|
output["root_positions"],
|
|
397
463
|
output["foot_contacts"],
|
|
398
464
|
skeleton,
|
|
399
465
|
constraint_lst=constraint_lst if constraint_lst else None,
|
|
466
|
+
**postprocess_kwargs,
|
|
400
467
|
)
|
|
401
468
|
output.update(corrected)
|
|
402
469
|
|
|
@@ -417,6 +484,45 @@ def main(argv=None, preloaded_model=None):
|
|
|
417
484
|
generated_joints = np.asarray(motion_dict["posed_joints"])
|
|
418
485
|
root_index = skeleton.root_idx
|
|
419
486
|
pose_reports = []
|
|
487
|
+
|
|
488
|
+
# Quality metrics alongside the pose/waypoint accuracy: the operator can
|
|
489
|
+
# judge a path by them, not just trust it. Continuity is the largest
|
|
490
|
+
# one-frame root jump (chained seams show up here); surface contact
|
|
491
|
+
# verifies that stance frames keep the foot planted instead of skating.
|
|
492
|
+
def motion_continuity(root_positions):
|
|
493
|
+
if len(root_positions) < 2:
|
|
494
|
+
return None
|
|
495
|
+
deltas = np.linalg.norm(np.diff(root_positions[:, [0, 2]], axis=0), axis=1)
|
|
496
|
+
worst = int(deltas.argmax()) + 1 if len(deltas) else 0
|
|
497
|
+
return {
|
|
498
|
+
"mean_jump_m": round(float(deltas.mean()), 6),
|
|
499
|
+
"max_jump_m": round(float(deltas.max()), 6),
|
|
500
|
+
"max_jump_frame": worst,
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
def surface_contact_verified(contacts, posed, foot_indices):
|
|
504
|
+
if contacts is None or not len(foot_indices):
|
|
505
|
+
return None
|
|
506
|
+
contacts_arr = np.asarray(contacts)
|
|
507
|
+
if contacts_arr.ndim == 3:
|
|
508
|
+
contacts_arr = contacts_arr[0]
|
|
509
|
+
checked = 0
|
|
510
|
+
held = 0
|
|
511
|
+
for contact_idx, joint_idx in enumerate(foot_indices[: contacts_arr.shape[1]]):
|
|
512
|
+
ys = posed[:, joint_idx, 1]
|
|
513
|
+
for frame in range(1, len(posed)):
|
|
514
|
+
if contacts_arr[frame, contact_idx] > 0.5 and contacts_arr[frame - 1, contact_idx] > 0.5:
|
|
515
|
+
checked += 1
|
|
516
|
+
if abs(ys[frame] - ys[frame - 1]) < 0.02:
|
|
517
|
+
held += 1
|
|
518
|
+
return checked == 0 or (held / checked) >= 0.8
|
|
519
|
+
|
|
520
|
+
root_positions = np.asarray(motion_dict["root_positions"])
|
|
521
|
+
foot_indices = [idx for idx, name in enumerate(skeleton.bone_order_names) if "foot" in name.lower() or "toe" in name.lower()]
|
|
522
|
+
continuity = motion_continuity(root_positions)
|
|
523
|
+
contact_verified = surface_contact_verified(
|
|
524
|
+
motion_dict.get("foot_contacts"), generated_joints, foot_indices
|
|
525
|
+
)
|
|
420
526
|
for dst_frame, requested in pose_targets:
|
|
421
527
|
achieved = generated_joints[dst_frame]
|
|
422
528
|
root_error = float(np.linalg.norm(achieved[root_index] - requested[root_index]))
|
|
@@ -439,6 +545,12 @@ def main(argv=None, preloaded_model=None):
|
|
|
439
545
|
"frames": int(generated_joints.shape[0]),
|
|
440
546
|
"fps": int(fps),
|
|
441
547
|
"model": resolved_model,
|
|
548
|
+
"surface_contact_verified": contact_verified,
|
|
549
|
+
"continuity": continuity,
|
|
550
|
+
"postprocess": {
|
|
551
|
+
"contact_threshold": postprocess_kwargs.get("contact_threshold", 0.5) if use_postprocess else None,
|
|
552
|
+
"root_margin": postprocess_kwargs.get("root_margin", 0.04) if use_postprocess else None,
|
|
553
|
+
},
|
|
442
554
|
"poses": pose_reports,
|
|
443
555
|
"waypoints": measure_waypoints(waypoints, generated_joints, skeleton),
|
|
444
556
|
}
|
|
@@ -174,6 +174,18 @@ def parse_args(argv=None):
|
|
|
174
174
|
action="store_true",
|
|
175
175
|
help="Don't apply motion post-processing (foot-skate reduction).",
|
|
176
176
|
)
|
|
177
|
+
parser.add_argument(
|
|
178
|
+
"--root-margin",
|
|
179
|
+
type=float,
|
|
180
|
+
default=None,
|
|
181
|
+
help="Root position correction margin for post-processing (default: ARDY's 0.04 m). Larger = looser pin grip, less foot skate.",
|
|
182
|
+
)
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"--contact-threshold",
|
|
185
|
+
type=float,
|
|
186
|
+
default=None,
|
|
187
|
+
help="Foot contact detection threshold for post-processing (default: ARDY's 0.5).",
|
|
188
|
+
)
|
|
177
189
|
parser.add_argument(
|
|
178
190
|
"--checkpoints_dir",
|
|
179
191
|
type=str,
|
|
@@ -596,8 +608,13 @@ def main(argv=None, preloaded_model=None):
|
|
|
596
608
|
with torch.no_grad():
|
|
597
609
|
output = model.motion_rep.inverse(acc, is_normalized=True)
|
|
598
610
|
|
|
611
|
+
postprocess_kwargs = {}
|
|
599
612
|
use_postprocess = "g1" not in resolved_model.lower() and not args.no_postprocess
|
|
600
613
|
if use_postprocess:
|
|
614
|
+
if args.root_margin is not None:
|
|
615
|
+
postprocess_kwargs["root_margin"] = args.root_margin
|
|
616
|
+
if args.contact_threshold is not None:
|
|
617
|
+
postprocess_kwargs["contact_threshold"] = args.contact_threshold
|
|
601
618
|
corrected = post_process_motion(
|
|
602
619
|
output["local_rot_mats"],
|
|
603
620
|
output["root_positions"],
|
|
@@ -606,6 +623,7 @@ def main(argv=None, preloaded_model=None):
|
|
|
606
623
|
# With waypoints, ARDY's own postprocess enforces the pinned root
|
|
607
624
|
# contacts instead of skating them away (as the one-shot path).
|
|
608
625
|
constraint_lst=constraint_lst if constraint_lst else None,
|
|
626
|
+
**postprocess_kwargs,
|
|
609
627
|
)
|
|
610
628
|
output.update(corrected)
|
|
611
629
|
|
|
@@ -632,10 +650,41 @@ def main(argv=None, preloaded_model=None):
|
|
|
632
650
|
f"generated frame count {frames} does not match the planned segment table "
|
|
633
651
|
f"({total_frames}); segment boundaries would be mislabeled."
|
|
634
652
|
)
|
|
653
|
+
# Same quality lens as the constrained report: which postprocess knobs
|
|
654
|
+
# were used, and whether stance frames keep the feet planted.
|
|
655
|
+
def _surface_contact_verified():
|
|
656
|
+
contacts = motion_dict.get("foot_contacts")
|
|
657
|
+
if contacts is None:
|
|
658
|
+
return None
|
|
659
|
+
contacts_arr = np.asarray(contacts)
|
|
660
|
+
if contacts_arr.ndim == 3:
|
|
661
|
+
contacts_arr = contacts_arr[0]
|
|
662
|
+
posed = np.asarray(motion_dict["posed_joints"])
|
|
663
|
+
foot_indices = [
|
|
664
|
+
idx
|
|
665
|
+
for idx, name in enumerate(model.skeleton.bone_order_names)
|
|
666
|
+
if "foot" in name.lower() or "toe" in name.lower()
|
|
667
|
+
]
|
|
668
|
+
checked = 0
|
|
669
|
+
held = 0
|
|
670
|
+
for contact_idx, joint_idx in enumerate(foot_indices[: contacts_arr.shape[1]]):
|
|
671
|
+
ys = posed[:, joint_idx, 1]
|
|
672
|
+
for frame in range(1, len(posed)):
|
|
673
|
+
if contacts_arr[frame, contact_idx] > 0.5 and contacts_arr[frame - 1, contact_idx] > 0.5:
|
|
674
|
+
checked += 1
|
|
675
|
+
if abs(ys[frame] - ys[frame - 1]) < 0.02:
|
|
676
|
+
held += 1
|
|
677
|
+
return checked == 0 or (held / checked) >= 0.8
|
|
678
|
+
|
|
635
679
|
result = {
|
|
636
680
|
"frames": frames,
|
|
637
681
|
"fps": int(fps),
|
|
638
682
|
"model": resolved_model,
|
|
683
|
+
"surface_contact_verified": _surface_contact_verified(),
|
|
684
|
+
"postprocess": {
|
|
685
|
+
"contact_threshold": postprocess_kwargs.get("contact_threshold", 0.5) if use_postprocess else None,
|
|
686
|
+
"root_margin": postprocess_kwargs.get("root_margin", 0.04) if use_postprocess else None,
|
|
687
|
+
},
|
|
639
688
|
"segments": seg_bounds,
|
|
640
689
|
"continuity": _continuity_metrics(np.asarray(motion_dict["posed_joints"]), boundaries),
|
|
641
690
|
"boundary_gate": {
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract.mjs — the bridge's GPU motion-extraction side. The browser's
|
|
3
|
+
* MediaPipe path is per-frame guessing (jittery, rough blocking only); this
|
|
4
|
+
* route ships the footage to the ARDY box, runs SAM-3D-Body over the whole
|
|
5
|
+
* clip (temporal context, real 3D body prior), converts the returned Mixamo
|
|
6
|
+
* BVH to cskel27 arrays and serves them as an ordinary motion npz — so the
|
|
7
|
+
* app loads a GPU-extracted take through the exact same loadMotion path an
|
|
8
|
+
* ARDY generation uses.
|
|
9
|
+
*
|
|
10
|
+
* Same posture as generation: children die with the client connection, the
|
|
11
|
+
* served npz enters the motion allowlist only after this process wrote and
|
|
12
|
+
* verified it, and every failure is a NAMED reason.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { killGroup, track } from "./runners/proc.mjs";
|
|
21
|
+
import { EXTRACT_FPS_MAX, conformToExtractFps } from "./footage.mjs";
|
|
22
|
+
import { motionArraysToNpzMembers, writeNpz } from "./npz.mjs";
|
|
23
|
+
import { bvhToCskel27Motion, parseBvh } from "./bvh-cskel27.mjs";
|
|
24
|
+
|
|
25
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const OUT_DIR = join(HERE, "out");
|
|
27
|
+
const SAM_DIR = "~/cclay-ingest/SAM3DBody-cpp"; // the box-side checkout the old ingest pipeline left behind
|
|
28
|
+
// Non-interactive ssh shells carry no LD_LIBRARY_PATH, and the CUDA EP needs
|
|
29
|
+
// the cudnn that lives inside the ingest workspace's venv — without it the
|
|
30
|
+
// pipeline silently falls back to CPU and then refuses to load at all.
|
|
31
|
+
const SAM_ENV = 'LD_LIBRARY_PATH="$(echo $HOME/cclay-ingest/.venv/lib/python3.12/site-packages/nvidia/*/lib | tr \' \' :)"';
|
|
32
|
+
const MAX_UPLOAD_BYTES = 300 * 1024 * 1024;
|
|
33
|
+
const EXTRACT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
34
|
+
const SSH_BASE_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=15"];
|
|
35
|
+
|
|
36
|
+
// Extraction can run on a DIFFERENT machine than ARDY generation — e.g. a
|
|
37
|
+
// Slurm GPU cluster (measured ~5× the default box). Point it there with:
|
|
38
|
+
// CCLAY_EXTRACT_HOST ssh destination (default: CCLAY_ARDY_HOST)
|
|
39
|
+
// CCLAY_EXTRACT_SSH_PORT ssh port (default 22)
|
|
40
|
+
// CCLAY_EXTRACT_TMP REMOTE work dir for video/BVH — must be visible
|
|
41
|
+
// to the node that runs the job (a Slurm node's
|
|
42
|
+
// /tmp is node-local, use a shared home path)
|
|
43
|
+
// CCLAY_EXTRACT_CMD remote command invoked as `CMD <video> <bvh>`;
|
|
44
|
+
// it owns GPU allocation (srun …) and env. Unset →
|
|
45
|
+
// the default box invocation below.
|
|
46
|
+
function sshHost() {
|
|
47
|
+
return process.env.CCLAY_EXTRACT_HOST?.trim() || process.env.CCLAY_ARDY_HOST?.trim() || "";
|
|
48
|
+
}
|
|
49
|
+
const EXTRACT_SSH_PORT = process.env.CCLAY_EXTRACT_SSH_PORT?.trim() || "";
|
|
50
|
+
const EXTRACT_TMP = process.env.CCLAY_EXTRACT_TMP?.trim() || "/tmp";
|
|
51
|
+
const EXTRACT_CMD = process.env.CCLAY_EXTRACT_CMD?.trim() || "";
|
|
52
|
+
const SSH_OPTS = EXTRACT_SSH_PORT ? [...SSH_BASE_OPTS, "-p", EXTRACT_SSH_PORT] : SSH_BASE_OPTS;
|
|
53
|
+
const SCP_OPTS = EXTRACT_SSH_PORT ? [...SSH_BASE_OPTS, "-P", EXTRACT_SSH_PORT] : SSH_BASE_OPTS;
|
|
54
|
+
|
|
55
|
+
/** Read a raw binary request body with a hard cap. The bridge's json readBody
|
|
56
|
+
* is utf-8 and would corrupt video bytes. */
|
|
57
|
+
function readVideoBody(req, limitBytes) {
|
|
58
|
+
return new Promise((resolvePromise, reject) => {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
let total = 0;
|
|
61
|
+
req.on("data", (chunk) => {
|
|
62
|
+
total += chunk.length;
|
|
63
|
+
if (total > limitBytes) {
|
|
64
|
+
reject(new Error("extract-upload-too-large"));
|
|
65
|
+
req.destroy();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
});
|
|
70
|
+
req.on("end", () => resolvePromise(Buffer.concat(chunks)));
|
|
71
|
+
req.on("error", (err) => reject(new Error(`request aborted: ${err.message}`)));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function run(command, args, { children, timeoutMs, onLine }) {
|
|
76
|
+
return new Promise((resolvePromise, reject) => {
|
|
77
|
+
const child = spawn(command, args, { detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
78
|
+
children.add(child);
|
|
79
|
+
track(child);
|
|
80
|
+
let err = "";
|
|
81
|
+
let buffered = "";
|
|
82
|
+
const timer = timeoutMs
|
|
83
|
+
? setTimeout(() => {
|
|
84
|
+
killGroup(child);
|
|
85
|
+
reject(new Error("extract-timeout"));
|
|
86
|
+
}, timeoutMs)
|
|
87
|
+
: null;
|
|
88
|
+
const feed = (text) => {
|
|
89
|
+
buffered += text;
|
|
90
|
+
const lines = buffered.split(/[\r\n]/);
|
|
91
|
+
buffered = lines.pop() ?? "";
|
|
92
|
+
for (const line of lines) if (line) onLine?.(line);
|
|
93
|
+
};
|
|
94
|
+
child.stdout.on("data", (chunk) => feed(String(chunk)));
|
|
95
|
+
child.stderr.on("data", (chunk) => {
|
|
96
|
+
err += chunk;
|
|
97
|
+
feed(String(chunk));
|
|
98
|
+
});
|
|
99
|
+
child.on("error", (error) => {
|
|
100
|
+
if (timer) clearTimeout(timer);
|
|
101
|
+
children.delete(child);
|
|
102
|
+
reject(new Error(`spawn ${command}: ${error.message}`));
|
|
103
|
+
});
|
|
104
|
+
child.on("close", (code) => {
|
|
105
|
+
if (timer) clearTimeout(timer);
|
|
106
|
+
children.delete(child);
|
|
107
|
+
if (code === 0) resolvePromise();
|
|
108
|
+
else reject(new Error(err.split("\n").filter(Boolean).pop() || `${command} exited ${code}`));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* POST /ardy/extract — input is either JSON {footage:"<id>"} referencing a
|
|
115
|
+
* bridge-downloaded clip, or the raw video bytes themselves. Answer ndjson:
|
|
116
|
+
* {event:"status", message} upload / extract / convert
|
|
117
|
+
* {event:"progress", stage:"extract", ratio} per-frame GPU movement
|
|
118
|
+
* {event:"done", motionUrl, frames, fps}
|
|
119
|
+
* {event:"error", message} a NAMED reason
|
|
120
|
+
*/
|
|
121
|
+
export async function handleExtract(req, res, { readBody, footagePath, registerMotion }) {
|
|
122
|
+
const host = sshHost();
|
|
123
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
124
|
+
let localVideo = null;
|
|
125
|
+
let uploadedTemp = null;
|
|
126
|
+
let cappedTemp = null;
|
|
127
|
+
|
|
128
|
+
if (/^application\/json\b/.test(contentType)) {
|
|
129
|
+
let body;
|
|
130
|
+
try {
|
|
131
|
+
body = JSON.parse(await readBody(req));
|
|
132
|
+
} catch (err) {
|
|
133
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
134
|
+
res.end(`${JSON.stringify({ ok: false, reason: err.message })}\n`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
localVideo = typeof body?.footage === "string" ? footagePath(body.footage) : null;
|
|
138
|
+
if (!localVideo) {
|
|
139
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
140
|
+
res.end(`${JSON.stringify({ ok: false, reason: "extract-footage-unknown" })}\n`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
let bytes;
|
|
145
|
+
try {
|
|
146
|
+
bytes = await readVideoBody(req, MAX_UPLOAD_BYTES);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
149
|
+
res.end(`${JSON.stringify({ ok: false, reason: err.message })}\n`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (bytes.length < 1024) {
|
|
153
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
154
|
+
res.end(`${JSON.stringify({ ok: false, reason: "extract-upload-empty" })}\n`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
158
|
+
uploadedTemp = join(OUT_DIR, `extract-upload-${Date.now()}.mp4`);
|
|
159
|
+
writeFileSync(uploadedTemp, bytes);
|
|
160
|
+
localVideo = uploadedTemp;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
res.writeHead(200, { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store" });
|
|
164
|
+
const send = (obj) => {
|
|
165
|
+
if (res.writableEnded) return;
|
|
166
|
+
try {
|
|
167
|
+
res.write(`${JSON.stringify(obj)}\n`);
|
|
168
|
+
} catch {
|
|
169
|
+
/* socket gone */
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const children = new Set();
|
|
173
|
+
const cleanupLocal = () => {
|
|
174
|
+
if (uploadedTemp) rmSync(uploadedTemp, { force: true });
|
|
175
|
+
if (cappedTemp) rmSync(cappedTemp, { force: true });
|
|
176
|
+
};
|
|
177
|
+
const fail = (message) => {
|
|
178
|
+
send({ event: "error", message });
|
|
179
|
+
res.end();
|
|
180
|
+
cleanupLocal();
|
|
181
|
+
};
|
|
182
|
+
res.on("close", () => {
|
|
183
|
+
if (!res.writableEnded) {
|
|
184
|
+
console.error(`[bridge] client disconnected mid-extract; killing ${children.size} child group(s)`);
|
|
185
|
+
for (const child of children) killGroup(child);
|
|
186
|
+
children.clear();
|
|
187
|
+
cleanupLocal();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (!host) {
|
|
192
|
+
fail("extract-host-missing");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const stamp = `${Date.now()}-${randomBytes(3).toString("hex")}`;
|
|
197
|
+
const remoteVideo = `${EXTRACT_TMP}/cclay-extract-${stamp}.mp4`;
|
|
198
|
+
const remoteBvh = `${EXTRACT_TMP}/cclay-extract-${stamp}.bvh`;
|
|
199
|
+
const cleanupRemote = () => {
|
|
200
|
+
run("ssh", [...SSH_OPTS, host, `rm -f ${remoteVideo} ${remoteBvh} ${remoteBvh.replace(/\.bvh$/, "")}_*.bvh`], {
|
|
201
|
+
children: new Set(),
|
|
202
|
+
timeoutMs: 30000,
|
|
203
|
+
}).catch(() => {});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Both intake routes converge here, and only here is the rate SAM will see
|
|
207
|
+
// certain: a bridge download was already normalized at the same ceiling
|
|
208
|
+
// (so this probes ≤ the cap and re-encodes nothing — a second encode would
|
|
209
|
+
// cost a generation of quality for no frames removed), while raw bytes
|
|
210
|
+
// posted from the browser are whatever the user's camera shot. Frame rate
|
|
211
|
+
// only: the clip keeps its length and its speed.
|
|
212
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
213
|
+
// Claimed before the pass runs, not after it succeeds: a half-written file
|
|
214
|
+
// from an ffmpeg that died mid-encode has to be swept too, and the rm is a
|
|
215
|
+
// no-op when the pass never wrote anything.
|
|
216
|
+
cappedTemp = join(OUT_DIR, `extract-capped-${stamp}.mp4`);
|
|
217
|
+
try {
|
|
218
|
+
const conformed = await conformToExtractFps(localVideo, cappedTemp, {
|
|
219
|
+
children,
|
|
220
|
+
onCap: (fps) => {
|
|
221
|
+
send({ event: "status", message: "normalizing" });
|
|
222
|
+
console.error(`[bridge] extract input capped to ${fps} fps (ceiling ${EXTRACT_FPS_MAX})`);
|
|
223
|
+
},
|
|
224
|
+
onProgress: (ratio) => send({ event: "progress", stage: "normalize", ratio }),
|
|
225
|
+
});
|
|
226
|
+
if (conformed.capped) {
|
|
227
|
+
localVideo = conformed.path;
|
|
228
|
+
} else if (conformed.fps === null) {
|
|
229
|
+
console.error("[bridge] extract input rate unreadable; sending it to the box as it is");
|
|
230
|
+
}
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error(`[bridge] extract fps cap failed: ${err.message}`);
|
|
233
|
+
// The same ffmpeg pass the download path runs, so the same named
|
|
234
|
+
// reason — the UI already tells the user a conversion is what broke.
|
|
235
|
+
fail("footage-normalize-failed");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
send({ event: "status", message: "uploading" });
|
|
241
|
+
await run("scp", [...SCP_OPTS, localVideo, `${host}:${remoteVideo}`], { children, timeoutMs: 300000 });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.error(`[bridge] extract upload failed: ${err.message}`);
|
|
244
|
+
fail("extract-upload-failed");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// SAM-3D-Body's OFFLINE multi-pass renderer — the live binary's causal
|
|
249
|
+
// filter lags in phase and skips the repair passes, and measured 80 %
|
|
250
|
+
// more wrist jitter on the same clip. The offline pipeline runs identity
|
|
251
|
+
// tracking, gap fill, spike interpolation (--interpolate-jitter), then
|
|
252
|
+
// ZERO-PHASE forward+backward smoothing at 6 Hz, and --foot-contact's
|
|
253
|
+
// per-foot leg IK pins planted feet against skate. --max-persons 2
|
|
254
|
+
// matches the scene: CozyClay holds two subjects, so the two most
|
|
255
|
+
// confident performers come back (one BVH each) and a single-person clip
|
|
256
|
+
// simply yields one file.
|
|
257
|
+
send({ event: "status", message: "extracting" });
|
|
258
|
+
try {
|
|
259
|
+
const remoteCommand = EXTRACT_CMD
|
|
260
|
+
? `${EXTRACT_CMD} ${remoteVideo} ${remoteBvh}`
|
|
261
|
+
: `cd ${SAM_DIR} && ${SAM_ENV} ./build/offline_sam_3dbody_render ` +
|
|
262
|
+
`--onnx-dir ./onnx --gguf ./onnx/pipeline.gguf --yolo ./onnx/yolo.onnx ` +
|
|
263
|
+
`--from ${remoteVideo} --bvh ${remoteBvh} --bvh-template ./mixamo.bvh --max-persons 2 ` +
|
|
264
|
+
`--smoothing zero-phase --bw-cutoff 6 --interpolate-jitter --foot-contact`;
|
|
265
|
+
await run(
|
|
266
|
+
"ssh",
|
|
267
|
+
[...SSH_OPTS, host, remoteCommand],
|
|
268
|
+
{
|
|
269
|
+
children,
|
|
270
|
+
timeoutMs: EXTRACT_TIMEOUT_MS,
|
|
271
|
+
onLine: (line) => {
|
|
272
|
+
// "[pass1] 120 / 514 frames (eta ~3 s)" — pass1 is the
|
|
273
|
+
// GPU inference and dominates the wall clock.
|
|
274
|
+
const progress = /\[pass1\]\s+(\d+)\s*\/\s*(\d+) frames/.exec(line);
|
|
275
|
+
if (progress && Number(progress[2]) > 0) {
|
|
276
|
+
send({ event: "progress", stage: "extract", ratio: Math.min(1, Number(progress[1]) / Number(progress[2])) });
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error(`[bridge] extract run failed: ${err.message}`);
|
|
283
|
+
cleanupRemote();
|
|
284
|
+
fail("extract-run-failed");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
send({ event: "status", message: "converting" });
|
|
289
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
290
|
+
// One BVH per tracked person. Person 0 must exist; person 1 is optional
|
|
291
|
+
// (a single-person clip yields one file, and that is not an error).
|
|
292
|
+
const motions = [];
|
|
293
|
+
for (let person = 0; person < 2; person += 1) {
|
|
294
|
+
const localBvh = join(OUT_DIR, `extract-${stamp}-p${person}.bvh`);
|
|
295
|
+
try {
|
|
296
|
+
await run("scp", [...SCP_OPTS, `${host}:${remoteBvh.replace(/\.bvh$/, "")}_${person}.bvh`, localBvh], {
|
|
297
|
+
children,
|
|
298
|
+
timeoutMs: 120000,
|
|
299
|
+
});
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (person === 0) {
|
|
302
|
+
console.error(`[bridge] extract fetch failed: ${err.message}`);
|
|
303
|
+
cleanupRemote();
|
|
304
|
+
fail("extract-no-person");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
motions.push(bvhToCskel27Motion(parseBvh(readFileSync(localBvh, "utf8"))));
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error(`[bridge] extract convert failed (person ${person}): ${err.message}`);
|
|
313
|
+
if (person === 0) {
|
|
314
|
+
cleanupRemote();
|
|
315
|
+
fail("extract-convert-failed");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
} finally {
|
|
319
|
+
rmSync(localBvh, { force: true });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
cleanupRemote();
|
|
323
|
+
cleanupLocal();
|
|
324
|
+
|
|
325
|
+
const takes = [];
|
|
326
|
+
for (let person = 0; person < motions.length; person += 1) {
|
|
327
|
+
const motion = motions[person];
|
|
328
|
+
const id = person === 0 ? stamp : `${Date.now()}-${randomBytes(3).toString("hex")}`;
|
|
329
|
+
const npzPath = join(OUT_DIR, `extract-${id}.npz`);
|
|
330
|
+
try {
|
|
331
|
+
// motion.personScale goes into the archive as `person_scale`: the
|
|
332
|
+
// conversion divided this person's root travel by it, so the take
|
|
333
|
+
// is only metrically right when the character is scaled by the same
|
|
334
|
+
// number. Shipping it in the response alone would let a reload —
|
|
335
|
+
// or any other path to the same npz — replay the stride at
|
|
336
|
+
// canonical size. The response keeps the field for older clients.
|
|
337
|
+
writeNpz(npzPath, motionArraysToNpzMembers(motion));
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.error(`[bridge] extract npz write failed (person ${person}): ${err.message}`);
|
|
340
|
+
if (person === 0) {
|
|
341
|
+
fail("extract-convert-failed");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
registerMotion(id, npzPath);
|
|
347
|
+
takes.push({
|
|
348
|
+
motionUrl: `/ardy/motions/${id}`,
|
|
349
|
+
frames: motion.frames,
|
|
350
|
+
fps: motion.fps,
|
|
351
|
+
personScale: motion.personScale,
|
|
352
|
+
// person 1's placement RELATIVE to person 0, in the shared raw
|
|
353
|
+
// camera space (real metres, X/Z on the floor plane).
|
|
354
|
+
offsetX: person === 0 ? 0 : motion.rawRootStart[0] - motions[0].rawRootStart[0],
|
|
355
|
+
offsetZ: person === 0 ? 0 : motion.rawRootStart[2] - motions[0].rawRootStart[2],
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
send({
|
|
359
|
+
event: "done",
|
|
360
|
+
motionUrl: takes[0].motionUrl,
|
|
361
|
+
frames: takes[0].frames,
|
|
362
|
+
fps: takes[0].fps,
|
|
363
|
+
personScale: takes[0].personScale,
|
|
364
|
+
takes,
|
|
365
|
+
});
|
|
366
|
+
res.end();
|
|
367
|
+
}
|