cozyclay 1.1.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 (138) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +91 -25
  3. package/THIRD_PARTY_NOTICES.md +68 -1
  4. package/bin/cozyclay.mjs +71 -8
  5. package/dist/CNAME +1 -0
  6. package/dist/ai-camera-control/index.html +406 -0
  7. package/dist/app/index.html +57 -0
  8. package/dist/assets/app-B3U5aut1.js +4811 -0
  9. package/dist/assets/app-BrRF0wso.css +1 -0
  10. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  11. package/dist/favicon.ico +0 -0
  12. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  13. package/dist/fonts/Inter-OFL.txt +92 -0
  14. package/dist/fonts/README.md +15 -0
  15. package/dist/icons/apple-touch-icon.png +0 -0
  16. package/dist/icons/icon-192.png +0 -0
  17. package/dist/icons/icon-32.png +0 -0
  18. package/dist/icons/icon-512.png +0 -0
  19. package/dist/icons/icon-maskable-512.png +0 -0
  20. package/dist/index.html +450 -5
  21. package/dist/manifest.webmanifest +42 -0
  22. package/dist/media/cozyclay-demo-poster.jpg +0 -0
  23. package/dist/media/cozyclay-logo.png +0 -0
  24. package/dist/robots.txt +4 -0
  25. package/dist/sitemap.xml +15 -0
  26. package/dist/sw.js +127 -0
  27. package/mcp/LIVE-PROTOCOL.md +63 -0
  28. package/mcp/README.md +142 -0
  29. package/mcp/ardy-prompts.mjs +170 -0
  30. package/mcp/live-hub.mjs +105 -0
  31. package/mcp/package.json +24 -0
  32. package/mcp/server.mjs +1394 -0
  33. package/package.json +122 -78
  34. package/src/App.jsx +4235 -1047
  35. package/src/ardy/client.js +5 -4
  36. package/src/ardy/cskel27.js +7 -2
  37. package/src/ardy/ik.js +25 -15
  38. package/src/ardy/mp4-duration.js +102 -0
  39. package/src/ardy/npz.js +64 -3
  40. package/src/ardy/playback.js +31 -1
  41. package/src/ardy/prompt-clips.js +7 -2
  42. package/src/ardy/retime.js +211 -0
  43. package/src/ardy/timeline-coordinates.js +24 -0
  44. package/src/ardy/timeline.jsx +650 -70
  45. package/src/ardy/to-cskel27.js +34 -12
  46. package/src/ardy/trim.js +33 -0
  47. package/src/ardy/waypoints.js +14 -9
  48. package/src/asset-pane.jsx +36 -0
  49. package/src/camera-block.js +60 -0
  50. package/src/camera-follow.js +49 -10
  51. package/src/camera-rail-schedule.js +176 -0
  52. package/src/controls.jsx +16 -2
  53. package/src/cuts.js +184 -0
  54. package/src/dualview.jsx +14 -8
  55. package/src/generation/client.js +56 -0
  56. package/src/generation/session.js +47 -0
  57. package/src/generation/shot-spec.js +40 -0
  58. package/src/generation/use-generation.js +55 -0
  59. package/src/hierarchy-model.js +104 -30
  60. package/src/hierarchy-panel.jsx +306 -26
  61. package/src/live-control.js +122 -0
  62. package/src/locale-toggle.jsx +18 -0
  63. package/src/locale.js +34 -0
  64. package/src/main.jsx +5 -0
  65. package/src/matte-editor.js +543 -0
  66. package/src/matte.js +503 -0
  67. package/src/multimodel-ingest.js +344 -0
  68. package/src/object-catalog.jsx +32 -3
  69. package/src/object-gizmo.jsx +43 -15
  70. package/src/planview.jsx +208 -89
  71. package/src/pose-extract/browser.js +42 -0
  72. package/src/pose-extract/detector.js +75 -0
  73. package/src/pose-extract/fit.js +194 -0
  74. package/src/pose-extract/index.js +56 -0
  75. package/src/pose-extract/keyframes.js +74 -0
  76. package/src/pose-extract/landmarks.js +162 -0
  77. package/src/pose-extract/take.js +87 -0
  78. package/src/pose-extract/video-frames.js +91 -0
  79. package/src/pose-thumbs.js +152 -0
  80. package/src/poses.js +114 -0
  81. package/src/posestudio.jsx +434 -22
  82. package/src/project-browser.jsx +135 -0
  83. package/src/project.js +289 -0
  84. package/src/props.jsx +69 -3
  85. package/src/pwa.js +42 -0
  86. package/src/result-modal.jsx +124 -0
  87. package/src/room.jsx +14 -35
  88. package/src/scene-asset-cache.js +125 -0
  89. package/src/scene-assets.js +288 -0
  90. package/src/scene-objects.js +245 -7
  91. package/src/scenes.js +404 -0
  92. package/src/shot-authoring.js +246 -77
  93. package/src/shot.js +8 -4
  94. package/src/styles.css +2151 -183
  95. package/src/ui.jsx +54 -5
  96. package/tools/ardy/BRIDGE.md +73 -6
  97. package/tools/ardy/README.md +64 -5
  98. package/tools/ardy/bridge.mjs +146 -390
  99. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  100. package/tools/ardy/cclay_constrained_generate.py +561 -0
  101. package/tools/ardy/cclay_motion_edit.py +74 -9
  102. package/tools/ardy/cclay_sequence_generate.py +117 -7
  103. package/tools/ardy/cclay_worker.py +304 -0
  104. package/tools/ardy/extract.mjs +367 -0
  105. package/tools/ardy/footage.mjs +462 -0
  106. package/tools/ardy/merge-text-encoder.py +160 -0
  107. package/tools/ardy/npz.mjs +74 -9
  108. package/tools/ardy/run-local.mjs +233 -0
  109. package/tools/ardy/run-on-box.sh +25 -0
  110. package/tools/ardy/run-sequence-on-box.sh +15 -0
  111. package/tools/ardy/runners/index.mjs +23 -0
  112. package/tools/ardy/runners/local.mjs +452 -0
  113. package/tools/ardy/runners/proc.mjs +125 -0
  114. package/tools/ardy/runners/remote.mjs +252 -0
  115. package/tools/ardy/setup-local.mjs +189 -0
  116. package/tools/ardy/setup-text-encoder-on-box.sh +93 -0
  117. package/tools/ardy/setup-text-encoder.py +296 -0
  118. package/tools/generation/README.md +29 -0
  119. package/tools/generation/bridge.mjs +149 -0
  120. package/tools/generation/job-store.mjs +51 -0
  121. package/tools/generation/providers/kling.mjs +76 -0
  122. package/tools/generation/providers/runway.mjs +101 -0
  123. package/tools/generation/providers/seedance.mjs +87 -0
  124. package/tools/generation/providers/shared.mjs +49 -0
  125. package/tools/generation/providers/veo.mjs +84 -0
  126. package/tools/metrics/archive-traffic.mjs +138 -0
  127. package/dist/assets/index-BW_S1YRy.js +0 -4395
  128. package/dist/assets/index-nRmmJgap.css +0 -1
  129. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  130. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  131. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  132. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  133. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  134. package/tools/qa-browser.mjs +0 -84
  135. package/tools/qa-crop3.mjs +0 -22
  136. package/tools/qa-playview.mjs +0 -49
  137. package/tools/qa-screenshot.mjs +0 -24
  138. package/tools/qa-visual.mjs +0 -51
@@ -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
+ }