cozyclay 1.0.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 (85) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/bin/cozyclay.mjs +194 -0
  5. package/dist/ardy/cskel27-rest.json +557 -0
  6. package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
  8. package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
  9. package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
  10. package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
  11. package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
  12. package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
  13. package/dist/assets/index-BW_S1YRy.js +4395 -0
  14. package/dist/assets/index-nRmmJgap.css +1 -0
  15. package/dist/demo/walk-then-stop.npz +0 -0
  16. package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
  17. package/dist/fonts/instrument-serif-latin.woff2 +0 -0
  18. package/dist/fonts/inter-latin.woff2 +0 -0
  19. package/dist/index.html +13 -0
  20. package/dist/models/x-bot-tpose.fbx +0 -0
  21. package/dist/models/y-bot-tpose.fbx +0 -0
  22. package/package.json +80 -0
  23. package/src/App.jsx +3840 -0
  24. package/src/ardy/client.js +140 -0
  25. package/src/ardy/convert.js +313 -0
  26. package/src/ardy/cskel27-neutral.js +39 -0
  27. package/src/ardy/cskel27.js +68 -0
  28. package/src/ardy/export.js +157 -0
  29. package/src/ardy/ik.js +610 -0
  30. package/src/ardy/npz.js +520 -0
  31. package/src/ardy/playback.js +414 -0
  32. package/src/ardy/prompt-clips.js +16 -0
  33. package/src/ardy/timeline-coordinates.js +17 -0
  34. package/src/ardy/timeline-resize.js +11 -0
  35. package/src/ardy/timeline.jsx +723 -0
  36. package/src/ardy/to-cskel27.js +205 -0
  37. package/src/ardy/waypoints.js +364 -0
  38. package/src/camera-follow.js +366 -0
  39. package/src/camera-move.js +286 -0
  40. package/src/controls.jsx +245 -0
  41. package/src/dualview.jsx +305 -0
  42. package/src/hierarchy-model.js +77 -0
  43. package/src/hierarchy-panel.jsx +365 -0
  44. package/src/history.js +90 -0
  45. package/src/main.jsx +10 -0
  46. package/src/object-catalog.jsx +90 -0
  47. package/src/object-gizmo.jsx +755 -0
  48. package/src/planview.jsx +653 -0
  49. package/src/poses.js +424 -0
  50. package/src/posestudio.jsx +811 -0
  51. package/src/props.jsx +302 -0
  52. package/src/room.jsx +64 -0
  53. package/src/scene-history.js +125 -0
  54. package/src/scene-objects.js +420 -0
  55. package/src/shot-authoring.js +113 -0
  56. package/src/shot.js +239 -0
  57. package/src/styles.css +5639 -0
  58. package/src/ui.jsx +391 -0
  59. package/src/use-render-activity.js +96 -0
  60. package/tools/ardy/BRIDGE.md +255 -0
  61. package/tools/ardy/README.md +136 -0
  62. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  63. package/tools/ardy/bridge.mjs +1427 -0
  64. package/tools/ardy/cclay_motion_edit.py +445 -0
  65. package/tools/ardy/cclay_sequence_generate.py +595 -0
  66. package/tools/ardy/dump-npz.py +205 -0
  67. package/tools/ardy/extract-rest.mjs +299 -0
  68. package/tools/ardy/npz.mjs +335 -0
  69. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  70. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  71. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  72. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  73. package/tools/ardy/pose-to-npz.mjs +106 -0
  74. package/tools/ardy/run-edit-on-box.sh +73 -0
  75. package/tools/ardy/run-on-box.sh +568 -0
  76. package/tools/ardy/run-sequence-on-box.sh +162 -0
  77. package/tools/ardy/visual-qa.mjs +188 -0
  78. package/tools/ardy/vq-car.mjs +52 -0
  79. package/tools/dev-full.mjs +29 -0
  80. package/tools/process-supervisor.mjs +63 -0
  81. package/tools/qa-browser.mjs +84 -0
  82. package/tools/qa-crop3.mjs +22 -0
  83. package/tools/qa-playview.mjs +49 -0
  84. package/tools/qa-screenshot.mjs +24 -0
  85. package/tools/qa-visual.mjs +51 -0
