cozyclay 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +91 -25
- package/THIRD_PARTY_NOTICES.md +68 -1
- package/bin/cozyclay.mjs +71 -8
- package/dist/CNAME +1 -0
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +57 -0
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/favicon.ico +0 -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/icons/apple-touch-icon.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-32.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/icon-maskable-512.png +0 -0
- package/dist/index.html +450 -5
- package/dist/manifest.webmanifest +42 -0
- package/dist/media/cozyclay-demo-poster.jpg +0 -0
- package/dist/media/cozyclay-logo.png +0 -0
- package/dist/robots.txt +4 -0
- package/dist/sitemap.xml +15 -0
- package/dist/sw.js +127 -0
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -78
- package/src/App.jsx +4235 -1047
- package/src/ardy/client.js +5 -4
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/mp4-duration.js +102 -0
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +24 -0
- package/src/ardy/timeline.jsx +650 -70
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/ardy/waypoints.js +14 -9
- package/src/asset-pane.jsx +36 -0
- package/src/camera-block.js +60 -0
- package/src/camera-follow.js +49 -10
- package/src/camera-rail-schedule.js +176 -0
- package/src/controls.jsx +16 -2
- package/src/cuts.js +184 -0
- package/src/dualview.jsx +14 -8
- package/src/generation/client.js +56 -0
- package/src/generation/session.js +47 -0
- package/src/generation/shot-spec.js +40 -0
- package/src/generation/use-generation.js +55 -0
- package/src/hierarchy-model.js +104 -30
- package/src/hierarchy-panel.jsx +306 -26
- package/src/live-control.js +122 -0
- package/src/locale-toggle.jsx +18 -0
- package/src/locale.js +34 -0
- package/src/main.jsx +5 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-catalog.jsx +32 -3
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +208 -89
- package/src/pose-extract/browser.js +42 -0
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/fit.js +194 -0
- package/src/pose-extract/index.js +56 -0
- package/src/pose-extract/keyframes.js +74 -0
- package/src/pose-extract/landmarks.js +162 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/poses.js +114 -0
- package/src/posestudio.jsx +434 -22
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/pwa.js +42 -0
- package/src/result-modal.jsx +124 -0
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +404 -0
- package/src/shot-authoring.js +246 -77
- package/src/shot.js +8 -4
- package/src/styles.css +2151 -183
- package/src/ui.jsx +54 -5
- package/tools/ardy/BRIDGE.md +73 -6
- package/tools/ardy/README.md +64 -5
- package/tools/ardy/bridge.mjs +146 -390
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +561 -0
- package/tools/ardy/cclay_motion_edit.py +74 -9
- package/tools/ardy/cclay_sequence_generate.py +117 -7
- package/tools/ardy/cclay_worker.py +304 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/merge-text-encoder.py +160 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-local.mjs +233 -0
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/index.mjs +23 -0
- package/tools/ardy/runners/local.mjs +452 -0
- package/tools/ardy/runners/proc.mjs +125 -0
- package/tools/ardy/runners/remote.mjs +252 -0
- package/tools/ardy/setup-local.mjs +189 -0
- package/tools/ardy/setup-text-encoder-on-box.sh +93 -0
- package/tools/ardy/setup-text-encoder.py +296 -0
- package/tools/generation/README.md +29 -0
- package/tools/generation/bridge.mjs +149 -0
- package/tools/generation/job-store.mjs +51 -0
- package/tools/generation/providers/kling.mjs +76 -0
- package/tools/generation/providers/runway.mjs +101 -0
- package/tools/generation/providers/seedance.mjs +87 -0
- package/tools/generation/providers/shared.mjs +49 -0
- package/tools/generation/providers/veo.mjs +84 -0
- package/tools/metrics/archive-traffic.mjs +138 -0
- package/dist/assets/index-BW_S1YRy.js +0 -4395
- package/dist/assets/index-nRmmJgap.css +0 -1
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -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/qa-browser.mjs +0 -84
- package/tools/qa-crop3.mjs +0 -22
- package/tools/qa-playview.mjs +0 -49
- package/tools/qa-screenshot.mjs +0 -24
- package/tools/qa-visual.mjs +0 -51
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract.mjs — the bridge's GPU motion-extraction side. The browser's
|
|
3
|
+
* MediaPipe path is per-frame guessing (jittery, rough blocking only); this
|
|
4
|
+
* route ships the footage to the ARDY box, runs SAM-3D-Body over the whole
|
|
5
|
+
* clip (temporal context, real 3D body prior), converts the returned Mixamo
|
|
6
|
+
* BVH to cskel27 arrays and serves them as an ordinary motion npz — so the
|
|
7
|
+
* app loads a GPU-extracted take through the exact same loadMotion path an
|
|
8
|
+
* ARDY generation uses.
|
|
9
|
+
*
|
|
10
|
+
* Same posture as generation: children die with the client connection, the
|
|
11
|
+
* served npz enters the motion allowlist only after this process wrote and
|
|
12
|
+
* verified it, and every failure is a NAMED reason.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { killGroup, track } from "./runners/proc.mjs";
|
|
21
|
+
import { EXTRACT_FPS_MAX, conformToExtractFps } from "./footage.mjs";
|
|
22
|
+
import { motionArraysToNpzMembers, writeNpz } from "./npz.mjs";
|
|
23
|
+
import { bvhToCskel27Motion, parseBvh } from "./bvh-cskel27.mjs";
|
|
24
|
+
|
|
25
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const OUT_DIR = join(HERE, "out");
|
|
27
|
+
const SAM_DIR = "~/cclay-ingest/SAM3DBody-cpp"; // the box-side checkout the old ingest pipeline left behind
|
|
28
|
+
// Non-interactive ssh shells carry no LD_LIBRARY_PATH, and the CUDA EP needs
|
|
29
|
+
// the cudnn that lives inside the ingest workspace's venv — without it the
|
|
30
|
+
// pipeline silently falls back to CPU and then refuses to load at all.
|
|
31
|
+
const SAM_ENV = 'LD_LIBRARY_PATH="$(echo $HOME/cclay-ingest/.venv/lib/python3.12/site-packages/nvidia/*/lib | tr \' \' :)"';
|
|
32
|
+
const MAX_UPLOAD_BYTES = 300 * 1024 * 1024;
|
|
33
|
+
const EXTRACT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
34
|
+
const SSH_BASE_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=15"];
|
|
35
|
+
|
|
36
|
+
// Extraction can run on a DIFFERENT machine than ARDY generation — e.g. a
|
|
37
|
+
// Slurm GPU cluster (measured ~5× the default box). Point it there with:
|
|
38
|
+
// CCLAY_EXTRACT_HOST ssh destination (default: CCLAY_ARDY_HOST)
|
|
39
|
+
// CCLAY_EXTRACT_SSH_PORT ssh port (default 22)
|
|
40
|
+
// CCLAY_EXTRACT_TMP REMOTE work dir for video/BVH — must be visible
|
|
41
|
+
// to the node that runs the job (a Slurm node's
|
|
42
|
+
// /tmp is node-local, use a shared home path)
|
|
43
|
+
// CCLAY_EXTRACT_CMD remote command invoked as `CMD <video> <bvh>`;
|
|
44
|
+
// it owns GPU allocation (srun …) and env. Unset →
|
|
45
|
+
// the default box invocation below.
|
|
46
|
+
function sshHost() {
|
|
47
|
+
return process.env.CCLAY_EXTRACT_HOST?.trim() || process.env.CCLAY_ARDY_HOST?.trim() || "";
|
|
48
|
+
}
|
|
49
|
+
const EXTRACT_SSH_PORT = process.env.CCLAY_EXTRACT_SSH_PORT?.trim() || "";
|
|
50
|
+
const EXTRACT_TMP = process.env.CCLAY_EXTRACT_TMP?.trim() || "/tmp";
|
|
51
|
+
const EXTRACT_CMD = process.env.CCLAY_EXTRACT_CMD?.trim() || "";
|
|
52
|
+
const SSH_OPTS = EXTRACT_SSH_PORT ? [...SSH_BASE_OPTS, "-p", EXTRACT_SSH_PORT] : SSH_BASE_OPTS;
|
|
53
|
+
const SCP_OPTS = EXTRACT_SSH_PORT ? [...SSH_BASE_OPTS, "-P", EXTRACT_SSH_PORT] : SSH_BASE_OPTS;
|
|
54
|
+
|
|
55
|
+
/** Read a raw binary request body with a hard cap. The bridge's json readBody
|
|
56
|
+
* is utf-8 and would corrupt video bytes. */
|
|
57
|
+
function readVideoBody(req, limitBytes) {
|
|
58
|
+
return new Promise((resolvePromise, reject) => {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
let total = 0;
|
|
61
|
+
req.on("data", (chunk) => {
|
|
62
|
+
total += chunk.length;
|
|
63
|
+
if (total > limitBytes) {
|
|
64
|
+
reject(new Error("extract-upload-too-large"));
|
|
65
|
+
req.destroy();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
});
|
|
70
|
+
req.on("end", () => resolvePromise(Buffer.concat(chunks)));
|
|
71
|
+
req.on("error", (err) => reject(new Error(`request aborted: ${err.message}`)));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function run(command, args, { children, timeoutMs, onLine }) {
|
|
76
|
+
return new Promise((resolvePromise, reject) => {
|
|
77
|
+
const child = spawn(command, args, { detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
78
|
+
children.add(child);
|
|
79
|
+
track(child);
|
|
80
|
+
let err = "";
|
|
81
|
+
let buffered = "";
|
|
82
|
+
const timer = timeoutMs
|
|
83
|
+
? setTimeout(() => {
|
|
84
|
+
killGroup(child);
|
|
85
|
+
reject(new Error("extract-timeout"));
|
|
86
|
+
}, timeoutMs)
|
|
87
|
+
: null;
|
|
88
|
+
const feed = (text) => {
|
|
89
|
+
buffered += text;
|
|
90
|
+
const lines = buffered.split(/[\r\n]/);
|
|
91
|
+
buffered = lines.pop() ?? "";
|
|
92
|
+
for (const line of lines) if (line) onLine?.(line);
|
|
93
|
+
};
|
|
94
|
+
child.stdout.on("data", (chunk) => feed(String(chunk)));
|
|
95
|
+
child.stderr.on("data", (chunk) => {
|
|
96
|
+
err += chunk;
|
|
97
|
+
feed(String(chunk));
|
|
98
|
+
});
|
|
99
|
+
child.on("error", (error) => {
|
|
100
|
+
if (timer) clearTimeout(timer);
|
|
101
|
+
children.delete(child);
|
|
102
|
+
reject(new Error(`spawn ${command}: ${error.message}`));
|
|
103
|
+
});
|
|
104
|
+
child.on("close", (code) => {
|
|
105
|
+
if (timer) clearTimeout(timer);
|
|
106
|
+
children.delete(child);
|
|
107
|
+
if (code === 0) resolvePromise();
|
|
108
|
+
else reject(new Error(err.split("\n").filter(Boolean).pop() || `${command} exited ${code}`));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* POST /ardy/extract — input is either JSON {footage:"<id>"} referencing a
|
|
115
|
+
* bridge-downloaded clip, or the raw video bytes themselves. Answer ndjson:
|
|
116
|
+
* {event:"status", message} upload / extract / convert
|
|
117
|
+
* {event:"progress", stage:"extract", ratio} per-frame GPU movement
|
|
118
|
+
* {event:"done", motionUrl, frames, fps}
|
|
119
|
+
* {event:"error", message} a NAMED reason
|
|
120
|
+
*/
|
|
121
|
+
export async function handleExtract(req, res, { readBody, footagePath, registerMotion }) {
|
|
122
|
+
const host = sshHost();
|
|
123
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
124
|
+
let localVideo = null;
|
|
125
|
+
let uploadedTemp = null;
|
|
126
|
+
let cappedTemp = null;
|
|
127
|
+
|
|
128
|
+
if (/^application\/json\b/.test(contentType)) {
|
|
129
|
+
let body;
|
|
130
|
+
try {
|
|
131
|
+
body = JSON.parse(await readBody(req));
|
|
132
|
+
} catch (err) {
|
|
133
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
134
|
+
res.end(`${JSON.stringify({ ok: false, reason: err.message })}\n`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
localVideo = typeof body?.footage === "string" ? footagePath(body.footage) : null;
|
|
138
|
+
if (!localVideo) {
|
|
139
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
140
|
+
res.end(`${JSON.stringify({ ok: false, reason: "extract-footage-unknown" })}\n`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
let bytes;
|
|
145
|
+
try {
|
|
146
|
+
bytes = await readVideoBody(req, MAX_UPLOAD_BYTES);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
149
|
+
res.end(`${JSON.stringify({ ok: false, reason: err.message })}\n`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (bytes.length < 1024) {
|
|
153
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
154
|
+
res.end(`${JSON.stringify({ ok: false, reason: "extract-upload-empty" })}\n`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
158
|
+
uploadedTemp = join(OUT_DIR, `extract-upload-${Date.now()}.mp4`);
|
|
159
|
+
writeFileSync(uploadedTemp, bytes);
|
|
160
|
+
localVideo = uploadedTemp;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
res.writeHead(200, { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store" });
|
|
164
|
+
const send = (obj) => {
|
|
165
|
+
if (res.writableEnded) return;
|
|
166
|
+
try {
|
|
167
|
+
res.write(`${JSON.stringify(obj)}\n`);
|
|
168
|
+
} catch {
|
|
169
|
+
/* socket gone */
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const children = new Set();
|
|
173
|
+
const cleanupLocal = () => {
|
|
174
|
+
if (uploadedTemp) rmSync(uploadedTemp, { force: true });
|
|
175
|
+
if (cappedTemp) rmSync(cappedTemp, { force: true });
|
|
176
|
+
};
|
|
177
|
+
const fail = (message) => {
|
|
178
|
+
send({ event: "error", message });
|
|
179
|
+
res.end();
|
|
180
|
+
cleanupLocal();
|
|
181
|
+
};
|
|
182
|
+
res.on("close", () => {
|
|
183
|
+
if (!res.writableEnded) {
|
|
184
|
+
console.error(`[bridge] client disconnected mid-extract; killing ${children.size} child group(s)`);
|
|
185
|
+
for (const child of children) killGroup(child);
|
|
186
|
+
children.clear();
|
|
187
|
+
cleanupLocal();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (!host) {
|
|
192
|
+
fail("extract-host-missing");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const stamp = `${Date.now()}-${randomBytes(3).toString("hex")}`;
|
|
197
|
+
const remoteVideo = `${EXTRACT_TMP}/cclay-extract-${stamp}.mp4`;
|
|
198
|
+
const remoteBvh = `${EXTRACT_TMP}/cclay-extract-${stamp}.bvh`;
|
|
199
|
+
const cleanupRemote = () => {
|
|
200
|
+
run("ssh", [...SSH_OPTS, host, `rm -f ${remoteVideo} ${remoteBvh} ${remoteBvh.replace(/\.bvh$/, "")}_*.bvh`], {
|
|
201
|
+
children: new Set(),
|
|
202
|
+
timeoutMs: 30000,
|
|
203
|
+
}).catch(() => {});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Both intake routes converge here, and only here is the rate SAM will see
|
|
207
|
+
// certain: a bridge download was already normalized at the same ceiling
|
|
208
|
+
// (so this probes ≤ the cap and re-encodes nothing — a second encode would
|
|
209
|
+
// cost a generation of quality for no frames removed), while raw bytes
|
|
210
|
+
// posted from the browser are whatever the user's camera shot. Frame rate
|
|
211
|
+
// only: the clip keeps its length and its speed.
|
|
212
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
213
|
+
// Claimed before the pass runs, not after it succeeds: a half-written file
|
|
214
|
+
// from an ffmpeg that died mid-encode has to be swept too, and the rm is a
|
|
215
|
+
// no-op when the pass never wrote anything.
|
|
216
|
+
cappedTemp = join(OUT_DIR, `extract-capped-${stamp}.mp4`);
|
|
217
|
+
try {
|
|
218
|
+
const conformed = await conformToExtractFps(localVideo, cappedTemp, {
|
|
219
|
+
children,
|
|
220
|
+
onCap: (fps) => {
|
|
221
|
+
send({ event: "status", message: "normalizing" });
|
|
222
|
+
console.error(`[bridge] extract input capped to ${fps} fps (ceiling ${EXTRACT_FPS_MAX})`);
|
|
223
|
+
},
|
|
224
|
+
onProgress: (ratio) => send({ event: "progress", stage: "normalize", ratio }),
|
|
225
|
+
});
|
|
226
|
+
if (conformed.capped) {
|
|
227
|
+
localVideo = conformed.path;
|
|
228
|
+
} else if (conformed.fps === null) {
|
|
229
|
+
console.error("[bridge] extract input rate unreadable; sending it to the box as it is");
|
|
230
|
+
}
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error(`[bridge] extract fps cap failed: ${err.message}`);
|
|
233
|
+
// The same ffmpeg pass the download path runs, so the same named
|
|
234
|
+
// reason — the UI already tells the user a conversion is what broke.
|
|
235
|
+
fail("footage-normalize-failed");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
send({ event: "status", message: "uploading" });
|
|
241
|
+
await run("scp", [...SCP_OPTS, localVideo, `${host}:${remoteVideo}`], { children, timeoutMs: 300000 });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.error(`[bridge] extract upload failed: ${err.message}`);
|
|
244
|
+
fail("extract-upload-failed");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// SAM-3D-Body's OFFLINE multi-pass renderer — the live binary's causal
|
|
249
|
+
// filter lags in phase and skips the repair passes, and measured 80 %
|
|
250
|
+
// more wrist jitter on the same clip. The offline pipeline runs identity
|
|
251
|
+
// tracking, gap fill, spike interpolation (--interpolate-jitter), then
|
|
252
|
+
// ZERO-PHASE forward+backward smoothing at 6 Hz, and --foot-contact's
|
|
253
|
+
// per-foot leg IK pins planted feet against skate. --max-persons 2
|
|
254
|
+
// matches the scene: CozyClay holds two subjects, so the two most
|
|
255
|
+
// confident performers come back (one BVH each) and a single-person clip
|
|
256
|
+
// simply yields one file.
|
|
257
|
+
send({ event: "status", message: "extracting" });
|
|
258
|
+
try {
|
|
259
|
+
const remoteCommand = EXTRACT_CMD
|
|
260
|
+
? `${EXTRACT_CMD} ${remoteVideo} ${remoteBvh}`
|
|
261
|
+
: `cd ${SAM_DIR} && ${SAM_ENV} ./build/offline_sam_3dbody_render ` +
|
|
262
|
+
`--onnx-dir ./onnx --gguf ./onnx/pipeline.gguf --yolo ./onnx/yolo.onnx ` +
|
|
263
|
+
`--from ${remoteVideo} --bvh ${remoteBvh} --bvh-template ./mixamo.bvh --max-persons 2 ` +
|
|
264
|
+
`--smoothing zero-phase --bw-cutoff 6 --interpolate-jitter --foot-contact`;
|
|
265
|
+
await run(
|
|
266
|
+
"ssh",
|
|
267
|
+
[...SSH_OPTS, host, remoteCommand],
|
|
268
|
+
{
|
|
269
|
+
children,
|
|
270
|
+
timeoutMs: EXTRACT_TIMEOUT_MS,
|
|
271
|
+
onLine: (line) => {
|
|
272
|
+
// "[pass1] 120 / 514 frames (eta ~3 s)" — pass1 is the
|
|
273
|
+
// GPU inference and dominates the wall clock.
|
|
274
|
+
const progress = /\[pass1\]\s+(\d+)\s*\/\s*(\d+) frames/.exec(line);
|
|
275
|
+
if (progress && Number(progress[2]) > 0) {
|
|
276
|
+
send({ event: "progress", stage: "extract", ratio: Math.min(1, Number(progress[1]) / Number(progress[2])) });
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error(`[bridge] extract run failed: ${err.message}`);
|
|
283
|
+
cleanupRemote();
|
|
284
|
+
fail("extract-run-failed");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
send({ event: "status", message: "converting" });
|
|
289
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
290
|
+
// One BVH per tracked person. Person 0 must exist; person 1 is optional
|
|
291
|
+
// (a single-person clip yields one file, and that is not an error).
|
|
292
|
+
const motions = [];
|
|
293
|
+
for (let person = 0; person < 2; person += 1) {
|
|
294
|
+
const localBvh = join(OUT_DIR, `extract-${stamp}-p${person}.bvh`);
|
|
295
|
+
try {
|
|
296
|
+
await run("scp", [...SCP_OPTS, `${host}:${remoteBvh.replace(/\.bvh$/, "")}_${person}.bvh`, localBvh], {
|
|
297
|
+
children,
|
|
298
|
+
timeoutMs: 120000,
|
|
299
|
+
});
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (person === 0) {
|
|
302
|
+
console.error(`[bridge] extract fetch failed: ${err.message}`);
|
|
303
|
+
cleanupRemote();
|
|
304
|
+
fail("extract-no-person");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
motions.push(bvhToCskel27Motion(parseBvh(readFileSync(localBvh, "utf8"))));
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error(`[bridge] extract convert failed (person ${person}): ${err.message}`);
|
|
313
|
+
if (person === 0) {
|
|
314
|
+
cleanupRemote();
|
|
315
|
+
fail("extract-convert-failed");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
} finally {
|
|
319
|
+
rmSync(localBvh, { force: true });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
cleanupRemote();
|
|
323
|
+
cleanupLocal();
|
|
324
|
+
|
|
325
|
+
const takes = [];
|
|
326
|
+
for (let person = 0; person < motions.length; person += 1) {
|
|
327
|
+
const motion = motions[person];
|
|
328
|
+
const id = person === 0 ? stamp : `${Date.now()}-${randomBytes(3).toString("hex")}`;
|
|
329
|
+
const npzPath = join(OUT_DIR, `extract-${id}.npz`);
|
|
330
|
+
try {
|
|
331
|
+
// motion.personScale goes into the archive as `person_scale`: the
|
|
332
|
+
// conversion divided this person's root travel by it, so the take
|
|
333
|
+
// is only metrically right when the character is scaled by the same
|
|
334
|
+
// number. Shipping it in the response alone would let a reload —
|
|
335
|
+
// or any other path to the same npz — replay the stride at
|
|
336
|
+
// canonical size. The response keeps the field for older clients.
|
|
337
|
+
writeNpz(npzPath, motionArraysToNpzMembers(motion));
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.error(`[bridge] extract npz write failed (person ${person}): ${err.message}`);
|
|
340
|
+
if (person === 0) {
|
|
341
|
+
fail("extract-convert-failed");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
registerMotion(id, npzPath);
|
|
347
|
+
takes.push({
|
|
348
|
+
motionUrl: `/ardy/motions/${id}`,
|
|
349
|
+
frames: motion.frames,
|
|
350
|
+
fps: motion.fps,
|
|
351
|
+
personScale: motion.personScale,
|
|
352
|
+
// person 1's placement RELATIVE to person 0, in the shared raw
|
|
353
|
+
// camera space (real metres, X/Z on the floor plane).
|
|
354
|
+
offsetX: person === 0 ? 0 : motion.rawRootStart[0] - motions[0].rawRootStart[0],
|
|
355
|
+
offsetZ: person === 0 ? 0 : motion.rawRootStart[2] - motions[0].rawRootStart[2],
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
send({
|
|
359
|
+
event: "done",
|
|
360
|
+
motionUrl: takes[0].motionUrl,
|
|
361
|
+
frames: takes[0].frames,
|
|
362
|
+
fps: takes[0].fps,
|
|
363
|
+
personScale: takes[0].personScale,
|
|
364
|
+
takes,
|
|
365
|
+
});
|
|
366
|
+
res.end();
|
|
367
|
+
}
|