cozyclay 1.2.0 → 1.5.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 (113) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/LICENSE +67 -80
  3. package/LICENSES/GPL-3.0-or-later.txt +674 -0
  4. package/LICENSING.md +32 -0
  5. package/README.md +48 -6
  6. package/THIRD_PARTY_NOTICES.md +47 -1
  7. package/bin/cozyclay.mjs +120 -50
  8. package/bin/mcp-runtime.mjs +115 -0
  9. package/bin/open-browser.mjs +10 -0
  10. package/bin/update-check.mjs +158 -0
  11. package/dist/ai-camera-control/index.html +406 -0
  12. package/dist/app/index.html +5 -5
  13. package/dist/ardy/assembled/run-jump-15s.npz +0 -0
  14. package/dist/assets/app-CqOk49Bk.css +1 -0
  15. package/dist/assets/app-zu633VCI.js +4811 -0
  16. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  17. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  18. package/dist/fonts/Inter-OFL.txt +92 -0
  19. package/dist/fonts/README.md +15 -0
  20. package/dist/index.html +62 -17
  21. package/dist/sitemap.xml +7 -1
  22. package/mcp/LIVE-PROTOCOL.md +144 -0
  23. package/mcp/README.md +160 -0
  24. package/mcp/ardy-prompts.mjs +188 -0
  25. package/mcp/live-hub.mjs +319 -0
  26. package/mcp/package.json +27 -0
  27. package/mcp/runtime/package-lock.json +1211 -0
  28. package/mcp/runtime/package.json +16 -0
  29. package/mcp/server.mjs +2008 -0
  30. package/package.json +142 -90
  31. package/src/App.jsx +5475 -1028
  32. package/src/ardy/cskel27.js +7 -2
  33. package/src/ardy/ik.js +25 -15
  34. package/src/ardy/motion-edit.js +172 -0
  35. package/src/ardy/npz.js +64 -3
  36. package/src/ardy/playback.js +31 -1
  37. package/src/ardy/pose-pin.js +97 -0
  38. package/src/ardy/prompt-clips.js +7 -2
  39. package/src/ardy/retime.js +211 -0
  40. package/src/ardy/root-drop.js +180 -0
  41. package/src/ardy/timeline-coordinates.js +13 -0
  42. package/src/ardy/timeline-resize.js +3 -1
  43. package/src/ardy/timeline.jsx +471 -63
  44. package/src/ardy/to-cskel27.js +34 -12
  45. package/src/ardy/trim.js +33 -0
  46. package/src/ardy/waypoints.js +4 -1
  47. package/src/asset-pane.jsx +354 -0
  48. package/src/asset-shelf.js +82 -0
  49. package/src/camera-block.js +42 -0
  50. package/src/camera-follow.js +149 -27
  51. package/src/camera-move.js +45 -19
  52. package/src/camera-rail-schedule.js +6 -0
  53. package/src/cuts.js +67 -54
  54. package/src/dualview.jsx +58 -12
  55. package/src/generation/generation-request.js +17 -0
  56. package/src/generation/use-generation.js +3 -14
  57. package/src/hierarchy-model.js +100 -16
  58. package/src/hierarchy-panel.jsx +139 -6
  59. package/src/live-control.js +142 -0
  60. package/src/matte-editor.js +543 -0
  61. package/src/matte.js +503 -0
  62. package/src/mp4-muxer.js +52 -0
  63. package/src/multimodel-ingest.js +344 -0
  64. package/src/object-gizmo.jsx +248 -25
  65. package/src/offscreen-export.js +166 -0
  66. package/src/otio.js +217 -0
  67. package/src/planview.jsx +57 -38
  68. package/src/pose-extract/detector.js +80 -0
  69. package/src/pose-extract/image-frame.js +40 -0
  70. package/src/pose-extract/index.js +4 -0
  71. package/src/pose-extract/take.js +114 -0
  72. package/src/pose-extract/video-frames.js +91 -0
  73. package/src/pose-thumbs.js +152 -0
  74. package/src/poses.js +19 -265
  75. package/src/posestudio.jsx +441 -17
  76. package/src/project-browser.jsx +135 -0
  77. package/src/project-poses.js +9 -0
  78. package/src/project.js +373 -0
  79. package/src/props.jsx +69 -3
  80. package/src/room.jsx +89 -37
  81. package/src/sample-at.js +136 -0
  82. package/src/scene-asset-cache.js +166 -0
  83. package/src/scene-assets.js +378 -0
  84. package/src/scene-objects.js +308 -7
  85. package/src/scenes.js +235 -26
  86. package/src/shot-authoring.js +120 -41
  87. package/src/shot.js +53 -13
  88. package/src/source-offer.jsx +12 -0
  89. package/src/stable-items.js +83 -0
  90. package/src/styles.css +2283 -349
  91. package/src/ui.jsx +32 -4
  92. package/src/usd-camera.js +145 -0
  93. package/tools/ardy/BRIDGE.md +11 -7
  94. package/tools/ardy/README.md +24 -5
  95. package/tools/ardy/artifacts.mjs +34 -0
  96. package/tools/ardy/bridge.mjs +89 -12
  97. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  98. package/tools/ardy/cclay_constrained_generate.py +123 -11
  99. package/tools/ardy/cclay_sequence_generate.py +49 -0
  100. package/tools/ardy/extract.mjs +372 -0
  101. package/tools/ardy/footage.mjs +467 -0
  102. package/tools/ardy/npz.mjs +74 -9
  103. package/tools/ardy/run-on-box.sh +25 -0
  104. package/tools/ardy/run-sequence-on-box.sh +15 -0
  105. package/tools/ardy/runners/local.mjs +4 -1
  106. package/tools/ardy/runners/remote.mjs +8 -2
  107. package/tools/ardy/setup-text-encoder.py +14 -4
  108. package/tools/dev-full.mjs +63 -5
  109. package/tools/generation/bridge.mjs +14 -1
  110. package/tools/process-supervisor.mjs +97 -1
  111. package/tools/run-tests.mjs +169 -0
  112. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  113. package/dist/assets/app-DgZvaAE1.css +0 -1
