cozyclay 2.1.0 → 3.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 (583) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +12 -30
  3. package/bin/agent/agent-routes.mjs +40 -113
  4. package/bin/agent/agent-runner.mjs +9 -10
  5. package/bin/agent/cliproxy-provider.mjs +135 -0
  6. package/bin/agent/credential-store.mjs +30 -7
  7. package/bin/agent/pi-tools.mjs +4 -1
  8. package/bin/agent/provider-keys.mjs +32 -0
  9. package/bin/agent/provider-login.mjs +110 -0
  10. package/bin/agent/provider-routes.mjs +99 -0
  11. package/bin/agent/providers.mjs +19 -97
  12. package/bin/agent/script-tool.mjs +17 -4
  13. package/bin/agent/studio-motion-reference.mjs +23 -0
  14. package/bin/agent/studio-prompt.mjs +1 -1
  15. package/bin/agent/studio-rebase-text.mjs +32 -0
  16. package/bin/agent/studio-rebase.mjs +26 -39
  17. package/bin/agent/studio-tools.mjs +9 -28
  18. package/bin/agent/tool-arguments.mjs +49 -0
  19. package/bin/agent/turn-frames.mjs +66 -0
  20. package/bin/codex-auth.mjs +9 -2
  21. package/bin/cozyclay.mjs +10 -116
  22. package/bin/live/cli.mjs +22 -6
  23. package/bin/mcp-runtime.mjs +2 -12
  24. package/bin/package-signature.mjs +0 -4
  25. package/dist/app/index.html +7 -6
  26. package/dist/assets/EXRLoader-CvVIvGVE.js +1 -0
  27. package/dist/assets/analytics-C9sUnndd.js +1 -0
  28. package/dist/assets/app-CCh--1Hc.js +5087 -0
  29. package/dist/assets/app-f-fkV5uP.css +1 -0
  30. package/dist/assets/compile-worker-CkxiD6lH.js +5 -0
  31. package/dist/assets/fflate.module-KbGj5-H6.js +5 -0
  32. package/dist/assets/{first-shot-handoff-BlT33CGf.js → first-shot-handoff-CC4xe4HM.js} +1 -1
  33. package/dist/assets/{landing-BSesrCpL.js → landing-DjHD8u7k.js} +1 -1
  34. package/dist/assets/scene-asset-sync-Hx_FzdOH.js +20 -0
  35. package/dist/assets/scene-asset-sync-_S8NQl5B.css +1 -0
  36. package/dist/assets/workflow-C4yKH1p4.js +179 -0
  37. package/dist/cozyclay-package.json +1 -1
  38. package/dist/index.html +3 -3
  39. package/dist/llms.txt +14 -0
  40. package/dist/sw.js +3 -6
  41. package/dist/workflow/index.html +5 -4
  42. package/mcp/LIVE-PROTOCOL.md +7 -47
  43. package/mcp/README.md +16 -26
  44. package/mcp/live-hub.mjs +6 -10
  45. package/mcp/package.json +1 -3
  46. package/mcp/tool-handlers.mjs +4 -168
  47. package/package.json +3 -22
  48. package/src/App.jsx +174 -2639
  49. package/src/agent-progress.js +79 -0
  50. package/src/agent-turn-mark.js +30 -0
  51. package/src/analytics.js +1 -19
  52. package/src/app-context.js +5 -0
  53. package/src/app-stage.jsx +2 -34
  54. package/src/asset-pane.jsx +6 -5
  55. package/src/camera-block.js +21 -0
  56. package/src/camera-direction.js +120 -0
  57. package/src/character-kind.js +3 -9
  58. package/src/command-bus.js +8 -6
  59. package/src/commands/cast.js +5 -34
  60. package/src/commands/elements/character.js +1 -1
  61. package/src/commands/elements/motion.js +1 -6
  62. package/src/commands/elements/object.js +6 -2
  63. package/src/commands/elements/shot.js +4 -1
  64. package/src/commands/elements.js +38 -14
  65. package/src/commands/index.js +3 -1
  66. package/src/commands/kits.js +37 -0
  67. package/src/commands/motion-timing.js +57 -0
  68. package/src/commands/motion.js +9 -152
  69. package/src/commands/motion3-compile.js +65 -0
  70. package/src/commands/motion3.js +207 -0
  71. package/src/commands/objects.js +6 -4
  72. package/src/commands/shot.js +49 -11
  73. package/src/commands/stage.js +14 -1
  74. package/src/commands/view.js +4 -1
  75. package/src/document-mirrors.js +42 -0
  76. package/src/document-store.js +4 -0
  77. package/src/domains/cast.js +19 -194
  78. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  79. package/src/domains/motion/take-source.js +51 -0
  80. package/src/domains/motion.js +60 -1838
  81. package/src/domains/scenes.js +12 -55
  82. package/src/domains/shots.js +2 -2
  83. package/src/domains/stage.js +4 -2
  84. package/src/draft-ghost.js +46 -0
  85. package/src/draft-ghost.jsx +129 -0
  86. package/src/dualview.jsx +4 -3
  87. package/src/effects/EffectsLayer.jsx +112 -0
  88. package/src/effects/effect-meshes.js +232 -0
  89. package/src/effects/effect-record.js +103 -0
  90. package/src/effects/emitter.js +59 -0
  91. package/src/effects/simulate.js +261 -0
  92. package/src/effects/state.js +59 -0
  93. package/src/execution-telemetry.js +2 -3
  94. package/src/first-success-guide.jsx +5 -5
  95. package/src/ghost-set.js +257 -0
  96. package/src/held-object-edit.jsx +113 -0
  97. package/src/held-object-pose.js +175 -0
  98. package/src/hierarchy-model.js +1 -1
  99. package/src/hierarchy-panel.jsx +1 -1
  100. package/src/imported-mesh.jsx +103 -0
  101. package/src/kits/held.js +84 -0
  102. package/src/kits/index.js +70 -0
  103. package/src/kits/smoking.js +57 -0
  104. package/src/light-blockers.js +93 -0
  105. package/src/motion/beat-board-model.js +84 -0
  106. package/src/motion/beat-board.css +124 -0
  107. package/src/motion/beat-board.jsx +59 -0
  108. package/src/motion/center-of-mass.js +66 -0
  109. package/src/motion/fix-collisions.js +850 -0
  110. package/src/motion/physics-review.js +118 -0
  111. package/src/{ardy → motion}/physics-support.js +1 -76
  112. package/src/motion/rig-snapshot.js +22 -0
  113. package/src/motion/take-segments.jsx +140 -0
  114. package/src/{ardy → motion}/timeline-coordinates.js +0 -21
  115. package/src/{ardy → motion}/timeline.css +1 -2
  116. package/src/{ardy → motion}/timeline.jsx +48 -419
  117. package/src/motion/waypoints.js +76 -0
  118. package/src/motion3/beats.js +83 -0
  119. package/src/motion3/body.js +158 -0
  120. package/src/motion3/bones.js +47 -0
  121. package/src/motion3/carried.js +47 -0
  122. package/src/motion3/compile-job.js +75 -0
  123. package/src/motion3/compile-worker.js +10 -0
  124. package/src/motion3/compiler/anchors.js +105 -0
  125. package/src/motion3/compiler/arm-orient.js +128 -0
  126. package/src/motion3/compiler/channels.js +125 -0
  127. package/src/motion3/compiler/compile.js +205 -0
  128. package/src/motion3/compiler/gait-rhythm.js +97 -0
  129. package/src/motion3/compiler/grip.js +171 -0
  130. package/src/motion3/compiler/head-steady.js +48 -0
  131. package/src/motion3/compiler/hold.js +134 -0
  132. package/src/motion3/compiler/idle.js +113 -0
  133. package/src/motion3/compiler/limbs.js +170 -0
  134. package/src/motion3/compiler/math.js +72 -0
  135. package/src/motion3/compiler/pose.js +162 -0
  136. package/src/motion3/compiler/quat.js +49 -0
  137. package/src/motion3/compiler/skeleton.js +74 -0
  138. package/src/motion3/compiler/validate-common.js +32 -0
  139. package/src/motion3/compiler/validate-goals.js +118 -0
  140. package/src/motion3/compiler/validate-objects.js +143 -0
  141. package/src/motion3/compiler/validate-parts.js +116 -0
  142. package/src/motion3/compiler/validate.js +122 -0
  143. package/src/motion3/compiler/vocab.js +73 -0
  144. package/src/motion3/compiler/walk-pelvis.js +275 -0
  145. package/src/motion3/compiler/walk.js +207 -0
  146. package/src/motion3/compiler/wrist-clamp.js +32 -0
  147. package/src/motion3/delta.js +47 -0
  148. package/src/motion3/describe.js +174 -0
  149. package/src/motion3/docs.js +27 -0
  150. package/src/motion3/edit-carry.js +21 -0
  151. package/src/motion3/fix/ease.js +16 -0
  152. package/src/motion3/fix/ground.js +37 -0
  153. package/src/motion3/fix/index.js +52 -0
  154. package/src/motion3/fix/push.js +71 -0
  155. package/src/motion3/fix/reach.js +46 -0
  156. package/src/motion3/fix/retime.js +79 -0
  157. package/src/motion3/fix/self.js +211 -0
  158. package/src/motion3/fix/slide.js +70 -0
  159. package/src/motion3/fix/turn.js +51 -0
  160. package/src/motion3/follow.js +39 -0
  161. package/src/motion3/free-motion.js +173 -0
  162. package/src/motion3/grip-choice.js +130 -0
  163. package/src/motion3/hold-plan.js +157 -0
  164. package/src/motion3/json-patch.js +77 -0
  165. package/src/motion3/key-aim.js +105 -0
  166. package/src/motion3/key-edit.js +82 -0
  167. package/src/motion3/lenient.js +127 -0
  168. package/src/motion3/object-keys.js +236 -0
  169. package/src/motion3/object-pose.js +182 -0
  170. package/src/motion3/object-tracks.js +12 -0
  171. package/src/motion3/pipeline.js +102 -0
  172. package/src/motion3/playback.js +51 -0
  173. package/src/motion3/probes.js +91 -0
  174. package/src/motion3/qa/body-checks.js +34 -0
  175. package/src/motion3/qa/check-names.js +14 -0
  176. package/src/motion3/qa/checks.js +121 -0
  177. package/src/motion3/qa/contact-checks.js +51 -0
  178. package/src/motion3/qa/explain.js +80 -0
  179. package/src/motion3/qa/fmt.js +5 -0
  180. package/src/motion3/qa/foot-checks.js +72 -0
  181. package/src/motion3/qa/geometry.js +77 -0
  182. package/src/motion3/qa/goals.js +191 -0
  183. package/src/motion3/qa/ground.js +60 -0
  184. package/src/motion3/qa/hints-contact.js +179 -0
  185. package/src/motion3/qa/hints-goal.js +160 -0
  186. package/src/motion3/qa/hints-knee.js +24 -0
  187. package/src/motion3/qa/hints-motion.js +209 -0
  188. package/src/motion3/qa/hints-object.js +91 -0
  189. package/src/motion3/qa/index.js +42 -0
  190. package/src/motion3/qa/key-checks.js +82 -0
  191. package/src/motion3/qa/near.js +96 -0
  192. package/src/motion3/qa/object-checks.js +135 -0
  193. package/src/motion3/qa/patch.js +161 -0
  194. package/src/motion3/qa/severity.js +38 -0
  195. package/src/motion3/qa/spec-checks.js +65 -0
  196. package/src/motion3/qa/style.js +218 -0
  197. package/src/motion3/qa/wrist-checks.js +69 -0
  198. package/src/motion3/qa/wrist-obs.js +60 -0
  199. package/src/motion3/refs.js +120 -0
  200. package/src/motion3/repair-tune.js +121 -0
  201. package/src/motion3/repair.js +83 -0
  202. package/src/motion3/report.js +67 -0
  203. package/src/motion3/rigs/index.js +14 -0
  204. package/src/motion3/rigs/x-bot-tpose-body.json +1 -0
  205. package/src/motion3/rigs/x-bot-tpose.json +1 -0
  206. package/src/motion3/rigs/y-bot-tpose-body.json +1 -0
  207. package/src/motion3/rigs/y-bot-tpose.json +1 -0
  208. package/src/motion3/scene.js +60 -0
  209. package/src/motion3/schema/brief.js +89 -0
  210. package/src/motion3/schema/fields.js +44 -0
  211. package/src/motion3/schema/spec-examples.js +46 -0
  212. package/src/motion3/schema/topics.js +111 -0
  213. package/src/motion3/schema/types.js +147 -0
  214. package/src/motion3/search.js +117 -0
  215. package/src/motion3/second-hand.js +112 -0
  216. package/src/motion3/skills/basic.js +82 -0
  217. package/src/motion3/skills/book.js +76 -0
  218. package/src/motion3/skills/car.js +145 -0
  219. package/src/motion3/skills/frames-auto.js +29 -0
  220. package/src/motion3/skills/index.js +83 -0
  221. package/src/motion3/skills/info.js +65 -0
  222. package/src/motion3/skills/smoke-kit.js +109 -0
  223. package/src/motion3/skills/smoke.js +160 -0
  224. package/src/motion3/spec-store.js +59 -0
  225. package/src/motion3/take-edit.js +155 -0
  226. package/src/motion3/take.js +63 -0
  227. package/src/motion3/timeline.js +127 -0
  228. package/src/motion3/views.js +121 -0
  229. package/src/motion3/walk-plan.js +119 -0
  230. package/src/object-catalog.jsx +7 -1
  231. package/src/object-colors.js +98 -0
  232. package/src/object-gizmo-parts.js +115 -0
  233. package/src/object-gizmo.jsx +37 -122
  234. package/src/object-glow.js +139 -0
  235. package/src/object-hidden.js +33 -0
  236. package/src/object-look.js +196 -0
  237. package/src/object-size.js +10 -0
  238. package/src/object-travel.js +71 -6
  239. package/src/panels/CameraPanel.jsx +7 -7
  240. package/src/panels/HdrEnvironmentControls.jsx +59 -0
  241. package/src/panels/LightPanel.jsx +25 -4
  242. package/src/panels/ObjectLookPanel.jsx +88 -0
  243. package/src/panels/PoseImagePanel.jsx +1 -1
  244. package/src/panels/PosePanel.jsx +2 -17
  245. package/src/panels/RigControlPanel.jsx +5 -53
  246. package/src/panels/SubjectBox.jsx +4 -1
  247. package/src/panels/details.css +14 -0
  248. package/src/posestudio.jsx +4 -27
  249. package/src/project-browser.jsx +3 -4
  250. package/src/project-resources.js +18 -31
  251. package/src/project.js +13 -104
  252. package/src/props.jsx +25 -280
  253. package/src/rail-dot-drag.js +23 -0
  254. package/src/render-passes.js +10 -0
  255. package/src/resource-status.jsx +1 -0
  256. package/src/result-modal.jsx +4 -19
  257. package/src/room.jsx +20 -12
  258. package/src/scene-assets.js +8 -6
  259. package/src/scene-light-place.js +72 -0
  260. package/src/scene-lights.jsx +148 -0
  261. package/src/scene-objects.js +31 -139
  262. package/src/scenes.js +15 -105
  263. package/src/semantic-edit.js +2 -5
  264. package/src/set-pieces.jsx +183 -0
  265. package/src/settings-menu.jsx +3 -11
  266. package/src/shell/BottomDock.jsx +11 -53
  267. package/src/shell/CompositionGuideButton.jsx +20 -0
  268. package/src/shell/DetailsSlot.jsx +16 -200
  269. package/src/shell/GhostsMenuGroup.jsx +74 -0
  270. package/src/shell/LibrarySlot.jsx +1 -3
  271. package/src/shell/MenuBar.jsx +3 -3
  272. package/src/shell/PreferencesDialog.jsx +15 -264
  273. package/src/shell/PreferencesSlot.jsx +2 -8
  274. package/src/shell/ShotPreviewHeader.jsx +17 -0
  275. package/src/shell/TopBar.jsx +5 -83
  276. package/src/shell/ViewportToolbar.jsx +23 -93
  277. package/src/shell/agent-glass.css +19 -6
  278. package/src/shell/details-selection.js +42 -0
  279. package/src/shell/ghosts-menu.css +22 -0
  280. package/src/shell/glass-regions.css +29 -3
  281. package/src/shell/preferences.css +6 -1
  282. package/src/shell/viewport.css +12 -0
  283. package/src/shot-authoring.js +4 -7
  284. package/src/soft-shadows.js +37 -0
  285. package/src/stage-attach-frames.js +12 -12
  286. package/src/stage-canvas-rigs.jsx +3 -3
  287. package/src/stage-capture.jsx +42 -8
  288. package/src/stage-characters.jsx +22 -216
  289. package/src/stage-editor-gizmos.jsx +6 -6
  290. package/src/stage-environment.js +93 -0
  291. package/src/stage-hdr-builtins.js +107 -0
  292. package/src/stage-hdr-image.js +116 -0
  293. package/src/stage-hdr-library.js +70 -0
  294. package/src/stage-hdr-map.js +94 -0
  295. package/src/stage-hdr.js +130 -0
  296. package/src/stage-lighting.js +149 -0
  297. package/src/stage-occlusion.js +210 -0
  298. package/src/stage-sky.jsx +159 -0
  299. package/src/stage-startup-defaults.js +5 -9
  300. package/src/studio-actions.js +12 -14
  301. package/src/studio-agent-commands.js +46 -16
  302. package/src/studio-agent-motion.js +9 -9
  303. package/src/studio-agent-protocol.js +46 -73
  304. package/src/studio-app-binding.js +29 -45
  305. package/src/studio-asset-list.js +17 -0
  306. package/src/studio-contact-sheet.js +37 -5
  307. package/src/studio-elements.js +27 -7
  308. package/src/studio-entity-bounds.js +18 -0
  309. package/src/studio-error-codes.js +8 -0
  310. package/src/studio-export-framing.js +152 -0
  311. package/src/studio-lighting-check.js +103 -0
  312. package/src/studio-motion-visual.js +150 -0
  313. package/src/studio-schema-errors.js +80 -0
  314. package/src/studio-support-gap.js +74 -0
  315. package/src/styles.css +1 -19
  316. package/src/timeline-defaults.js +5 -0
  317. package/src/timeline-extent.js +8 -20
  318. package/src/tutorial-walk.js +17 -0
  319. package/src/ui-scrub.js +1 -1
  320. package/src/use-shot-camera-tracks.js +12 -0
  321. package/src/view-ghosts.js +97 -0
  322. package/src/view-ghosts.jsx +49 -0
  323. package/src/workflow/AgentPanel.jsx +47 -166
  324. package/src/workflow/AgentProgressCard.jsx +107 -0
  325. package/src/workflow/AgentReplyText.jsx +37 -0
  326. package/src/workflow/AgentTurnSteps.jsx +84 -0
  327. package/src/workflow/LoginPanel.jsx +140 -0
  328. package/src/workflow/WorkflowBuilder.jsx +7 -23
  329. package/src/workflow/agent-client.js +54 -51
  330. package/src/workflow/agent-panel.css +71 -1
  331. package/src/workflow/agent-progress-card.css +167 -0
  332. package/src/workflow/agent-progress-view.js +47 -0
  333. package/src/workflow/agent-turn-steps.css +113 -0
  334. package/src/workflow/agent-turn.js +191 -0
  335. package/src/workflow/login-client.js +34 -0
  336. package/src/workflow/motion-input.js +18 -31
  337. package/src/workflow/node-schema.js +1 -1
  338. package/src/workflow/reply-markdown.js +69 -0
  339. package/src/workflow/scene-asset-sync.js +8 -8
  340. package/tools/agent-session-stats.mjs +61 -0
  341. package/tools/dev-full.mjs +5 -56
  342. package/tools/eslint-baseline.json +12 -13
  343. package/tools/headless-studio.mjs +85 -0
  344. package/tools/knip-baseline.json +0 -179
  345. package/tools/loc-baseline.json +33 -83
  346. package/tools/motion3/extract-body.mjs +103 -0
  347. package/tools/motion3/extract-rig.mjs +85 -0
  348. package/tools/process-supervisor.mjs +0 -88
  349. package/tools/run-tests.mjs +9 -124
  350. package/bin/agent/motion-runtime.mjs +0 -76
  351. package/dist/ardy/cskel27-rest.json +0 -557
  352. package/dist/assets/analytics-B41FmPYI.js +0 -1
  353. package/dist/assets/app-Do-Og5Pu.css +0 -1
  354. package/dist/assets/app-DwP-tl2x.js +0 -4920
  355. package/dist/assets/config-Bx7pYsD9.js +0 -1
  356. package/dist/assets/config-DeoFTYzb.css +0 -1
  357. package/dist/assets/demo-Dt0qZfS7.js +0 -1
  358. package/dist/assets/scene-asset-sync-BCWDJB1d.js +0 -19
  359. package/dist/assets/scene-asset-sync-DjJ_cJ2R.css +0 -1
  360. package/dist/assets/ticket-jqMhqL88.js +0 -1
  361. package/dist/assets/workflow-Br-K7Srs.js +0 -179
  362. package/dist/demo/walk-then-stop.npz +0 -0
  363. package/mcp/ardy-prompts.mjs +0 -218
  364. package/src/ardy/auto-fix-panel.css +0 -108
  365. package/src/ardy/auto-physics.js +0 -1194
  366. package/src/ardy/client.js +0 -153
  367. package/src/ardy/convert.js +0 -313
  368. package/src/ardy/cskel27-neutral.js +0 -39
  369. package/src/ardy/cskel27.js +0 -77
  370. package/src/ardy/export.js +0 -165
  371. package/src/ardy/fix-collisions.js +0 -2257
  372. package/src/ardy/foot-lock.js +0 -230
  373. package/src/ardy/motion-calibration.js +0 -118
  374. package/src/ardy/motion-edit.js +0 -213
  375. package/src/ardy/motion-url.js +0 -55
  376. package/src/ardy/mp4-duration.js +0 -102
  377. package/src/ardy/npz.js +0 -595
  378. package/src/ardy/physics-panel.css +0 -21
  379. package/src/ardy/physics-panel.jsx +0 -89
  380. package/src/ardy/physics-refine.js +0 -111
  381. package/src/ardy/physics-review.js +0 -567
  382. package/src/ardy/physics-temporal.js +0 -54
  383. package/src/ardy/platform-fit-panel.css +0 -64
  384. package/src/ardy/platform-fit-panel.jsx +0 -41
  385. package/src/ardy/platform-fit.js +0 -326
  386. package/src/ardy/playback.js +0 -711
  387. package/src/ardy/pose-pin.js +0 -97
  388. package/src/ardy/prompt-clips.js +0 -30
  389. package/src/ardy/retime.js +0 -211
  390. package/src/ardy/root-drop.js +0 -369
  391. package/src/ardy/timeline-resize.js +0 -13
  392. package/src/ardy/to-cskel27.js +0 -225
  393. package/src/ardy/trim.js +0 -33
  394. package/src/ardy/waypoints.js +0 -369
  395. package/src/ardy-bridge-contract.js +0 -281
  396. package/src/domains/motion/motion-video-capture.js +0 -664
  397. package/src/i2v-motion-client.js +0 -77
  398. package/src/i2v-motion-studio.jsx +0 -180
  399. package/src/line-edit.js +0 -1348
  400. package/src/motion/generation.js +0 -105
  401. package/src/motion-readiness-ui.jsx +0 -67
  402. package/src/motion-readiness.js +0 -40
  403. package/src/motion-resources.js +0 -177
  404. package/src/motion-store.js +0 -65
  405. package/src/motion-trail.js +0 -601
  406. package/src/multimodel-ingest.js +0 -394
  407. package/src/panels/PromptBlocksPanel.jsx +0 -444
  408. package/src/panels/VideoCapturePanel.jsx +0 -181
  409. package/src/pose-extract/browser.js +0 -42
  410. package/src/pose-extract/detector.js +0 -100
  411. package/src/pose-extract/fit.js +0 -227
  412. package/src/pose-extract/image-frame.js +0 -51
  413. package/src/pose-extract/index.js +0 -58
  414. package/src/pose-extract/keyframes.js +0 -74
  415. package/src/pose-extract/landmarks.js +0 -162
  416. package/src/pose-extract/mirror.js +0 -153
  417. package/src/pose-extract/take.js +0 -131
  418. package/src/pose-extract/video-frames.js +0 -91
  419. package/src/take-recipe.js +0 -165
  420. package/src/trail-key-conflicts.js +0 -39
  421. package/src/trail-pick.js +0 -59
  422. package/src/use-i2v-capture-modal.js +0 -15
  423. package/tools/ardy/BRIDGE.md +0 -37
  424. package/tools/ardy/GVHMR-NPZ.md +0 -68
  425. package/tools/ardy/README.md +0 -124
  426. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  427. package/tools/ardy/artifacts.mjs +0 -34
  428. package/tools/ardy/bridge.mjs +0 -1732
  429. package/tools/ardy/bvh-cskel27.mjs +0 -1265
  430. package/tools/ardy/cclay_constrained_generate.py +0 -561
  431. package/tools/ardy/cclay_gvhmr_worker.py +0 -264
  432. package/tools/ardy/cclay_motion_edit.py +0 -510
  433. package/tools/ardy/cclay_sequence_generate.py +0 -705
  434. package/tools/ardy/cclay_worker.py +0 -304
  435. package/tools/ardy/dump-npz.py +0 -205
  436. package/tools/ardy/extract-rest.mjs +0 -299
  437. package/tools/ardy/extract.mjs +0 -507
  438. package/tools/ardy/footage.mjs +0 -467
  439. package/tools/ardy/gvhmr-floor.mjs +0 -102
  440. package/tools/ardy/gvhmr-to-cskel27.mjs +0 -21
  441. package/tools/ardy/gvhmr_fastpath.py +0 -168
  442. package/tools/ardy/gvhmr_trajectory.py +0 -240
  443. package/tools/ardy/merge-text-encoder.py +0 -160
  444. package/tools/ardy/mocap-metrics.mjs +0 -200
  445. package/tools/ardy/mocap-quality-gate.mjs +0 -110
  446. package/tools/ardy/motion-stabilize.mjs +0 -348
  447. package/tools/ardy/npz.mjs +0 -414
  448. package/tools/ardy/pose-to-npz.mjs +0 -106
  449. package/tools/ardy/prompt-limits.mjs +0 -5
  450. package/tools/ardy/run-edit-on-box.sh +0 -73
  451. package/tools/ardy/run-local.mjs +0 -233
  452. package/tools/ardy/run-on-box.sh +0 -593
  453. package/tools/ardy/run-sequence-on-box.sh +0 -177
  454. package/tools/ardy/runners/gvhmr-worker.mjs +0 -206
  455. package/tools/ardy/runners/index.mjs +0 -18
  456. package/tools/ardy/runners/local.mjs +0 -455
  457. package/tools/ardy/runners/proc.mjs +0 -125
  458. package/tools/ardy/runners/remote.mjs +0 -252
  459. package/tools/ardy/setup-local.mjs +0 -189
  460. package/tools/ardy/setup-text-encoder-on-box.sh +0 -93
  461. package/tools/ardy/setup-text-encoder.py +0 -306
  462. package/tools/ardy/smpl-cskel27.mjs +0 -178
  463. package/tools/ardy/visual-qa.mjs +0 -196
  464. package/tools/ardy/vq-car.mjs +0 -52
  465. package/tools/bench/EXP3.md +0 -61
  466. package/tools/bench/cclay_bench_extract_incam.py +0 -125
  467. package/tools/bench/cclay_bench_extract_obs.py +0 -204
  468. package/tools/bench/cclay_bench_runner.py +0 -42
  469. package/tools/bench/cube-contact.mjs +0 -162
  470. package/tools/bench/exp3.mjs +0 -152
  471. package/tools/bench/extract-bench-lib.mjs +0 -216
  472. package/tools/bench/extract-bench.mjs +0 -302
  473. package/tools/bench/fal-generate.mjs +0 -88
  474. package/tools/bench/fit/README.md +0 -189
  475. package/tools/bench/fit/camera.mjs +0 -59
  476. package/tools/bench/fit/contact.mjs +0 -419
  477. package/tools/bench/fit/footlock.mjs +0 -234
  478. package/tools/bench/fit/motion.mjs +0 -85
  479. package/tools/bench/fit/pin.mjs +0 -27
  480. package/tools/bench/fit/remote.mjs +0 -105
  481. package/tools/bench/fit-bench.mjs +0 -126
  482. package/tools/bench/fit-sanity.mjs +0 -45
  483. package/tools/bench/metrics.mjs +0 -313
  484. package/tools/bench/obs/depth.mjs +0 -258
  485. package/tools/bench/obs/extrinsics.mjs +0 -175
  486. package/tools/bench/obs/fit_mannequin_betas.py +0 -340
  487. package/tools/bench/obs/ground.mjs +0 -294
  488. package/tools/bench/obs/heading.mjs +0 -151
  489. package/tools/bench/obs/ladder.mjs +0 -555
  490. package/tools/bench/obs/mannequin-betas.json +0 -124
  491. package/tools/bench/obs/remote.mjs +0 -83
  492. package/tools/bench/obs/rest_joints.py +0 -52
  493. package/tools/bench/obs/ybot-targets.mjs +0 -49
  494. package/tools/bench/obs-bench.mjs +0 -520
  495. package/tools/bench/score.mjs +0 -343
  496. package/tools/bench/summarize.mjs +0 -86
  497. package/tools/demo-worker/README.md +0 -137
  498. package/tools/demo-worker/api-client.mjs +0 -233
  499. package/tools/demo-worker/generate.mjs +0 -301
  500. package/tools/demo-worker/index.mjs +0 -466
  501. package/tools/demo-worker/sign-core.mjs +0 -112
  502. package/tools/demo-worker/sign.mjs +0 -161
  503. package/tools/gt-render/browser.mjs +0 -159
  504. package/tools/gt-render/camera-math.mjs +0 -276
  505. package/tools/gt-render/page.mjs +0 -267
  506. package/tools/gt-render/render.mjs +0 -514
  507. package/tools/gt-render/scene-box.mjs +0 -31
  508. package/tools/gt-render/take-transform.mjs +0 -87
  509. package/tools/kimodo/compare-seam.mjs +0 -108
  510. package/tools/kimodo/constraints.mjs +0 -244
  511. package/tools/kimodo/edit.mjs +0 -259
  512. package/tools/kimodo/effector-constraints.mjs +0 -200
  513. package/tools/kimodo/generate.mjs +0 -646
  514. package/tools/kimodo/local-output.mjs +0 -265
  515. package/tools/kimodo/measure-pose.mjs +0 -65
  516. package/tools/kimodo/measure-preserve.mjs +0 -899
  517. package/tools/kimodo/measure-waypoints.mjs +0 -55
  518. package/tools/kimodo/mlx-generate.py +0 -111
  519. package/tools/kimodo/pose-constraints.mjs +0 -248
  520. package/tools/kimodo/preserve-mask.mjs +0 -543
  521. package/tools/kimodo/read-npz.mjs +0 -189
  522. package/tools/kimodo/run-edit-on-box.mjs +0 -155
  523. package/tools/kimodo/run-sequence-on-box.mjs +0 -178
  524. package/tools/kimodo/runner.mjs +0 -284
  525. package/tools/kimodo/seam-batch.mjs +0 -77
  526. package/tools/kimodo/setup-local.mjs +0 -30
  527. package/tools/kimodo/setup-on-box.sh +0 -339
  528. package/tools/kimodo/soma77-to-cskel27.mjs +0 -305
  529. package/tools/morphgs/assets/gen_truth.py +0 -39
  530. package/tools/morphgs/assets/mesh_ori_rig.txt +0 -27
  531. package/tools/morphgs/assets/playback-check.mjs +0 -30
  532. package/tools/morphgs/demo-gate.sh +0 -26
  533. package/tools/morphgs/fbx2morphgs.mjs +0 -68
  534. package/tools/morphgs/morphgs-to-cskel27.mjs +0 -105
  535. package/tools/morphgs/patches/preprocess_src-none-mode.patch +0 -76
  536. package/tools/morphgs/setup-on-cluster.sh +0 -126
  537. package/tools/projflow/driver.py +0 -1069
  538. package/tools/projflow/generate.mjs +0 -805
  539. package/tools/projflow/hml22-to-cskel27.mjs +0 -735
  540. package/tools/projflow/line-edit-job.mjs +0 -547
  541. package/tools/projflow/replay.mjs +0 -452
  542. package/tools/projflow/runner.mjs +0 -269
  543. package/tools/projflow/service.mjs +0 -742
  544. package/tools/track/DESIGN.md +0 -513
  545. package/tools/track/backfill-provenance.mjs +0 -116
  546. package/tools/track/budget.mjs +0 -8
  547. package/tools/track/check-rig.mjs +0 -209
  548. package/tools/track/diagnostics.schema.json +0 -60
  549. package/tools/track/export-rig.mjs +0 -256
  550. package/tools/track/fallback.mjs +0 -7
  551. package/tools/track/fk-parity-fixture.mjs +0 -161
  552. package/tools/track/gate.mjs +0 -349
  553. package/tools/track/masks.mjs +0 -318
  554. package/tools/track/metrics.mjs +0 -201
  555. package/tools/track/publish-obs.mjs +0 -108
  556. package/tools/track/py/check_env.py +0 -61
  557. package/tools/track/py/eval_lr.py +0 -229
  558. package/tools/track/py/lr_viterbi.py +0 -294
  559. package/tools/track/py/masks.py +0 -405
  560. package/tools/track/py/objective.py +0 -382
  561. package/tools/track/py/rig.py +0 -508
  562. package/tools/track/py/scene.py +0 -326
  563. package/tools/track/py/test_joint_indices.py +0 -123
  564. package/tools/track/py/test_lr_viterbi.py +0 -195
  565. package/tools/track/py/test_masks.py +0 -229
  566. package/tools/track/py/test_rig.py +0 -288
  567. package/tools/track/py/test_scene.py +0 -354
  568. package/tools/track/py/test_track.py +0 -328
  569. package/tools/track/py/track.py +0 -411
  570. package/tools/track/remote.mjs +0 -140
  571. package/tools/track/rig-dump.mjs +0 -227
  572. package/tools/track/run-box-tests.mjs +0 -14
  573. package/tools/track/run-box.mjs +0 -168
  574. package/tools/track/setup-box.sh +0 -59
  575. package/tools/track/study-2d.mjs +0 -728
  576. /package/src/{ardy → motion}/collision-blockers.js +0 -0
  577. /package/src/{ardy → motion}/ground.js +0 -0
  578. /package/src/{ardy → motion}/ik-drag.js +0 -0
  579. /package/src/{ardy → motion}/ik-key-json.js +0 -0
  580. /package/src/{ardy → motion}/ik.js +0 -0
  581. /package/src/{ardy → motion}/physics-surface.js +0 -0
  582. /package/src/{ardy → motion}/playback-clock.js +0 -0
  583. /package/src/{ardy → motion}/range-pin.js +0 -0
