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.
Files changed (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +37 -1
  4. package/bin/cozyclay.mjs +51 -2
  5. package/dist/ai-camera-control/index.html +406 -0
  6. package/dist/app/index.html +5 -5
  7. package/dist/assets/app-B3U5aut1.js +4811 -0
  8. package/dist/assets/app-BrRF0wso.css +1 -0
  9. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  10. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  11. package/dist/fonts/Inter-OFL.txt +92 -0
  12. package/dist/fonts/README.md +15 -0
  13. package/dist/index.html +60 -16
  14. package/dist/sitemap.xml +7 -1
  15. package/mcp/LIVE-PROTOCOL.md +63 -0
  16. package/mcp/README.md +142 -0
  17. package/mcp/ardy-prompts.mjs +170 -0
  18. package/mcp/live-hub.mjs +105 -0
  19. package/mcp/package.json +24 -0
  20. package/mcp/server.mjs +1394 -0
  21. package/package.json +122 -90
  22. package/src/App.jsx +2957 -514
  23. package/src/ardy/cskel27.js +7 -2
  24. package/src/ardy/ik.js +25 -15
  25. package/src/ardy/npz.js +64 -3
  26. package/src/ardy/playback.js +31 -1
  27. package/src/ardy/prompt-clips.js +7 -2
  28. package/src/ardy/retime.js +211 -0
  29. package/src/ardy/timeline-coordinates.js +13 -0
  30. package/src/ardy/timeline.jsx +124 -5
  31. package/src/ardy/to-cskel27.js +34 -12
  32. package/src/ardy/trim.js +33 -0
  33. package/src/asset-pane.jsx +36 -0
  34. package/src/dualview.jsx +14 -8
  35. package/src/hierarchy-model.js +95 -13
  36. package/src/hierarchy-panel.jsx +139 -6
  37. package/src/live-control.js +122 -0
  38. package/src/matte-editor.js +543 -0
  39. package/src/matte.js +503 -0
  40. package/src/multimodel-ingest.js +344 -0
  41. package/src/object-gizmo.jsx +43 -15
  42. package/src/planview.jsx +43 -32
  43. package/src/pose-extract/detector.js +75 -0
  44. package/src/pose-extract/index.js +3 -0
  45. package/src/pose-extract/take.js +87 -0
  46. package/src/pose-extract/video-frames.js +91 -0
  47. package/src/pose-thumbs.js +152 -0
  48. package/src/posestudio.jsx +361 -11
  49. package/src/project-browser.jsx +135 -0
  50. package/src/project.js +289 -0
  51. package/src/props.jsx +69 -3
  52. package/src/room.jsx +14 -35
  53. package/src/scene-asset-cache.js +125 -0
  54. package/src/scene-assets.js +288 -0
  55. package/src/scene-objects.js +245 -7
  56. package/src/scenes.js +207 -26
  57. package/src/shot-authoring.js +55 -13
  58. package/src/styles.css +1296 -129
  59. package/tools/ardy/BRIDGE.md +3 -2
  60. package/tools/ardy/README.md +9 -5
  61. package/tools/ardy/bridge.mjs +57 -1
  62. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  63. package/tools/ardy/cclay_constrained_generate.py +123 -11
  64. package/tools/ardy/cclay_sequence_generate.py +49 -0
  65. package/tools/ardy/extract.mjs +367 -0
  66. package/tools/ardy/footage.mjs +462 -0
  67. package/tools/ardy/npz.mjs +74 -9
  68. package/tools/ardy/run-on-box.sh +25 -0
  69. package/tools/ardy/run-sequence-on-box.sh +15 -0
  70. package/tools/ardy/runners/remote.mjs +8 -2
  71. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  72. 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 via ``FullBodyConstraintSet`` — joint
16
- positions, joint rotations, root X/Y/Z and heading, exactly the semantics
17
- BRIDGE.md documents for pose pinning.
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 -> (local_rot_mats [F,J,3,3], posed_joints [F,J,3]) tensors.
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
- return local, posed
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 = _default_history_frames(fps, model.gen_horizon_len, patch)
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
- FullBodyConstraintSet(
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
+ }