cozyclay 1.9.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +171 -0
- package/bin/agent/agent-routes.mjs +74 -70
- package/bin/agent/agent-runner.mjs +10 -3
- package/bin/agent/motion-runtime.mjs +32 -260
- package/bin/agent/providers.mjs +46 -3
- package/bin/agent/session-store.mjs +41 -5
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-tools.mjs +48 -11
- package/bin/cozyclay.mjs +26 -8
- package/bin/live/cli.mjs +22 -3
- package/bin/mcp-runtime.mjs +35 -20
- package/bin/telemetry-state.mjs +5 -2
- package/bin/update-check.mjs +15 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-MVZOEW8O.js +1 -0
- package/dist/assets/app-C1sdVbg4.css +1 -0
- package/dist/assets/app-CA55tcI3.js +4876 -0
- package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
- package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
- package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
- package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
- package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
- package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
- package/dist/assets/workflow-DRdwoDBT.css +1 -0
- package/dist/assets/workflow-dNPGY9xz.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
- package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
- package/dist/fonts/README.md +13 -9
- package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
- package/dist/index.html +7 -7
- package/dist/privacy/index.html +13 -8
- package/dist/sitemap.xml +6 -6
- package/dist/workflow/index.html +5 -5
- package/mcp/LIVE-PROTOCOL.md +1 -1
- package/mcp/live-hub.mjs +35 -93
- package/mcp/mesh-file.mjs +48 -0
- package/mcp/server.mjs +6 -77
- package/mcp/tool-handlers.mjs +224 -377
- package/package.json +2 -1
- package/src/App.jsx +1273 -9075
- package/src/analytics.js +393 -9
- package/src/app-context.js +211 -0
- package/src/app-stage.jsx +25 -24
- package/src/ardy/auto-fix-panel.css +108 -0
- package/src/ardy/collision-blockers.js +9 -3
- package/src/ardy/fix-collisions.js +10 -66
- package/src/ardy/ground.js +4 -4
- package/src/ardy/ik-drag.js +101 -0
- package/src/ardy/ik-key-json.js +43 -0
- package/src/ardy/ik.js +122 -76
- package/src/ardy/physics-panel.css +2 -0
- package/src/ardy/physics-panel.jsx +17 -5
- package/src/ardy/platform-fit-panel.css +64 -0
- package/src/ardy/platform-fit-panel.jsx +40 -0
- package/src/ardy/platform-fit.js +326 -0
- package/src/ardy/playback.js +166 -20
- package/src/ardy/range-pin.js +299 -0
- package/src/ardy/timeline-coordinates.js +26 -0
- package/src/ardy/timeline.css +1199 -0
- package/src/ardy/timeline.jsx +149 -41
- package/src/ardy/waypoints.js +2 -2
- package/src/asset-pane.css +462 -0
- package/src/asset-pane.jsx +461 -232
- package/src/command-bus.js +349 -0
- package/src/commands/ai.js +31 -0
- package/src/commands/cast.js +140 -0
- package/src/commands/elements/character.js +13 -0
- package/src/commands/elements/motion.js +14 -0
- package/src/commands/elements/object.js +22 -0
- package/src/commands/elements/scene.js +16 -0
- package/src/commands/elements/shot.js +9 -0
- package/src/commands/elements/stage.js +11 -0
- package/src/commands/elements.js +155 -0
- package/src/commands/export.js +20 -0
- package/src/commands/index.js +40 -0
- package/src/commands/motion.js +231 -0
- package/src/commands/objects.js +174 -0
- package/src/commands/project.js +53 -0
- package/src/commands/scene.js +81 -0
- package/src/commands/shared.js +19 -0
- package/src/commands/shot.js +103 -0
- package/src/commands/stage.js +17 -0
- package/src/commands/view.js +42 -0
- package/src/document-store.js +144 -0
- package/src/domains/cast.js +1007 -0
- package/src/domains/motion.js +3219 -0
- package/src/domains/objects.js +932 -0
- package/src/domains/scenes.js +817 -0
- package/src/domains/shots.js +404 -0
- package/src/domains/stage.js +93 -0
- package/src/dualview.jsx +113 -57
- package/src/facing-marks.js +3 -0
- package/src/first-success-guide.jsx +14 -14
- package/src/grid-view.js +18 -5
- package/src/hierarchy-model.js +24 -16
- package/src/hierarchy-panel.css +386 -0
- package/src/hierarchy-panel.jsx +150 -21
- package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
- package/src/i2v-motion-studio.jsx +180 -0
- package/src/ik-camera.js +3 -0
- package/src/main.jsx +6 -0
- package/src/motion/generation.js +105 -0
- package/src/motion-readiness-ui.jsx +4 -4
- package/src/motion-readiness.js +11 -0
- package/src/motion-trail.js +366 -9
- package/src/object-gizmo.jsx +7 -1
- package/src/otio.js +2 -1
- package/src/panels/CameraPanel.jsx +60 -0
- package/src/panels/CharacterTransformPanel.jsx +48 -0
- package/src/panels/EnvironmentPanel.jsx +40 -0
- package/src/panels/Foldout.jsx +32 -0
- package/src/panels/LightPanel.jsx +24 -0
- package/src/panels/ObjectTransformPanel.jsx +554 -0
- package/src/panels/PosePanel.jsx +97 -0
- package/src/panels/ProjectPanel.jsx +33 -0
- package/src/panels/PromptBlocksPanel.jsx +442 -0
- package/src/panels/PropsPanel.jsx +70 -0
- package/src/panels/ReferenceImageField.jsx +91 -0
- package/src/panels/RigControlPanel.jsx +78 -0
- package/src/panels/RigPanel.jsx +37 -0
- package/src/panels/SubjectBox.jsx +47 -0
- package/src/panels/SubjectsPanel.jsx +39 -0
- package/src/panels/VideoCapturePanel.jsx +180 -0
- package/src/panels/details.css +1171 -0
- package/src/panels/motion.css +197 -0
- package/src/panels/pose.css +39 -0
- package/src/planview.jsx +60 -31
- package/src/posestudio.jsx +59 -4
- package/src/project-browser.css +1073 -0
- package/src/project-browser.jsx +284 -123
- package/src/range-pin-object-transform.js +19 -0
- package/src/range-pin-panel.css +518 -0
- package/src/range-pin-panel.jsx +344 -0
- package/src/result-modal.jsx +5 -5
- package/src/room.jsx +79 -51
- package/src/scene-objects.js +37 -1
- package/src/scenes.js +5 -0
- package/src/semantic-edit.js +2 -0
- package/src/settings-menu.jsx +39 -105
- package/src/shell/BottomDock.jsx +450 -0
- package/src/shell/DetailsSlot.jsx +513 -0
- package/src/shell/LibrarySlot.jsx +86 -0
- package/src/shell/MenuBar.jsx +601 -0
- package/src/shell/OutlinerSlot.jsx +50 -0
- package/src/shell/PreferencesDialog.jsx +538 -0
- package/src/shell/PreferencesSlot.jsx +40 -0
- package/src/shell/StatusBar.jsx +125 -0
- package/src/shell/StudioShell.jsx +92 -0
- package/src/shell/TopBar.jsx +129 -0
- package/src/shell/ViewportToolbar.jsx +656 -0
- package/src/shell/agent-glass.css +296 -0
- package/src/shell/dock.css +93 -0
- package/src/shell/glass-regions.css +985 -0
- package/src/shell/glass.css +687 -0
- package/src/shell/log-store.js +52 -0
- package/src/shell/mode.css +9 -0
- package/src/shell/preferences.css +651 -0
- package/src/shell/shell.css +282 -0
- package/src/shell/studio-shell-context.js +19 -0
- package/src/shell/topbar.css +435 -0
- package/src/shell/viewport.css +451 -0
- package/src/store/authored-intent.js +22 -0
- package/src/store/runtime-adapters.js +92 -0
- package/src/store/scene-stage.js +21 -0
- package/src/store/use-document-store.js +14 -0
- package/src/studio-actions.js +210 -0
- package/src/studio-agent-commands.js +41 -271
- package/src/studio-agent-context.js +39 -10
- package/src/studio-agent-motion.js +122 -373
- package/src/studio-agent-protocol.js +67 -31
- package/src/studio-app-binding.js +379 -0
- package/src/studio-contact-sheet.js +73 -0
- package/src/studio-elements.js +58 -51
- package/src/styles/themes.css +200 -0
- package/src/styles/tokens.css +69 -0
- package/src/styles.css +460 -1742
- package/src/theme.js +44 -0
- package/src/timeline-extent.js +16 -0
- package/src/trail-key-conflicts.js +39 -0
- package/src/trail-pick.js +59 -0
- package/src/ui.jsx +8 -8
- package/src/use-case-question.jsx +66 -0
- package/src/workflow/AgentPanel.jsx +82 -69
- package/src/workflow/agent-client.js +12 -2
- package/src/workflow/agent-panel.css +34 -32
- package/src/workflow/cozy-scene-node.css +2 -0
- package/src/workflow/workflow.css +2 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +164 -146
- package/tools/ardy/visual-qa.mjs +5 -5
- package/tools/bench/EXP3.md +61 -0
- package/tools/bench/cclay_bench_extract_incam.py +125 -0
- package/tools/bench/cclay_bench_extract_obs.py +204 -0
- package/tools/bench/cclay_bench_runner.py +42 -0
- package/tools/bench/cube-contact.mjs +162 -0
- package/tools/bench/exp3.mjs +152 -0
- package/tools/bench/extract-bench-lib.mjs +216 -0
- package/tools/bench/extract-bench.mjs +302 -0
- package/tools/bench/fal-generate.mjs +88 -0
- package/tools/bench/fit/README.md +189 -0
- package/tools/bench/fit/camera.mjs +59 -0
- package/tools/bench/fit/contact.mjs +419 -0
- package/tools/bench/fit/footlock.mjs +234 -0
- package/tools/bench/fit/motion.mjs +85 -0
- package/tools/bench/fit/pin.mjs +27 -0
- package/tools/bench/fit/remote.mjs +105 -0
- package/tools/bench/fit-bench.mjs +126 -0
- package/tools/bench/fit-sanity.mjs +45 -0
- package/tools/bench/metrics.mjs +313 -0
- package/tools/bench/obs/depth.mjs +258 -0
- package/tools/bench/obs/extrinsics.mjs +175 -0
- package/tools/bench/obs/fit_mannequin_betas.py +340 -0
- package/tools/bench/obs/ground.mjs +294 -0
- package/tools/bench/obs/heading.mjs +151 -0
- package/tools/bench/obs/ladder.mjs +555 -0
- package/tools/bench/obs/mannequin-betas.json +124 -0
- package/tools/bench/obs/remote.mjs +83 -0
- package/tools/bench/obs/rest_joints.py +52 -0
- package/tools/bench/obs/ybot-targets.mjs +49 -0
- package/tools/bench/obs-bench.mjs +520 -0
- package/tools/bench/score.mjs +343 -0
- package/tools/bench/summarize.mjs +86 -0
- package/tools/dev/pages/privacy.html +13 -8
- package/tools/gt-render/browser.mjs +159 -0
- package/tools/gt-render/camera-math.mjs +276 -0
- package/tools/gt-render/page.mjs +267 -0
- package/tools/gt-render/render.mjs +514 -0
- package/tools/gt-render/scene-box.mjs +31 -0
- package/tools/gt-render/take-transform.mjs +87 -0
- package/tools/morphgs/assets/gen_truth.py +39 -0
- package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
- package/tools/morphgs/assets/playback-check.mjs +30 -0
- package/tools/morphgs/demo-gate.sh +26 -0
- package/tools/morphgs/fbx2morphgs.mjs +68 -0
- package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
- package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
- package/tools/morphgs/setup-on-cluster.sh +126 -0
- package/tools/qa/css-rule-usage.mjs +283 -0
- package/tools/qa/studio-control-count.mjs +70 -32
- package/tools/run-tests.mjs +134 -12
- package/tools/track/DESIGN.md +513 -0
- package/tools/track/backfill-provenance.mjs +116 -0
- package/tools/track/budget.mjs +8 -0
- package/tools/track/check-rig.mjs +209 -0
- package/tools/track/diagnostics.schema.json +60 -0
- package/tools/track/export-rig.mjs +256 -0
- package/tools/track/fallback.mjs +7 -0
- package/tools/track/fk-parity-fixture.mjs +161 -0
- package/tools/track/gate.mjs +349 -0
- package/tools/track/masks.mjs +318 -0
- package/tools/track/metrics.mjs +201 -0
- package/tools/track/publish-obs.mjs +108 -0
- package/tools/track/py/check_env.py +61 -0
- package/tools/track/py/eval_lr.py +229 -0
- package/tools/track/py/lr_viterbi.py +294 -0
- package/tools/track/py/masks.py +405 -0
- package/tools/track/py/objective.py +382 -0
- package/tools/track/py/rig.py +508 -0
- package/tools/track/py/scene.py +326 -0
- package/tools/track/py/test_joint_indices.py +123 -0
- package/tools/track/py/test_lr_viterbi.py +195 -0
- package/tools/track/py/test_masks.py +229 -0
- package/tools/track/py/test_rig.py +288 -0
- package/tools/track/py/test_scene.py +354 -0
- package/tools/track/py/test_track.py +328 -0
- package/tools/track/py/track.py +411 -0
- package/tools/track/remote.mjs +140 -0
- package/tools/track/rig-dump.mjs +227 -0
- package/tools/track/run-box-tests.mjs +14 -0
- package/tools/track/run-box.mjs +168 -0
- package/tools/track/setup-box.sh +59 -0
- package/tools/track/study-2d.mjs +728 -0
- package/dist/assets/analytics-B1hnH66c.js +0 -1
- package/dist/assets/app-BWusbqgO.js +0 -4861
- package/dist/assets/app-qKDo4PBX.css +0 -1
- package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
- package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
- package/dist/assets/workflow-CVwzMhz2.js +0 -179
- package/dist/assets/workflow-DsudxKHF.css +0 -1
- package/src/fal-motion-studio.jsx +0 -180
- package/src/scene-history.js +0 -129
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* extract-bench.mjs — GVHMR extraction bench (#430, validation step T2).
|
|
4
|
+
*
|
|
5
|
+
* Feeds T1 ground-truth renders (<input>/<motion>/<variant>/video.mp4 +
|
|
6
|
+
* camera.json) to the GPU box under named conditions and converts every
|
|
7
|
+
* result with production's post-processing, so T3 can score conditions
|
|
8
|
+
* against the ground truth without re-running anything:
|
|
9
|
+
*
|
|
10
|
+
* <out>/<motion>/<variant>/<condition>/
|
|
11
|
+
* gvhmr.npz the runner's SMPL output, untouched
|
|
12
|
+
* motion.npz cskel27, smplToCskel27Motion -> stabilizeMotion ->
|
|
13
|
+
* guardTrajectoryFloor, written like /ardy/extract writes it
|
|
14
|
+
* log.txt the runner's log, lines prefixed with elapsed seconds
|
|
15
|
+
* result.json flags, detection counts, timings, quality report,
|
|
16
|
+
* stabilisation and trajectory diagnostics
|
|
17
|
+
*
|
|
18
|
+
* `prod` is handleExtract's path step for step (fps conform, upload, the
|
|
19
|
+
* bridge's persistent worker with its request, conversion with the env
|
|
20
|
+
* defaults); the other conditions call cclay_gvhmr_extract.py directly with
|
|
21
|
+
* one variable changed. Runs are serial, on the box they use only
|
|
22
|
+
* /tmp/cclay-bench-<stamp>/ (removed at exit) — the one exception is the
|
|
23
|
+
* persistent worker's own content-addressed code dir, which gvhmrWorker owns.
|
|
24
|
+
* Pure logic: extract-bench-lib.mjs.
|
|
25
|
+
*/
|
|
26
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
27
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
28
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { dirname, join, resolve } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
import { conformToExtractFps } from "../ardy/footage.mjs";
|
|
33
|
+
import { qualityReportForMotion } from "../ardy/extract.mjs";
|
|
34
|
+
import { guardTrajectoryFloor } from "../ardy/gvhmr-floor.mjs";
|
|
35
|
+
import { stabilizeMotion } from "../ardy/motion-stabilize.mjs";
|
|
36
|
+
import { motionArraysToNpzMembers, writeNpz } from "../ardy/npz.mjs";
|
|
37
|
+
import { gvhmrWorker } from "../ardy/runners/gvhmr-worker.mjs";
|
|
38
|
+
import { globalChildren, killGroup, streamLines, track } from "../ardy/runners/proc.mjs";
|
|
39
|
+
import { smplToCskel27Motion } from "../ardy/smpl-cskel27.mjs";
|
|
40
|
+
import { readNpz } from "../kimodo/read-npz.mjs";
|
|
41
|
+
import { USAGE, parseArgs, parseRunnerLog, resolveCondition } from "./extract-bench-lib.mjs";
|
|
42
|
+
|
|
43
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
const REPO = resolve(HERE, "..", "..");
|
|
45
|
+
const GVHMR_DIR = "~/cclay-ingest/GVHMR";
|
|
46
|
+
const LAUNCHER = join(HERE, "cclay_bench_runner.py");
|
|
47
|
+
const RUN_TIMEOUT_MS = 30 * 60 * 1000;
|
|
48
|
+
// Same ssh options handleExtract uses (CCLAY_EXTRACT_SSH_PORT included) — the
|
|
49
|
+
// worker client is keyed on them, as the bridge's is.
|
|
50
|
+
const SSH_BASE_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=15"];
|
|
51
|
+
const SSH_PORT = process.env.CCLAY_EXTRACT_SSH_PORT?.trim() || "";
|
|
52
|
+
const SSH_OPTS = SSH_PORT ? [...SSH_BASE_OPTS, "-p", SSH_PORT] : SSH_BASE_OPTS;
|
|
53
|
+
const SCP_OPTS = SSH_PORT ? [...SSH_BASE_OPTS, "-P", SSH_PORT] : SSH_BASE_OPTS;
|
|
54
|
+
|
|
55
|
+
/** Spawn a child in its own group; resolve on exit 0, reject with its last
|
|
56
|
+
* output line otherwise. Every line (split on \r too, for tqdm) -> onLine. */
|
|
57
|
+
function exec(command, args, { onLine, timeoutMs = 0 } = {}) {
|
|
58
|
+
return new Promise((resolvePromise, reject) => {
|
|
59
|
+
const child = spawn(command, args, { detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
60
|
+
track(child);
|
|
61
|
+
let last = "";
|
|
62
|
+
const feed = (line) => {
|
|
63
|
+
for (const part of line.split("\r")) {
|
|
64
|
+
if (!part.trim()) continue;
|
|
65
|
+
last = part;
|
|
66
|
+
onLine?.(part);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
streamLines(child.stdout, feed);
|
|
70
|
+
streamLines(child.stderr, feed);
|
|
71
|
+
const timer = timeoutMs ? setTimeout(() => { killGroup(child); reject(new Error(`${command} timed out`)); }, timeoutMs) : null;
|
|
72
|
+
child.once("error", (err) => { clearTimeout(timer); reject(new Error(`spawn ${command}: ${err.message}`)); });
|
|
73
|
+
child.once("close", (code) => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
if (code === 0) resolvePromise();
|
|
76
|
+
else reject(new Error(last.trim() || `${command} exited ${code}`));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ssh = (host, command, options) => exec("ssh", [...SSH_OPTS, host, command], options);
|
|
82
|
+
const scp = (from, to, options) => exec("scp", [...SCP_OPTS, from, to], options);
|
|
83
|
+
const seconds = (since) => Number(((performance.now() - since) / 1000).toFixed(3));
|
|
84
|
+
const readJson = (path) => (existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null);
|
|
85
|
+
|
|
86
|
+
function commitId() {
|
|
87
|
+
const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: REPO, encoding: "utf8" }).stdout.trim();
|
|
88
|
+
const dirty = spawnSync("git", ["status", "--porcelain"], { cwd: REPO, encoding: "utf8" }).stdout.trim();
|
|
89
|
+
return head ? `${head}${dirty ? "-dirty" : ""}` : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function discoverMotions(input) {
|
|
93
|
+
return readdirSync(input, { withFileTypes: true })
|
|
94
|
+
.filter((entry) => entry.isDirectory())
|
|
95
|
+
.filter((entry) => readdirSync(join(input, entry.name), { withFileTypes: true })
|
|
96
|
+
.some((sub) => sub.isDirectory() && existsSync(join(input, entry.name, sub.name, "video.mp4"))))
|
|
97
|
+
.map((entry) => entry.name)
|
|
98
|
+
.sort();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildPlan(options) {
|
|
102
|
+
const motions = options.motions ?? discoverMotions(options.input);
|
|
103
|
+
const runs = [];
|
|
104
|
+
for (const motion of motions) {
|
|
105
|
+
for (const variant of options.variants) {
|
|
106
|
+
const source = join(options.input, motion, variant);
|
|
107
|
+
for (const condition of options.conditions) {
|
|
108
|
+
const camera = readJson(join(source, "camera.json"));
|
|
109
|
+
runs.push({
|
|
110
|
+
motion, variant, source,
|
|
111
|
+
out: join(options.out, motion, variant, condition.name),
|
|
112
|
+
plan: resolveCondition(condition, { cameraFMm: camera?.gvhmrFMm ?? null, env: process.env }),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return runs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runOne(run, { host, benchDir, worker, commit }) {
|
|
121
|
+
const started = performance.now();
|
|
122
|
+
const { motion, variant, source, out, plan } = run;
|
|
123
|
+
rmSync(out, { recursive: true, force: true });
|
|
124
|
+
mkdirSync(out, { recursive: true });
|
|
125
|
+
const logPath = join(out, "log.txt");
|
|
126
|
+
writeFileSync(logPath, "");
|
|
127
|
+
const log = (line) => appendFileSync(logPath, `[+${((performance.now() - started) / 1000).toFixed(2)}s] ${line}\n`);
|
|
128
|
+
const video = join(source, "video.mp4");
|
|
129
|
+
const videoBytes = readFileSync(video);
|
|
130
|
+
const camera = readJson(join(source, "camera.json"));
|
|
131
|
+
const meta = readJson(join(source, "meta.json"));
|
|
132
|
+
const timings = {};
|
|
133
|
+
const result = {
|
|
134
|
+
ok: false,
|
|
135
|
+
tool: "tools/bench/extract-bench.mjs",
|
|
136
|
+
commit,
|
|
137
|
+
createdAt: new Date().toISOString(),
|
|
138
|
+
motion, variant, condition: plan.name, base: plan.base, fmm: plan.fmm, host,
|
|
139
|
+
input: {
|
|
140
|
+
video, sha256: createHash("sha256").update(videoBytes).digest("hex"), bytes: videoBytes.length,
|
|
141
|
+
camera: camera && { width: camera.width, height: camera.height, fx: camera.fx, gvhmrFMm: camera.gvhmrFMm ?? null },
|
|
142
|
+
meta: meta && { frames: meta.frames, fps: meta.fps, variant: meta.variant, source: meta.source?.path ?? null },
|
|
143
|
+
},
|
|
144
|
+
flags: {
|
|
145
|
+
path: plan.path, detector: plan.detector, keypoints: plan.keypoints, fMm: plan.fMm,
|
|
146
|
+
...(plan.path === "direct" ? { runnerArgs: plan.runnerArgs, launcher: plan.detector === "palette" ? "runner" : "bench-launcher" } : { workerRequest: plan.workerFields }),
|
|
147
|
+
stabilize: plan.stabilize,
|
|
148
|
+
},
|
|
149
|
+
timings,
|
|
150
|
+
};
|
|
151
|
+
const localTmp = mkdtempSync(join(tmpdir(), "cclay-bench-"));
|
|
152
|
+
const runKey = `${motion}-${variant}-${plan.name}`.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
153
|
+
const remoteDir = `${benchDir}/${runKey}`;
|
|
154
|
+
const remoteVideo = `${remoteDir}/video.mp4`;
|
|
155
|
+
const remoteNpz = `${remoteDir}/gvhmr.npz`;
|
|
156
|
+
const outRoot = `${remoteDir}/gvhmr-out`;
|
|
157
|
+
let step = "conform";
|
|
158
|
+
try {
|
|
159
|
+
// handleExtract's rate ceiling: a ≤30 fps render passes through as is.
|
|
160
|
+
let at = performance.now();
|
|
161
|
+
const conformed = await conformToExtractFps(video, join(localTmp, "capped.mp4"));
|
|
162
|
+
result.conform = { fps: conformed.fps, capped: conformed.capped };
|
|
163
|
+
timings.conformS = seconds(at);
|
|
164
|
+
|
|
165
|
+
step = "upload";
|
|
166
|
+
at = performance.now();
|
|
167
|
+
await ssh(host, `umask 077 && mkdir -p '${remoteDir}'`, { timeoutMs: 60000 });
|
|
168
|
+
await scp(conformed.path, `${host}:${remoteVideo}`, { timeoutMs: 300000 });
|
|
169
|
+
timings.uploadS = seconds(at);
|
|
170
|
+
|
|
171
|
+
step = "extract";
|
|
172
|
+
at = performance.now();
|
|
173
|
+
let performanceReport = null;
|
|
174
|
+
if (plan.path === "worker") {
|
|
175
|
+
const request = { video: remoteVideo, output: remoteNpz, outRoot, ...plan.workerFields };
|
|
176
|
+
log(`[bench] worker request ${JSON.stringify(request)}`);
|
|
177
|
+
performanceReport = await worker().run(request, { timeoutMs: RUN_TIMEOUT_MS, onLine: log });
|
|
178
|
+
} else {
|
|
179
|
+
const program = plan.detector === "palette"
|
|
180
|
+
? "cclay_gvhmr_extract.py"
|
|
181
|
+
: `${benchDir}/cclay_bench_runner.py cclay_gvhmr_extract.py`;
|
|
182
|
+
const command = `cd ${GVHMR_DIR} && .venv/bin/python ${program} ${remoteVideo} ${remoteNpz} ${plan.runnerArgs.join(" ")} --out-root ${outRoot}`;
|
|
183
|
+
log(`[bench] ssh ${command}`);
|
|
184
|
+
await ssh(host, command, { timeoutMs: RUN_TIMEOUT_MS, onLine: log });
|
|
185
|
+
}
|
|
186
|
+
timings.extractS = seconds(at);
|
|
187
|
+
result.performance = performanceReport;
|
|
188
|
+
|
|
189
|
+
step = "fetch";
|
|
190
|
+
at = performance.now();
|
|
191
|
+
const gvhmrNpz = join(out, "gvhmr.npz");
|
|
192
|
+
await scp(`${host}:${remoteNpz}`, gvhmrNpz, { timeoutMs: 120000 });
|
|
193
|
+
timings.fetchS = seconds(at);
|
|
194
|
+
|
|
195
|
+
step = "convert";
|
|
196
|
+
at = performance.now();
|
|
197
|
+
const events = performanceReport?.trajectory?.events ?? [];
|
|
198
|
+
const converted = stabilizeMotion(smplToCskel27Motion(readNpz(gvhmrNpz)), plan.stabilize);
|
|
199
|
+
const guarded = guardTrajectoryFloor(converted, events);
|
|
200
|
+
result.quality = qualityReportForMotion(guarded.motion);
|
|
201
|
+
if (events.length) performanceReport.trajectory.floor = guarded.diagnostics;
|
|
202
|
+
writeNpz(join(out, "motion.npz"), motionArraysToNpzMembers(guarded.motion));
|
|
203
|
+
timings.convertS = seconds(at);
|
|
204
|
+
result.motionInfo = { frames: guarded.motion.frames, fps: guarded.motion.fps, personScale: guarded.motion.personScale };
|
|
205
|
+
result.stabilization = guarded.motion.stabilization ?? null;
|
|
206
|
+
result.trajectory = { events, floor: guarded.diagnostics };
|
|
207
|
+
result.ok = true;
|
|
208
|
+
} catch (err) {
|
|
209
|
+
result.error = { step, message: err.message };
|
|
210
|
+
log(`[bench] FAILED at ${step}: ${err.message}`);
|
|
211
|
+
} finally {
|
|
212
|
+
rmSync(localTmp, { recursive: true, force: true });
|
|
213
|
+
await ssh(host, `rm -rf '${remoteDir}'`, { timeoutMs: 60000 }).catch((err) => log(`[bench] remote cleanup failed: ${err.message}`));
|
|
214
|
+
}
|
|
215
|
+
const summary = parseRunnerLog(readFileSync(logPath, "utf8"));
|
|
216
|
+
const segmentation = result.performance?.segmentation;
|
|
217
|
+
result.detection = summary.detectedFrames != null
|
|
218
|
+
? { detector: summary.detector, detectedFrames: summary.detectedFrames, totalFrames: summary.totalFrames, detectionRate: summary.detectionRate, source: summary.detectionSource }
|
|
219
|
+
: segmentation?.detectedFrames != null
|
|
220
|
+
? { detector: segmentation.detector, detectedFrames: segmentation.detectedFrames, totalFrames: segmentation.frames, detectionRate: segmentation.detectionRate, source: "worker-segmentation" }
|
|
221
|
+
: { detector: summary.detector, detectedFrames: null, totalFrames: null, detectionRate: null, source: null };
|
|
222
|
+
result.runnerLog = summary;
|
|
223
|
+
timings.totalS = seconds(started);
|
|
224
|
+
writeFileSync(join(out, "result.json"), `${JSON.stringify(result, null, "\t")}\n`);
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function main() {
|
|
229
|
+
let options;
|
|
230
|
+
try {
|
|
231
|
+
options = parseArgs(process.argv.slice(2), process.env);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.error(`extract-bench: ${err.message}\n\n${USAGE}`);
|
|
234
|
+
process.exit(2);
|
|
235
|
+
}
|
|
236
|
+
if (options.help) {
|
|
237
|
+
console.log(USAGE);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
options.input = resolve(options.input);
|
|
241
|
+
options.out = resolve(options.out);
|
|
242
|
+
const runs = buildPlan(options);
|
|
243
|
+
for (const run of runs) {
|
|
244
|
+
if (!existsSync(join(run.source, "video.mp4"))) throw new Error(`missing input ${join(run.source, "video.mp4")}`);
|
|
245
|
+
}
|
|
246
|
+
if (options.dryRun) {
|
|
247
|
+
for (const { motion, variant, plan } of runs) {
|
|
248
|
+
const flags = plan.path === "worker" ? JSON.stringify(plan.workerFields) : plan.runnerArgs.join(" ");
|
|
249
|
+
console.log(`${motion}/${variant}/${plan.name}\t${plan.path}\t${flags}`);
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const host = options.host;
|
|
255
|
+
const commit = commitId();
|
|
256
|
+
const benchDir = `/tmp/cclay-bench-${Date.now()}-${randomBytes(3).toString("hex")}`;
|
|
257
|
+
let client = null;
|
|
258
|
+
const worker = () => (client ??= gvhmrWorker({ host, sshOptions: SSH_OPTS, scpOptions: SCP_OPTS }));
|
|
259
|
+
const cleanupRemote = () => spawnSync("ssh", [...SSH_OPTS, host, `rm -rf '${benchDir}'`], { stdio: "ignore", timeout: 60000 });
|
|
260
|
+
process.once("SIGINT", () => {
|
|
261
|
+
console.error("extract-bench: interrupted; stopping children and removing the box temp dir");
|
|
262
|
+
client?.stop("extract-cancelled");
|
|
263
|
+
for (const child of globalChildren) killGroup(child);
|
|
264
|
+
cleanupRemote();
|
|
265
|
+
process.exit(130);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await ssh(host, `umask 077 && mkdir -p '${benchDir}'`, { timeoutMs: 60000 });
|
|
269
|
+
if (runs.some((run) => run.plan.path === "direct" && run.plan.detector !== "palette")) {
|
|
270
|
+
await scp(LAUNCHER, `${host}:${benchDir}/cclay_bench_runner.py`, { timeoutMs: 60000 });
|
|
271
|
+
}
|
|
272
|
+
let failures = 0;
|
|
273
|
+
try {
|
|
274
|
+
for (const run of runs) {
|
|
275
|
+
const label = `${run.motion}/${run.variant}/${run.plan.name}`;
|
|
276
|
+
const previous = readJson(join(run.out, "result.json"));
|
|
277
|
+
if (previous?.ok && !options.force) {
|
|
278
|
+
console.log(`skip ${label} (result.json ok; --force re-runs)`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
console.log(`run ${label} [${run.plan.path}]`);
|
|
282
|
+
const result = await runOne(run, { host, benchDir, worker, commit });
|
|
283
|
+
if (!result.ok) failures += 1;
|
|
284
|
+
const d = result.detection;
|
|
285
|
+
console.log(`${result.ok ? "ok " : "FAIL"} ${label} detected ${d.detectedFrames ?? "?"}/${d.totalFrames ?? "?"} (${d.detector ?? "?"})` +
|
|
286
|
+
` extract ${result.timings.extractS ?? "-"} s total ${result.timings.totalS} s` +
|
|
287
|
+
(result.ok ? ` quality ${result.quality.pass ? "pass" : "fail"}` : ` — ${result.error.step}: ${result.error.message}`));
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
client?.stop("extract-bench-finished");
|
|
291
|
+
cleanupRemote();
|
|
292
|
+
}
|
|
293
|
+
if (failures) {
|
|
294
|
+
console.error(`extract-bench: ${failures} run(s) failed`);
|
|
295
|
+
process.exitCode = 1;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
main().catch((err) => {
|
|
300
|
+
console.error(`extract-bench: ${err.stack ?? err.message}`);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
9
|
+
import { buildH3LockedPrompt } from "../../bin/agent/video-adapters.mjs";
|
|
10
|
+
|
|
11
|
+
export const FAL_MODEL = "minimax/h3-max-turbo/image-to-video";
|
|
12
|
+
export function falRequest({ prompt, firstImage, lastImage }) {
|
|
13
|
+
if (!String(prompt ?? "").trim() || !firstImage || !lastImage) throw new Error("prompt and both endpoint images are required");
|
|
14
|
+
// H3's documented contract has no seed field: independent requests, not
|
|
15
|
+
// a claimed deterministic seed sweep. Do not silently send ignored seeds.
|
|
16
|
+
return { prompt: buildH3LockedPrompt(prompt), image_url: firstImage, end_image_url: lastImage, resolution: "480P", duration: 5, prompt_expansion_mode: "disabled" };
|
|
17
|
+
}
|
|
18
|
+
export function costFields(value) {
|
|
19
|
+
if (!value || typeof value !== "object") return {};
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
22
|
+
if (/cost|price|billing|usage/i.test(key)) out[key] = entry;
|
|
23
|
+
else if (entry && typeof entry === "object") { const nested = costFields(entry); if (Object.keys(nested).length) out[key] = nested; }
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/** Unbounded queue wait, finite individual HTTP requests. Every response is
|
|
28
|
+
* logged by the caller. Test injects fetch + wait, never wall-clock polling. */
|
|
29
|
+
export async function runFal(body, { key, fetchImpl = fetch, wait = () => delay(5000), onEvent = () => {} }) {
|
|
30
|
+
if (!key) throw new Error("fal key is required");
|
|
31
|
+
const request = async (url, options = {}) => {
|
|
32
|
+
const response = await fetchImpl(url, { ...options, headers: { authorization: `Key ${key}`, ...options.headers }, signal: AbortSignal.timeout(120000) });
|
|
33
|
+
if (!response.ok) throw new Error(`fal HTTP ${response.status} at ${new URL(url).pathname}`);
|
|
34
|
+
const value = await response.json(); onEvent({ at: new Date().toISOString(), url, response: value, cost: costFields(value) }); return value;
|
|
35
|
+
};
|
|
36
|
+
const queued = await request(`https://queue.fal.run/${FAL_MODEL}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
37
|
+
let status = queued;
|
|
38
|
+
for (;;) {
|
|
39
|
+
const video = status.video?.url ?? status.output?.video?.url ?? status.video_url;
|
|
40
|
+
if (video) return { queued, result: status, video };
|
|
41
|
+
if (["FAILED", "CANCELLED"].includes(status.status)) throw new Error(`fal ${status.status}: ${status.error ?? "no error detail"}`);
|
|
42
|
+
if (status.status === "COMPLETED") {
|
|
43
|
+
const url = status.response_url ?? queued.response_url;
|
|
44
|
+
if (!url) throw new Error("fal completed without response_url");
|
|
45
|
+
status = await request(url);
|
|
46
|
+
if (!(status.video?.url ?? status.output?.video?.url ?? status.video_url)) throw new Error("fal completed without video URL");
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const url = status.status_url ?? queued.status_url;
|
|
50
|
+
if (!url) throw new Error("fal queue response lacks status_url");
|
|
51
|
+
await wait(); status = await request(url);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
55
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
56
|
+
const { values: v } = parseArgs({ args: argv, options: { scenario: { type: "string" }, variant: { type: "string", default: "shaded" }, out: { type: "string" }, prompt: { type: "string" }, key: { type: "string" }, runs: { type: "string", default: "3" }, "dry-run": { type: "boolean" }, help: { type: "boolean" } } });
|
|
57
|
+
if (v.help) { console.log("usage: node tools/bench/fal-generate.mjs --scenario <GT motion dir> --out <clips root> [--prompt <action>] [--key <file>] [--runs 3] [--variant shaded|skin] [--dry-run]\nReads scenario.json prompt or --prompt; writes <out>/<scenario>-<variant>-01/{video.mp4,request.json,A.png,B.png}. No seed is sent (independent H3 requests)."); return; }
|
|
58
|
+
if (!v.scenario || !v.out) throw new Error("--scenario and --out required");
|
|
59
|
+
const runs = Number(v.runs); if (!Number.isInteger(runs) || runs < 1) throw new Error("--runs must be a positive integer");
|
|
60
|
+
if (!["shaded", "skin"].includes(v.variant)) throw new Error("--variant must be shaded or skin");
|
|
61
|
+
const scenario = resolve(v.scenario), name = basename(scenario), source = join(scenario, v.variant);
|
|
62
|
+
const meta = JSON.parse(readFileSync(join(source, "meta.json"))), info = existsSync(join(scenario, "scenario.json")) ? JSON.parse(readFileSync(join(scenario, "scenario.json"))) : {};
|
|
63
|
+
const prompt = v.prompt ?? info.prompt;
|
|
64
|
+
const still = frame => execFileSync("ffmpeg", ["-v", "error", "-i", join(source, "video.mp4"), "-vf", `select=eq(n\\,${frame})`, "-frames:v", "1", "-f", "image2pipe", "-c:v", "png", "pipe:1"], { maxBuffer: 10 * 1024 * 1024 });
|
|
65
|
+
const a = still(0), b = still(meta.frames - 1);
|
|
66
|
+
if (!a.length || !b.length) throw new Error("missing endpoint frame");
|
|
67
|
+
const body = falRequest({ prompt, firstImage: `data:image/png;base64,${a.toString("base64")}`, lastImage: `data:image/png;base64,${b.toString("base64")}` });
|
|
68
|
+
const keyPath = v.key ?? resolve(process.env.HOME, "ccFalToMocap/.fal-key");
|
|
69
|
+
const dry = v["dry-run"] || !existsSync(keyPath);
|
|
70
|
+
const key = dry ? null : readFileSync(keyPath, "utf8").trim();
|
|
71
|
+
for (let i = 1; i <= runs; i++) {
|
|
72
|
+
const dir = join(resolve(v.out), `${name}-${v.variant}-${String(i).padStart(2, "0")}`); mkdirSync(dir, { recursive: true });
|
|
73
|
+
const recordPath = join(dir, "request.json");
|
|
74
|
+
if (existsSync(recordPath) && JSON.parse(readFileSync(recordPath)).ok && existsSync(join(dir, "video.mp4"))) { console.log(`skip completed ${basename(dir)}`); continue; }
|
|
75
|
+
writeFileSync(join(dir, "A.png"), a); writeFileSync(join(dir, "B.png"), b);
|
|
76
|
+
const record = { model: FAL_MODEL, scenario, variant: v.variant, run: i, independentRun: true, seed: null, dryRun: dry, ok: false, body: { ...body, image_url: "A.png", end_image_url: "B.png" }, endpoints: { A: sha(a), B: sha(b), sourceFrames: [0, meta.frames - 1] }, events: [], cost: null };
|
|
77
|
+
const save = () => writeFileSync(recordPath, JSON.stringify(record, null, 2) + "\n"); save();
|
|
78
|
+
if (dry) { console.log(`dry-run ${basename(dir)}: ${v["dry-run"] ? "requested" : "key missing"}`); continue; }
|
|
79
|
+
try {
|
|
80
|
+
const result = await runFal(body, { key, onEvent: event => { record.events.push(event); save(); console.log(`[${basename(dir)}] ${event.response.status ?? "result"} ${event.response.request_id ?? ""}`); } });
|
|
81
|
+
const response = await fetch(result.video, { signal: AbortSignal.timeout(300000) }); if (!response.ok) throw new Error(`video HTTP ${response.status}`);
|
|
82
|
+
writeFileSync(join(dir, "video.mp4"), Buffer.from(await response.arrayBuffer()));
|
|
83
|
+
record.ok = true; record.requestId = result.queued.request_id; record.result = result.result;
|
|
84
|
+
record.cost = record.events.map(e => e.cost).filter(c => Object.keys(c).length); record.costNote = record.cost.length ? "Provider-reported fields, not estimated." : "Provider supplied no cost fields; unknown, not zero.";
|
|
85
|
+
} catch (error) { record.error = error.message; throw error; } finally { save(); }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(e => { console.error(e.stack); process.exitCode = 1; });
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# F0-F5 cumulative fitting bench (#432)
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
node tools/bench/fit-bench.mjs \
|
|
5
|
+
--input /path/to/evidence/gt \
|
|
6
|
+
--extract /path/to/evidence/exp1/extract \
|
|
7
|
+
--poses /path/to/evidence/prep/gt-motions \
|
|
8
|
+
--out /path/to/evidence/exp2/fit \
|
|
9
|
+
--variant shaded --host yun@ubuntu-baremetal \
|
|
10
|
+
--motions walk,run,turn,sit,standup,jump,pickup,stepup
|
|
11
|
+
|
|
12
|
+
# Native YOLO/ViTPose on the grey mannequin. F0 exists in T2; if F1
|
|
13
|
+
# yolo-vitpose+fmm is absent, fit-bench extracts it serially with --host.
|
|
14
|
+
node tools/bench/fit-bench.mjs \
|
|
15
|
+
--input /path/to/evidence/gt --extract /path/to/evidence/exp1/extract \
|
|
16
|
+
--poses /path/to/evidence/prep/gt-motions --out /path/to/evidence/exp2/fit-skin \
|
|
17
|
+
--variant skin --base-condition yolo-vitpose --host yun@ubuntu-baremetal
|
|
18
|
+
|
|
19
|
+
# Evaluation ONLY. This program's output is never consumed by fitting.
|
|
20
|
+
node tools/bench/fit-sanity.mjs /path/to/evidence/exp2/fit /path/to/evidence/prep/gt-motions
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Each `<out>/<motion>/F0..F5/motion.npz` is a normal cskel27 archive:
|
|
24
|
+
`local_rot_mats`, `root_positions`, `posed_joints`, `fps`, with production's
|
|
25
|
+
optional `bone_scale` / `person_scale`. `result.json` records provenance,
|
|
26
|
+
registration, configuration, contact diagnostics and deviations. Camera evidence
|
|
27
|
+
is saved to `<out>/<motion>/incam.npz`, with its run log and cache manifest.
|
|
28
|
+
A different variant or base condition needs a different output root. Existing
|
|
29
|
+
mismatched results are rejected, not silently overwritten. `--base-condition`
|
|
30
|
+
defaults to `prod` (the existing F0/F1 motion bytes are unchanged); it accepts a
|
|
31
|
+
T2 base condition without `+fmm`. F0 is copied from that condition and F1 from
|
|
32
|
+
the same condition with `+fmm`. T2 already supports `yolo-vitpose+fmm` for direct
|
|
33
|
+
runs. If only F1 is missing, the CLI invokes T2 for that one motion/variant/
|
|
34
|
+
condition after the idle-GPU check. It never substitutes F0 for missing F1.
|
|
35
|
+
|
|
36
|
+
## Information boundary
|
|
37
|
+
|
|
38
|
+
**No fitting stage reads or measures GT intermediate motion.** The allowed
|
|
39
|
+
inputs are extracted video motion, known camera, known character body, the
|
|
40
|
+
user's two endpoint poses and scene geometry. `readEndpoints` decodes a source
|
|
41
|
+
archive but exposes only its first/last rotation matrices and roots. It never
|
|
42
|
+
examines intermediate arrays, joint trajectories or GT body measurements. A
|
|
43
|
+
test poisons all other source data and exercises the real CLI successfully.
|
|
44
|
+
`fit-sanity.mjs` is a separate read-only evaluator and is the only code here
|
|
45
|
+
that consumes the complete reference trajectory.
|
|
46
|
+
|
|
47
|
+
| Stage | New information | Operation |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| F0 | None | Byte-identical T2 `<base-condition>/motion.npz` (default `prod`). |
|
|
50
|
+
| F1 | Known focal | Byte-identical T2 `<base-condition>+fmm/motion.npz`. |
|
|
51
|
+
| F2 | Known static OpenCV camera extrinsics | Register the first F1 pelvis/orientation to the predicted camera-space pelvis/orientation, then apply the same rigid transform to every frame. |
|
|
52
|
+
| F3 | Known character bone factors | Regenerate FK from F2 rotations with canonical offsets times the character's factors. Defaults to all ones, equivalent to `boneScale:1`. |
|
|
53
|
+
| F4 | User's A and B poses only | Slerp local rotations toward A/B; smoothstep endpoint root-offset ramps. Default 0.5 s, capped at half the clip. |
|
|
54
|
+
| F5 | Floor Y=0 and optional axis-aligned boxes | Low/slow stance detection, rigid support locking, floor and skeletal bone/box penetration resolution. |
|
|
55
|
+
|
|
56
|
+
## F2: why one static registration
|
|
57
|
+
|
|
58
|
+
The new `cclay_bench_extract_incam.py` imports the box's **unchanged**
|
|
59
|
+
`cclay_gvhmr_extract.py` and runs its `main()` under the same imported
|
|
60
|
+
`FastRuntime` and `trajectory_job` wrappers as the production worker. It passes
|
|
61
|
+
`--static-cam`, known `--f-mm`, production detector/keypoint flags, and
|
|
62
|
+
production smoothing sigma. It does not copy the model or preprocessing
|
|
63
|
+
pipeline and does not edit anything in the GVHMR checkout. For a direct T2
|
|
64
|
+
condition such as `yolo-vitpose`, the camera launcher uses the same YOLO and
|
|
65
|
+
ViTPose flags and **does not** enable `FastRuntime` or `trajectory_job`. The
|
|
66
|
+
bench-only `--bench-direct` switch is removed before calling the runner. This
|
|
67
|
+
keeps camera evidence matched to F1 rather than quietly using palette evidence
|
|
68
|
+
for a YOLO fit. The condition is included in cache signatures and result metadata.
|
|
69
|
+
|
|
70
|
+
The launcher adds the raw in-camera SMPL-X parameters and recomputes the
|
|
71
|
+
camera pelvis with the same SMPL-X -> SMPL mesh regressor as the runner.
|
|
72
|
+
**SMPL model `transl` is not its mesh-regressed pelvis**, so substituting it
|
|
73
|
+
would introduce a shape-dependent translation error. It smooths camera pelvis
|
|
74
|
+
and root orientation with the runner's own `smooth_motion_params`.
|
|
75
|
+
|
|
76
|
+
All transforms multiply column vectors. Added NPZ members:
|
|
77
|
+
|
|
78
|
+
| Member | Shape | Meaning |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `incam_raw_global_orient`, `incam_raw_transl` | T,3 | Raw camera-space SMPL-X model parameters. |
|
|
81
|
+
| `incam_raw_body_pose`, `incam_raw_betas` | T,63 / T,10 | Raw model articulation and shape. |
|
|
82
|
+
| `incam_global_orient`, `incam_pelvis` | T,3 | Smoothed camera orientation and mesh-regressed pelvis. |
|
|
83
|
+
| `ayfz_to_camera` | T,4,4 | Per-frame relation between output ayfz pelvis/orientation and the camera prediction. |
|
|
84
|
+
| `ay_to_ayfz` | 4,4 | Runner's exact pre-smoothing normalization, including floor and heading. |
|
|
85
|
+
| `ay_global_orient`, `ay_transl` | T,3 | Raw predicted gravity-aligned model parameters. |
|
|
86
|
+
| `K_fullimg` | T,3,3 | Intrinsics actually used by GVHMR. |
|
|
87
|
+
|
|
88
|
+
The per-frame `ayfz_to_camera` is diagnostic. Camera-space depth and the
|
|
89
|
+
integrated global trajectory can disagree even with a static camera. Applying
|
|
90
|
+
the entire time-varying relation would replace F1's trajectory with noisy
|
|
91
|
+
monocular camera translation. Instead F2 preserves production's trajectory
|
|
92
|
+
(including stabilization/contact anchoring and any descent/floor correction)
|
|
93
|
+
and only fixes its initial reference frame:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
R_f1_to_cam = R_incam[0] * transpose(R_f1[0])
|
|
97
|
+
R = transpose(R_world_to_cam) * R_f1_to_cam
|
|
98
|
+
p_world = transpose(R_world_to_cam) * (p_incam[0] - t_world_to_cam)
|
|
99
|
+
t = p_world - R * p_f1[0]
|
|
100
|
+
p_F2[f] = R * p_F1[f] + t
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Only the Hips local rotation is multiplied by `R`; child local rotations are
|
|
104
|
+
unchanged. All positional channels get the same SE(3). No GT roots, floor
|
|
105
|
+
alignment, heading search, scale search or Procrustes fit enters F2. Camera
|
|
106
|
+
JSON must be static and a proper rigid OpenCV transform. Its `worldToCamera`
|
|
107
|
+
(y down, z forward), not the Three/OpenGL matrix, is inverted. Camera evidence
|
|
108
|
+
intrinsics must agree with the known focal.
|
|
109
|
+
|
|
110
|
+
## Deliberate conversion deviations
|
|
111
|
+
|
|
112
|
+
- F0/F1 are untouched production archives: `smplToCskel27Motion`,
|
|
113
|
+
`stabilizeMotion`, and `guardTrajectoryFloor` have already run in T2.
|
|
114
|
+
- F2 is cumulative on **that F1**, not a fresh conversion of the additional
|
|
115
|
+
inference. The new inference supplies camera evidence only. Its first
|
|
116
|
+
pelvis replaces production's arbitrary initial floor datum; re-grounding
|
|
117
|
+
here would undo camera placement. Static registration uses one frame and
|
|
118
|
+
inherits that frame's monocular depth/orientation uncertainty.
|
|
119
|
+
- F3 keeps F2's root in metres and `personScale=1`; it does not scale the
|
|
120
|
+
camera translation to compensate for changed body proportions. It rebuilds
|
|
121
|
+
positions by FK, avoiding production's independently smoothed joint arrays
|
|
122
|
+
when those disagree with constant bone lengths. There is no second
|
|
123
|
+
stabilization, foot anchoring or percentile-floor pass.
|
|
124
|
+
- Character factors default to canonical cskel27 (all ones), not a fresh
|
|
125
|
+
measurement of the rendered y-bot mesh. `--body body.json` accepts the known
|
|
126
|
+
character's 27 positive per-bone factors. This follows the issue's
|
|
127
|
+
`boneScale:1` option and leaves playback's own rig mapping intact.
|
|
128
|
+
- F4 preserves F3 bone lengths and uses source NPZ first/last poses exactly as
|
|
129
|
+
requested. The last source frame is pinned even when rate conversion puts
|
|
130
|
+
the last rendered sample slightly before it (180@30 vs 144@24).
|
|
131
|
+
- F5 is a **skeletal**, not skinned-mesh, constraint pass. One support point at
|
|
132
|
+
a time is locked by rigid root translation, preserving rotations and bone
|
|
133
|
+
vectors. Eligible feet/toes are within 4 cm of a known surface and move at
|
|
134
|
+
<=0.18 m/s for >=0.08 s. A hand can support on a known box face; no box means
|
|
135
|
+
no invented hand contact. Smoothstep bridges carry offsets between runs.
|
|
136
|
+
Simultaneous two-foot or hand/foot contacts are not a multi-effector IK solve.
|
|
137
|
+
- Box collision tests include open-interior bone segments, not just joints.
|
|
138
|
+
On collision a conservative root shift clears the whole skeleton along a
|
|
139
|
+
separating axis, preserving lengths. No skin radius, self-collision or
|
|
140
|
+
collision-free swept trajectory between frames is claimed. Boxes can force
|
|
141
|
+
substantial displacement. Conflicting projections fail rather than silently
|
|
142
|
+
emit penetrations. Floor/scene safety wins over incompatible locks and A/B
|
|
143
|
+
pins; `sceneOverrides` and `maxLockResidualM` disclose this.
|
|
144
|
+
|
|
145
|
+
Scene format (`--scene`):
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{"boxes":[{"min":[-0.5,0,-0.5],"max":[0.5,1,0.5]}]}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## GPU ownership and caches
|
|
152
|
+
|
|
153
|
+
Wait until other T2 sweeps have finished. The CLI refuses any other GVHMR
|
|
154
|
+
Python process, including an idle persistent worker, and checks `nvidia-smi`
|
|
155
|
+
compute memory before upload and again immediately before inference. This is
|
|
156
|
+
a safety check, not a lock respected by independently launched jobs; callers
|
|
157
|
+
must coordinate those launches. No other process is killed. The bench runs
|
|
158
|
+
serially and uses only `/tmp/cclay-fit-*` on the box; the run directory is
|
|
159
|
+
removed in success/failure paths, and cleanup failure is an error. Abrupt
|
|
160
|
+
process termination or a broken SSH connection can require manual cleanup.
|
|
161
|
+
|
|
162
|
+
Local camera caches bind the video, focal/flags and deployed code hashes. The
|
|
163
|
+
box runner SHA is recorded. The runner is externally managed: if it changes,
|
|
164
|
+
use `--force-incam`. `--incam-root` supplies already extracted
|
|
165
|
+
`<root>/<motion>/incam.npz` without touching the GPU (also the CPU integration
|
|
166
|
+
test path).
|
|
167
|
+
|
|
168
|
+
## Verification and sanity metric
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
node test/verify-fit-bench.mjs
|
|
172
|
+
npm run build # dist is required by an existing suite
|
|
173
|
+
node tools/run-tests.mjs
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Tests cover camera inverse and proper-rotation validation, rigid trajectory
|
|
177
|
+
registration, body/FK preservation, endpoint slerp and antipodes, short clips,
|
|
178
|
+
falloff boundaries, floor and stance locking, segment/box crossings, bone
|
|
179
|
+
length preservation, archive round trips, byte-identical F0/F1, and the real
|
|
180
|
+
CLI with poisoned intermediate GT data. No timing waits or external GPU are
|
|
181
|
+
needed by tests.
|
|
182
|
+
|
|
183
|
+
`fit-sanity.mjs` compares output cskel27 `posed_joints` with source NPZ
|
|
184
|
+
`posed_joints` at matching seconds (linear interpolation of reference
|
|
185
|
+
positions). Root-relative error averages joints 1..26; root error is Hips.
|
|
186
|
+
There is **no alignment**. It writes per-motion/per-stage and aggregate tables
|
|
187
|
+
in millimetres. These are not official rendered-rig errors: Studio playback
|
|
188
|
+
applies rig scale and bind offsets, F2 is scene metres, and the requested F4
|
|
189
|
+
source endpoints are native cskel27. Official grading by #431 remains separate.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { matMul, matTranspose, quatToMat } from "../../../src/ardy/convert.js";
|
|
2
|
+
import { add, cloneMotion, matVec, member, readMat, sub, vec } from "./motion.mjs";
|
|
3
|
+
|
|
4
|
+
export function axisAngleMatrix(v) {
|
|
5
|
+
const angle = Math.hypot(...v), scale = angle < 1e-12 ? 0.5 : Math.sin(angle / 2) / angle;
|
|
6
|
+
return quatToMat([Math.cos(angle / 2), ...v.map(x => x * scale)]);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** OpenCV x-right/y-down/z-forward, NOT the OpenGL camera rotation. */
|
|
10
|
+
export function cameraToWorld(camera) {
|
|
11
|
+
const t = camera.worldToCamera;
|
|
12
|
+
if (!camera.static || !Array.isArray(t) || t.length !== 4 || !t.every(row => Array.isArray(row) && row.length === 4 && row.every(Number.isFinite))) {
|
|
13
|
+
throw new Error("camera: expected static OpenCV worldToCamera [4,4]");
|
|
14
|
+
}
|
|
15
|
+
const r = t.slice(0, 3).map(row => row.slice(0, 3)), rt = matTranspose(r);
|
|
16
|
+
const identity = matMul(r, rt);
|
|
17
|
+
const det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1]) - r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0]) + r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]);
|
|
18
|
+
if (identity.some((row, i) => row.some((x, j) => Math.abs(x - Number(i === j)) > 1e-5)) || Math.abs(det - 1) > 1e-5 || t[3].some((x, i) => Math.abs(x - Number(i === 3)) > 1e-6)) {
|
|
19
|
+
throw new Error("camera: worldToCamera must be a rigid proper transform");
|
|
20
|
+
}
|
|
21
|
+
return { rotation: rt, translation: matVec(rt, t.slice(0, 3).map(row => -row[3])) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function transformMotion(motion, { rotation, translation }) {
|
|
25
|
+
const out = cloneMotion(motion);
|
|
26
|
+
for (let f = 0; f < motion.frames; f++) {
|
|
27
|
+
out.rootPos.set(add(matVec(rotation, vec(motion.rootPos, f * 3)), translation), f * 3);
|
|
28
|
+
out.rotMats.set(matMul(rotation, readMat(motion.rotMats, f * 243)).flat(), f * 243);
|
|
29
|
+
for (let j = 0; j < 27; j++) {
|
|
30
|
+
const o = (f * 27 + j) * 3;
|
|
31
|
+
out.posedJoints.set(add(matVec(rotation, vec(motion.posedJoints, o)), translation), o);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** F2: static registration, never a trajectory fit to GT. The new box run
|
|
38
|
+
* supplies the camera-space pelvis and orientation. F1 supplies the SAME
|
|
39
|
+
* production-smoothed ayfz root orientation. Register their first frames,
|
|
40
|
+
* then apply ONE rigid transform to all of F1. This removes the arbitrary
|
|
41
|
+
* initial heading/origin/floor datum while preserving production trajectory,
|
|
42
|
+
* body, stabilisation and trajectory-floor safety relative to that datum.
|
|
43
|
+
* Per-frame world->camera transforms are saved as diagnostics, not used as
|
|
44
|
+
* a moving camera: their drift includes monocular depth/velocity disagreement.
|
|
45
|
+
*/
|
|
46
|
+
export function registerCamera(motion, incam, camera) {
|
|
47
|
+
const n = incam.incam_pelvis?.shape[0];
|
|
48
|
+
if (n !== motion.frames) throw new Error("incam and F1 frame counts disagree");
|
|
49
|
+
const pelvis = member(incam, "incam_pelvis", [n, 3]);
|
|
50
|
+
const orient = member(incam, "incam_global_orient", [n, 3]);
|
|
51
|
+
if (Math.abs(member(incam, "fps", [])[0] - motion.fps) > 1e-6) throw new Error("incam and F1 fps disagree");
|
|
52
|
+
const c2w = cameraToWorld(camera);
|
|
53
|
+
const f1ToCamera = matMul(axisAngleMatrix(vec(orient)), matTranspose(readMat(motion.rotMats)));
|
|
54
|
+
const rotation = matMul(c2w.rotation, f1ToCamera);
|
|
55
|
+
const worldPelvis = add(matVec(c2w.rotation, vec(pelvis)), c2w.translation);
|
|
56
|
+
const translation = sub(worldPelvis, matVec(rotation, vec(motion.rootPos)));
|
|
57
|
+
return { motion: transformMotion(motion, { rotation, translation }),
|
|
58
|
+
diagnostics: { rotation, translation, worldPelvis, anchorFrame: 0, registration: "static-predicted-pelvis", preservesF1RelativeTrajectory: true } };
|
|
59
|
+
}
|