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.
- package/LICENSE +674 -0
- package/README.md +109 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/cozyclay.mjs +194 -0
- package/dist/ardy/cskel27-rest.json +557 -0
- package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
- package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
- package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
- package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
- package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
- package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
- package/dist/assets/index-BW_S1YRy.js +4395 -0
- package/dist/assets/index-nRmmJgap.css +1 -0
- package/dist/demo/walk-then-stop.npz +0 -0
- package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
- package/dist/fonts/instrument-serif-latin.woff2 +0 -0
- package/dist/fonts/inter-latin.woff2 +0 -0
- package/dist/index.html +13 -0
- package/dist/models/x-bot-tpose.fbx +0 -0
- package/dist/models/y-bot-tpose.fbx +0 -0
- package/package.json +80 -0
- package/src/App.jsx +3840 -0
- package/src/ardy/client.js +140 -0
- package/src/ardy/convert.js +313 -0
- package/src/ardy/cskel27-neutral.js +39 -0
- package/src/ardy/cskel27.js +68 -0
- package/src/ardy/export.js +157 -0
- package/src/ardy/ik.js +610 -0
- package/src/ardy/npz.js +520 -0
- package/src/ardy/playback.js +414 -0
- package/src/ardy/prompt-clips.js +16 -0
- package/src/ardy/timeline-coordinates.js +17 -0
- package/src/ardy/timeline-resize.js +11 -0
- package/src/ardy/timeline.jsx +723 -0
- package/src/ardy/to-cskel27.js +205 -0
- package/src/ardy/waypoints.js +364 -0
- package/src/camera-follow.js +366 -0
- package/src/camera-move.js +286 -0
- package/src/controls.jsx +245 -0
- package/src/dualview.jsx +305 -0
- package/src/hierarchy-model.js +77 -0
- package/src/hierarchy-panel.jsx +365 -0
- package/src/history.js +90 -0
- package/src/main.jsx +10 -0
- package/src/object-catalog.jsx +90 -0
- package/src/object-gizmo.jsx +755 -0
- package/src/planview.jsx +653 -0
- package/src/poses.js +424 -0
- package/src/posestudio.jsx +811 -0
- package/src/props.jsx +302 -0
- package/src/room.jsx +64 -0
- package/src/scene-history.js +125 -0
- package/src/scene-objects.js +420 -0
- package/src/shot-authoring.js +113 -0
- package/src/shot.js +239 -0
- package/src/styles.css +5639 -0
- package/src/ui.jsx +391 -0
- package/src/use-render-activity.js +96 -0
- package/tools/ardy/BRIDGE.md +255 -0
- package/tools/ardy/README.md +136 -0
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +1427 -0
- package/tools/ardy/cclay_motion_edit.py +445 -0
- package/tools/ardy/cclay_sequence_generate.py +595 -0
- package/tools/ardy/dump-npz.py +205 -0
- package/tools/ardy/extract-rest.mjs +299 -0
- package/tools/ardy/npz.mjs +335 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/ardy/pose-to-npz.mjs +106 -0
- package/tools/ardy/run-edit-on-box.sh +73 -0
- package/tools/ardy/run-on-box.sh +568 -0
- package/tools/ardy/run-sequence-on-box.sh +162 -0
- package/tools/ardy/visual-qa.mjs +188 -0
- package/tools/ardy/vq-car.mjs +52 -0
- package/tools/dev-full.mjs +29 -0
- package/tools/process-supervisor.mjs +63 -0
- package/tools/qa-browser.mjs +84 -0
- package/tools/qa-crop3.mjs +22 -0
- package/tools/qa-playview.mjs +49 -0
- package/tools/qa-screenshot.mjs +24 -0
- 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
|
+
}
|