@@ -1,1265 +0,0 @@
1
- /**
2
- * bvh-cskel27.mjs — SAM-3D-Body's Mixamo BVH → the exact motion arrays the
3
- * npz decoder consumes ({ frames, fps, rotMats, rootPos, posedJoints }).
4
- *
5
- * Both skeletons rest in the same T-pose convention (Y-up, spine +Y, left
6
- * arm +X — verified against CSKEL27_NEUTRAL and the BVH template generator),
7
- * so a BVH world rotation IS the cskel27 global rotation for the matching
8
- * joint, and each cskel27 local is recovered as parentGlobalᵀ · global.
9
- * Joints cskel27 has and the BVH does not (Spine3, the Hand ends) borrow the
10
- * nearest BVH ancestor's world rotation, which makes their local rotation
11
- * identity — the same "not authored" rule poseToCskel27 applies.
12
- *
13
- * Joint positions are re-grown with FK over the CANONICAL cskel27 skeleton:
14
- * the take must move CozyClay's body, not drag SAM's per-clip bone lengths
15
- * into the scene. The root trajectory is scaled by the leg-length ratio and
16
- * floor-shifted so the clip's lowest foot sample touches Y=0.
17
- */
18
-
19
- import { CSKEL27_JOINTS, CSKEL27_PARENTS } from "../../src/ardy/cskel27.js";
20
- import { deriveBoneOffsets, forwardKinematics, globalRotations, matMul, matToQuat, matTranspose, quatToMat } from "../../src/ardy/convert.js";
21
- import { canonicalCskel27Reference } from "../../src/ardy/to-cskel27.js";
22
- import { slerpQuat } from "../../src/ardy/retime.js";
23
-
24
- const CM_TO_M = 0.01;
25
-
26
- // EVERY temporal constant in this file is a DURATION, resolved to frames
27
- // against the clip's own fps at runtime. It used to be a mix, and the frame
28
- // counts were a measured bug: SAM extractions arrive at whatever rate the
29
- // footage ran at — the two fixtures are 30 fps, the user's real capture is
30
- // 60 — and a constant written as N FRAMES covers half the time at 60 fps, so
31
- // it filters half as hard exactly where the per-frame input noise is twice as
32
- // dense (foot wobble measured 3.4 cm on the 60 fps take against 0.6 cm on the
33
- // 30 fps fixtures). Before the conversion, feeding this module a 60 fps
34
- // rendering of the SAME motion produced 1.8x the time-normalized ankle jitter
35
- // and 1.7x the knee jerk of the 30 fps one, and dropped grounding 86.6 → 81.4 %.
36
- // Every duration below is written as <30 fps frames>/30 so that 30 fps
37
- // reproduces exactly the numbers each comment was measured at.
38
-
39
- // Stabilization, ported from the proven Blender retarget script the old
40
- // ingest pipeline used (sam3d-retarget-shadow.py):
41
- // - SPIN: SAM's BVH occasionally winds the root nearly 360° in a few frames
42
- // when yaw crosses ±180; detected by root angular SPEED (deg/s — as a
43
- // per-frame threshold the same physical spin reads half as fast at 60 fps
44
- // and the repair silently stops firing) and bridged with one shortest-path
45
- // slerp, corrected on EVERY joint so local articulation survives.
46
- // - TORSO: mild zero-lag three-frame slerp on the trunk only.
47
- // - LEGS: the same slerp, harder, on both leg chains — see LEG_BLEND.
48
- // - ARMS: a One-Euro filter instead (#84) — speed-adaptive, so guard-band
49
- // tremor is cut while punch speed survives; see the ARM_* block.
50
- const SPIN_DEG_PER_S = 25 * 30; // = 25°/frame at 30 fps
51
- const TORSO_BLEND = 0.2;
52
- const TORSO_JOINTS = ["Hips", "Spine", "Spine1", "Spine2", "Neck", "Head"].map((n) => `mixamorig:${n}`);
53
- // The leg chains get the SAME zero-lag 3-frame slerp, at double strength. This
54
- // is the single largest measured lever on foot tremble: SAM estimates the legs
55
- // frame-independently and they are the joints a fixed camera sees worst
56
- // (self-occlusion behind the torso on every guard-up stance), so their
57
- // per-frame rotation noise is what the ankle — and then the whole grounding
58
- // stack downstream — has to fight. On its own it takes ankle accel RMS
59
- // 2.32 → 1.71 cm and toe 2.53 → 1.83 on boxing-offline (2.29 → 1.65 / 2.50 →
60
- // 1.78 on shadow17); take it back out of the finished stack and the ankle
61
- // returns to 2.35. It costs almost nothing in motion: the 95th percentile
62
- // ankle speed falls 318 → 303 cm/s, so the footwork is smoothed, not killed.
63
- // Arms deliberately do NOT get this slerp: a punch is a two-frame
64
- // acceleration into an impact, and blending it with its neighbours is exactly
65
- // what softens the landing the animation exists to show — measured, this
66
- // slerp on the arm chains cuts peak hand speed 802 -> 724 cm/s while leaving
67
- // the guard-band wrist tremor it was meant to fix untouched (1.36 -> 1.36).
68
- // A foot has no such event — it plants and it swings — so the same filter
69
- // costs it nothing. The arms get the speed-adaptive filter below instead.
70
- const LEG_BLEND = 0.4;
71
- const LEG_JOINTS = ["LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase", "RightUpLeg", "RightLeg", "RightFoot", "RightToeBase"]
72
- .map((n) => `mixamorig:${n}`);
73
- // ARMS (#84): a One-Euro filter on the arm chains only — an adaptive low-pass
74
- // whose cutoff RISES with angular speed. The neighbour slerp above is the
75
- // wrong tool here: it attenuates by frequency regardless of amplitude, and a
76
- // punch is a two-frame acceleration into contact that shares its band with
77
- // the tremor. One-Euro separates them by SPEED instead: near rest the chain
78
- // is filtered at ARM_MIN_CUTOFF_HZ (SAM's per-frame shoulder/elbow noise is
79
- // what reads as wrist tremble in a guard), while a strike drives the cutoff
80
- // up by ARM_BETA per °/s until the filter passes it essentially untouched.
81
- // Constants are durations/rates (Hz, °/s), so 30 and 60 fps clips filter the
82
- // same amount of TIME — dt enters only through the alpha formula (measured:
83
- // wrist accel per second² lands within 1.20x across a rate doubling, inside
84
- // the legs' 1.35x bar).
85
- // Measured on the fixtures (boxing / shadow17): wrist accel RMS 2.76 -> 2.56
86
- // / 2.69 -> 2.49 cm, the guard-band wrist accel (hands under 1.2 m/s, the
87
- // tremble the complaint is about) 1.36 -> 1.24 / 1.28 -> 1.19, while PEAK
88
- // hand speed keeps 799 of 802 cm/s — against the rejected leg-style slerp's
89
- // 724 with the guard band untouched. Swept: a lower ARM_MIN_CUTOFF_HZ buys
90
- // nothing (a boxer's arms are never still enough for the floor term to bind)
91
- // and a higher ARM_BETA_PER_DEG_S under-filters the guard band; the speed
92
- // cutoff at 5 Hz is what lets the filter snap OPEN on punch onset instead of
93
- // shaving its first frame (1 Hz there cost 25 cm/s of peak).
94
- const ARM_JOINTS = ["LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand", "RightShoulder", "RightArm", "RightForeArm", "RightHand"]
95
- .map((n) => `mixamorig:${n}`);
96
- const ARM_MIN_CUTOFF_HZ = 1.5; // rest-tremor low-pass on the arm chain
97
- const ARM_BETA_PER_DEG_S = 0.02; // cutoff gain: +1 Hz per 20 °/s of joint speed
98
- const ARM_SPEED_CUTOFF_HZ = 5.0; // smoothing on the speed estimate the cutoff reads
99
- // Neighbour span of that slerp: ±1 frame at 30 fps. Widening the span instead
100
- // of repeating the pass would be wrong — a stride-2 three-tap filter has unity
101
- // gain at Nyquist, so at 60 fps it would leave the every-other-frame noise
102
- // (the dominant term) completely untouched. Repeating a ±1-frame pass keeps
103
- // the stopband and matches the 30 fps kernel in TIME (see smoothingPasses).
104
- const TORSO_SMOOTH_HALF_S = 1 / 30;
105
- const ROOT_XZ_SMOOTH_HALF_S = 2 / 30; // root translation jitter is camera noise, not gait (5-frame window at 30 fps)
106
- // Grounding: the ground line PINS the SUPPORT sole to zero (the fixed-camera
107
- // scope makes "wherever the planted sole lands" the floor by definition),
108
- // with a jump permit so fast+tall+brief excursions of BOTH feet keep their
109
- // measured air (see 4c). The version this replaces read the floor off the
110
- // merely LOWEST foot, which is the SWING leg most of the time — measured on
111
- // both fixtures that foot is horizontally still on 6–13 % of frames and runs
112
- // 0.7 m/s median — so its per-frame estimation error went straight into body
113
- // height and the hip popped at each of the ~3 support changes per second.
114
- // On top of the line: per-foot contact RUNS with hysteresis, then a 2-bone
115
- // leg IK that pins each planted ankle in XZ. Occlusion errors (a forward bend
116
- // hides the legs and SAM floats or sinks them by ±25 cm) are bridged by XZ
117
- // consistency: a foot that leaves "contact" but reappears at the same spot
118
- // never actually moved, so the gap is pinned too — while a real jump either
119
- // travels or lifts past the bridge ceiling and keeps its air. Whatever still
120
- // ends up under the floor is corrected in the LEG rather than in the body
121
- // (4f), because that is where the error is.
122
- // SUPPORT — is this foot on the floor at all? Deliberately generous: a foot
123
- // that pivots, drags or rocks while it carries weight is still the floor
124
- // witness, and the boxer's slower ankle never drops below 0.43 m/s anyway.
125
- // The three together put at least one foot in support on 82 % of frames; a
126
- // strict reading leaves the ground line nothing to stand on for half the clip.
127
- const SUPPORT_BAND_M = 0.16; // ankle within this of its own rolling low...
128
- const SUPPORT_RISE_MPS = 1.0; // ...not climbing or dropping like a swing leg...
129
- const SUPPORT_MIN_RUN_S = 4 / 30; // ...and holding for at least this long (0.13 s)
130
- // Baseline of the vertical-speed central difference the two gates above read.
131
- // A ±2-FRAME difference measures 0.13 s at 30 fps but 0.07 s at 60, where
132
- // SAM's per-frame estimation noise (ankle accel RMS ~2.4 cm) has not yet
133
- // averaged out — the gate would then be reading noise rather than movement and
134
- // reject planted feet. Per-frame deltas are mostly that noise; this baseline is
135
- // long enough to read the actual movement underneath it.
136
- const SPEED_DIFF_HALF_S = 2 / 30;
137
- // The hand-over. REF_MARGIN_M is what makes the reference sticky: it takes a
138
- // real weight shift to move the floor onto the other foot, not the 3.6 cm
139
- // median disagreement between the two feet's soles. Reference changes fall
140
- // from 3.0/s (reading whichever foot is lowest) to 1.2/s.
141
- const REF_MARGIN_M = 0.05; // the other foot must be in support AND this much lower to take over
142
- const TRANSFER_BLEND_HALF_S = 3 / 30; // hand-over cross-fade (7-frame window at 30 fps), box-smoothed twice → an S-ramp with no corner
143
- const FLOOR_SAG_M = 0.02; // how far the stance line may sit above the lowest sole
144
- // CONTACT — is this foot ALSO nailed to one spot? This gate feeds the XZ foot
145
- // LOCK, which teleports the ankle onto a single point, so it must stay strict:
146
- // widening it to cover footwork (a 14 cm band / 12 cm wander patch) made the
147
- // lock fire on 41 % of frames and drag ankles up to 18 cm — legs visibly
148
- // flying. The ground line does NOT depend on this track (it reads `support`
149
- // above), so strictness here costs nothing but a little residual foot slide.
150
- // Stillness is still judged over the RUN rather than per frame: 1 cm of SAM's
151
- // per-frame XZ noise already reads as 0.3 m/s.
152
- const CONTACT_BAND_M = 0.06; // ankle within this of its own rolling low (two-sided: a below-floor dip is an ERROR, not a contact)
153
- const CONTACT_SETTLE_MPS = 0.6; // ...not dropping or rising like a swing leg...
154
- const CONTACT_WANDER_M = 0.03; // ...and never leaving one patch of floor for the whole run
155
- const CONTACT_MIN_RUN_S = 3 / 30; // shortest accepted contact / bridged flicker gap (0.1 s)
156
- const BRIDGE_MAX_GAP_S = 0.8; // occlusion gaps longer than this are believed instead
157
- const BRIDGE_MAX_XZ_M = 0.12; // the foot must reappear this close to where it left
158
- const BRIDGE_MAX_LIFT_M = 0.25; // ankle lift above this reads as a real jump, never bridged
159
- const LOCK_BLEND_S = 4 / 30; // IK ramp at each contact-run edge (0.13 s)
160
- // A plant that would have to be dragged further than this was never a plant —
161
- // the run passed the wander gate but the lock point still lands far from where
162
- // this frame's ankle actually is (occlusion bridging can extend a run across
163
- // exactly such a stretch). Snapping the leg there is worse than the slide the
164
- // lock exists to remove, so the correction is skipped instead of ramped.
165
- const LOCK_MAX_PULL_M = 0.04;
166
- // Jump permit — the only measured air the foot-pinned ground line believes.
167
- // SAM's depth-drift floats are SLOW (the worst on the boxing clip climb
168
- // ~20 cm over a second-plus); a real hop is FAST and BRIEF. An excursion of
169
- // the lowest sole must clear all three gates to keep its air.
170
- const AIR_EPS_M = 0.05; // candidate air: lowest sole this far above its local floor
171
- const JUMP_MIN_PEAK_M = 0.15; // must peak at least this high...
172
- const JUMP_MIN_RISE_MPS = 0.7; // ...climbing at least this fast...
173
- const JUMP_MAX_AIR_S = 0.9; // ...and land within ballistic time, or it is drift
174
- // Ground-line smoothing: sole jitter must not shake the root. Short on
175
- // purpose — every centimetre the smoothed line drifts off the measured sole is
176
- // a centimetre 4f has to take back out of a leg, and past a few frames that
177
- // correction costs more than the smoothing saves (hip jerk RMS 0.58 cm at a
178
- // 5-frame window, 0.90 at 13). Re-swept: 5 frames was still too long. Roughly
179
- // HALF the floor penetration the 4f guard has to repair was manufactured here
180
- // — on exactly the frames the guard fires, the smoothed line sat +0.61 cm
181
- // above the measured sole — and a 3-frame window beats a 5-frame one on knee
182
- // jerk (3.44 vs 3.68°), grounding (87.2 vs 86.6 %) and hover (1.64 vs 1.86 cm)
183
- // while the hip cost the longer window was bought for is 0.01 cm. In the
184
- // finished stack it matters more than that: with the shaped guard in front of
185
- // it, putting the window back to 5 frames costs 2.1 points of grounding
186
- // (86.2 → 84.1 %) for 0.004 cm of hip.
187
- const PIN_SMOOTH_HALF_S = 1 / 30; // 3-frame window at 30 fps
188
- // The 4f penetration guard's correction is SHAPED over this half-window before
189
- // it is applied. Raw, the guard fired on 228/514 frames (44 %), switching on
190
- // and off 6.8 times a second in bursts of median length 3 frames, and the
191
- // correction's own second difference was 1.10 cm RMS — it fixed the sole and
192
- // injected a per-frame tremble into the leg doing it. ±2 frames at 30 fps is
193
- // the shortest window that spans that median burst.
194
- const GUARD_SHAPE_HALF_S = 2 / 30;
195
- // "Near the floor" is always measured against a LOCAL low, never a global
196
- // constant, because SAM's vertical drift moves the apparent ground by tens of
197
- // cm across a clip. Half a second is long enough to contain a whole step and
198
- // short enough that the drift inside it is small. Already a duration before
199
- // this file's constants were converted; named here so the block is complete.
200
- const LOCAL_LOW_HALF_S = 0.5;
201
-
202
- /** Frames covering `seconds` at `fps`, never fewer than `min` — a run length,
203
- * a blend ramp or a central-difference baseline of zero frames is not an
204
- * algorithm, so a slow clip degrades to the tightest meaningful setting
205
- * rather than to a no-op. */
206
- function framesFor(seconds, fps, min = 1) {
207
- return Math.max(min, Math.round(seconds * fps));
208
- }
209
-
210
- /** Odd, centred moving-average window covering ±`halfSeconds` — the smoothers
211
- * below all read `Math.floor(window / 2)` on each side, so the window has to
212
- * be built from the half-width to stay symmetric at any rate. */
213
- function windowFor(halfSeconds, fps) {
214
- return 2 * framesFor(halfSeconds, fps) + 1;
215
- }
216
-
217
- /** How many passes of the three-frame slerp reproduce ONE pass at 30 fps.
218
- * A three-tap smoother's spread grows as sqrt(passes) FRAMES, i.e.
219
- * sqrt(passes)/fps seconds, so holding the duration fixed costs
220
- * (halfSeconds * fps)² passes — 1 at 30 fps, 4 at 60. */
221
- function smoothingPasses(halfSeconds, fps) {
222
- return Math.max(1, Math.round((halfSeconds * fps) ** 2));
223
- }
224
-
225
- // cskel27 joint -> BVH joint (mixamorig: prefix added at lookup). Identity
226
- // mapping except the joints the BVH does not carry.
227
- const BVH_SOURCE = Object.fromEntries(CSKEL27_JOINTS.map((name) => [name, name]));
228
- BVH_SOURCE.Spine3 = "Spine2";
229
- BVH_SOURCE.LeftHandEnd = "LeftHand";
230
- BVH_SOURCE.RightHandEnd = "RightHand";
231
-
232
- /** Minimal BVH reader: hierarchy (names, parents, offsets, channels) and the
233
- * motion table. Throws with a named reason on anything malformed. */
234
- export function parseBvh(text) {
235
- if (typeof text !== "string" || !text.includes("HIERARCHY")) throw new Error("bvh-malformed");
236
- const tokens = text.slice(text.indexOf("HIERARCHY")).split(/\s+/);
237
- let cursor = 0;
238
- const next = () => tokens[cursor++];
239
- const peek = () => tokens[cursor];
240
- const joints = [];
241
- const stack = [];
242
-
243
- if (next() !== "HIERARCHY") throw new Error("bvh-malformed");
244
- while (cursor < tokens.length) {
245
- const token = next();
246
- if (token === "ROOT" || token === "JOINT") {
247
- const name = next();
248
- const joint = { name, parent: stack.length ? stack[stack.length - 1] : -1, offset: [0, 0, 0], channels: [] };
249
- joints.push(joint);
250
- if (next() !== "{") throw new Error("bvh-malformed");
251
- stack.push(joints.length - 1);
252
- } else if (token === "OFFSET") {
253
- const target = stack[stack.length - 1];
254
- const offset = [Number(next()), Number(next()), Number(next())];
255
- if (!offset.every(Number.isFinite)) throw new Error("bvh-malformed");
256
- if (target !== undefined && joints[target].endPending) joints[target].end = offset;
257
- else if (target !== undefined) joints[target].offset = offset;
258
- } else if (token === "CHANNELS") {
259
- const count = Number(next());
260
- const channels = [];
261
- for (let i = 0; i < count; i += 1) channels.push(next());
262
- joints[stack[stack.length - 1]].channels = channels;
263
- } else if (token === "End") {
264
- next(); // "Site"
265
- if (next() !== "{") throw new Error("bvh-malformed");
266
- joints[stack[stack.length - 1]].endPending = true;
267
- } else if (token === "}") {
268
- const top = stack[stack.length - 1];
269
- if (top !== undefined && joints[top].endPending) joints[top].endPending = false;
270
- else stack.pop();
271
- if (stack.length === 0 && peek() === "MOTION") break;
272
- } else if (token === "MOTION") {
273
- break;
274
- }
275
- }
276
- if (next() !== "MOTION" && tokens[cursor - 1] !== "MOTION") {
277
- // the while-loop may have consumed MOTION already via peek/break
278
- }
279
- // Align cursor to just after "MOTION".
280
- while (tokens[cursor - 1] !== "MOTION") {
281
- if (cursor >= tokens.length) throw new Error("bvh-malformed");
282
- cursor += 1;
283
- }
284
- if (next() !== "Frames:") throw new Error("bvh-malformed");
285
- const frames = Number(next());
286
- if (next() !== "Frame" || next() !== "Time:") throw new Error("bvh-malformed");
287
- const frameTimeS = Number(next());
288
- if (!Number.isInteger(frames) || frames < 1 || !(frameTimeS > 0)) throw new Error("bvh-malformed");
289
- const channelTotal = joints.reduce((sum, joint) => sum + joint.channels.length, 0);
290
- const values = new Float64Array(frames * channelTotal);
291
- for (let i = 0; i < values.length; i += 1) {
292
- const value = Number(next());
293
- if (!Number.isFinite(value)) throw new Error("bvh-motion-truncated");
294
- values[i] = value;
295
- }
296
- return { joints, frames, frameTimeS, channelTotal, values };
297
- }
298
-
299
- function rotX(deg) {
300
- const r = (deg * Math.PI) / 180;
301
- const c = Math.cos(r);
302
- const s = Math.sin(r);
303
- return [[1, 0, 0], [0, c, -s], [0, s, c]];
304
- }
305
- function rotY(deg) {
306
- const r = (deg * Math.PI) / 180;
307
- const c = Math.cos(r);
308
- const s = Math.sin(r);
309
- return [[c, 0, s], [0, 1, 0], [-s, 0, c]];
310
- }
311
- function rotZ(deg) {
312
- const r = (deg * Math.PI) / 180;
313
- const c = Math.cos(r);
314
- const s = Math.sin(r);
315
- return [[c, -s, 0], [s, c, 0], [0, 0, 1]];
316
- }
317
- const ROTATORS = { Xrotation: rotX, Yrotation: rotY, Zrotation: rotZ };
318
-
319
- /** Convert a parsed Mixamo BVH into cskel27 motion arrays. */
320
- export function bvhToCskel27Motion(bvh) {
321
- const { joints, frames, frameTimeS, channelTotal, values } = bvh;
322
- const bvhIndex = new Map(joints.map((joint, index) => [joint.name, index]));
323
- const sourceIndex = CSKEL27_JOINTS.map((name) => {
324
- const source = `mixamorig:${BVH_SOURCE[name]}`;
325
- if (!bvhIndex.has(source)) throw new Error(`bvh-missing-joint:${source}`);
326
- return bvhIndex.get(source);
327
- });
328
- const channelStart = [];
329
- let running = 0;
330
- for (const joint of joints) {
331
- channelStart.push(running);
332
- running += joint.channels.length;
333
- }
334
-
335
- const jointCount = CSKEL27_JOINTS.length;
336
- const fps = Math.round(1 / frameTimeS);
337
- // Every duration in the constants block, resolved against THIS clip's rate.
338
- // Nothing below this line may be written as a frame count.
339
- const spinDegPerFrame = SPIN_DEG_PER_S / fps;
340
- const torsoPasses = smoothingPasses(TORSO_SMOOTH_HALF_S, fps);
341
- const rootXzWindow = windowFor(ROOT_XZ_SMOOTH_HALF_S, fps);
342
- const supportMinRun = framesFor(SUPPORT_MIN_RUN_S, fps);
343
- const contactMinRun = framesFor(CONTACT_MIN_RUN_S, fps);
344
- const speedDiffHalf = framesFor(SPEED_DIFF_HALF_S, fps);
345
- const transferBlendWindow = windowFor(TRANSFER_BLEND_HALF_S, fps);
346
- const pinSmoothWindow = windowFor(PIN_SMOOTH_HALF_S, fps);
347
- const lockBlendFrames = framesFor(LOCK_BLEND_S, fps);
348
- const guardShapeHalf = framesFor(GUARD_SHAPE_HALF_S, fps);
349
- const localLowHalf = framesFor(LOCAL_LOW_HALF_S, fps);
350
- const rotMats = new Float32Array(frames * jointCount * 9);
351
- const rootPos = new Float32Array(frames * 3);
352
- const posedJoints = new Float32Array(frames * jointCount * 3);
353
-
354
- const skeleton = canonicalCskel27Reference();
355
- const offsets = deriveBoneOffsets(skeleton.posed_joints, skeleton.local_rot_mats);
356
-
357
- // Scale the root trajectory to CozyClay's body: SAM writes per-clip bone
358
- // lengths, and a 92 cm-legged skeleton's hip path on a 88 cm-legged body
359
- // floats or sinks. Ratio of hip-to-ankle chain lengths, both skeletons.
360
- const canonicalLeg = chainLength(skeleton.posed_joints, ["LeftUpLeg", "LeftLeg", "LeftFoot"]);
361
- const bvhLeg =
362
- (magnitude(joints[bvhIndex.get("mixamorig:LeftLeg")].offset) +
363
- magnitude(joints[bvhIndex.get("mixamorig:LeftFoot")].offset)) * CM_TO_M;
364
- const rootScale = bvhLeg > 1e-6 ? canonicalLeg / bvhLeg : 1;
365
-
366
- const IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
367
- const rootChannels = joints[0].channels;
368
-
369
- // Pass 1 — BVH world rotations for every joint on every frame: local
370
- // eulers composed in listed channel order, chained down the hierarchy.
371
- const worldsByFrame = new Array(frames);
372
- for (let f = 0; f < frames; f += 1) {
373
- const base = f * channelTotal;
374
- const worlds = new Array(joints.length);
375
- for (let j = 0; j < joints.length; j += 1) {
376
- const joint = joints[j];
377
- let local = IDENTITY;
378
- const start = base + channelStart[j];
379
- for (let c = 0; c < joint.channels.length; c += 1) {
380
- const rotate = ROTATORS[joint.channels[c]];
381
- if (!rotate) continue; // position channels are read separately
382
- local = matMul(local, rotate(values[start + c]));
383
- }
384
- worlds[j] = joint.parent < 0 ? local : matMul(worlds[joint.parent], local);
385
- }
386
- worldsByFrame[f] = worlds;
387
- // Root position: the position channels, cm → m, scaled.
388
- const start = base + channelStart[0];
389
- for (let c = 0; c < rootChannels.length; c += 1) {
390
- if (rootChannels[c] === "Xposition") rootPos[f * 3] = values[start + c] * CM_TO_M * rootScale;
391
- else if (rootChannels[c] === "Yposition") rootPos[f * 3 + 1] = values[start + c] * CM_TO_M * rootScale;
392
- else if (rootChannels[c] === "Zposition") rootPos[f * 3 + 2] = values[start + c] * CM_TO_M * rootScale;
393
- }
394
- }
395
- // Frame-0 root in RAW metres (unscaled, untouched by any later pass):
396
- // every person extracted from the same clip shares this camera space, so
397
- // the difference between two takes' rawRootStart is their real-world
398
- // relative placement in the scene.
399
- const rawRootStart = [rootPos[0] / rootScale, rootPos[1] / rootScale, rootPos[2] / rootScale];
400
-
401
- // Pass 2 — repair root spins, then stabilize the torso and the legs (see
402
- // header). The two joint sets are disjoint and each pass snapshots its own
403
- // input, so the order between them does not change the result.
404
- repairRootSpins(worldsByFrame, bvhIndex.get("mixamorig:Hips"), spinDegPerFrame);
405
- const bvhIndices = (names) => names.map((name) => bvhIndex.get(name)).filter((i) => i !== undefined);
406
- stabilizeChain(worldsByFrame, bvhIndices(TORSO_JOINTS), TORSO_BLEND, torsoPasses);
407
- stabilizeChain(worldsByFrame, bvhIndices(LEG_JOINTS), LEG_BLEND, torsoPasses);
408
- oneEuroChain(worldsByFrame, bvhIndices(ARM_JOINTS), fps);
409
-
410
- // The BVH's own sole heights, via FK over ITS skeleton. SAM's offline
411
- // foot-contact pass already leveled the ground relationship in that
412
- // space (median lowest sole ≈ 2 cm); re-growing the pose on CozyClay's
413
- // canonical proportions would shift it (a crouched leg of different
414
- // thigh/shin ratio lands its foot at a different height), so the raw
415
- // sole track is carried over as the reference to restore.
416
- const rawSoleScaled = new Float64Array(frames);
417
- {
418
- const soleJoints = ["LeftFoot", "RightFoot", "LeftToeBase", "RightToeBase"]
419
- .map((name) => bvhIndex.get(`mixamorig:${name}`))
420
- .filter((index) => index !== undefined);
421
- const positions = new Array(joints.length);
422
- for (let f = 0; f < frames; f += 1) {
423
- const worlds = worldsByFrame[f];
424
- positions[0] = [rootPos[f * 3] / (CM_TO_M * rootScale), rootPos[f * 3 + 1] / (CM_TO_M * rootScale), rootPos[f * 3 + 2] / (CM_TO_M * rootScale)];
425
- for (let j = 1; j < joints.length; j += 1) {
426
- const parent = joints[j].parent;
427
- const rotated = matVec3(worlds[parent], joints[j].offset);
428
- positions[j] = [
429
- positions[parent][0] + rotated[0],
430
- positions[parent][1] + rotated[1],
431
- positions[parent][2] + rotated[2],
432
- ];
433
- }
434
- let low = Infinity;
435
- for (const j of soleJoints) low = Math.min(low, positions[j][1]);
436
- rawSoleScaled[f] = low * CM_TO_M * rootScale;
437
- }
438
- }
439
-
440
- // Root translation: camera-space jitter is noise, not gait — X/Z ride a
441
- // short centred average.
442
- smoothTrack(rootPos, frames, 0, rootXzWindow);
443
- smoothTrack(rootPos, frames, 2, rootXzWindow);
444
-
445
- // Pass 3 — cskel27 locals: parentGlobalᵀ · global along the cskel27 chain.
446
- for (let f = 0; f < frames; f += 1) {
447
- const worlds = worldsByFrame[f];
448
- for (let j = 0; j < jointCount; j += 1) {
449
- const global = worlds[sourceIndex[j]];
450
- const parent = CSKEL27_PARENTS[j];
451
- const local = parent === null ? global : matMul(matTranspose(worlds[sourceIndex[parent]]), global);
452
- const o = (f * jointCount + j) * 9;
453
- rotMats[o] = local[0][0]; rotMats[o + 1] = local[0][1]; rotMats[o + 2] = local[0][2];
454
- rotMats[o + 3] = local[1][0]; rotMats[o + 4] = local[1][1]; rotMats[o + 5] = local[1][2];
455
- rotMats[o + 6] = local[2][0]; rotMats[o + 7] = local[2][1]; rotMats[o + 8] = local[2][2];
456
- }
457
- }
458
-
459
- // Pass 4 — grounding and foot lock (see the constants block), then one
460
- // global shift so the lowest contact sits exactly on Y=0.
461
- const readLocals = (f) => {
462
- const frameLocals = new Array(jointCount);
463
- for (let j = 0; j < jointCount; j += 1) {
464
- const o = (f * jointCount + j) * 9;
465
- frameLocals[j] = [
466
- [rotMats[o], rotMats[o + 1], rotMats[o + 2]],
467
- [rotMats[o + 3], rotMats[o + 4], rotMats[o + 5]],
468
- [rotMats[o + 6], rotMats[o + 7], rotMats[o + 8]],
469
- ];
470
- }
471
- return frameLocals;
472
- };
473
- const rootAt = (f) => [rootPos[f * 3], rootPos[f * 3 + 1], rootPos[f * 3 + 2]];
474
- const writeLocal = (f, j, m) => {
475
- const o = (f * jointCount + j) * 9;
476
- rotMats[o] = m[0][0]; rotMats[o + 1] = m[0][1]; rotMats[o + 2] = m[0][2];
477
- rotMats[o + 3] = m[1][0]; rotMats[o + 4] = m[1][1]; rotMats[o + 5] = m[1][2];
478
- rotMats[o + 6] = m[2][0]; rotMats[o + 7] = m[2][1]; rotMats[o + 8] = m[2][2];
479
- };
480
- const J = (name) => CSKEL27_JOINTS.indexOf(name);
481
- const feet = ["LeftToeBase", "RightToeBase", "LeftFoot", "RightFoot"].map(J);
482
- const legs = [
483
- { hip: J("LeftUpLeg"), knee: J("LeftLeg"), ankle: J("LeftFoot") },
484
- { hip: J("RightUpLeg"), knee: J("RightLeg"), ankle: J("RightFoot") },
485
- ];
486
- const hipsIdx = J("Hips");
487
-
488
- // 4a) ankle and sole tracks from a first FK sweep — and the proportion
489
- // correction: with the SAME joint angles, a crouched leg of different
490
- // thigh/shin ratio lands its sole at a different height, which reads as
491
- // a constant hover on a fighter who never fully straightens. The root is
492
- // re-seated per frame so the canonical body's lowest sole reproduces the
493
- // (scaled) sole height SAM's own leveled skeleton had.
494
- const toes = [J("LeftToeBase"), J("RightToeBase")];
495
- const ankle = [new Float64Array(frames * 3), new Float64Array(frames * 3)];
496
- const kneeY = [new Float64Array(frames), new Float64Array(frames)];
497
- const lowestSole = new Float64Array(frames * 2);
498
- for (let f = 0; f < frames; f += 1) {
499
- let positions = forwardKinematics(readLocals(f), offsets, rootAt(f));
500
- const canonSole = Math.min(
501
- positions[legs[0].ankle][1], positions[toes[0]][1],
502
- positions[legs[1].ankle][1], positions[toes[1]][1]
503
- );
504
- const reseat = rawSoleScaled[f] - canonSole;
505
- if (Math.abs(reseat) > 1e-9) {
506
- rootPos[f * 3 + 1] += reseat;
507
- positions = forwardKinematics(readLocals(f), offsets, rootAt(f));
508
- }
509
- for (let s = 0; s < 2; s += 1) {
510
- ankle[s][f * 3] = positions[legs[s].ankle][0];
511
- ankle[s][f * 3 + 1] = positions[legs[s].ankle][1];
512
- ankle[s][f * 3 + 2] = positions[legs[s].ankle][2];
513
- kneeY[s][f] = positions[legs[s].knee][1];
514
- lowestSole[f * 2 + s] = Math.min(positions[legs[s].ankle][1], positions[toes[s]][1]);
515
- }
516
- }
517
-
518
- // 4b) per-foot stance evidence, in two nested strengths, because the two
519
- // consumers want different things. Both are measured against that foot's
520
- // LOCAL low, not a global constant: SAM's vertical drift moves the apparent
521
- // ground by tens of cm across a clip, so "near the floor" can only mean
522
- // "near where this foot bottoms out around now".
523
- // - SUPPORT (4c, the ground line) only has to know which foot is ON the
524
- // floor. A boxer's lead foot pivots and drags while it carries his
525
- // weight; it is still the floor witness, so support does NOT gate on
526
- // horizontal speed — only on being low and vertically settled.
527
- // - CONTACT (4e, the XZ foot lock) additionally needs the foot to STAY in
528
- // one place, since the lock nails it there. That is judged over the RUN
529
- // (see splitByXzWander), not per frame: 1 cm of SAM's per-frame XZ noise
530
- // already reads as 0.3 m/s over a ±2-frame baseline, so an instantaneous
531
- // speed gate tight enough to reject a real step also rejects every
532
- // jittery-but-planted foot — which is exactly how the old single
533
- // detector ended up firing on 4 % of foot-frames, leaving both the
534
- // ground line and the lock with nothing to hold on to.
535
- const support = [new Array(frames).fill(false), new Array(frames).fill(false)];
536
- const realContact = [new Array(frames).fill(false), new Array(frames).fill(false)];
537
- const lockContact = [new Array(frames).fill(false), new Array(frames).fill(false)];
538
- const settleLimit = CONTACT_SETTLE_MPS / fps;
539
- const riseLimit = SUPPORT_RISE_MPS / fps;
540
- const lowHalf = localLowHalf;
541
- for (let s = 0; s < 2; s += 1) {
542
- for (let f = 0; f < frames; f += 1) {
543
- // Central difference over SPEED_DIFF_HALF_S (±2 frames at 30 fps):
544
- // per-frame deltas are mostly estimation noise (ankle accel RMS
545
- // ~2.4 cm), a 0.13 s baseline reads the actual movement underneath
546
- // it. The result is a per-FRAME delta, which is what riseLimit and
547
- // settleLimit are (m/s ÷ fps), so both sides scale together.
548
- const before = Math.max(0, f - speedDiffHalf);
549
- const after = Math.min(frames - 1, f + speedDiffHalf);
550
- const span = Math.max(1, after - before);
551
- const dy = (ankle[s][after * 3 + 1] - ankle[s][before * 3 + 1]) / span;
552
- let localLow = Infinity;
553
- for (let k = Math.max(0, f - lowHalf); k <= Math.min(frames - 1, f + lowHalf); k += 1) {
554
- localLow = Math.min(localLow, ankle[s][k * 3 + 1]);
555
- }
556
- const above = ankle[s][f * 3 + 1] - localLow;
557
- const belowKnee = ankle[s][f * 3 + 1] < kneeY[s][f] - 0.05;
558
- support[s][f] = above < SUPPORT_BAND_M && Math.abs(dy) < riseLimit && belowKnee;
559
- realContact[s][f] = above < CONTACT_BAND_M && Math.abs(dy) < settleLimit && belowKnee;
560
- }
561
- // Run cleaning IS the hysteresis: below supportMinRun neither a
562
- // flicker of evidence starts a stance nor a flicker of noise ends one,
563
- // so the ground reference cannot chatter between feet frame by frame.
564
- cleanRuns(support[s], supportMinRun);
565
- cleanRuns(realContact[s], contactMinRun);
566
- // ...then the lockable half of the evidence: keep only the stretches
567
- // the ankle spends inside one CONTACT_WANDER_M patch of floor. This is
568
- // what separates "planted, estimated noisily" from "stepping".
569
- splitByXzWander(realContact[s], ankle[s], CONTACT_WANDER_M, contactMinRun);
570
- }
571
-
572
- // 4c) the measured ground line: STANCE PINNING with a jump permit. The
573
- // scope is fixed-camera footage, so wherever a planted sole lands IS the
574
- // ground — pinning it to zero kills SAM's vertical drift outright. (The
575
- // rolling-quantile line two revisions back still floated up to 23 cm
576
- // whenever a window went majority-dip.) What the floor is read FROM is the
577
- // SUPPORT foot, not whichever foot is lowest: measured on both fixtures
578
- // the lowest foot is horizontally still on 6–13 % of frames and runs
579
- // 0.7 m/s median — it is usually the SWING leg, so every swing-leg
580
- // estimation error went straight into body height and the hip popped ~1 cm
581
- // (5.5 cm worst) at each of the ~3 support changes per second.
582
- // The only measured air that survives is a PERMITTED jump: a fast, tall,
583
- // brief excursion of the lowest sole above its local floor — the three
584
- // gates that separate a real hop from a slow depth-drift float or a
585
- // bend-occlusion lift. Over permitted air the ground interpolates takeoff
586
- // level → landing level, so the arc keeps its measured height.
587
- const soleAt = new Float64Array(frames); // physical lowest sole: jump gates and the sag clamp
588
- const soleSide = [new Float64Array(frames), new Float64Array(frames)];
589
- for (let f = 0; f < frames; f += 1) {
590
- soleSide[0][f] = lowestSole[f * 2];
591
- soleSide[1][f] = lowestSole[f * 2 + 1];
592
- soleAt[f] = Math.min(soleSide[0][f], soleSide[1][f]);
593
- }
594
- const airHalf = localLowHalf;
595
- const localFloor = new Float64Array(frames);
596
- for (let f = 0; f < frames; f += 1) {
597
- let low = Infinity;
598
- for (let k = Math.max(0, f - airHalf); k <= Math.min(frames - 1, f + airHalf); k += 1) low = Math.min(low, soleAt[k]);
599
- localFloor[f] = low;
600
- }
601
- const airborne = new Array(frames).fill(false);
602
- const candidate = new Array(frames).fill(false);
603
- for (let f = 0; f < frames; f += 1) candidate[f] = soleAt[f] - localFloor[f] > AIR_EPS_M;
604
- const maxAirFrames = Math.round(JUMP_MAX_AIR_S * fps);
605
- const airRuns = [];
606
- for (const run of contactRuns(candidate)) {
607
- let peak = 0;
608
- let rise = 0;
609
- for (let f = run.start; f <= run.end; f += 1) {
610
- peak = Math.max(peak, soleAt[f] - localFloor[f]);
611
- // The takeoff-speed gate reads the same SPEED_DIFF_HALF_S baseline
612
- // as the stance gates: over ±1 frame at 60 fps a real hop and a
613
- // noisy plant are indistinguishable.
614
- const before = Math.max(0, f - speedDiffHalf);
615
- const after = Math.min(frames - 1, f + speedDiffHalf);
616
- rise = Math.max(rise, ((soleAt[after] - soleAt[before]) / Math.max(1, after - before)) * fps);
617
- }
618
- if (peak >= JUMP_MIN_PEAK_M && rise >= JUMP_MIN_RISE_MPS && run.end - run.start + 1 <= maxAirFrames) {
619
- for (let f = run.start; f <= run.end; f += 1) airborne[f] = true;
620
- airRuns.push({ ...run, peak });
621
- }
622
- }
623
- // Which foot the floor is read from, frame by frame — a STICKY choice, so
624
- // that a stance the detector reports on both feet (double support runs 53 %
625
- // of this footage) does not hand the reference back and forth on whichever
626
- // sole the estimator happened to place a millimetre lower this frame. The
627
- // reference moves only when the foot holding it loses support outright, or
628
- // when the other foot is both in support and REF_MARGIN_M lower — a real
629
- // weight shift. With no evidence on either foot (flight, or footwork the
630
- // detector missed) the last reference is simply held; the jump permit below
631
- // owns those stretches.
632
- const refSide = new Int8Array(frames);
633
- {
634
- let side = soleSide[0][0] <= soleSide[1][0] ? 0 : 1;
635
- for (let f = 0; f < frames; f += 1) {
636
- const other = 1 - side;
637
- if (support[other][f] && (!support[side][f] || soleSide[other][f] < soleSide[side][f] - REF_MARGIN_M)) side = other;
638
- refSide[f] = side;
639
- }
640
- }
641
- // A support TRANSFER must never step. The two soles disagree by 3.6 cm
642
- // median — estimation error, not a floor that moved — so switching source
643
- // instantly dumps that whole gap into one frame of body height. Cross-fade
644
- // the two sole tracks instead: the 0/1 side track box-smoothed TWICE, so
645
- // the hand-over is an S-ramp with no corner at either end of it.
646
- const rightShare = new Float64Array(frames);
647
- for (let f = 0; f < frames; f += 1) rightShare[f] = refSide[f];
648
- smoothArray(rightShare, transferBlendWindow);
649
- smoothArray(rightShare, transferBlendWindow);
650
- const ground = new Float64Array(frames);
651
- for (let f = 0; f < frames; f += 1) {
652
- ground[f] = soleSide[0][f] * (1 - rightShare[f]) + soleSide[1][f] * rightShare[f];
653
- }
654
- // Permitted air: the floor does not move while nobody is standing on it,
655
- // so the line interpolates takeoff → landing and the hop keeps its arc.
656
- for (const run of contactRuns(airborne)) {
657
- const i0 = run.start - 1;
658
- const i1 = run.end + 1;
659
- const y0 = i0 >= 0 ? ground[i0] : i1 < frames ? ground[i1] : localFloor[run.start];
660
- const y1 = i1 < frames ? ground[i1] : y0;
661
- for (let f = run.start; f <= run.end; f += 1) {
662
- ground[f] = y0 + ((y1 - y0) * (f - i0)) / (i1 - i0);
663
- }
664
- }
665
- // Sag clamp: the stance line may sit at most FLOOR_SAG_M above the lowest
666
- // sole. Without it a foot the detector calls "swinging" while it is in
667
- // fact a few cm below the support foot would be driven under Y=0, and 4f's
668
- // per-frame penetration guard would hoist that single frame — trading the
669
- // pop we are removing for an identical one. Above permitted air the clamp
670
- // is inert (the soles are far above the line by construction).
671
- for (let f = 0; f < frames; f += 1) ground[f] = Math.min(ground[f], soleAt[f] + FLOOR_SAG_M);
672
- smoothArray(ground, pinSmoothWindow);
673
-
674
- if (process.env.BVH_GROUND_DEBUG) {
675
- const pctOf = (n) => `${((n / frames) * 100).toFixed(0)}%`;
676
- const trueCount = (track) => track.filter(Boolean).length;
677
- let either = 0;
678
- let switches = 0;
679
- for (let f = 0; f < frames; f += 1) {
680
- if (support[0][f] || support[1][f]) either += 1;
681
- if (f > 0 && refSide[f] !== refSide[f - 1]) switches += 1;
682
- }
683
- const contacts = trueCount(realContact[0]) + trueCount(realContact[1]);
684
- const jumps = airRuns.map((r) => `f${r.start}..${r.end}@${(r.peak * 100).toFixed(0)}cm`).join(" ") || "none";
685
- console.error(`[stance] support L=${pctOf(trueCount(support[0]))} R=${pctOf(trueCount(support[1]))} either=${pctOf(either)}; lockable contact ${contacts}/${frames * 2} (${((contacts / (frames * 2)) * 100).toFixed(0)}%); reference hand-overs ${switches} (${(switches / (frames / fps)).toFixed(1)}/s)`);
686
- console.error(`[ground] range=[${Math.min(...ground).toFixed(2)}..${Math.max(...ground).toFixed(2)}] jumps: ${jumps}`);
687
- }
688
-
689
- // 4d) level the root by subtracting the ground line.
690
- for (let f = 0; f < frames; f += 1) {
691
- rootPos[f * 3 + 1] -= ground[f];
692
- ankle[0][f * 3 + 1] -= ground[f];
693
- ankle[1][f * 3 + 1] -= ground[f];
694
- }
695
- const anchors = realContact[0].filter(Boolean).length + realContact[1].filter(Boolean).length;
696
-
697
- // XZ bridging runs in LEVELED space, where the planted-ankle level is a
698
- // constant again (the median over real contacts).
699
- const leveledPlant = [];
700
- for (let s = 0; s < 2; s += 1) {
701
- for (let f = 0; f < frames; f += 1) if (realContact[s][f]) leveledPlant.push(ankle[s][f * 3 + 1]);
702
- }
703
- leveledPlant.sort((a, b) => a - b);
704
- const plantY = leveledPlant[Math.floor(leveledPlant.length / 2)] ?? 0;
705
- for (let s = 0; s < 2; s += 1) {
706
- for (let f = 0; f < frames; f += 1) lockContact[s][f] = realContact[s][f];
707
- bridgeOcclusionGaps(lockContact[s], ankle[s], plantY, fps);
708
- }
709
-
710
- // How far the lock actually dragged an ankle sideways, the metric that
711
- // separates "removed a slide" from "threw the leg across the floor".
712
- let lockPullMax = 0;
713
- // 4e) foot lock: hold each contact run's ankle at its median planted spot
714
- // via 2-bone leg IK, ramped over the run edges. Positions are re-read
715
- // after leveling so the lock lives in the leveled space.
716
- if (anchors > 0) {
717
- for (let f = 0; f < frames; f += 1) {
718
- const positions = forwardKinematics(readLocals(f), offsets, rootAt(f));
719
- for (let s = 0; s < 2; s += 1) {
720
- ankle[s][f * 3] = positions[legs[s].ankle][0];
721
- ankle[s][f * 3 + 1] = positions[legs[s].ankle][1];
722
- ankle[s][f * 3 + 2] = positions[legs[s].ankle][2];
723
- }
724
- }
725
- for (let s = 0; s < 2; s += 1) {
726
- for (const run of contactRuns(lockContact[s])) {
727
- const lock = runLockPoint(run, realContact[s], ankle[s]);
728
- if (!lock) continue;
729
- for (let f = run.start; f <= run.end; f += 1) {
730
- const edge = Math.min(f - run.start + 1, run.end - f + 1);
731
- const weight = Math.min(1, edge / (lockBlendFrames + 1));
732
- const locals = readLocals(f);
733
- const worlds = globalRotations(locals);
734
- const positions = forwardKinematics(locals, offsets, rootAt(f));
735
- const current = positions[legs[s].ankle];
736
- const pull = Math.hypot(lock[0] - current[0], lock[2] - current[2]);
737
- if (pull > LOCK_MAX_PULL_M) continue;
738
- lockPullMax = Math.max(lockPullMax, pull * weight);
739
- const target = [
740
- current[0] + (lock[0] - current[0]) * weight,
741
- current[1] + (lock[1] - current[1]) * weight,
742
- current[2] + (lock[2] - current[2]) * weight,
743
- ];
744
- const solved = solveLegIk(positions, worlds, legs[s], hipsIdx, target);
745
- if (!solved) continue;
746
- writeLocal(f, legs[s].hip, solved.hipLocal);
747
- writeLocal(f, legs[s].knee, solved.kneeLocal);
748
- writeLocal(f, legs[s].ankle, solved.ankleLocal);
749
- }
750
- }
751
- }
752
- }
753
-
754
- // 4f) final FK, then the vertical placement. The reference is a robust
755
- // touch level (the 10th percentile of per-frame lowest soles) — NOT the
756
- // clip's global minimum: one residual estimation dip below the floor
757
- // would otherwise hoist the entire clip by its depth. What still pokes
758
- // through afterwards is caught by a one-sided guard (lifting a sunken
759
- // frame never squashes a jump; only chasing feet upward does).
760
- const lowestAt = new Float64Array(frames);
761
- for (let f = 0; f < frames; f += 1) {
762
- const positions = forwardKinematics(readLocals(f), offsets, rootAt(f));
763
- for (let j = 0; j < jointCount; j += 1) {
764
- const p = (f * jointCount + j) * 3;
765
- posedJoints[p] = positions[j][0];
766
- posedJoints[p + 1] = positions[j][1];
767
- posedJoints[p + 2] = positions[j][2];
768
- }
769
- let low = Infinity;
770
- for (const j of feet) low = Math.min(low, posedJoints[(f * jointCount + j) * 3 + 1]);
771
- lowestAt[f] = low;
772
- }
773
- // The ground line already brought touch-downs to ≈0; the last shift only
774
- // centres the touch CLUSTER on zero. A low percentile would land just
775
- // above the residual error dips instead and hoist the clip by their
776
- // depth — the exact 7 cm hover this replaced.
777
- const touchValues = [...lowestAt].filter((value) => value < 0.05).sort((a, b) => a - b);
778
- const sortedLowest = [...lowestAt].sort((a, b) => a - b);
779
- const shift = touchValues.length > frames * 0.05
780
- ? touchValues[Math.floor(touchValues.length / 2)]
781
- : sortedLowest[Math.floor(sortedLowest.length * 0.1)] ?? 0;
782
- if (Number.isFinite(shift) && Math.abs(shift) > 1e-6) {
783
- for (let f = 0; f < frames; f += 1) {
784
- rootPos[f * 3 + 1] -= shift;
785
- lowestAt[f] -= shift;
786
- for (let j = 0; j < jointCount; j += 1) posedJoints[(f * jointCount + j) * 3 + 1] -= shift;
787
- }
788
- }
789
- // A sole under the floor is a LEG error, not a body error: the ground line
790
- // stands on the SUPPORT foot, so what pokes through is the other leg's
791
- // per-frame estimate. Hoisting the whole body for it — the only thing this
792
- // guard used to do — wrote every one of those errors into hip height, on
793
- // 44 % of frames, 1.8 cm mean and 6 cm worst, and that rectified track was
794
- // measurably the largest single source of hip jitter in the take (hip jerk
795
- // RMS fell from 1.17 to 0.80 cm with the guard simply switched off).
796
- // So: lift the offending FOOT with the same 2-bone leg IK, where the error
797
- // actually is, and the hips never feel it. The body lift stays underneath
798
- // as the backstop for whatever the leg cannot reach — a leg already
799
- // straight has nowhere left to go — so nothing ever ends up below Y=0.
800
- //
801
- // The correction is SHAPED in time before it is applied, per leg. Raw, it
802
- // is a per-frame quantity computed from a per-frame estimate: it fired on
803
- // 44 % of frames in bursts of median length 3, switching on and off 6.8
804
- // times a second, and its own second difference was 1.10 cm RMS — so while
805
- // it fixed the sole it wrote that tremble straight into the knee. Shaping:
806
- // deficit → running MAX over ±guardShapeHalf → moving average over the
807
- // same half-window.
808
- // The DILATION BEFORE THE BLUR is what makes this safe: after the max,
809
- // every sample the average sees within ±guardShapeHalf of frame f is
810
- // already ≥ the raw deficit at f, so their mean is too — the smoothed
811
- // correction is pointwise ≥ the raw deficit everywhere and no floor
812
- // penetration can be reintroduced by the smoothing, which is the only
813
- // reason it is allowed to touch this correction at all. (Blurring the raw
814
- // deficit alone would halve the correction at the tip of every spike,
815
- // which is exactly where the sole is deepest under the floor.)
816
- // What this buys is measured in the leg's ANGLES, which is where the raw
817
- // guard's damage was: knee angle jerk 3.04 → 2.63°/frame² and the worst
818
- // single-frame knee change 16.2 → 14.5° on boxing-offline. The ankle's own
819
- // accel barely moves (1.70 → 1.72 cm) — the guard was never displacing the
820
- // foot much, it was chattering the joints that carry it. The cost is that
821
- // frames NEAR a penetration are lifted slightly above the floor: grounding
822
- // 87.2 → 86.2 %, which is most of why PIN_SMOOTH_HALF_S was re-swept.
823
- const legLift = { frames: 0, sum: 0, max: 0 };
824
- const bodyLift = { frames: 0, sum: 0, max: 0 };
825
- const soleOfLeg = (f, s) => Math.min(
826
- posedJoints[(f * jointCount + legs[s].ankle) * 3 + 1],
827
- posedJoints[(f * jointCount + toes[s]) * 3 + 1]
828
- );
829
- const correction = [0, 1].map((s) => {
830
- const deficit = new Float64Array(frames);
831
- for (let f = 0; f < frames; f += 1) deficit[f] = Math.max(0, -soleOfLeg(f, s));
832
- return shapeFloorCorrection(deficit, guardShapeHalf);
833
- });
834
- for (let f = 0; f < frames; f += 1) {
835
- let lifted = false;
836
- for (let s = 0; s < 2; s += 1) {
837
- const lift = correction[s][f];
838
- if (lift <= 1e-6) continue;
839
- const locals = readLocals(f);
840
- const positions = forwardKinematics(locals, offsets, rootAt(f));
841
- const current = positions[legs[s].ankle];
842
- const solved = solveLegIk(positions, globalRotations(locals), legs[s], hipsIdx, [current[0], current[1] + lift, current[2]]);
843
- if (!solved) continue;
844
- writeLocal(f, legs[s].hip, solved.hipLocal);
845
- writeLocal(f, legs[s].knee, solved.kneeLocal);
846
- writeLocal(f, legs[s].ankle, solved.ankleLocal);
847
- lifted = true;
848
- legLift.sum += lift;
849
- legLift.max = Math.max(legLift.max, lift);
850
- }
851
- if (lifted) {
852
- legLift.frames += 1;
853
- const positions = forwardKinematics(readLocals(f), offsets, rootAt(f));
854
- let low = Infinity;
855
- for (let j = 0; j < jointCount; j += 1) {
856
- const p = (f * jointCount + j) * 3;
857
- posedJoints[p] = positions[j][0];
858
- posedJoints[p + 1] = positions[j][1];
859
- posedJoints[p + 2] = positions[j][2];
860
- }
861
- for (const j of feet) low = Math.min(low, posedJoints[(f * jointCount + j) * 3 + 1]);
862
- lowestAt[f] = low;
863
- }
864
- const residual = -lowestAt[f];
865
- if (residual <= 1e-6) continue;
866
- bodyLift.frames += 1;
867
- bodyLift.sum += residual;
868
- bodyLift.max = Math.max(bodyLift.max, residual);
869
- rootPos[f * 3 + 1] += residual;
870
- lowestAt[f] += residual;
871
- for (let j = 0; j < jointCount; j += 1) posedJoints[(f * jointCount + j) * 3 + 1] += residual;
872
- }
873
- if (process.env.BVH_GROUND_DEBUG) {
874
- const report = (label, lift) =>
875
- `${label} ${lift.frames}/${frames} (${((lift.frames / frames) * 100).toFixed(0)}%) mean ${((lift.sum / Math.max(1, lift.frames)) * 100).toFixed(2)}cm max ${(lift.max * 100).toFixed(2)}cm`;
876
- console.error(`[guard] ${report("leg lifts", legLift)}; ${report("body lifts", bodyLift)}`);
877
- }
878
-
879
- // How large the FILMED person is relative to CozyClay's canonical body
880
- // (leg-chain ratio). The take itself stays canonical; the app may scale
881
- // the rendered character by this so the performer's real stature reads.
882
- const personScale = rootScale > 1e-6 ? 1 / rootScale : 1;
883
-
884
- return { frames, fps, rotMats, rootPos, posedJoints, personScale, rawRootStart, lockPullMax };
885
- }
886
-
887
- /** Angle in degrees between two rotation matrices, via the quat dot. */
888
- function angleBetweenDeg(a, b) {
889
- const qa = matToQuat(a);
890
- const qb = matToQuat(b);
891
- const dot = Math.min(1, Math.abs(qa[0] * qb[0] + qa[1] * qb[1] + qa[2] * qb[2] + qa[3] * qb[3]));
892
- return (2 * Math.acos(dot) * 180) / Math.PI;
893
- }
894
-
895
- /** Bridge intervals where the root spins impossibly fast with one shortest
896
- * path between the sound frames on either side, applying the correction to
897
- * EVERY joint so local articulation is preserved (the old retarget script's
898
- * repair, generalized from its hand-measured frame range). */
899
- function repairRootSpins(worldsByFrame, hipsIndex, degPerFrame) {
900
- const frames = worldsByFrame.length;
901
- if (hipsIndex === undefined || frames < 3) return;
902
- const fast = new Array(frames).fill(false);
903
- for (let f = 1; f < frames; f += 1) {
904
- fast[f] = angleBetweenDeg(worldsByFrame[f - 1][hipsIndex], worldsByFrame[f][hipsIndex]) > degPerFrame;
905
- }
906
- let f = 1;
907
- while (f < frames) {
908
- if (!fast[f]) {
909
- f += 1;
910
- continue;
911
- }
912
- let end = f;
913
- while (end + 1 < frames && fast[end + 1]) end += 1;
914
- const before = f - 1;
915
- const after = end + 1;
916
- if (before >= 0 && after < frames) {
917
- const qa = matToQuat(worldsByFrame[before][hipsIndex]);
918
- const qb = matToQuat(worldsByFrame[after][hipsIndex]);
919
- for (let inner = f; inner <= end; inner += 1) {
920
- const repaired = quatToMat(slerpQuat(qa, qb, (inner - before) / (after - before)));
921
- const correction = matMul(repaired, matTranspose(worldsByFrame[inner][hipsIndex]));
922
- const worlds = worldsByFrame[inner];
923
- for (let j = 0; j < worlds.length; j += 1) worlds[j] = matMul(correction, worlds[j]);
924
- }
925
- }
926
- f = end + 1;
927
- }
928
- }
929
-
930
- /** Zero-lag three-frame stabilization: each world rotation on the chain slides
931
- * `blend` of the way toward its immediate neighbours' midpoint, `passes`
932
- * times. Used on the torso (0.2) and both legs (0.4); the ARMS are never
933
- * passed in — see LEG_BLEND for why the same filter that costs a foot nothing
934
- * would blunt a punch. The neighbour span stays ±1 frame at every rate and
935
- * the DURATION is carried by the pass count, because a stride-N three-tap
936
- * filter passes Nyquist untouched (LEG/TORSO_SMOOTH_HALF_S). */
937
- function stabilizeChain(worldsByFrame, chainIndices, blend, passes) {
938
- const frames = worldsByFrame.length;
939
- if (frames < 3 || chainIndices.length === 0) return;
940
- for (let pass = 0; pass < passes; pass += 1) {
941
- for (const j of chainIndices) {
942
- const original = worldsByFrame.map((worlds) => matToQuat(worlds[j]));
943
- for (let f = 1; f < frames - 1; f += 1) {
944
- const mid = slerpQuat(original[f - 1], original[f + 1], 0.5);
945
- worldsByFrame[f][j] = quatToMat(slerpQuat(original[f], mid, blend));
946
- }
947
- }
948
- }
949
- }
950
-
951
- /** One-Euro on world rotations (#84): sequential per joint, the filtered
952
- * quaternion slides toward each frame's raw one by an alpha derived from a
953
- * cutoff that RISES with the (smoothed) angular speed — slow tremor is cut,
954
- * a fast strike passes. See the ARM_* constants for why the arms get this
955
- * instead of the neighbour slerp above. Causal (a first frame of lag at
956
- * rest, none at speed) where the slerp is zero-lag — acceptable on arms,
957
- * wrong on legs, whose planted-foot metrics read absolute position. */
958
- function oneEuroChain(worldsByFrame, chainIndices, fps) {
959
- const frames = worldsByFrame.length;
960
- if (frames < 2 || chainIndices.length === 0) return;
961
- const dt = 1 / fps;
962
- const alphaFor = (cutoffHz) => 1 / (1 + 1 / (2 * Math.PI * cutoffHz * dt));
963
- const speedAlpha = alphaFor(ARM_SPEED_CUTOFF_HZ);
964
- for (const j of chainIndices) {
965
- let filtered = matToQuat(worldsByFrame[0][j]);
966
- let speedFiltered = 0;
967
- for (let f = 1; f < frames; f += 1) {
968
- const raw = matToQuat(worldsByFrame[f][j]);
969
- const dot = Math.min(1, Math.abs(raw[0] * filtered[0] + raw[1] * filtered[1] + raw[2] * filtered[2] + raw[3] * filtered[3]));
970
- const speedDegPerS = ((2 * Math.acos(dot) * 180) / Math.PI) / dt;
971
- speedFiltered += speedAlpha * (speedDegPerS - speedFiltered);
972
- filtered = slerpQuat(filtered, raw, alphaFor(ARM_MIN_CUTOFF_HZ + ARM_BETA_PER_DEG_S * speedFiltered));
973
- worldsByFrame[f][j] = quatToMat(filtered);
974
- }
975
- }
976
- }
977
-
978
- /**
979
- * Shape a per-frame floor-penetration correction so that applying it does not
980
- * itself shake the leg: running MAX over ±half (dilation), then a centred
981
- * moving average over the same ±half. Returns a new track.
982
- *
983
- * SAFETY INVARIANT — the result is pointwise >= the input, so a correction
984
- * shaped this way can never reintroduce penetration the raw one removed. Every
985
- * sample the average sees at frame f is dilated[k] for some |k - f| <= half,
986
- * and dilated[k] >= source[f] because f is inside k's own ±half window; a mean
987
- * of values all >= source[f] is >= source[f]. (Blurring the raw deficit
988
- * without the dilation would instead halve the correction at the tip of every
989
- * spike, which is exactly where the sole is deepest under the floor.)
990
- * Exported so the invariant is tested directly rather than inferred.
991
- */
992
- export function shapeFloorCorrection(source, half) {
993
- const frames = source.length;
994
- const dilated = new Float64Array(frames);
995
- for (let f = 0; f < frames; f += 1) {
996
- let peak = 0;
997
- for (let k = Math.max(0, f - half); k <= Math.min(frames - 1, f + half); k += 1) peak = Math.max(peak, source[k]);
998
- dilated[f] = peak;
999
- }
1000
- const blurred = new Float64Array(frames);
1001
- for (let f = 0; f < frames; f += 1) {
1002
- let sum = 0;
1003
- let count = 0;
1004
- for (let k = Math.max(0, f - half); k <= Math.min(frames - 1, f + half); k += 1) {
1005
- sum += dilated[k];
1006
- count += 1;
1007
- }
1008
- blurred[f] = sum / count;
1009
- }
1010
- return blurred;
1011
- }
1012
-
1013
- /** In-place centred moving average of a plain array, boundary-clamped. */
1014
- function smoothArray(values, window) {
1015
- const half = Math.floor(window / 2);
1016
- const source = Float64Array.from(values);
1017
- for (let f = 0; f < values.length; f += 1) {
1018
- let sum = 0;
1019
- let count = 0;
1020
- for (let k = Math.max(0, f - half); k <= Math.min(values.length - 1, f + half); k += 1) {
1021
- sum += source[k];
1022
- count += 1;
1023
- }
1024
- values[f] = sum / count;
1025
- }
1026
- }
1027
-
1028
- /** Erode contact runs shorter than minRun, then bridge gaps shorter than
1029
- * minRun — single-frame flicker neither starts nor breaks a plant. */
1030
- function cleanRuns(track, minRun) {
1031
- const frames = track.length;
1032
- for (let f = 0; f < frames; ) {
1033
- if (!track[f]) { f += 1; continue; }
1034
- let g = f;
1035
- while (g < frames && track[g]) g += 1;
1036
- if (g - f < minRun) for (let k = f; k < g; k += 1) track[k] = false;
1037
- f = g;
1038
- }
1039
- for (let f = 0; f < frames; ) {
1040
- if (track[f]) { f += 1; continue; }
1041
- let g = f;
1042
- while (g < frames && !track[g]) g += 1;
1043
- if (f > 0 && g < frames && g - f < minRun) for (let k = f; k < g; k += 1) track[k] = true;
1044
- f = g;
1045
- }
1046
- }
1047
-
1048
- /** Cut a candidate contact track down to the stretches during which the ankle
1049
- * never leaves a `maxWander`-wide patch of floor, dropping what is then
1050
- * shorter than minRun. Net wander over a run is immune to the per-frame XZ
1051
- * noise that defeats an instantaneous speed gate, and still rejects a foot
1052
- * that is actually travelling — a step's bounding box blows past the patch
1053
- * within a few frames, so the run is closed exactly where the foot left. */
1054
- function splitByXzWander(track, anklePositions, maxWander, minRun) {
1055
- const kept = new Array(track.length).fill(false);
1056
- for (const run of contactRuns(track)) {
1057
- let start = run.start;
1058
- let minX = Infinity;
1059
- let maxX = -Infinity;
1060
- let minZ = Infinity;
1061
- let maxZ = -Infinity;
1062
- for (let f = run.start; f <= run.end; f += 1) {
1063
- const x = anklePositions[f * 3];
1064
- const z = anklePositions[f * 3 + 2];
1065
- const spanX = Math.max(maxX, x) - Math.min(minX, x);
1066
- const spanZ = Math.max(maxZ, z) - Math.min(minZ, z);
1067
- if (f > start && Math.hypot(spanX, spanZ) > maxWander) {
1068
- if (f - start >= minRun) for (let k = start; k < f; k += 1) kept[k] = true;
1069
- start = f;
1070
- minX = maxX = x;
1071
- minZ = maxZ = z;
1072
- continue;
1073
- }
1074
- minX = Math.min(minX, x); maxX = Math.max(maxX, x);
1075
- minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z);
1076
- }
1077
- if (run.end - start + 1 >= minRun) for (let k = start; k <= run.end; k += 1) kept[k] = true;
1078
- }
1079
- for (let f = 0; f < track.length; f += 1) track[f] = kept[f];
1080
- }
1081
-
1082
- /** Contiguous true runs of a boolean track as { start, end } (inclusive). */
1083
- function contactRuns(track) {
1084
- const runs = [];
1085
- for (let f = 0; f < track.length; ) {
1086
- if (!track[f]) { f += 1; continue; }
1087
- let g = f;
1088
- while (g < track.length && track[g]) g += 1;
1089
- runs.push({ start: f, end: g - 1 });
1090
- f = g;
1091
- }
1092
- return runs;
1093
- }
1094
-
1095
- /** Merge contact runs across short occlusion gaps: the foot left "contact"
1096
- * but reappears at (nearly) the same spot without ever lifting past the
1097
- * jump ceiling — so it never actually moved, and the gap is an estimation
1098
- * error to be pinned, not air to be preserved. */
1099
- export function bridgeOcclusionGaps(track, anklePositions, floorY, fps) {
1100
- const maxGap = Math.round(BRIDGE_MAX_GAP_S * fps);
1101
- const runs = contactRuns(track);
1102
- for (let i = 0; i + 1 < runs.length; i += 1) {
1103
- const a = runs[i];
1104
- const b = runs[i + 1];
1105
- const gap = b.start - a.end - 1;
1106
- if (gap <= 0 || gap > maxGap) continue;
1107
- const dx = anklePositions[b.start * 3] - anklePositions[a.end * 3];
1108
- const dz = anklePositions[b.start * 3 + 2] - anklePositions[a.end * 3 + 2];
1109
- if (Math.hypot(dx, dz) > BRIDGE_MAX_XZ_M) continue;
1110
- let maxLift = -Infinity;
1111
- for (let f = a.end + 1; f < b.start; f += 1) maxLift = Math.max(maxLift, anklePositions[f * 3 + 1] - floorY);
1112
- if (maxLift > BRIDGE_MAX_LIFT_M) continue; // a real jump keeps its air
1113
- for (let f = a.end + 1; f < b.start; f += 1) track[f] = true;
1114
- }
1115
- }
1116
-
1117
- /** Where a contact run pins its ankle: the median planted position over the
1118
- * run's REAL contact frames (bridged frames are the error being repaired). */
1119
- function runLockPoint(run, realTrack, anklePositions) {
1120
- const xs = [];
1121
- const ys = [];
1122
- const zs = [];
1123
- for (let f = run.start; f <= run.end; f += 1) {
1124
- if (!realTrack[f]) continue;
1125
- xs.push(anklePositions[f * 3]);
1126
- ys.push(anklePositions[f * 3 + 1]);
1127
- zs.push(anklePositions[f * 3 + 2]);
1128
- }
1129
- if (xs.length === 0) return null;
1130
- const median = (values) => values.sort((a, b) => a - b)[Math.floor(values.length / 2)];
1131
- return [median(xs), median(ys), median(zs)];
1132
- }
1133
-
1134
- function v3sub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
1135
- function v3add(a, b) { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; }
1136
- function v3scale(v, k) { return [v[0] * k, v[1] * k, v[2] * k]; }
1137
- function v3dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
1138
- function v3cross(a, b) {
1139
- return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
1140
- }
1141
- function v3len(v) { return Math.hypot(v[0], v[1], v[2]); }
1142
- function matVec3(m, v) {
1143
- return [
1144
- m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
1145
- m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
1146
- m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
1147
- ];
1148
- }
1149
-
1150
- /** Shortest rotation matrix taking direction u onto direction v (Rodrigues). */
1151
- function rotationBetween(u, v) {
1152
- const lu = v3len(u);
1153
- const lv = v3len(v);
1154
- if (lu < 1e-9 || lv < 1e-9) return null;
1155
- const a = v3scale(u, 1 / lu);
1156
- const b = v3scale(v, 1 / lv);
1157
- const c = v3cross(a, b);
1158
- const d = v3dot(a, b);
1159
- const s2 = v3dot(c, c);
1160
- if (s2 < 1e-14) {
1161
- if (d > 0) return [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
1162
- // opposite: 180 deg about any axis perpendicular to a
1163
- let axis = v3cross(a, [1, 0, 0]);
1164
- if (v3len(axis) < 1e-6) axis = v3cross(a, [0, 1, 0]);
1165
- const n = v3scale(axis, 1 / v3len(axis));
1166
- return [
1167
- [2 * n[0] * n[0] - 1, 2 * n[0] * n[1], 2 * n[0] * n[2]],
1168
- [2 * n[0] * n[1], 2 * n[1] * n[1] - 1, 2 * n[1] * n[2]],
1169
- [2 * n[0] * n[2], 2 * n[1] * n[2], 2 * n[2] * n[2] - 1],
1170
- ];
1171
- }
1172
- const K = [
1173
- [0, -c[2], c[1]],
1174
- [c[2], 0, -c[0]],
1175
- [-c[1], c[0], 0],
1176
- ];
1177
- const k = (1 - d) / s2;
1178
- const KK = matMul(K, K);
1179
- return [
1180
- [1 + K[0][0] + KK[0][0] * k, K[0][1] + KK[0][1] * k, K[0][2] + KK[0][2] * k],
1181
- [K[1][0] + KK[1][0] * k, 1 + K[1][1] + KK[1][1] * k, K[1][2] + KK[1][2] * k],
1182
- [K[2][0] + KK[2][0] * k, K[2][1] + KK[2][1] * k, 1 + K[2][2] + KK[2][2] * k],
1183
- ];
1184
- }
1185
-
1186
- /**
1187
- * Analytic 2-bone leg IK (research 11 §2: "hold the ankle world position and
1188
- * re-solve the two-bone leg chain"). Re-aims hip and knee so the ankle
1189
- * reaches `target`, keeps the knee's current bend plane, and leaves the
1190
- * foot's WORLD orientation untouched — a planted foot must not roll because
1191
- * the leg above it moved. Returns the new cskel27 LOCAL matrices, or null
1192
- * when the chain is degenerate.
1193
- */
1194
- function solveLegIk(positions, worlds, leg, hipsIdx, target) {
1195
- const H = positions[leg.hip];
1196
- const K = positions[leg.knee];
1197
- const A = positions[leg.ankle];
1198
- const L1 = v3len(v3sub(K, H));
1199
- const L2 = v3len(v3sub(A, K));
1200
- if (L1 < 1e-4 || L2 < 1e-4) return null;
1201
- let d = v3len(v3sub(target, H));
1202
- const eps = 1e-3;
1203
- d = Math.max(Math.abs(L1 - L2) + eps, Math.min(L1 + L2 - eps, d));
1204
- if (d < 1e-4) return null;
1205
- const dir = v3scale(v3sub(target, H), 1 / v3len(v3sub(target, H)));
1206
- // Preserve the current bend plane: the knee keeps pointing the way it
1207
- // already points, projected off the new hip→ankle axis.
1208
- const hk = v3sub(K, H);
1209
- let bend = v3sub(hk, v3scale(dir, v3dot(hk, dir)));
1210
- if (v3len(bend) < 1e-6) {
1211
- bend = v3sub(matVec3(worlds[leg.hip], [0, 0, 1]), v3scale(dir, v3dot(matVec3(worlds[leg.hip], [0, 0, 1]), dir)));
1212
- if (v3len(bend) < 1e-6) return null;
1213
- }
1214
- bend = v3scale(bend, 1 / v3len(bend));
1215
- const a1 = (L1 * L1 - L2 * L2 + d * d) / (2 * d);
1216
- const h = Math.sqrt(Math.max(0, L1 * L1 - a1 * a1));
1217
- const newKnee = v3add(H, v3add(v3scale(dir, a1), v3scale(bend, h)));
1218
- const clampedTarget = v3add(H, v3scale(dir, d));
1219
-
1220
- const hipDelta = rotationBetween(v3sub(K, H), v3sub(newKnee, H));
1221
- if (!hipDelta) return null;
1222
- const ankleAfterHip = v3add(newKnee, matVec3(hipDelta, v3sub(A, K)));
1223
- const kneeDelta = rotationBetween(v3sub(ankleAfterHip, newKnee), v3sub(clampedTarget, newKnee));
1224
- if (!kneeDelta) return null;
1225
-
1226
- const hipGlobal = matMul(hipDelta, worlds[leg.hip]);
1227
- const kneeGlobal = matMul(kneeDelta, matMul(hipDelta, worlds[leg.knee]));
1228
- return {
1229
- hipLocal: matMul(matTranspose(worlds[hipsIdx]), hipGlobal),
1230
- kneeLocal: matMul(matTranspose(hipGlobal), kneeGlobal),
1231
- // the foot keeps its ORIGINAL world orientation under the new knee
1232
- ankleLocal: matMul(matTranspose(kneeGlobal), worlds[leg.ankle]),
1233
- };
1234
- }
1235
-
1236
- /** Centred moving average over one interleaved component of a stride-3 track. */
1237
- function smoothTrack(track, frames, component, window) {
1238
- const half = Math.floor(window / 2);
1239
- const source = new Float64Array(frames);
1240
- for (let f = 0; f < frames; f += 1) source[f] = track[f * 3 + component];
1241
- for (let f = 0; f < frames; f += 1) {
1242
- let sum = 0;
1243
- let count = 0;
1244
- for (let k = Math.max(0, f - half); k <= Math.min(frames - 1, f + half); k += 1) {
1245
- sum += source[k];
1246
- count += 1;
1247
- }
1248
- track[f * 3 + component] = sum / count;
1249
- }
1250
- }
1251
-
1252
-
1253
- function magnitude(v) {
1254
- return Math.hypot(v[0], v[1], v[2]);
1255
- }
1256
-
1257
- function chainLength(positions, names) {
1258
- let total = 0;
1259
- for (let i = 1; i < names.length; i += 1) {
1260
- const a = positions[CSKEL27_JOINTS.indexOf(names[i - 1])];
1261
- const b = positions[CSKEL27_JOINTS.indexOf(names[i])];
1262
- total += Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
1263
- }
1264
- return total;
1265
- }