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.
Files changed (286) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/LIVE-PROTOCOL.md +1 -1
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. 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
+ }