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.
- package/CHANGELOG.md +193 -0
- package/LICENSE +67 -80
- package/LICENSES/GPL-3.0-or-later.txt +674 -0
- package/LICENSING.md +32 -0
- package/README.md +48 -6
- package/THIRD_PARTY_NOTICES.md +47 -1
- package/bin/cozyclay.mjs +120 -50
- package/bin/mcp-runtime.mjs +115 -0
- package/bin/open-browser.mjs +10 -0
- package/bin/update-check.mjs +158 -0
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/ardy/assembled/run-jump-15s.npz +0 -0
- package/dist/assets/app-CqOk49Bk.css +1 -0
- package/dist/assets/app-zu633VCI.js +4811 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/index.html +62 -17
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +144 -0
- package/mcp/README.md +160 -0
- package/mcp/ardy-prompts.mjs +188 -0
- package/mcp/live-hub.mjs +319 -0
- package/mcp/package.json +27 -0
- package/mcp/runtime/package-lock.json +1211 -0
- package/mcp/runtime/package.json +16 -0
- package/mcp/server.mjs +2008 -0
- package/package.json +142 -90
- package/src/App.jsx +5475 -1028
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/motion-edit.js +172 -0
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/pose-pin.js +97 -0
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/root-drop.js +180 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline-resize.js +3 -1
- package/src/ardy/timeline.jsx +471 -63
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/ardy/waypoints.js +4 -1
- package/src/asset-pane.jsx +354 -0
- package/src/asset-shelf.js +82 -0
- package/src/camera-block.js +42 -0
- package/src/camera-follow.js +149 -27
- package/src/camera-move.js +45 -19
- package/src/camera-rail-schedule.js +6 -0
- package/src/cuts.js +67 -54
- package/src/dualview.jsx +58 -12
- package/src/generation/generation-request.js +17 -0
- package/src/generation/use-generation.js +3 -14
- package/src/hierarchy-model.js +100 -16
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +142 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/mp4-muxer.js +52 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +248 -25
- package/src/offscreen-export.js +166 -0
- package/src/otio.js +217 -0
- package/src/planview.jsx +57 -38
- package/src/pose-extract/detector.js +80 -0
- package/src/pose-extract/image-frame.js +40 -0
- package/src/pose-extract/index.js +4 -0
- package/src/pose-extract/take.js +114 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/poses.js +19 -265
- package/src/posestudio.jsx +441 -17
- package/src/project-browser.jsx +135 -0
- package/src/project-poses.js +9 -0
- package/src/project.js +373 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +89 -37
- package/src/sample-at.js +136 -0
- package/src/scene-asset-cache.js +166 -0
- package/src/scene-assets.js +378 -0
- package/src/scene-objects.js +308 -7
- package/src/scenes.js +235 -26
- package/src/shot-authoring.js +120 -41
- package/src/shot.js +53 -13
- package/src/source-offer.jsx +12 -0
- package/src/stable-items.js +83 -0
- package/src/styles.css +2283 -349
- package/src/ui.jsx +32 -4
- package/src/usd-camera.js +145 -0
- package/tools/ardy/BRIDGE.md +11 -7
- package/tools/ardy/README.md +24 -5
- package/tools/ardy/artifacts.mjs +34 -0
- package/tools/ardy/bridge.mjs +89 -12
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +123 -11
- package/tools/ardy/cclay_sequence_generate.py +49 -0
- package/tools/ardy/extract.mjs +372 -0
- package/tools/ardy/footage.mjs +467 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/local.mjs +4 -1
- package/tools/ardy/runners/remote.mjs +8 -2
- package/tools/ardy/setup-text-encoder.py +14 -4
- package/tools/dev-full.mjs +63 -5
- package/tools/generation/bridge.mjs +14 -1
- package/tools/process-supervisor.mjs +97 -1
- package/tools/run-tests.mjs +169 -0
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- 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
|
+
}
|
package/tools/ardy/npz.mjs
CHANGED
|
@@ -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)
|
|
17
|
-
*
|
|
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({
|
|
203
|
-
|
|
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
|
-
/**
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/tools/ardy/run-on-box.sh
CHANGED
|
@@ -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")"
|