@@ -0,0 +1,1427 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bridge.mjs - dev-only HTTP sidecar that drives the ARDY loop for the
4
+ * CozyClay SPA.
5
+ *
6
+ * CozyClay stays a static SPA: `vite build` emits a dist/ that needs no
7
+ * server. The box work (pose -> npz -> remote constrained generation) cannot
8
+ * run in a browser, so this sidecar exposes it on 127.0.0.1:5181 and the Vite
9
+ * dev server proxies /ardy to it. The sidecar is OPTIONAL: when it is not
10
+ * running the app behaves exactly as before, with the generate affordance
11
+ * unavailable. The production build never depends on it.
12
+ *
13
+ * The heavy lifting is done by the existing, verified CLIs - pose-to-npz.mjs
14
+ * and run-on-box.sh - never reimplemented here. The box host, repo, generator
15
+ * venv and encoder URL come from the SAME env var names run-on-box.sh reads
16
+ * (with the same defaults), so the bridge and the scripts cannot disagree.
17
+ *
18
+ * Security posture (detailed in BRIDGE.md):
19
+ * - binds 127.0.0.1 only;
20
+ * - every child is spawn()ed with an argv ARRAY; request data never reaches
21
+ * a shell string (the only remote shell strings are built from the box's
22
+ * own listing, regex-whitelisted, or from operator env vars);
23
+ * - every request field is validated before use: prompt length-capped,
24
+ * duration/dstFrame range-checked, base matched against the list the box
25
+ * actually reported, waypoints bounds/order-checked, body size-capped;
26
+ * posePin boolean-checked (default true = full-body pose constraint;
27
+ * false = path/prompt only, pose ignored, base optional - with waypoints
28
+ * and no base the box free-generates the base clip first, two-pass);
29
+ * - generated npz files are served back through an in-memory allowlist
30
+ * populated only after this process generated and verified the file; a
31
+ * path is never accepted from the URL;
32
+ * - a client disconnect kills the detached child process group instead of
33
+ * orphaning an ssh session.
34
+ */
35
+
36
+ import { createServer } from "node:http";
37
+ import { spawn } from "node:child_process";
38
+ import { randomBytes } from "node:crypto";
39
+ import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
40
+ import { basename, dirname, join, resolve, sep } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+ import { decodeMotionNpz } from "../../src/ardy/npz.js";
43
+ import { motionArraysToNpzMembers, replaceMotionSegment, writeNpz } from "./npz.mjs";
44
+ const HERE = dirname(fileURLToPath(import.meta.url));
45
+ const REPO = resolve(HERE, "../..");
46
+ const OUT_DIR = join(HERE, "out");
47
+ const REFS_DIR = join(OUT_DIR, "refs");
48
+ const DUMP_SCRIPT = join(HERE, "dump-npz.py");
49
+ const POSE_TO_NPZ = join(HERE, "pose-to-npz.mjs");
50
+ const RUN_ON_BOX = join(HERE, "run-on-box.sh");
51
+ const RUN_SEQUENCE_ON_BOX = join(HERE, "run-sequence-on-box.sh");
52
+ const RUN_EDIT_ON_BOX = join(HERE, "run-edit-on-box.sh");
53
+
54
+ const BIND_HOST = "127.0.0.1"; // loopback only: this process shells out
55
+ const DEFAULT_PORT = 5181;
56
+ const HEALTH_TTL_MS = 5000; // the UI polls health; a cached answer avoids hammering ssh
57
+ const BASES_TTL_MS = 120000; // the box's motion list changes rarely
58
+ const MAX_BODY_BYTES = 1024 * 1024;
59
+ const PROMPT_MAX_CHARS = 500;
60
+ const DURATION_MIN = 0.15;
61
+ const DURATION_MAX = 1200;
62
+ const SEED_MAX = 2 ** 31 - 1; // optional request seed: an integer in 0..2**31-1 (bridge contract)
63
+ const FPS = 20; // ARDY Core is 20 fps; clip length is int(duration * 20)
64
+ const ROOT_2D_RANGE_M = 20; // |x| and |z| cap, meters (ARDY Y-up, X/Z horizontal)
65
+ const HEADING_RANGE_RAD = 2 * Math.PI; // |heading| cap, radians
66
+ const WAYPOINTS_MAX = 32; // sparse authored root keys including frame 0
67
+ const WAYPOINT_CLIP_MAX_S = 10; // ARDY trained window: one-shot constrained calls must fit it
68
+ const WAYPOINT_SPEED_MIN_MPS = 0.3; // dense-sample gait floor (authored floor 0.5, arcs dip through corners)
69
+ const WAYPOINT_SPEED_MAX_MPS = 3.6; // dense-sample ceiling (authored ceiling 3.0)
70
+ const WAYPOINT_HOLD_EPS_M = 0.06; // pairs closer than this are a deliberate hold, any duration
71
+ const MOTION_ALLOWLIST_MAX = 64; // newest runs only; evicted ids become stale 404s
72
+ // The bridge's own gen stamp (<epoch-ms>-<3 random bytes hex>); keeping the
73
+ // motions URL id to that shape keeps /ardy/motions/<run-id> predictable and
74
+ // path-free (the id is a lookup key, never a path).
75
+ const MOTION_ID = /^[0-9]+-[0-9a-f]{6}$/;
76
+
77
+ // Same env var names as run-on-box.sh, so health, the bases listing and
78
+ // generation all talk to one operator-configured host/venv/encoder.
79
+ const HOST = process.env.CCLAY_ARDY_HOST?.trim() || "";
80
+ const REMOTE = process.env.CCLAY_ARDY_REPO || "$HOME/ardy"; // literal $HOME: the REMOTE shell expands it
81
+ const VENV_PY = process.env.CCLAY_ARDY_VENV || "~/ardy/.venv-cuda/bin/python"; // generator venv (tilde expands on the box)
82
+ const ENCODER_URL = process.env.CCLAY_ARDY_ENCODER_URL || "http://127.0.0.1:9550/";
83
+ // Numpy-only read paths (bases listing, reference dumps) use the CPU venv on
84
+ // purpose: the box's two venvs are NOT interchangeable, and this is the one
85
+ // the dump tooling is verified under.
86
+ const DUMP_PY = "~/ardy/.venv/bin/python";
87
+
88
+ // BatchMode: never hang on a password prompt. ConnectTimeout fails fast on a
89
+ // dead host; ServerAlive* drops a wedged connection (same set run-on-box.sh
90
+ // uses).
91
+ const SSH_OPTS = [
92
+ "-o", "BatchMode=yes",
93
+ "-o", "ConnectTimeout=10",
94
+ "-o", "ServerAliveInterval=30",
95
+ "-o", "ServerAliveCountMax=240",
96
+ ];
97
+ const SCP_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"];
98
+
99
+ // A box base path is produced by the box's own `ls outputs/*.npz` /
100
+ // `ls outputs/omb/*.npz` and is still whitelisted before it is embedded in a
101
+ // remote shell string: only a plain, metacharacter-free relative npz path is
102
+ // ever allowed through.
103
+ const SAFE_BASE_PATH = /^outputs\/(?:omb\/)?[A-Za-z0-9._-]+\.npz$/;
104
+ const SAFE_BASE_ID = /^[A-Za-z0-9._-]+$/;
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // process helpers
108
+ // ---------------------------------------------------------------------------
109
+
110
+ // All children spawned for generation are detached so they lead their own
111
+ // process group; killing the group takes down bash AND the ssh it is waiting
112
+ // on, so no remote session is orphaned. Tracked globally so Ctrl-C can clean
113
+ // up too.
114
+ const globalChildren = new Set();
115
+
116
+ function track(child) {
117
+ globalChildren.add(child);
118
+ child.once("close", () => globalChildren.delete(child));
119
+ }
120
+
121
+ function killGroup(child) {
122
+ try {
123
+ process.kill(-child.pid, "SIGTERM");
124
+ } catch {
125
+ /* process group already gone */
126
+ }
127
+ // SIGKILL backup for anything that ignores SIGTERM; unref'd so it never
128
+ // keeps the bridge alive on its own.
129
+ const backup = setTimeout(() => {
130
+ try {
131
+ process.kill(-child.pid, "SIGKILL");
132
+ } catch {
133
+ /* gone */
134
+ }
135
+ }, 3000);
136
+ backup.unref();
137
+ }
138
+
139
+ // Run a short, non-streaming child (ssh/scp probes) to completion.
140
+ function run(argv, { timeoutMs = 0 } = {}) {
141
+ return new Promise((resolvePromise, reject) => {
142
+ const child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
143
+ let stdout = "";
144
+ let stderr = "";
145
+ let settled = false;
146
+ let timer = null;
147
+ const finish = (err, code) => {
148
+ if (settled) return;
149
+ settled = true;
150
+ if (timer) clearTimeout(timer);
151
+ if (err) reject(err);
152
+ else resolvePromise({ code, stdout, stderr });
153
+ };
154
+ child.stdout.setEncoding("utf8");
155
+ child.stderr.setEncoding("utf8");
156
+ child.stdout.on("data", (chunk) => {
157
+ stdout += chunk;
158
+ });
159
+ child.stderr.on("data", (chunk) => {
160
+ stderr += chunk;
161
+ });
162
+ child.on("error", (err) => finish(err));
163
+ child.on("close", (code) => finish(null, code));
164
+ if (timeoutMs > 0) {
165
+ timer = setTimeout(() => {
166
+ try {
167
+ child.kill("SIGKILL");
168
+ } catch {
169
+ /* gone */
170
+ }
171
+ finish(new Error(`timed out after ${timeoutMs} ms`));
172
+ }, timeoutMs);
173
+ }
174
+ });
175
+ }
176
+
177
+ // Split a child's stdout/stderr into lines as they arrive; empty lines are
178
+ // dropped. The trailing partial line is flushed on end so the generator's
179
+ // last line is never lost.
180
+ function streamLines(stream, onLine) {
181
+ let buffer = "";
182
+ stream.setEncoding("utf8");
183
+ stream.on("data", (chunk) => {
184
+ buffer += chunk;
185
+ let idx;
186
+ while ((idx = buffer.indexOf("\n")) !== -1) {
187
+ const line = buffer.slice(0, idx).replace(/\r$/, "");
188
+ buffer = buffer.slice(idx + 1);
189
+ if (line) onLine(line);
190
+ }
191
+ });
192
+ stream.on("end", () => {
193
+ const rest = buffer.replace(/\r$/, "");
194
+ if (rest) onLine(rest);
195
+ });
196
+ }
197
+
198
+ // Resolves with the exit code; rejects when the child could not be started.
199
+ function runStreaming(child, onLine) {
200
+ return new Promise((resolvePromise, reject) => {
201
+ let settled = false;
202
+ const finish = (fn, value) => {
203
+ if (!settled) {
204
+ settled = true;
205
+ fn(value);
206
+ }
207
+ };
208
+ child.on("error", (err) => finish(reject, err));
209
+ child.on("close", (code) => finish(resolvePromise, code));
210
+ for (const [streamName, stream] of [
211
+ ["stdout", child.stdout],
212
+ ["stderr", child.stderr],
213
+ ]) {
214
+ streamLines(stream, (line) => onLine(line, streamName));
215
+ }
216
+ });
217
+ }
218
+
219
+ function lastLine(text) {
220
+ const lines = text
221
+ .split("\n")
222
+ .map((line) => line.trim())
223
+ .filter((line) => line.length > 0);
224
+ return lines.length ? lines[lines.length - 1] : "";
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // box probes (health, bases) with caching
229
+ // ---------------------------------------------------------------------------
230
+
231
+ // One ssh round trip covers everything health needs: the host answering is
232
+ // the ssh call itself, then the encoder HTTP code and the device the
233
+ // generator venv would pick are read on the box with the same env the
234
+ // generator runs under. The device line mirrors run-on-box.sh's probe
235
+ // exactly, so what health reports is what the generation will use.
236
+ function healthRemoteCmd() {
237
+ return [
238
+ `ENC="$(curl -s -o /dev/null -w '%{http_code}' -m 10 ${ENCODER_URL})"`,
239
+ '[ -n "$ENC" ] || ENC="000"',
240
+ `DEV="$(cd ${REMOTE} && ${VENV_PY} -c 'import torch; print("cuda:0" if torch.cuda.is_available() else "cpu")')"`,
241
+ `printf 'encoder=%s\\ndevice=%s\\n' "$ENC" "$DEV"`,
242
+ ].join(" && ");
243
+ }
244
+
245
+ async function probeHealth() {
246
+ const { code, stdout, stderr } = await run(
247
+ ["ssh", ...SSH_OPTS, HOST, healthRemoteCmd()],
248
+ { timeoutMs: 60_000 }
249
+ );
250
+ if (code !== 0) {
251
+ throw new Error(`ssh probe on ${HOST} failed (exit ${code}): ${lastLine(stderr)}`);
252
+ }
253
+ const fields = {};
254
+ for (const line of stdout.split("\n")) {
255
+ const match = /^(encoder|device)=(.*)$/.exec(line.trim());
256
+ if (match) fields[match[1]] = match[2];
257
+ }
258
+ if (!("encoder" in fields)) {
259
+ throw new Error(`encoder probe on ${HOST} produced no answer`);
260
+ }
261
+ if (fields.encoder !== "200") {
262
+ throw new Error(`text encoder at ${ENCODER_URL} answered ${fields.encoder}, not 200`);
263
+ }
264
+ if (!("device" in fields) || fields.device === "") {
265
+ const tail = lastLine(stderr);
266
+ throw new Error(
267
+ `device probe on ${HOST} produced no answer${tail ? `: ${tail}` : ""}`
268
+ );
269
+ }
270
+ if (!/^(cpu|cuda:\d+)$/.test(fields.device)) {
271
+ throw new Error(
272
+ `device probe on ${HOST} returned unexpected ${JSON.stringify(fields.device)}`
273
+ );
274
+ }
275
+ return { ok: true, host: HOST, encoder: Number(fields.encoder), device: fields.device };
276
+ }
277
+
278
+ // Frame counts come from a real load of each npz, in ONE ssh call: the script
279
+ // is fed to the box's python over a quoted heredoc (no local shell step), and
280
+ // entries that fail to load are skipped with a note on the box stderr instead
281
+ // of failing the whole listing.
282
+ function basesRemoteCmd() {
283
+ const script = [
284
+ "import glob, json, sys",
285
+ "import numpy as np",
286
+ "entries = []",
287
+ 'for pattern in ("outputs/*.npz", "outputs/omb/*.npz"):',
288
+ " for path in sorted(glob.glob(pattern)):",
289
+ " try:",
290
+ ' data = np.load(path, allow_pickle=False)',
291
+ ' frames = int(data["posed_joints"].shape[0])',
292
+ " data.close()",
293
+ " except Exception as exc:",
294
+ ' print("bases: skipping %s: %s" % (path, exc), file=sys.stderr)',
295
+ " continue",
296
+ ' entries.append({"id": path.rsplit("/", 1)[-1][:-4], "path": path, "frames": frames})',
297
+ "print(json.dumps(entries))",
298
+ ].join("\n");
299
+ return `cd ${REMOTE} && ${DUMP_PY} - <<'COZYCLAY_PY_EOF'\n${script}\nCOZYCLAY_PY_EOF`;
300
+ }
301
+
302
+ async function listBases() {
303
+ const { code, stdout, stderr } = await run(
304
+ ["ssh", ...SSH_OPTS, HOST, basesRemoteCmd()],
305
+ { timeoutMs: 120_000 }
306
+ );
307
+ if (code !== 0) {
308
+ throw new Error(`bases listing on ${HOST} failed (exit ${code}): ${lastLine(stderr)}`);
309
+ }
310
+ let entries;
311
+ try {
312
+ entries = JSON.parse(stdout);
313
+ } catch (err) {
314
+ throw new Error(
315
+ `bases listing on ${HOST} did not parse as JSON: ${lastLine(stderr) || err.message}`
316
+ );
317
+ }
318
+ if (!Array.isArray(entries)) {
319
+ throw new Error(`bases listing on ${HOST} was not an array`);
320
+ }
321
+ const bases = [];
322
+ for (const entry of entries) {
323
+ if (
324
+ !entry ||
325
+ typeof entry !== "object" ||
326
+ typeof entry.id !== "string" ||
327
+ typeof entry.path !== "string" ||
328
+ !Number.isInteger(entry.frames) ||
329
+ entry.frames < 0
330
+ ) {
331
+ console.error(`[bridge] skipping malformed bases entry from ${HOST}: ${JSON.stringify(entry)}`);
332
+ continue;
333
+ }
334
+ if (!SAFE_BASE_ID.test(entry.id) || !SAFE_BASE_PATH.test(entry.path)) {
335
+ console.error(`[bridge] skipping unsafe bases entry from ${HOST}: ${JSON.stringify(entry)}`);
336
+ continue;
337
+ }
338
+ bases.push({ id: entry.id, path: entry.path, frames: entry.frames });
339
+ }
340
+ // Duplicate ids (same name under outputs/ and outputs/omb/) are kept as-is;
341
+ // generate matches the first, which is exactly the lookup order
342
+ // run-on-box.sh uses, so the two cannot pick different files.
343
+ return { bases };
344
+ }
345
+
346
+ let healthCache = null;
347
+ let healthInflight = null;
348
+
349
+ // Successes AND failures are cached for the TTL so a dead box does not turn
350
+ // the UI's health polling into an ssh stampede.
351
+ async function getHealth() {
352
+ const now = Date.now();
353
+ if (healthCache && now - healthCache.at < HEALTH_TTL_MS) {
354
+ if (healthCache.error) throw healthCache.error;
355
+ return healthCache.value;
356
+ }
357
+ if (!healthInflight) {
358
+ healthInflight = probeHealth()
359
+ .then((value) => {
360
+ healthCache = { at: Date.now(), value };
361
+ return value;
362
+ })
363
+ .catch((err) => {
364
+ healthCache = { at: Date.now(), error: err };
365
+ throw err;
366
+ })
367
+ .finally(() => {
368
+ healthInflight = null;
369
+ });
370
+ }
371
+ return healthInflight;
372
+ }
373
+
374
+ let basesCache = null;
375
+ let basesInflight = null;
376
+
377
+ async function getBases() {
378
+ const now = Date.now();
379
+ if (basesCache && now - basesCache.at < BASES_TTL_MS) {
380
+ if (basesCache.error) throw basesCache.error;
381
+ return basesCache.value;
382
+ }
383
+ if (!basesInflight) {
384
+ basesInflight = listBases()
385
+ .then((value) => {
386
+ basesCache = { at: Date.now(), value };
387
+ return value;
388
+ })
389
+ .catch((err) => {
390
+ basesCache = { at: Date.now(), error: err };
391
+ throw err;
392
+ })
393
+ .finally(() => {
394
+ basesInflight = null;
395
+ });
396
+ }
397
+ return basesInflight;
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // reference frame dumps
402
+ // ---------------------------------------------------------------------------
403
+
404
+ // The synthetic pose borrows its skeleton proportions and root from the base
405
+ // clip, so a frame dump of the chosen base is required by pose-to-npz.mjs.
406
+ // Dumps are cached on disk under tools/ardy/out/refs/<base>.json and produced
407
+ // by running tools/ardy/dump-npz.py on the box (copied to /tmp, never into
408
+ // ~/ardy, removed afterwards). Concurrent requests for the same base share
409
+ // one dump via refInflight.
410
+ const refInflight = new Map();
411
+
412
+ function ensureReference(baseId, basePath) {
413
+ mkdirSync(REFS_DIR, { recursive: true });
414
+ const cachePath = join(REFS_DIR, `${baseId}.json`);
415
+ if (existsSync(cachePath)) {
416
+ try {
417
+ const cached = JSON.parse(readFileSync(cachePath, "utf8"));
418
+ if (cached && cached.schema === "ardy.frame.v1") return Promise.resolve(cachePath);
419
+ console.error(`[bridge] cached reference ${cachePath} is not ardy.frame.v1; re-dumping`);
420
+ } catch {
421
+ console.error(`[bridge] cached reference ${cachePath} is unreadable; re-dumping`);
422
+ }
423
+ }
424
+ if (refInflight.has(baseId)) return refInflight.get(baseId);
425
+ const promise = dumpReference(baseId, basePath, cachePath).finally(() => {
426
+ refInflight.delete(baseId);
427
+ });
428
+ refInflight.set(baseId, promise);
429
+ return promise;
430
+ }
431
+
432
+ async function dumpReference(baseId, basePath, cachePath) {
433
+ // basePath is already whitelisted by SAFE_BASE_PATH, so it cannot carry
434
+ // shell metacharacters into the remote command below.
435
+ const suffix = `${process.pid}-${randomBytes(4).toString("hex")}`;
436
+ const remoteScript = `/tmp/cozyclay-dump-${suffix}.py`;
437
+ try {
438
+ await run(
439
+ ["scp", "-q", ...SCP_OPTS, DUMP_SCRIPT, `${HOST}:${remoteScript}`],
440
+ { timeoutMs: 30_000 }
441
+ );
442
+ const { stdout, stderr } = await run(
443
+ ["ssh", ...SSH_OPTS, HOST, `cd ${REMOTE} && ${DUMP_PY} ${remoteScript} ${basePath} 0`],
444
+ { timeoutMs: 120_000 }
445
+ );
446
+ let parsed;
447
+ try {
448
+ parsed = JSON.parse(stdout);
449
+ } catch (err) {
450
+ throw new Error(
451
+ `reference dump for ${baseId} produced no JSON: ${lastLine(stderr) || err.message}`
452
+ );
453
+ }
454
+ if (!parsed || parsed.schema !== "ardy.frame.v1") {
455
+ throw new Error(
456
+ `reference dump for ${baseId} has schema ${JSON.stringify(parsed && parsed.schema)}`
457
+ );
458
+ }
459
+ writeFileSync(cachePath, stdout);
460
+ return cachePath;
461
+ } finally {
462
+ // Cleanup failure must never mask the real result.
463
+ await run(["ssh", ...SSH_OPTS, HOST, `rm -f ${remoteScript}`], { timeoutMs: 30_000 }).catch(
464
+ (err) => console.error(`[bridge] could not remove ${remoteScript} on ${HOST}: ${err.message}`)
465
+ );
466
+ }
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // request validation
471
+ // ---------------------------------------------------------------------------
472
+
473
+ // Returns an error message naming the offending field, or null when valid.
474
+ function validateGenerate(body) {
475
+ if (!body || typeof body !== "object" || Array.isArray(body)) return "request body must be a JSON object";
476
+ if (body.posePin !== undefined && typeof body.posePin !== "boolean") {
477
+ return `field 'posePin' must be a boolean, got ${JSON.stringify(body.posePin)}`;
478
+ }
479
+ if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) return "field 'prompt' must be a non-empty string";
480
+ if (body.prompt.length > PROMPT_MAX_CHARS) return `field 'prompt' is ${body.prompt.length} chars; the cap is ${PROMPT_MAX_CHARS}`;
481
+ if (typeof body.duration !== "number" || !Number.isFinite(body.duration)) return `field 'duration' must be a finite number`;
482
+ if (body.duration < DURATION_MIN || body.duration > DURATION_MAX) return `field 'duration' must be in ${DURATION_MIN}..${DURATION_MAX} seconds`;
483
+ const clipFrames = Math.floor(body.duration * FPS);
484
+ if (clipFrames < 3) return `field 'duration' yields fewer than 3 frames`;
485
+
486
+ const posePinned = body.posePin !== false;
487
+ if (posePinned) {
488
+ const poses = body.motionEdit
489
+ ? body.motionEdit.edits
490
+ : Array.isArray(body.poses)
491
+ ? body.poses
492
+ : body.pose
493
+ ? [{ frame: body.dstFrame, pose: body.pose }]
494
+ : null;
495
+ if (!poses || poses.length === 0 || poses.length > 64) return `field 'poses' must have 1..64 entries when posePin is true`;
496
+ let previous = -1;
497
+ for (let i = 0; i < poses.length; i += 1) {
498
+ const entry = poses[i];
499
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return `field 'poses[${i}]' must be an object`;
500
+ if (!Number.isInteger(entry.frame) || entry.frame < 0 || entry.frame >= clipFrames) return `field 'poses[${i}].frame' must be an integer in 0..${clipFrames - 1}`;
501
+ if (entry.frame <= previous) return `field 'poses' frames must be strictly ascending and distinct`;
502
+ previous = entry.frame;
503
+ if (!entry.pose || typeof entry.pose !== "object" || Array.isArray(entry.pose)) return `field 'poses[${i}].pose' must be an object`;
504
+ if (entry.pose.schema !== "cozyclay.pose.v1") return `field 'poses[${i}].pose.schema' must be "cozyclay.pose.v1"`;
505
+ if (!Array.isArray(entry.pose.root) || entry.pose.root.length !== 3 || !entry.pose.root.every(Number.isFinite)) return `field 'poses[${i}].pose.root' must be [x, y, z] finite metres`;
506
+ }
507
+ }
508
+ if (body.base !== undefined && (typeof body.base !== "string" || body.base.length === 0)) return "field 'base' must be a non-empty string";
509
+ if (body.segments !== undefined) {
510
+ const error = validateSegments(body.segments, clipFrames);
511
+ if (error) return error;
512
+ if (posePinned) return "field 'segments' uses autoregressive history and requires posePin:false";
513
+ // segments + waypoints run TOGETHER on the sequence generator: the
514
+ // Root2D constraint set is built over the whole rollout and each
515
+ // chained call sees its own slice (the interactive demo's pattern).
516
+ // The trained window then binds each segment call, not the total.
517
+ if (body.waypoints !== undefined) {
518
+ for (let i = 0; i < body.segments.length; i += 1) {
519
+ const segment = body.segments[i];
520
+ if (segment.endFrame - segment.startFrame > WAYPOINT_CLIP_MAX_S * FPS) {
521
+ return `field 'segments[${i}]' must be <= ${WAYPOINT_CLIP_MAX_S} seconds when 'waypoints' are present (each chained call must fit ARDY's trained window)`;
522
+ }
523
+ }
524
+ }
525
+ }
526
+ if (body.regenerateSegments !== undefined) {
527
+ const error = validateRegenerateSegments(body.regenerateSegments, clipFrames);
528
+ if (error) return error;
529
+ if (!posePinned) return "field 'regenerateSegments' requires posePin:true";
530
+ if (body.segments !== undefined || body.waypoints !== undefined) {
531
+ return "field 'regenerateSegments' cannot be combined with segments or waypoints";
532
+ }
533
+ if (typeof body.sourceMotion !== "string" || !/^\/ardy\/motions\/[0-9]+-[0-9a-f]{6}$/.test(body.sourceMotion)) {
534
+ return "field 'sourceMotion' must be a generated /ardy/motions/<run-id> URL";
535
+ }
536
+ }
537
+ if (body.motionEdit !== undefined) {
538
+ const edit = body.motionEdit;
539
+ if (!edit || typeof edit !== "object" || Array.isArray(edit)) return "field 'motionEdit' must be an object";
540
+ if (!posePinned) return "field 'motionEdit' requires posePin:true";
541
+ if (body.segments !== undefined || body.regenerateSegments !== undefined || body.waypoints !== undefined) {
542
+ return "field 'motionEdit' cannot be combined with segments, regenerateSegments, or waypoints";
543
+ }
544
+ if (typeof edit.sourceMotion !== "string" || !/^\/ardy\/motions\/[0-9]+-[0-9a-f]{6}$/.test(edit.sourceMotion)) {
545
+ return "field 'motionEdit.sourceMotion' must be a generated /ardy/motions/<run-id> URL";
546
+ }
547
+ if (
548
+ !Number.isInteger(edit.startFrame) ||
549
+ !Number.isInteger(edit.endFrame) ||
550
+ edit.startFrame < 0 ||
551
+ edit.endFrame > clipFrames ||
552
+ edit.endFrame - edit.startFrame < 3
553
+ ) {
554
+ return `field 'motionEdit' range must be 3+ frames inside 0..${clipFrames}`;
555
+ }
556
+ if (!Array.isArray(edit.edits) || edit.edits.length < 1 || edit.edits.length > 64) {
557
+ return "field 'motionEdit.edits' must have 1..64 entries";
558
+ }
559
+ for (let index = 0; index < edit.edits.length; index += 1) {
560
+ const entry = edit.edits[index];
561
+ if (!Array.isArray(entry.tracks) || entry.tracks.length < 1 || entry.tracks.some((track) => typeof track !== "string")) {
562
+ return `field 'motionEdit.edits[${index}].tracks' must contain track names`;
563
+ }
564
+ }
565
+ for (const key of ["contextBefore", "contextAfter"]) {
566
+ if (!Number.isInteger(edit[key]) || edit[key] < 0 || edit[key] > 160) {
567
+ return `field 'motionEdit.${key}' must be an integer in 0..160`;
568
+ }
569
+ }
570
+ }
571
+ if (body.waypoints !== undefined) {
572
+ // Without segments, a root path runs the ONE-SHOT constrained
573
+ // generator: a single model sampling call for the whole clip, so the
574
+ // clip itself must fit ARDY's 10 s trained window. With segments the
575
+ // sequence generator chains calls and the per-segment check above is
576
+ // the binding one instead.
577
+ if (body.segments === undefined && body.duration > WAYPOINT_CLIP_MAX_S) {
578
+ return `field 'duration' must be <= ${WAYPOINT_CLIP_MAX_S} seconds when 'waypoints' are present without 'segments' (one-shot constrained generation; ARDY's trained window)`;
579
+ }
580
+ const error = validateWaypoints(body.waypoints, clipFrames);
581
+ if (error) return error;
582
+ }
583
+ if (body.seed !== undefined && (!Number.isInteger(body.seed) || body.seed < 0 || body.seed > SEED_MAX)) return `field 'seed' must be an integer in 0..${SEED_MAX}`;
584
+ if (body.cpu !== undefined && typeof body.cpu !== "boolean") return `field 'cpu' must be a boolean`;
585
+ return null;
586
+ }
587
+
588
+ function validateSegments(segments, clipFrames) {
589
+ if (!Array.isArray(segments) || segments.length < 2 || segments.length > 64) return "field 'segments' must have 2..64 entries";
590
+ let cursor = 0;
591
+ for (let i = 0; i < segments.length; i += 1) {
592
+ const segment = segments[i];
593
+ if (!segment || typeof segment !== "object" || Array.isArray(segment)) return `field 'segments[${i}]' must be an object`;
594
+ if (!Number.isInteger(segment.startFrame) || !Number.isInteger(segment.endFrame)) return `field 'segments[${i}]' frames must be integers`;
595
+ if (segment.startFrame !== cursor || segment.endFrame <= segment.startFrame || segment.endFrame > clipFrames) return `field 'segments' must be contiguous from frame 0 through ${clipFrames}`;
596
+ if (segment.endFrame - segment.startFrame < 3) return `field 'segments[${i}]' must contain at least 3 frames`;
597
+ if (typeof segment.prompt !== "string" || !segment.prompt.trim() || segment.prompt.length > PROMPT_MAX_CHARS) return `field 'segments[${i}].prompt' must be 1..${PROMPT_MAX_CHARS} characters`;
598
+ cursor = segment.endFrame;
599
+ }
600
+ return cursor === clipFrames ? null : `field 'segments' must end at frame ${clipFrames}`;
601
+ }
602
+
603
+ function validateRegenerateSegments(segments, clipFrames) {
604
+ if (!Array.isArray(segments) || segments.length < 1 || segments.length > 64) {
605
+ return "field 'regenerateSegments' must have 1..64 entries";
606
+ }
607
+ let previousEnd = -1;
608
+ for (let i = 0; i < segments.length; i += 1) {
609
+ const segment = segments[i];
610
+ if (!segment || typeof segment !== "object" || Array.isArray(segment)) {
611
+ return `field 'regenerateSegments[${i}]' must be an object`;
612
+ }
613
+ if (
614
+ !Number.isInteger(segment.startFrame) ||
615
+ !Number.isInteger(segment.endFrame) ||
616
+ segment.startFrame < 0 ||
617
+ segment.endFrame > clipFrames ||
618
+ segment.endFrame - segment.startFrame < 4
619
+ ) {
620
+ return `field 'regenerateSegments[${i}]' must be a 4+ frame range inside 0..${clipFrames}`;
621
+ }
622
+ if (segment.startFrame < previousEnd) {
623
+ return "field 'regenerateSegments' must be sorted and non-overlapping";
624
+ }
625
+ if (typeof segment.prompt !== "string" || !segment.prompt.trim() || segment.prompt.length > PROMPT_MAX_CHARS) {
626
+ return `field 'regenerateSegments[${i}].prompt' must be 1..${PROMPT_MAX_CHARS} characters`;
627
+ }
628
+ previousEnd = segment.endFrame;
629
+ }
630
+ return null;
631
+ }
632
+
633
+ // Returns an error message naming the offending field, or null when valid.
634
+ // The fixed contract: 2..32 sparse {frame,x,z,heading} keys, starting at
635
+ // frame 0 with strictly ascending frames. ARDY generates every in-between
636
+ // frame. x/z are finite meters in [-20,20], heading null or finite radians
637
+ // in [-2π,2π].
638
+ // Every rejection names 'waypoints' so the client can point at the offending
639
+ // entry.
640
+ function validateWaypoints(waypoints, clipFrames) {
641
+ if (!Array.isArray(waypoints)) {
642
+ return "field 'waypoints' must be an array";
643
+ }
644
+ if (waypoints.length < 2 || waypoints.length > WAYPOINTS_MAX) {
645
+ return `field 'waypoints' must have 2..${WAYPOINTS_MAX} sparse entries, got ${waypoints.length}`;
646
+ }
647
+ let prevFrame = -1;
648
+ for (let i = 0; i < waypoints.length; i += 1) {
649
+ const wp = waypoints[i];
650
+ if (!wp || typeof wp !== "object" || Array.isArray(wp)) {
651
+ return `field 'waypoints[${i}]' must be an object`;
652
+ }
653
+ if (!Number.isInteger(wp.frame) || wp.frame < 0 || wp.frame >= clipFrames) {
654
+ return `field 'waypoints[${i}].frame' must be an integer in 0..${clipFrames - 1}, got ${JSON.stringify(wp.frame)}`;
655
+ }
656
+ if (wp.frame <= prevFrame) {
657
+ return `field 'waypoints' frames must be strictly ascending: frame ${wp.frame} duplicates or precedes frame ${prevFrame} (index ${i})`;
658
+ }
659
+ prevFrame = wp.frame;
660
+ if (i === 0 && wp.frame !== 0) {
661
+ return `field 'waypoints[0].frame' must be 0 (start + destination path), got ${wp.frame}`;
662
+ }
663
+ for (const axis of ["x", "z"]) {
664
+ const value = wp[axis];
665
+ if (typeof value !== "number" || !Number.isFinite(value)) {
666
+ return `field 'waypoints[${i}].${axis}' must be a finite number, got ${JSON.stringify(value)}`;
667
+ }
668
+ if (value < -ROOT_2D_RANGE_M || value > ROOT_2D_RANGE_M) {
669
+ return `field 'waypoints[${i}].${axis}' ${value} is outside -${ROOT_2D_RANGE_M}..${ROOT_2D_RANGE_M} meters`;
670
+ }
671
+ }
672
+ if (!("heading" in wp) || wp.heading === undefined) {
673
+ return `field 'waypoints[${i}].heading' is missing; must be null or a number of radians`;
674
+ }
675
+ if (wp.heading !== null) {
676
+ if (typeof wp.heading !== "number" || !Number.isFinite(wp.heading)) {
677
+ return `field 'waypoints[${i}].heading' must be null or a finite number of radians, got ${JSON.stringify(wp.heading)}`;
678
+ }
679
+ if (Math.abs(wp.heading) > HEADING_RANGE_RAD) {
680
+ return `field 'waypoints[${i}].heading' ${wp.heading} rad is outside -2π..2π`;
681
+ }
682
+ }
683
+ }
684
+ // Root pins are inpainting observations the model cannot refuse, so a pin
685
+ // pair demanding an inexpressible pace collapses the gait into
686
+ // foot-sliding instead of erroring. The bands are wider than the authored
687
+ // 0.5..3 m/s walk band because these are the dense C1 samples, which dip
688
+ // through corners and pause dead in authored holds.
689
+ // holdPair[i]: the pair ending at waypoint i is a deliberate hold.
690
+ // Index 0 has no pair — false, so the first real leg gets no exemption.
691
+ const holdPair = waypoints.map((wp, i) =>
692
+ i === 0 ? false : Math.hypot(wp.x - waypoints[i - 1].x, wp.z - waypoints[i - 1].z) <= WAYPOINT_HOLD_EPS_M,
693
+ );
694
+ for (let i = 1; i < waypoints.length; i += 1) {
695
+ if (holdPair[i]) continue; // a deliberate hold
696
+ const prev = waypoints[i - 1];
697
+ const wp = waypoints[i];
698
+ const speed = Math.hypot(wp.x - prev.x, wp.z - prev.z) / ((wp.frame - prev.frame) / FPS);
699
+ if (speed > WAYPOINT_SPEED_MAX_MPS) {
700
+ return `field 'waypoints[${i}]' implies ${speed.toFixed(1)} m/s from waypoint ${i - 1} — faster than the ${WAYPOINT_SPEED_MAX_MPS} m/s locomotion ceiling`;
701
+ }
702
+ // Pairs beside a hold are the C1 ramp down to (or up from) zero and
703
+ // legitimately pass under the floor; everywhere else, sub-gait creep
704
+ // means foot-sliding.
705
+ const besideHold = holdPair[i - 1] || (i + 1 < waypoints.length && holdPair[i + 1]);
706
+ if (speed < WAYPOINT_SPEED_MIN_MPS && !besideHold) {
707
+ return `field 'waypoints[${i}]' implies ${speed.toFixed(2)} m/s from waypoint ${i - 1} — below the ${WAYPOINT_SPEED_MIN_MPS} m/s gait floor (hold still or move at walking pace, nothing between)`;
708
+ }
709
+ }
710
+ return null;
711
+ }
712
+
713
+ function readBody(req, limitBytes) {
714
+ return new Promise((resolvePromise, reject) => {
715
+ const chunks = [];
716
+ let total = 0;
717
+ req.on("data", (chunk) => {
718
+ total += chunk.length;
719
+ if (total > limitBytes) {
720
+ reject(new Error(`request body exceeds ${limitBytes} bytes`));
721
+ req.destroy();
722
+ return;
723
+ }
724
+ chunks.push(chunk);
725
+ });
726
+ req.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8")));
727
+ req.on("error", (err) => reject(new Error(`request aborted: ${err.message}`)));
728
+ });
729
+ }
730
+
731
+ // ---------------------------------------------------------------------------
732
+ // endpoints
733
+ // ---------------------------------------------------------------------------
734
+
735
+ function sendJson(res, status, obj) {
736
+ res.writeHead(status, {
737
+ "Content-Type": "application/json",
738
+ "Cache-Control": "no-store",
739
+ });
740
+ res.end(`${JSON.stringify(obj)}\n`);
741
+ }
742
+
743
+ // The generator's single-line JSON report is the LAST stdout line of
744
+ // cclay_constrained_generate.py and is compact-printed (starts with "{").
745
+ // Constrained generation reports carry target_space; autoregressive sequence
746
+ // reports carry a segment table and continuity metrics.
747
+ function tryParseReport(line) {
748
+ const trimmed = line.trimStart();
749
+ if (!trimmed.startsWith("{")) return null;
750
+ let parsed;
751
+ try {
752
+ parsed = JSON.parse(trimmed);
753
+ } catch {
754
+ return null;
755
+ }
756
+ const constrained = parsed && typeof parsed === "object" && typeof parsed.target_space === "string";
757
+ const sequence =
758
+ parsed &&
759
+ typeof parsed === "object" &&
760
+ Number.isInteger(parsed.frames) &&
761
+ Number.isInteger(parsed.fps) &&
762
+ Array.isArray(parsed.segments) &&
763
+ parsed.continuity &&
764
+ typeof parsed.continuity === "object";
765
+ const motionEdit =
766
+ parsed &&
767
+ typeof parsed === "object" &&
768
+ Array.isArray(parsed.edit_range) &&
769
+ Array.isArray(parsed.history_range) &&
770
+ Array.isArray(parsed.future_range);
771
+ if (!constrained && !sequence && !motionEdit) {
772
+ return null;
773
+ }
774
+ return parsed;
775
+ }
776
+
777
+ // Force the same box config onto run-on-box.sh even when the bridge was
778
+ // started with different env values; they must not disagree.
779
+ function boxEnv() {
780
+ return {
781
+ ...process.env,
782
+ CCLAY_ARDY_HOST: HOST,
783
+ CCLAY_ARDY_REPO: REMOTE,
784
+ CCLAY_ARDY_VENV: VENV_PY,
785
+ CCLAY_ARDY_ENCODER_URL: ENCODER_URL,
786
+ };
787
+ }
788
+
789
+ // ---------------------------------------------------------------------------
790
+ // generated-motion delivery
791
+ // ---------------------------------------------------------------------------
792
+
793
+ // run-id -> absolute npz path, populated ONLY after this process generated
794
+ // the file and verified it on disk (see handleGenerate's done branch). The
795
+ // GET handler never accepts a path from the URL: an id that is not in this
796
+ // map is 404 by definition. Capped so a long-lived dev sidecar cannot grow
797
+ // without bound; evicted ids become stale and 404, which is documented.
798
+ const motionAllowlist = new Map();
799
+
800
+ function registerMotion(runId, absPath) {
801
+ motionAllowlist.set(runId, absPath);
802
+ if (motionAllowlist.size > MOTION_ALLOWLIST_MAX) {
803
+ motionAllowlist.delete(motionAllowlist.keys().next().value);
804
+ }
805
+ }
806
+
807
+ // Serves /ardy/motions/<run-id>; returns the HTTP status to log.
808
+ function serveMotion(req, res, pathname) {
809
+ const match = /^\/ardy\/motions\/([^/]+)$/.exec(pathname);
810
+ if (!match) {
811
+ sendJson(res, 404, { ok: false, reason: `not found: ${req.method} ${pathname}` });
812
+ return 404;
813
+ }
814
+ const runId = match[1];
815
+ if (!MOTION_ID.test(runId) || !motionAllowlist.has(runId)) {
816
+ sendJson(res, 404, { ok: false, reason: `unknown or expired motion "${runId}"` });
817
+ return 404;
818
+ }
819
+ const absPath = motionAllowlist.get(runId);
820
+ // The map only ever holds paths this process joined under OUT_DIR, but
821
+ // the check is re-applied at serve time: nothing outside tools/ardy/out
822
+ // is ever served, no matter how it got in.
823
+ if (!absPath.startsWith(`${OUT_DIR}${sep}`)) {
824
+ sendJson(res, 404, { ok: false, reason: `motion "${runId}" is outside ${OUT_DIR}` });
825
+ return 404;
826
+ }
827
+ let size;
828
+ try {
829
+ size = statSync(absPath).size;
830
+ } catch {
831
+ sendJson(res, 404, { ok: false, reason: `motion "${runId}" is no longer on disk` });
832
+ return 404;
833
+ }
834
+ res.writeHead(200, {
835
+ "Content-Type": "application/octet-stream",
836
+ "Content-Length": size,
837
+ "Content-Disposition": `attachment; filename="${basename(absPath)}"`,
838
+ "Cache-Control": "no-store",
839
+ });
840
+ createReadStream(absPath)
841
+ .on("error", (err) => {
842
+ console.error(`[bridge] error streaming ${absPath}: ${err.message}`);
843
+ res.destroy();
844
+ })
845
+ .pipe(res);
846
+ return 200;
847
+ }
848
+
849
+ async function handleGenerate(req, res) {
850
+ const started = Date.now();
851
+ const finish = (status) => {
852
+ console.log(`[bridge] POST /ardy/generate -> ${status} (${Date.now() - started} ms)`);
853
+ };
854
+
855
+ let raw;
856
+ try {
857
+ raw = await readBody(req, MAX_BODY_BYTES);
858
+ } catch (err) {
859
+ sendJson(res, 400, { ok: false, reason: err.message });
860
+ finish(400);
861
+ return;
862
+ }
863
+ let body;
864
+ try {
865
+ body = JSON.parse(raw);
866
+ } catch (err) {
867
+ sendJson(res, 400, { ok: false, reason: `malformed JSON body: ${err.message}` });
868
+ finish(400);
869
+ return;
870
+ }
871
+ const invalid = validateGenerate(body);
872
+ if (invalid) {
873
+ sendJson(res, 400, { ok: false, reason: invalid });
874
+ finish(400);
875
+ return;
876
+ }
877
+ // A given base must be one of the ids the box actually reported, so a
878
+ // typo or a guessed id never reaches run-on-box. posePin:false requests
879
+ // may omit base entirely - with waypoints that is the two-pass mode
880
+ // (run-on-box.sh free-generates the base clip first) and without
881
+ // waypoints it is free generation - so the listing is skipped there.
882
+ const posePinned = body.posePin !== false;
883
+ const requestedPoses = posePinned
884
+ ? (body.motionEdit
885
+ ? body.motionEdit.edits
886
+ : Array.isArray(body.poses)
887
+ ? body.poses
888
+ : [{ frame: body.dstFrame, pose: body.pose }])
889
+ : [];
890
+ const baseLabel = body.base === undefined ? "internal-neutral" : body.base;
891
+ const dstFrameLabel = requestedPoses.map((entry) => entry.frame).join(",") || "none";
892
+ let match = null;
893
+ let sourceMotionPath = null;
894
+ if (body.regenerateSegments || body.motionEdit) {
895
+ const sourceUrl = body.motionEdit?.sourceMotion || body.sourceMotion;
896
+ const sourceId = sourceUrl.slice("/ardy/motions/".length);
897
+ sourceMotionPath = motionAllowlist.get(sourceId) || null;
898
+ if (!sourceMotionPath || !existsSync(sourceMotionPath)) {
899
+ sendJson(res, 400, { ok: false, reason: `field 'sourceMotion': unknown or expired motion "${sourceId}"` });
900
+ finish(400);
901
+ return;
902
+ }
903
+ }
904
+ if (body.base !== undefined) {
905
+ let bases;
906
+ try {
907
+ bases = await getBases();
908
+ } catch (err) {
909
+ sendJson(res, 503, { ok: false, reason: `cannot list base motions on ${HOST}: ${err.message}` });
910
+ finish(503);
911
+ return;
912
+ }
913
+ match = bases.bases.find((entry) => entry.id === body.base);
914
+ if (!match) {
915
+ sendJson(res, 400, {
916
+ ok: false,
917
+ reason: `field 'base': unknown base "${body.base}" (the box reports ${bases.bases.length} base motion(s))`,
918
+ });
919
+ finish(400);
920
+ return;
921
+ }
922
+ }
923
+ res.writeHead(200, {
924
+ "Content-Type": "application/x-ndjson",
925
+ "Cache-Control": "no-store",
926
+ });
927
+ const send = (obj) => {
928
+ if (res.writableEnded) return;
929
+ try {
930
+ res.write(`${JSON.stringify(obj)}\n`);
931
+ } catch {
932
+ /* socket gone; the close handler below kills the children */
933
+ }
934
+ };
935
+ const sendStatus = (message) => send({ event: "status", message });
936
+ const sendError = (message) => {
937
+ send({ event: "error", message });
938
+ res.end();
939
+ };
940
+
941
+ // Children spawned for THIS request, killed on client disconnect or any
942
+ // terminal error. Detached groups, so killGroup takes down the whole tree
943
+ // (bash + the ssh session it waits on).
944
+ const children = new Set();
945
+ const spawnTracked = (command, args, options) => {
946
+ const child = spawn(command, args, options);
947
+ children.add(child);
948
+ track(child);
949
+ return child;
950
+ };
951
+ const killChildren = () => {
952
+ for (const child of children) killGroup(child);
953
+ children.clear();
954
+ };
955
+ res.on("close", () => {
956
+ if (!res.writableEnded) {
957
+ console.error(
958
+ `[bridge] client disconnected mid-generate; killing ${children.size} child process group(s)`
959
+ );
960
+ killChildren();
961
+ }
962
+ });
963
+
964
+ const stamp = `${Date.now()}-${randomBytes(3).toString("hex")}`;
965
+ const poseJsonPaths = requestedPoses.map((_, index) => join(OUT_DIR, `gen-${stamp}-pose-${index}.json`));
966
+ const poseNpzPaths = requestedPoses.map((_, index) => join(OUT_DIR, `gen-${stamp}-pose-${index}.npz`));
967
+ // posePin:false runs carry no pose, so the artifact name says what the
968
+ // run actually was: constrained (pose pinned) vs generated (no pose).
969
+ const outNpzPath = join(OUT_DIR, posePinned ? `gen-${stamp}-constrained.npz` : `gen-${stamp}-generated.npz`);
970
+ try {
971
+ mkdirSync(OUT_DIR, { recursive: true });
972
+
973
+ // --- pose -> ARDY motion npz (local, fast). Skipped when posePin is
974
+ // false: the pose constraint is dropped, so there is nothing to
975
+ // convert, dump a reference for, or push.
976
+ if (posePinned) {
977
+ for (let index = 0; index < requestedPoses.length; index += 1) {
978
+ const entry = requestedPoses[index];
979
+ const poseJsonPath = poseJsonPaths[index];
980
+ const poseNpzPath = poseNpzPaths[index];
981
+ writeFileSync(poseJsonPath, `${JSON.stringify(entry.pose, null, 2)}
982
+ `);
983
+ sendStatus(`[bridge] pose ${index + 1}/${requestedPoses.length} frame ${entry.frame} written`);
984
+ const conv = spawnTracked(
985
+ process.execPath,
986
+ [POSE_TO_NPZ, poseJsonPath, "--out", poseNpzPath],
987
+ { cwd: REPO, detached: true }
988
+ );
989
+ const convLast = { stderr: "", stdout: "" };
990
+ const convCode = await runStreaming(conv, (line, streamName) => {
991
+ convLast[streamName] = line;
992
+ sendStatus(line);
993
+ });
994
+ children.delete(conv);
995
+ if (convCode !== 0) {
996
+ killChildren();
997
+ sendError(`pose-to-npz failed (exit ${convCode}): ${convLast.stderr || convLast.stdout || "no output"}`);
998
+ finish(200);
999
+ return;
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ // --- generation on the box -----------------------------------------
1005
+ const clipFrames = Math.floor(body.duration * FPS);
1006
+ const segments = body.segments || [{ startFrame: 0, endFrame: clipFrames, prompt: body.prompt }];
1007
+ if (body.motionEdit) {
1008
+ const manifestPath = join(OUT_DIR, `gen-${stamp}-edit-manifest.json`);
1009
+ const manifest = {
1010
+ start_frame: body.motionEdit.startFrame,
1011
+ end_frame: body.motionEdit.endFrame,
1012
+ edits: body.motionEdit.edits.map((entry, index) => ({
1013
+ frame: entry.frame,
1014
+ tracks: entry.tracks,
1015
+ root: entry.pose.root,
1016
+ pose_path: `pose-${index}.npz`,
1017
+ })),
1018
+ };
1019
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
1020
+ const args = [
1021
+ RUN_EDIT_ON_BOX,
1022
+ "--source", sourceMotionPath,
1023
+ "--manifest", manifestPath,
1024
+ "--prompt", body.prompt,
1025
+ "--context-before", String(body.motionEdit.contextBefore),
1026
+ "--context-after", String(body.motionEdit.contextAfter),
1027
+ "--output", outNpzPath,
1028
+ ];
1029
+ if (Number.isInteger(body.seed)) args.push("--seed", String(body.seed));
1030
+ for (const posePath of poseNpzPaths) args.push("--pose", posePath);
1031
+ sendStatus(
1032
+ `[bridge] editing frames ${body.motionEdit.startFrame}..${body.motionEdit.endFrame - 1} ` +
1033
+ "with ARDY history and sparse constraints"
1034
+ );
1035
+ const box = spawnTracked("bash", args, { cwd: REPO, detached: true, env: boxEnv() });
1036
+ const last = { stderr: "", stdout: "" };
1037
+ let report = null;
1038
+ let done = null;
1039
+ const code = await runStreaming(box, (line, streamName) => {
1040
+ last[streamName] = line;
1041
+ if (streamName === "stdout") {
1042
+ const parsed = tryParseReport(line);
1043
+ if (parsed) {
1044
+ report = parsed;
1045
+ return;
1046
+ }
1047
+ const marker = /^run-edit-on-box: done - (.+) \((\d+) bytes\)$/.exec(line);
1048
+ if (marker) {
1049
+ done = { path: marker[1], bytes: Number(marker[2]) };
1050
+ return;
1051
+ }
1052
+ }
1053
+ sendStatus(line);
1054
+ });
1055
+ children.delete(box);
1056
+ if (code !== 0) throw new Error(`run-edit-on-box failed (exit ${code}): ${last.stderr || last.stdout || "no output"}`);
1057
+ if (!done || done.path !== outNpzPath) throw new Error("run-edit-on-box did not return the requested output");
1058
+ const finalSize = statSync(outNpzPath).size;
1059
+ if (finalSize !== done.bytes) throw new Error("run-edit-on-box output size mismatch");
1060
+ if (report) send({ event: "report", report });
1061
+ registerMotion(stamp, outNpzPath);
1062
+ send({ event: "done", output: outNpzPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
1063
+ res.end();
1064
+ console.log(`[bridge] context-aware motion edit finished: ${outNpzPath}`);
1065
+ killChildren();
1066
+ finish(200);
1067
+ return;
1068
+ }
1069
+ if (body.segments) {
1070
+ const args = [RUN_SEQUENCE_ON_BOX];
1071
+ for (const segment of segments) {
1072
+ args.push(
1073
+ "--segment",
1074
+ segment.prompt,
1075
+ String((segment.endFrame - segment.startFrame) / FPS)
1076
+ );
1077
+ }
1078
+ // Root waypoints ride the same chained rollout (rollout-global
1079
+ // frames): the sequence generator slices the constraint set per
1080
+ // segment call, so a path and a prompt schedule coexist.
1081
+ for (const wp of body.waypoints ?? []) {
1082
+ args.push("--root-2d", String(wp.frame), String(wp.x), String(wp.z), wp.heading === null ? "none" : String(wp.heading));
1083
+ }
1084
+ if (Number.isInteger(body.seed)) args.push("--seed", String(body.seed));
1085
+ if (body.cpu === true) args.push("--cpu");
1086
+ args.push("--output", outNpzPath);
1087
+ sendStatus(
1088
+ `[bridge] generating ${segments.length} blocks in one autoregressive ARDY session` +
1089
+ (body.waypoints?.length ? ` with a ${body.waypoints.length}-pin root path` : "")
1090
+ );
1091
+
1092
+ const box = spawnTracked("bash", args, { cwd: REPO, detached: true, env: boxEnv() });
1093
+ const last = { stderr: "", stdout: "" };
1094
+ let finalReport = null;
1095
+ let done = null;
1096
+ const code = await runStreaming(box, (line, streamName) => {
1097
+ last[streamName] = line;
1098
+ if (streamName === "stdout") {
1099
+ const parsed = tryParseReport(line);
1100
+ if (parsed) {
1101
+ finalReport = parsed;
1102
+ return;
1103
+ }
1104
+ const marker = /^run-sequence-on-box: done - (.+) \((\d+) bytes\)$/.exec(line);
1105
+ if (marker) {
1106
+ done = { path: marker[1], bytes: Number(marker[2]) };
1107
+ return;
1108
+ }
1109
+ }
1110
+ sendStatus(line);
1111
+ });
1112
+ children.delete(box);
1113
+ if (code !== 0) {
1114
+ throw new Error(`run-sequence-on-box failed (exit ${code}): ${last.stderr || last.stdout || "no output"}`);
1115
+ }
1116
+ if (!done) throw new Error('run-sequence-on-box exited 0 without a "done" marker');
1117
+ if (done.path !== outNpzPath) throw new Error(`run-sequence-on-box returned unexpected output ${done.path}`);
1118
+ const finalSize = statSync(outNpzPath).size;
1119
+ if (finalSize === 0 || finalSize !== done.bytes) {
1120
+ throw new Error(`run-sequence-on-box output size mismatch for ${outNpzPath}`);
1121
+ }
1122
+ if (finalReport) send({ event: "report", report: finalReport });
1123
+ registerMotion(stamp, outNpzPath);
1124
+ send({ event: "done", output: outNpzPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
1125
+ res.end();
1126
+ console.log(`[bridge] sequence generation finished: ${outNpzPath} (${segments.length} blocks)`);
1127
+ killChildren();
1128
+ finish(200);
1129
+ return;
1130
+ }
1131
+ const runSingle = async (segment, outputPath = outNpzPath) => {
1132
+ const localPoses = requestedPoses
1133
+ .map((entry, poseIndex) => ({ ...entry, poseIndex }))
1134
+ .filter((entry) => entry.frame >= segment.startFrame && entry.frame < segment.endFrame);
1135
+ if (posePinned && localPoses.length === 0) {
1136
+ throw new Error("generation has no pose constraint");
1137
+ }
1138
+ const segmentFrames = segment.endFrame - segment.startFrame;
1139
+ const args = [RUN_ON_BOX];
1140
+ for (const entry of localPoses) {
1141
+ args.push(
1142
+ "--pose-from",
1143
+ poseNpzPaths[entry.poseIndex],
1144
+ "0",
1145
+ String(entry.frame - segment.startFrame)
1146
+ );
1147
+ }
1148
+ if (match) args.push("--base", match.path);
1149
+ args.push("--prompt", segment.prompt, "--duration", String(segmentFrames / FPS));
1150
+ if (Number.isInteger(body.seed)) args.push("--seed", String(body.seed));
1151
+ if (body.cpu === true) args.push("--cpu");
1152
+ for (const wp of body.waypoints || []) {
1153
+ args.push("--root-2d", String(wp.frame), String(wp.x), String(wp.z), wp.heading === null ? "none" : String(wp.heading));
1154
+ }
1155
+ args.push("--output", outputPath);
1156
+ sendStatus(`[bridge] generating frames ${segment.startFrame}..${segment.endFrame - 1}`);
1157
+
1158
+ const box = spawnTracked("bash", args, { cwd: REPO, detached: true, env: boxEnv() });
1159
+ const last = { stderr: "", stdout: "" };
1160
+ let report = null;
1161
+ let done = null;
1162
+ const code = await runStreaming(box, (line, streamName) => {
1163
+ last[streamName] = line;
1164
+ if (streamName === "stdout") {
1165
+ const parsed = tryParseReport(line);
1166
+ if (parsed) {
1167
+ report = parsed;
1168
+ return;
1169
+ }
1170
+ const marker = /^run-on-box: done - (.+) \((\d+) bytes\)$/.exec(line);
1171
+ if (marker) {
1172
+ done = { path: marker[1], bytes: Number(marker[2]) };
1173
+ return;
1174
+ }
1175
+ }
1176
+ sendStatus(line);
1177
+ });
1178
+ children.delete(box);
1179
+ if (code !== 0) throw new Error(`run-on-box failed (exit ${code}): ${last.stderr || last.stdout || "no output"}`);
1180
+ if (!done) throw new Error('run-on-box exited 0 without a "run-on-box: done" marker');
1181
+ if (done.path !== outputPath) throw new Error(`run-on-box returned unexpected output ${done.path}`);
1182
+ const size = statSync(outputPath).size;
1183
+ if (size === 0 || size !== done.bytes) throw new Error(`run-on-box output size mismatch for ${outputPath}`);
1184
+ return report;
1185
+ };
1186
+
1187
+ if (body.regenerateSegments) {
1188
+ const source = await decodeMotionNpz(new Uint8Array(readFileSync(sourceMotionPath)));
1189
+ if (source.frames !== clipFrames || source.fps !== FPS) {
1190
+ throw new Error(`source motion is ${source.frames} frames @ ${source.fps} fps; expected ${clipFrames} @ ${FPS}`);
1191
+ }
1192
+ let result = source;
1193
+ const reports = [];
1194
+ for (let index = 0; index < body.regenerateSegments.length; index += 1) {
1195
+ const segment = body.regenerateSegments[index];
1196
+ const segmentPath = join(OUT_DIR, `gen-${stamp}-edit-${index}.npz`);
1197
+ const report = await runSingle(segment, segmentPath);
1198
+ const generated = await decodeMotionNpz(new Uint8Array(readFileSync(segmentPath)));
1199
+ const segmentFrames = segment.endFrame - segment.startFrame;
1200
+ if (generated.frames !== segmentFrames || generated.fps !== FPS) {
1201
+ throw new Error(`edited block ${index + 1} returned ${generated.frames} frames @ ${generated.fps} fps`);
1202
+ }
1203
+ result = replaceMotionSegment(result, generated, segment.startFrame);
1204
+ if (report) reports.push({ ...report, startFrame: segment.startFrame, endFrame: segment.endFrame });
1205
+ }
1206
+ writeNpz(outNpzPath, motionArraysToNpzMembers(result));
1207
+ const boundaryJumps = [];
1208
+ for (const segment of body.regenerateSegments) {
1209
+ for (const frame of [segment.startFrame, segment.endFrame]) {
1210
+ if (frame <= 0 || frame >= result.frames) continue;
1211
+ let maxJump = 0;
1212
+ for (let joint = 0; joint < 27; joint += 1) {
1213
+ const current = (frame * 27 + joint) * 3;
1214
+ const previous = current - 27 * 3;
1215
+ maxJump = Math.max(
1216
+ maxJump,
1217
+ Math.hypot(
1218
+ result.posedJoints[current] - result.posedJoints[previous],
1219
+ result.posedJoints[current + 1] - result.posedJoints[previous + 1],
1220
+ result.posedJoints[current + 2] - result.posedJoints[previous + 2]
1221
+ )
1222
+ );
1223
+ }
1224
+ boundaryJumps.push({ frame, max_joint_jump_m: maxJump });
1225
+ }
1226
+ }
1227
+ send({
1228
+ event: "report",
1229
+ report: {
1230
+ target_space: "skeleton_joint_center",
1231
+ frames: result.frames,
1232
+ fps: result.fps,
1233
+ regenerated_segments: body.regenerateSegments,
1234
+ segments: reports,
1235
+ boundaries: boundaryJumps,
1236
+ },
1237
+ });
1238
+ const finalSize = statSync(outNpzPath).size;
1239
+ registerMotion(stamp, outNpzPath);
1240
+ send({ event: "done", output: outNpzPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
1241
+ res.end();
1242
+ console.log(`[bridge] regenerated ${body.regenerateSegments.length} edited block(s): ${outNpzPath}`);
1243
+ killChildren();
1244
+ finish(200);
1245
+ return;
1246
+ }
1247
+
1248
+ const finalReport = await runSingle(segments[0]);
1249
+ if (finalReport) {
1250
+ const poseResults = finalReport.poses || [];
1251
+ const worst = (key) => poseResults.length ? Math.max(...poseResults.map((pose) => pose[key] ?? 0)) : null;
1252
+ finalReport.root_error_m = worst("root_error_m");
1253
+ finalReport.shape_mean_error_m = worst("shape_mean_error_m");
1254
+ finalReport.shape_max_error_m = worst("shape_max_error_m");
1255
+ finalReport.base_root_error_m = worst("base_root_error_m");
1256
+ finalReport.base_shape_mean_error_m = worst("base_shape_mean_error_m");
1257
+ finalReport.base_shape_max_error_m = worst("base_shape_max_error_m");
1258
+ send({ event: "report", report: finalReport });
1259
+ }
1260
+ const finalSize = statSync(outNpzPath).size;
1261
+ registerMotion(stamp, outNpzPath);
1262
+ send({ event: "done", output: outNpzPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
1263
+ res.end();
1264
+ console.log(`[bridge] generate finished: ${outNpzPath} (base ${baseLabel}, ${body.duration}s, dstFrame ${dstFrameLabel})`);
1265
+
1266
+ killChildren();
1267
+ finish(200);
1268
+ } catch (err) {
1269
+ killChildren();
1270
+ if (!res.writableEnded) sendError(`generate failed: ${err.message}`);
1271
+ console.error(`[bridge] generate error: ${err.stack || err}`);
1272
+ finish(200);
1273
+ }
1274
+ }
1275
+
1276
+ // ---------------------------------------------------------------------------
1277
+ // server
1278
+ // ---------------------------------------------------------------------------
1279
+
1280
+ function usage() {
1281
+ console.log(`usage: node tools/ardy/bridge.mjs [--port <n>]
1282
+
1283
+ Dev-only HTTP sidecar for the ARDY loop. See tools/ardy/BRIDGE.md.
1284
+
1285
+ --port <n> port to listen on (default 5181, loopback only)
1286
+
1287
+ env:
1288
+ COZYCLAY_BRIDGE_PORT same as --port (default 5181)
1289
+ CCLAY_ARDY_HOST ssh destination for the ARDY host (required)
1290
+ CCLAY_ARDY_REPO ARDY checkout on the box (default $HOME/ardy)
1291
+ CCLAY_ARDY_VENV generator venv python on the box (default ~/ardy/.venv-cuda/bin/python)
1292
+ CCLAY_ARDY_ENCODER_URL text encoder service (default http://127.0.0.1:9550/)
1293
+ `);
1294
+ }
1295
+
1296
+ function die(message) {
1297
+ console.error(`[bridge] ${message}`);
1298
+ process.exit(2);
1299
+ }
1300
+
1301
+ function resolvePort(argv) {
1302
+ for (let i = 0; i < argv.length; i += 1) {
1303
+ if (argv[i] === "--help" || argv[i] === "-h") {
1304
+ usage();
1305
+ process.exit(0);
1306
+ }
1307
+ if (argv[i] === "--port") {
1308
+ const value = argv[i + 1];
1309
+ if (value === undefined) die("--port needs a value");
1310
+ const port = Number(value);
1311
+ if (!Number.isInteger(port) || port < 1 || port > 65535) die(`invalid --port value '${value}'`);
1312
+ return port;
1313
+ }
1314
+ }
1315
+ if (process.env.COZYCLAY_BRIDGE_PORT !== undefined) {
1316
+ const port = Number(process.env.COZYCLAY_BRIDGE_PORT);
1317
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1318
+ die(`invalid COZYCLAY_BRIDGE_PORT value '${process.env.COZYCLAY_BRIDGE_PORT}'`);
1319
+ }
1320
+ return port;
1321
+ }
1322
+ return DEFAULT_PORT;
1323
+ }
1324
+
1325
+ const port = resolvePort(process.argv.slice(2));
1326
+ if (!HOST) die("CCLAY_ARDY_HOST is required (for example: user@ardy-host)");
1327
+
1328
+ const server = createServer((req, res) => {
1329
+ const started = Date.now();
1330
+ const pathname = (req.url || "/").split("?")[0];
1331
+ const log = (status) => {
1332
+ console.log(`[bridge] ${req.method} ${pathname} -> ${status} (${Date.now() - started} ms)`);
1333
+ };
1334
+
1335
+ if (req.method === "OPTIONS") {
1336
+ // 204 with NO CORS headers on purpose: a cross-origin browser
1337
+ // preflight must fail, and the same-origin Vite proxy never
1338
+ // preflights the bridge (it forwards server-side).
1339
+ res.writeHead(204);
1340
+ res.end();
1341
+ log(204);
1342
+ return;
1343
+ }
1344
+ if (pathname === "/ardy/health" && req.method === "GET") {
1345
+ getHealth()
1346
+ .then((value) => {
1347
+ sendJson(res, 200, value);
1348
+ log(200);
1349
+ })
1350
+ .catch((err) => {
1351
+ sendJson(res, 503, { ok: false, reason: err.message });
1352
+ log(503);
1353
+ });
1354
+ return;
1355
+ }
1356
+ if (pathname === "/ardy/bases" && req.method === "GET") {
1357
+ getBases()
1358
+ .then((value) => {
1359
+ sendJson(res, 200, value);
1360
+ log(200);
1361
+ })
1362
+ .catch((err) => {
1363
+ sendJson(res, 503, { ok: false, reason: err.message });
1364
+ log(503);
1365
+ });
1366
+ return;
1367
+ }
1368
+ if (pathname === "/ardy/generate" && req.method === "POST") {
1369
+ handleGenerate(req, res).catch((err) => {
1370
+ if (!res.headersSent) sendJson(res, 500, { ok: false, reason: `internal error: ${err.message}` });
1371
+ else {
1372
+ try {
1373
+ res.end();
1374
+ } catch {
1375
+ /* socket gone */
1376
+ }
1377
+ }
1378
+ console.error(`[bridge] ${req.method} ${pathname} threw: ${err.stack || err}`);
1379
+ log(500);
1380
+ });
1381
+ return;
1382
+ }
1383
+ if (/^\/ardy\/motions\//.test(pathname) && req.method === "GET") {
1384
+ log(serveMotion(req, res, pathname));
1385
+ return;
1386
+ }
1387
+ if (
1388
+ pathname === "/ardy/health" ||
1389
+ pathname === "/ardy/bases" ||
1390
+ pathname === "/ardy/generate" ||
1391
+ /^\/ardy\/motions\//.test(pathname)
1392
+ ) {
1393
+ sendJson(res, 405, { ok: false, reason: `method ${req.method} not allowed on ${pathname}` });
1394
+ log(405);
1395
+ return;
1396
+ }
1397
+ sendJson(res, 404, { ok: false, reason: `not found: ${req.method} ${pathname}` });
1398
+ log(404);
1399
+ });
1400
+
1401
+ server.on("clientError", (err, socket) => {
1402
+ if (socket.writable) {
1403
+ socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
1404
+ }
1405
+ console.error(`[bridge] client error: ${err.message}`);
1406
+ });
1407
+
1408
+ server.listen(port, BIND_HOST, () => {
1409
+ console.log(`[bridge] ARDY dev bridge listening on http://${BIND_HOST}:${port}`);
1410
+ console.log("[bridge] dev-only sidecar: the static dist/ build does not need it; stop with Ctrl-C");
1411
+ console.log(`[bridge] box ${HOST} (repo ${REMOTE}, generator venv ${VENV_PY}, encoder ${ENCODER_URL})`);
1412
+ });
1413
+
1414
+ server.on("error", (err) => {
1415
+ console.error(`[bridge] cannot listen on ${BIND_HOST}:${port}: ${err.message}`);
1416
+ process.exit(1);
1417
+ });
1418
+
1419
+ // Ctrl-C / SIGTERM: take the in-flight process groups down with us; ssh dies,
1420
+ // sshd closes the session, and the remote generation is not orphaned.
1421
+ for (const signal of ["SIGINT", "SIGTERM"]) {
1422
+ process.on(signal, () => {
1423
+ console.error(`[bridge] received ${signal}; killing ${globalChildren.size} in-flight child process group(s)`);
1424
+ for (const child of globalChildren) killGroup(child);
1425
+ process.exit(signal === "SIGINT" ? 130 : 143);
1426
+ });
1427
+ }