@@ -0,0 +1,467 @@
1
+ /**
2
+ * footage.mjs — the bridge's footage-fetch side: platform pages (YouTube,
3
+ * Vimeo, …) serve a player, not a file, and block cross-origin reads, so the
4
+ * BROWSER can never ingest them. This process can: yt-dlp downloads the
5
+ * video stream, ffmpeg locks it to a constant rate (platform video is often
6
+ * VFR — frame numbers would drift against seconds) no higher than extraction
7
+ * can use, and the result is served back over loopback where the ordinary
8
+ * ingest takes over.
9
+ *
10
+ * Same safety posture as motion delivery: served paths come only from an
11
+ * allowlist this process populated, a client disconnect kills the child
12
+ * process group, and every failure is a NAMED reason the UI can act on.
13
+ */
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { randomBytes } from "node:crypto";
17
+ import { createReadStream, existsSync, readdirSync, rmSync, statSync } from "node:fs";
18
+ import { dirname, join, sep } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { killGroup, track } from "./runners/proc.mjs";
21
+ import { createPrivateArtifactDir, removePrivateArtifactDir } from "./artifacts.mjs";
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+ const FOOTAGE_DIR = join(HERE, "out", "footage");
25
+ // Normalisation locks the clip to a CONSTANT rate and keeps the source's own
26
+ // rate up to the extraction ceiling below: platform video is often VFR (frame
27
+ // numbers drift against seconds), but resampling 30 → 24 would drop every 5th
28
+ // frame and turn smooth motion into a 4-step-1-jump judder that extraction
29
+ // reads as real velocity. The take is retimed to the 24 fps timeline AFTER
30
+ // extraction, in rotation space.
31
+ const FOOTAGE_FPS_FALLBACK = 30;
32
+ const FOOTAGE_FPS_MIN = 10;
33
+ const FOOTAGE_FPS_MAX = 60;
34
+ // The ceiling extraction is allowed to see. SAM-3D-Body solves every frame
35
+ // INDEPENDENTLY and errs a little each time, so twice the rate feeds twice the
36
+ // estimation noise per second while the smoothing windows behind it are
37
+ // counted in FRAMES and therefore span half as much time. Measured on two
38
+ // takes of the same session: a 60 fps / 1632-frame extract trembled 3.36 and
39
+ // 3.54 cm at the feet, a 30 fps / 270-frame one 0.60 and 0.72 cm — five times
40
+ // the wobble, plus double the GPU cost, for frames a 24 fps timeline can
41
+ // never show. Both intake paths clamp here; this is the single edit that
42
+ // moves the ceiling.
43
+ export const EXTRACT_FPS_MAX = 30;
44
+ const FOOTAGE_MAX_S = 900; // 15 min: previs reference, not an archive mirror
45
+ const FOOTAGE_MAX_HEIGHT = 1080;
46
+ const FOOTAGE_ALLOWLIST_MAX = 16; // newest downloads only; evicted ids 404
47
+ const FOOTAGE_ID = /^[0-9]+-[0-9a-f]{6}$/;
48
+ // Prefer the Homebrew binaries over whatever shadows them on PATH: a stale
49
+ // pip-installed yt-dlp is the classic cause of "The page needs to be
50
+ // reloaded" refusals from YouTube, and it sorts FIRST in a default PATH.
51
+ function firstExisting(override, candidates, fallback) {
52
+ if (override) return override;
53
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
54
+ return fallback;
55
+ }
56
+ const YTDLP = firstExisting(process.env.CCLAY_YTDLP, ["/opt/homebrew/bin/yt-dlp", "/usr/local/bin/yt-dlp"], "yt-dlp");
57
+ const FFMPEG = firstExisting(process.env.CCLAY_FFMPEG, ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"], "ffmpeg");
58
+ const FFPROBE = firstExisting(process.env.CCLAY_FFPROBE, ["/opt/homebrew/bin/ffprobe", "/usr/local/bin/ffprobe"], "ffprobe");
59
+ // The default (web) client serves full resolution but hits YouTube's
60
+ // "confirm you're not a bot" wall on music/mix content; the android client
61
+ // always passes but is capped around 360p. So every yt-dlp call goes web
62
+ // first and falls back to android ONLY on that specific refusal — quality
63
+ // when possible, availability when not.
64
+ const YTDLP_BASE = ["--no-playlist", "--no-warnings"];
65
+ const YTDLP_BOTFIX = ["--extractor-args", "youtube:player_client=android"];
66
+ // Two ways YouTube refuses the web client: the bot wall up front, or a 403
67
+ // on the media URLs partway through a download. Both mean "switch client".
68
+ const WEB_CLIENT_REFUSED = /confirm you.re not a bot|HTTP Error 403/i;
69
+
70
+ const footageAllowlist = new Map(); // id -> absolute mp4 path under FOOTAGE_DIR
71
+
72
+ /** `[download] 42.3% of ...` -> 0.423; null for any other line. */
73
+ export function parseDownloadRatio(line) {
74
+ const match = /\[download\]\s+([\d.]+)%/.exec(line);
75
+ if (!match) return null;
76
+ const percent = Number(match[1]);
77
+ if (!Number.isFinite(percent)) return null;
78
+ return Math.min(1, Math.max(0, percent / 100));
79
+ }
80
+
81
+ /** ffmpeg's `... time=00:00:12.34 ...` against the clip length -> 0..1. */
82
+ export function parseEncodeRatio(line, durationS) {
83
+ if (!(durationS > 0)) return null;
84
+ const match = /time=(\d+):(\d\d):(\d\d(?:\.\d+)?)/.exec(line);
85
+ if (!match) return null;
86
+ const seconds = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
87
+ if (!Number.isFinite(seconds)) return null;
88
+ return Math.min(1, Math.max(0, seconds / durationS));
89
+ }
90
+
91
+ /** yt-dlp's probe line `"123.45\t29.97\tSome title"` -> { durationS, fps,
92
+ * title }. A missing/absurd rate falls back to 30 and is clamped to a sane
93
+ * band — the ffmpeg pass hard-locks whatever rate is returned here. */
94
+ export function parseProbeLine(line) {
95
+ const parts = line.split("\t");
96
+ if (parts.length < 3) return null;
97
+ const durationS = Number(parts[0]);
98
+ const rawFps = Number(parts[1]);
99
+ const title = parts.slice(2).join("\t").trim();
100
+ if (!Number.isFinite(durationS) || durationS <= 0) return null;
101
+ const fps = Number.isFinite(rawFps) && rawFps > 0
102
+ ? Math.min(FOOTAGE_FPS_MAX, Math.max(FOOTAGE_FPS_MIN, Math.round(rawFps)))
103
+ : FOOTAGE_FPS_FALLBACK;
104
+ return { durationS, fps, title };
105
+ }
106
+
107
+ /**
108
+ * The rate a source of `sourceFps` may be fed to extraction at. Never
109
+ * upsamples — 24 stays 24, 25 stays 25 — because inventing frames invents
110
+ * motion. Above the ceiling it prefers an INTEGER divisor of the source
111
+ * (60→30, 50→25, 48→24, 120→30): dropping every n-th frame keeps the
112
+ * surviving frames exactly as they were shot, while landing on a foreign
113
+ * rate has to duplicate frames at uneven intervals and reads as judder. A
114
+ * rate with no divisor that lands in range (35, 45, …) takes the ceiling
115
+ * itself, and an unreadable rate takes it too — the ceiling is the safe
116
+ * answer, never the source's word.
117
+ */
118
+ export function capExtractFps(sourceFps) {
119
+ const fps = Math.round(Number(sourceFps));
120
+ if (!Number.isFinite(fps) || fps <= 0) return EXTRACT_FPS_MAX;
121
+ if (fps <= EXTRACT_FPS_MAX) return fps;
122
+ const divisor = Math.ceil(fps / EXTRACT_FPS_MAX);
123
+ return fps % divisor === 0 ? fps / divisor : EXTRACT_FPS_MAX;
124
+ }
125
+
126
+ /**
127
+ * The one ffmpeg pass both intake paths use: a CONSTANT `fps`, no audio, a
128
+ * faststart mp4. Nothing here touches presentation timestamps as a whole —
129
+ * `fps=N` only drops or repeats frames, so the clip comes out the same
130
+ * length and the same speed it went in.
131
+ */
132
+ export function normalizeArgs(inputPath, outputPath, fps) {
133
+ return [
134
+ "-y", "-i", inputPath,
135
+ "-vf", `fps=${fps}`,
136
+ "-an",
137
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
138
+ "-movflags", "+faststart",
139
+ outputPath,
140
+ ];
141
+ }
142
+
143
+ /** ffprobe's `key=num/den` block -> { fps, durationS }, either field null when
144
+ * the file would not say. avg_frame_rate is frames ÷ length over the whole
145
+ * stream, which is what extraction actually meets; r_frame_rate is only the
146
+ * container's base tick and reads as 90000 on some VFR muxes, so it is the
147
+ * fallback, not the answer. */
148
+ export function parseProbeStreamInfo(text) {
149
+ const source = typeof text === "string" ? text : "";
150
+ const ratio = (key) => {
151
+ const match = new RegExp(`^${key}=([\\d.]+)(?:/([\\d.]+))?$`, "m").exec(source);
152
+ if (!match) return null;
153
+ const value = match[2] === undefined ? Number(match[1]) : Number(match[1]) / Number(match[2]);
154
+ return Number.isFinite(value) && value > 0 ? value : null;
155
+ };
156
+ return { fps: ratio("avg_frame_rate") ?? ratio("r_frame_rate"), durationS: ratio("duration") };
157
+ }
158
+
159
+ function runCollect(command, args, { children, timeoutMs, onLine }) {
160
+ return new Promise((resolvePromise, reject) => {
161
+ const child = spawn(command, args, { detached: true, stdio: ["ignore", "pipe", "pipe"] });
162
+ children.add(child);
163
+ track(child);
164
+ let out = "";
165
+ let err = "";
166
+ let buffered = "";
167
+ const timer = timeoutMs
168
+ ? setTimeout(() => {
169
+ killGroup(child);
170
+ reject(new Error("footage-timeout"));
171
+ }, timeoutMs)
172
+ : null;
173
+ const feed = (text) => {
174
+ buffered += text;
175
+ // yt-dlp redraws progress with \r, ffmpeg logs stats to stderr the
176
+ // same way; both count as line breaks for the progress parsers.
177
+ const lines = buffered.split(/[\r\n]/);
178
+ buffered = lines.pop() ?? "";
179
+ for (const line of lines) if (line) onLine?.(line);
180
+ };
181
+ child.stdout.on("data", (chunk) => {
182
+ out += chunk;
183
+ feed(String(chunk));
184
+ });
185
+ child.stderr.on("data", (chunk) => {
186
+ err += chunk;
187
+ feed(String(chunk));
188
+ });
189
+ child.on("error", (error) => {
190
+ if (timer) clearTimeout(timer);
191
+ children.delete(child);
192
+ reject(new Error(`spawn ${command}: ${error.message}`));
193
+ });
194
+ child.on("close", (code) => {
195
+ if (timer) clearTimeout(timer);
196
+ children.delete(child);
197
+ if (buffered) onLine?.(buffered);
198
+ if (code === 0) resolvePromise({ out, err });
199
+ else reject(new Error(err.split("\n").filter(Boolean).pop() || `${command} exited ${code}`));
200
+ });
201
+ });
202
+ }
203
+
204
+ /** yt-dlp with the web client, retried via the android client only when
205
+ * YouTube's bot wall is the stated reason for the failure. */
206
+ async function runYtdlp(args, options) {
207
+ try {
208
+ return await runCollect(YTDLP, [...YTDLP_BASE, ...args], options);
209
+ } catch (error) {
210
+ if (!WEB_CLIENT_REFUSED.test(error?.message ?? "")) throw error;
211
+ console.error("[bridge] youtube refused the web client; retrying with the android client (≤360p)");
212
+ return runCollect(YTDLP, [...YTDLP_BASE, ...YTDLP_BOTFIX, ...args], options);
213
+ }
214
+ }
215
+
216
+ function cleanup(paths) {
217
+ for (const path of paths) {
218
+ try {
219
+ rmSync(path, { force: true });
220
+ } catch {
221
+ /* best effort */
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Bring a local video down to EXTRACT_FPS_MAX, if it is over it. Resolves to
228
+ * `{ path, fps, capped }`: `path` is the INPUT untouched whenever the source
229
+ * is already at or under the ceiling, so a bridge download — normalized at
230
+ * this same ceiling on the way in — is never re-encoded a second time for
231
+ * nothing. `fps` is what the file at `path` now runs at, i.e. what extraction
232
+ * will see; it is null only when ffprobe would not name a rate, in which case
233
+ * the clip goes through as it is rather than be refused over a probe hiccup.
234
+ * The ffmpeg failure itself throws, for the caller to name.
235
+ */
236
+ export async function conformToExtractFps(inputPath, outputPath, { children = new Set(), timeoutMs = 600000, onCap, onProgress } = {}) {
237
+ let probed = { fps: null, durationS: null };
238
+ try {
239
+ const { out } = await runCollect(
240
+ FFPROBE,
241
+ [
242
+ "-v", "error", "-select_streams", "v:0",
243
+ "-show_entries", "stream=avg_frame_rate,r_frame_rate",
244
+ "-show_entries", "format=duration",
245
+ "-of", "default=noprint_wrappers=1",
246
+ inputPath,
247
+ ],
248
+ { children, timeoutMs: 60000 }
249
+ );
250
+ probed = parseProbeStreamInfo(out);
251
+ } catch (err) {
252
+ console.error(`[bridge] fps probe failed for ${inputPath}: ${err.message}`);
253
+ }
254
+ if (probed.fps === null) return { path: inputPath, fps: null, capped: false };
255
+ const targetFps = capExtractFps(probed.fps);
256
+ if (targetFps >= Math.round(probed.fps)) return { path: inputPath, fps: targetFps, capped: false };
257
+ onCap?.(targetFps);
258
+ await runCollect(FFMPEG, normalizeArgs(inputPath, outputPath, targetFps), {
259
+ children,
260
+ timeoutMs,
261
+ onLine: (line) => {
262
+ const ratio = parseEncodeRatio(line, probed.durationS ?? 0);
263
+ if (ratio !== null) onProgress?.(ratio);
264
+ },
265
+ });
266
+ return { path: outputPath, fps: targetFps, capped: true };
267
+ }
268
+
269
+ /**
270
+ * POST /ardy/footage — body {url}, answer ndjson:
271
+ * {event:"status", message} stage announcements
272
+ * {event:"progress", stage, ratio} download / normalize movement
273
+ * {event:"done", url, title, durationS, fps}
274
+ * {event:"error", message} message is a NAMED reason
275
+ */
276
+ export async function handleFootage(req, res, readBody) {
277
+ let body;
278
+ try {
279
+ body = JSON.parse(await readBody(req));
280
+ } catch (err) {
281
+ res.writeHead(400, { "Content-Type": "application/json" });
282
+ res.end(`${JSON.stringify({ ok: false, reason: err.message })}\n`);
283
+ return;
284
+ }
285
+ const url = typeof body?.url === "string" ? body.url.trim() : "";
286
+ if (!/^https?:\/\/\S{1,2000}$/.test(url)) {
287
+ res.writeHead(400, { "Content-Type": "application/json" });
288
+ res.end(`${JSON.stringify({ ok: false, reason: "footage-url-invalid" })}\n`);
289
+ return;
290
+ }
291
+
292
+ res.writeHead(200, { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store" });
293
+ const send = (obj) => {
294
+ if (res.writableEnded) return;
295
+ try {
296
+ res.write(`${JSON.stringify(obj)}\n`);
297
+ } catch {
298
+ /* socket gone */
299
+ }
300
+ };
301
+ let cleanupArtifacts = () => {};
302
+ const fail = (message) => {
303
+ send({ event: "error", message });
304
+ res.end();
305
+ cleanupArtifacts();
306
+ };
307
+
308
+ const children = new Set();
309
+ res.on("close", () => {
310
+ if (!res.writableEnded) {
311
+ console.error(`[bridge] client disconnected mid-footage; killing ${children.size} child group(s)`);
312
+ for (const child of children) killGroup(child);
313
+ children.clear();
314
+ cleanupArtifacts();
315
+ }
316
+ });
317
+
318
+ const stamp = `${Date.now()}-${randomBytes(3).toString("hex")}`;
319
+ const artifactDir = createPrivateArtifactDir(FOOTAGE_DIR, "footage");
320
+ const rawPrefix = "raw";
321
+ const outPath = join(artifactDir, "normalized.mp4");
322
+ cleanupArtifacts = () => removePrivateArtifactDir(artifactDir);
323
+
324
+ // 1) probe: refuse lives (no duration) and long videos before any bytes.
325
+ send({ event: "status", message: "probing" });
326
+ let probe;
327
+ try {
328
+ const { out } = await runYtdlp(
329
+ ["--print", "%(duration)s\t%(fps)s\t%(title)s", url],
330
+ { children, timeoutMs: 60000 }
331
+ );
332
+ probe = parseProbeLine(out.split("\n").find((line) => line.includes("\t")) ?? "");
333
+ } catch (err) {
334
+ console.error(`[bridge] footage probe failed: ${err.message}`);
335
+ fail("footage-probe-failed");
336
+ return;
337
+ }
338
+ if (!probe) {
339
+ fail("footage-live-unsupported");
340
+ return;
341
+ }
342
+ if (probe.durationS > FOOTAGE_MAX_S) {
343
+ fail("footage-too-long");
344
+ return;
345
+ }
346
+
347
+ // 2) download the video stream only (audio never reaches the ingest).
348
+ send({ event: "status", message: "downloading" });
349
+ try {
350
+ await runYtdlp(
351
+ [
352
+ "--newline",
353
+ "-f", `bv*[height<=${FOOTAGE_MAX_HEIGHT}]/b[height<=${FOOTAGE_MAX_HEIGHT}]/b`,
354
+ "-o", join(artifactDir, `${rawPrefix}.%(ext)s`),
355
+ url,
356
+ ],
357
+ {
358
+ children,
359
+ timeoutMs: 10 * 60 * 1000,
360
+ onLine: (line) => {
361
+ const ratio = parseDownloadRatio(line);
362
+ if (ratio !== null) send({ event: "progress", stage: "download", ratio });
363
+ },
364
+ }
365
+ );
366
+ } catch (err) {
367
+ console.error(`[bridge] footage download failed: ${err.message}`);
368
+ cleanupArtifacts();
369
+ fail("footage-download-failed");
370
+ return;
371
+ }
372
+ const rawName = readdirSync(artifactDir).find((f) => f.startsWith(rawPrefix));
373
+ if (!rawName) {
374
+ fail("footage-download-failed");
375
+ return;
376
+ }
377
+ const rawPath = join(artifactDir, rawName);
378
+
379
+ // 3) lock to a CONSTANT rate at the source's own fps, capped at the rate
380
+ // extraction can use: VFR platform video would otherwise make "frame 385"
381
+ // a different instant than the timeline believes, and resampling to a
382
+ // foreign rate would judder the motion. Everything downstream — the done
383
+ // event below, the browser probe, the retime — is told THIS number, so the
384
+ // clip's declared rate is the rate SAM will actually meet.
385
+ const targetFps = capExtractFps(probe.fps);
386
+ send({ event: "status", message: "normalizing" });
387
+ try {
388
+ await runCollect(
389
+ FFMPEG,
390
+ normalizeArgs(rawPath, outPath, targetFps),
391
+ {
392
+ children,
393
+ timeoutMs: 10 * 60 * 1000,
394
+ onLine: (line) => {
395
+ const ratio = parseEncodeRatio(line, probe.durationS);
396
+ if (ratio !== null) send({ event: "progress", stage: "normalize", ratio });
397
+ },
398
+ }
399
+ );
400
+ } catch (err) {
401
+ console.error(`[bridge] footage normalize failed: ${err.message}`);
402
+ cleanupArtifacts();
403
+ fail("footage-normalize-failed");
404
+ return;
405
+ }
406
+ cleanup([rawPath]);
407
+
408
+ footageAllowlist.set(stamp, outPath);
409
+ if (footageAllowlist.size > FOOTAGE_ALLOWLIST_MAX) {
410
+ const oldest = footageAllowlist.keys().next().value;
411
+ removePrivateArtifactDir(dirname(footageAllowlist.get(oldest)));
412
+ footageAllowlist.delete(oldest);
413
+ }
414
+ send({
415
+ event: "done",
416
+ url: `/ardy/footage/${stamp}.mp4`,
417
+ footage: stamp,
418
+ title: probe.title,
419
+ durationS: probe.durationS,
420
+ fps: targetFps,
421
+ });
422
+ res.end();
423
+ }
424
+
425
+ /** Absolute path for an allowlisted footage id, or null — the extract route
426
+ * reuses a bridge-downloaded clip without a second trip through the browser. */
427
+ export function footagePath(id) {
428
+ if (typeof id !== "string" || !FOOTAGE_ID.test(id)) return null;
429
+ return footageAllowlist.get(id) ?? null;
430
+ }
431
+
432
+ /** GET /ardy/footage/<id>.mp4 — allowlist only, exactly like motion delivery. */
433
+ export function serveFootage(req, res, pathname) {
434
+ const sendJson = (status, obj) => {
435
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
436
+ res.end(`${JSON.stringify(obj)}\n`);
437
+ };
438
+ const match = /^\/ardy\/footage\/([^/]+)\.mp4$/.exec(pathname);
439
+ if (!match || !FOOTAGE_ID.test(match[1]) || !footageAllowlist.has(match[1])) {
440
+ sendJson(404, { ok: false, reason: `unknown or expired footage "${match?.[1] ?? pathname}"` });
441
+ return 404;
442
+ }
443
+ const absPath = footageAllowlist.get(match[1]);
444
+ if (!absPath.startsWith(`${FOOTAGE_DIR}${sep}`)) {
445
+ sendJson(404, { ok: false, reason: "footage path is outside the footage directory" });
446
+ return 404;
447
+ }
448
+ let size;
449
+ try {
450
+ size = statSync(absPath).size;
451
+ } catch {
452
+ sendJson(404, { ok: false, reason: "footage is no longer on disk" });
453
+ return 404;
454
+ }
455
+ res.writeHead(200, {
456
+ "Content-Type": "video/mp4",
457
+ "Content-Length": size,
458
+ "Cache-Control": "no-store",
459
+ });
460
+ createReadStream(absPath)
461
+ .on("error", (err) => {
462
+ console.error(`[bridge] error streaming ${absPath}: ${err.message}`);
463
+ res.destroy();
464
+ })
465
+ .pipe(res);
466
+ return 200;
467
+ }
@@ -13,8 +13,9 @@
13
13
  * runs its own FK. poseArraysToNpzMembers turns one CozyClay frame into
14
14
  * that exact 1-frame member set, so a hand-blocked pose can be materialized
15
15
  * as a standalone npz without ever hardcoding a rest skeleton: proportions
16
- * come from the base clip (deriveBoneOffsets) and the 8 joints CozyClay
17
- * does not author stay IDENTITY, i.e. "at ARDY rest".
16
+ * come from the base clip (deriveBoneOffsets), the 8 joints CozyClay does not
17
+ * author stay IDENTITY, and an optional int32 rotation mask prevents those
18
+ * identity placeholders from becoming rotation constraints.
18
19
  */
19
20
  import { writeFileSync } from "node:fs";
20
21
 
@@ -199,8 +200,12 @@ export function writeNpz(path, arrays) {
199
200
  * is the frames axis, so the generator's `[frame]` / `[frame, root_idx]`
200
201
  * indexing works unchanged.
201
202
  */
202
- export function poseArraysToNpzMembers({ local_rot_mats, posed_joints }) {
203
- return {
203
+ export function poseArraysToNpzMembers({
204
+ local_rot_mats,
205
+ posed_joints,
206
+ rotation_constraint_indices,
207
+ }) {
208
+ const members = {
204
209
  local_rot_mats: {
205
210
  data: flattenToF32(local_rot_mats, [27, 3, 3], "local_rot_mats"),
206
211
  shape: [1, 27, 3, 3],
@@ -210,6 +215,23 @@ export function poseArraysToNpzMembers({ local_rot_mats, posed_joints }) {
210
215
  shape: [1, 27, 3],
211
216
  },
212
217
  };
218
+ if (rotation_constraint_indices !== undefined) {
219
+ if (
220
+ !Array.isArray(rotation_constraint_indices) ||
221
+ rotation_constraint_indices.some(
222
+ (index) => !Number.isInteger(index) || index < 0 || index >= 27
223
+ )
224
+ ) {
225
+ throw new Error(
226
+ "poseArraysToNpzMembers: rotation_constraint_indices must contain joint indices in 0..26"
227
+ );
228
+ }
229
+ members.rotation_constraint_indices = {
230
+ data: Int32Array.from(rotation_constraint_indices),
231
+ shape: [rotation_constraint_indices.length],
232
+ };
233
+ }
234
+ return members;
213
235
  }
214
236
 
215
237
  /**
@@ -247,8 +269,23 @@ function flattenToF32(value, dims, name) {
247
269
  }
248
270
 
249
271
 
250
- /** Convert decoded ARDY motion arrays into writer members. */
251
- export function motionArraysToNpzMembers({ frames, fps, rotMats, rootPos, posedJoints }) {
272
+ /**
273
+ * Convert decoded ARDY motion arrays into writer members.
274
+ *
275
+ * `personScale` (the filmed performer's leg length as a fraction of the
276
+ * canonical body, from bvhToCskel27Motion) is written as a `person_scale`
277
+ * scalar when the source estimated one. It has to live IN the archive: the
278
+ * conversion already divided the root translation by it, so the trajectory is
279
+ * only metrically right once the character is scaled by the same number. A
280
+ * take whose stature travelled separately from its frames replays a filmed
281
+ * stride at canonical size and skates the feet.
282
+ *
283
+ * The member is APPENDED and only when present: an ARDY-generated take has no
284
+ * filmed performer, so its archive stays byte-identical to before, and a
285
+ * reader that ignores the extra member (numpy, dump-npz.py, the generators)
286
+ * sees the original four unchanged and in the original order.
287
+ */
288
+ export function motionArraysToNpzMembers({ frames, fps, rotMats, rootPos, posedJoints, personScale }) {
252
289
  if (!Number.isInteger(frames) || frames < 1) throw new Error("motionArraysToNpzMembers: frames must be positive");
253
290
  if (!Number.isInteger(fps) || fps < 1) throw new Error("motionArraysToNpzMembers: fps must be positive");
254
291
  const requireLength = (array, length, label) => {
@@ -259,12 +296,27 @@ export function motionArraysToNpzMembers({ frames, fps, rotMats, rootPos, posedJ
259
296
  requireLength(rotMats, frames * 27 * 9, "rotMats");
260
297
  requireLength(rootPos, frames * 3, "rootPos");
261
298
  requireLength(posedJoints, frames * 27 * 3, "posedJoints");
262
- return {
299
+ const members = {
263
300
  local_rot_mats: { data: rotMats, shape: [frames, 27, 3, 3] },
264
301
  root_positions: { data: rootPos, shape: [frames, 3] },
265
302
  posed_joints: { data: posedJoints, shape: [frames, 27, 3] },
266
303
  fps: { data: Int32Array.of(fps), shape: [] },
267
304
  };
305
+ if (personScale !== undefined && personScale !== null) {
306
+ if (!Number.isFinite(personScale) || personScale <= 0) {
307
+ throw new Error(
308
+ `motionArraysToNpzMembers: personScale must be a positive finite number, got ${personScale}`
309
+ );
310
+ }
311
+ // 1 is the canonical body — the reader's own default — so recording it
312
+ // would add a member that says nothing. Keeping it out means a
313
+ // generated take that passes through a decode/edit/rewrite round trip
314
+ // (bridge.mjs's motion edit reads person_scale back as 1) still writes
315
+ // the same four members it always did.
316
+ // float32 scalar, same 0-d shape convention as fps.
317
+ if (personScale !== 1) members.person_scale = { data: Float32Array.of(personScale), shape: [] };
318
+ }
319
+ return members;
268
320
  }
269
321
 
270
322
  /** Concatenate contiguous, already world-aligned generated blocks. */
@@ -276,6 +328,13 @@ export function stitchMotionSegments(segments) {
276
328
  if (!segments.every((segment) => segment.fps === fps)) {
277
329
  throw new Error("stitchMotionSegments: every segment must use the same fps");
278
330
  }
331
+ // One performer per stitch: the segments are consecutive blocks of the same
332
+ // body, so two different statures here means two different people were
333
+ // concatenated into one trajectory and the travel of at least one is wrong.
334
+ const scales = new Set(segments.map((segment) => segment.personScale).filter((value) => Number.isFinite(value)));
335
+ if (scales.size > 1) {
336
+ throw new Error("stitchMotionSegments: segments disagree on personScale");
337
+ }
279
338
  const frames = segments.reduce((sum, segment) => sum + segment.frames, 0);
280
339
  const concat = (key, stride) => {
281
340
  const out = new Float32Array(frames * stride);
@@ -290,13 +349,15 @@ export function stitchMotionSegments(segments) {
290
349
  }
291
350
  return out;
292
351
  };
293
- return {
352
+ const stitched = {
294
353
  frames,
295
354
  fps,
296
355
  rotMats: concat("rotMats", 27 * 9),
297
356
  rootPos: concat("rootPos", 3),
298
357
  posedJoints: concat("posedJoints", 27 * 3),
299
358
  };
359
+ if (scales.size === 1) stitched.personScale = [...scales][0];
360
+ return stitched;
300
361
  }
301
362
 
302
363
  /** Return a copy of a motion with one equal-fps replacement written at startFrame. */
@@ -325,11 +386,15 @@ export function replaceMotionSegment(base, replacement, startFrame) {
325
386
  out.set(patch, startFrame * stride);
326
387
  return out;
327
388
  };
328
- return {
389
+ const edited = {
329
390
  frames: base.frames,
330
391
  fps: base.fps,
331
392
  rotMats: copyAndReplace("rotMats", 27 * 9),
332
393
  rootPos: copyAndReplace("rootPos", 3),
333
394
  posedJoints: copyAndReplace("posedJoints", 27 * 3),
334
395
  };
396
+ // The base clip is still the same body after an edited span is written into
397
+ // it, and its root travel is still expressed against that stature.
398
+ if (Number.isFinite(base.personScale)) edited.personScale = base.personScale;
399
+ return edited;
335
400
  }
@@ -139,6 +139,9 @@ EOF
139
139
 
140
140
  POSE_NPZ="" # legacy positional pose; normalized into POSE_ARGS after parsing
141
141
  POSE_ARGS=() # repeatable triples: LOCAL_NPZ SRC_FRAME DST_FRAME
142
+ ROOT_MARGIN=""
143
+ CONTACT_THRESHOLD=""
144
+ HISTORY_FRAMES=""
142
145
  BASE=""
143
146
  PROMPT=""
144
147
  DURATION=""
@@ -182,6 +185,15 @@ while [[ $# -gt 0 ]]; do
182
185
  ROOT_2D_ARGS+=("$2" "$3" "$4" "$5"); shift 5 ;;
183
186
  --cpu)
184
187
  FORCE_CPU=1; shift ;;
188
+ --root-margin)
189
+ [[ $# -ge 2 && "$2" =~ ^[0-9]+([.][0-9]+)?$ ]] || { echo "run-on-box: --root-margin needs a non-negative number" >&2; usage; }
190
+ ROOT_MARGIN="$2"; shift 2 ;;
191
+ --history-frames)
192
+ [[ $# -ge 2 && "$2" =~ ^[0-9]+$ ]] || { echo "run-on-box: --history-frames needs a non-negative integer" >&2; usage; }
193
+ HISTORY_FRAMES="$2"; shift 2 ;;
194
+ --contact-threshold)
195
+ [[ $# -ge 2 && "$2" =~ ^[0-9]+([.][0-9]+)?$ ]] || { echo "run-on-box: --contact-threshold needs a non-negative number" >&2; usage; }
196
+ CONTACT_THRESHOLD="$2"; shift 2 ;;
185
197
  --output)
186
198
  [[ $# -ge 2 ]] || { echo "run-on-box: --output needs a path" >&2; usage; }
187
199
  OUTPUT="$2"; shift 2 ;;
@@ -353,6 +365,16 @@ if ! ssh "${SSH_OPTS[@]}" "$HOST" "$GEN_CHECK" </dev/null; then
353
365
  echo "run-on-box: ${HOST} is missing ${VENV_PY} or ${GEN_LABEL}; sync the ARDY sources to the box first (CozyClay scripts/ardy/sync-to-box --apply)" >&2
354
366
  exit 1
355
367
  fi
368
+
369
+ # The constrained generator is cclay-owned: the repo copy next to this
370
+ # script is the source of truth, synced up on every run so the box can
371
+ # never drift behind it (same pattern as run-sequence-on-box.sh).
372
+ if ! ssh "${SSH_OPTS[@]}" "$HOST" "cat > ${REMOTE}/scripts/cclay_constrained_generate.py" \
373
+ < "${HERE}/cclay_constrained_generate.py"; then
374
+ echo "run-on-box: could not sync cclay_constrained_generate.py to ${HOST}" >&2
375
+ exit 1
376
+ fi
377
+ echo "run-on-box: constrained generator synced to ${HOST}"
356
378
  echo "run-on-box: venv python and generator script present on ${HOST}"
357
379
 
358
380
 
@@ -478,6 +500,9 @@ build_remote_cmd() {
478
500
  cmd+=" $(printf '%q' "${ROOT_2D_ARGS[$((i + 2))]}")"
479
501
  cmd+=" $(printf '%q' "${ROOT_2D_ARGS[$((i + 3))]}")"
480
502
  done
503
+ [[ -z "$ROOT_MARGIN" ]] || cmd+=" --root-margin $(printf '%q' "$ROOT_MARGIN")"
504
+ [[ -z "$CONTACT_THRESHOLD" ]] || cmd+=" --contact-threshold $(printf '%q' "$CONTACT_THRESHOLD")"
505
+ [[ -z "$HISTORY_FRAMES" ]] || cmd+=" --history_frames $(printf '%q' "$HISTORY_FRAMES")"
481
506
  cmd+=" --output $(printf '%q' "${tmp_dir}/out")"
482
507
  if [[ -n "$SEED" ]]; then
483
508
  cmd+=" --seed $(printf '%q' "$SEED")"