cozyclay 2.1.1 → 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 (577) hide show
  1. package/CHANGELOG.md +60 -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 +3 -3
  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-msLI71g1.js → first-shot-handoff-CC4xe4HM.js} +1 -1
  33. package/dist/assets/{landing-ChUvB_zL.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/sw.js +3 -6
  40. package/dist/workflow/index.html +5 -4
  41. package/mcp/LIVE-PROTOCOL.md +7 -47
  42. package/mcp/README.md +16 -26
  43. package/mcp/live-hub.mjs +6 -10
  44. package/mcp/package.json +1 -3
  45. package/mcp/tool-handlers.mjs +4 -168
  46. package/package.json +3 -22
  47. package/src/App.jsx +150 -2624
  48. package/src/agent-progress.js +79 -0
  49. package/src/agent-turn-mark.js +30 -0
  50. package/src/analytics.js +1 -19
  51. package/src/app-context.js +5 -0
  52. package/src/app-stage.jsx +2 -34
  53. package/src/asset-pane.jsx +6 -5
  54. package/src/camera-block.js +21 -0
  55. package/src/camera-direction.js +120 -0
  56. package/src/character-kind.js +3 -9
  57. package/src/command-bus.js +8 -6
  58. package/src/commands/cast.js +5 -34
  59. package/src/commands/elements/character.js +1 -1
  60. package/src/commands/elements/motion.js +1 -6
  61. package/src/commands/elements/object.js +6 -2
  62. package/src/commands/elements/shot.js +4 -1
  63. package/src/commands/elements.js +38 -14
  64. package/src/commands/index.js +3 -1
  65. package/src/commands/kits.js +37 -0
  66. package/src/commands/motion-timing.js +57 -0
  67. package/src/commands/motion.js +9 -152
  68. package/src/commands/motion3-compile.js +65 -0
  69. package/src/commands/motion3.js +207 -0
  70. package/src/commands/objects.js +6 -4
  71. package/src/commands/shot.js +49 -11
  72. package/src/commands/stage.js +14 -1
  73. package/src/commands/view.js +4 -1
  74. package/src/document-mirrors.js +42 -0
  75. package/src/document-store.js +4 -0
  76. package/src/domains/cast.js +19 -194
  77. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  78. package/src/domains/motion/take-source.js +51 -0
  79. package/src/domains/motion.js +60 -1838
  80. package/src/domains/scenes.js +12 -55
  81. package/src/domains/shots.js +2 -2
  82. package/src/domains/stage.js +4 -2
  83. package/src/draft-ghost.js +46 -0
  84. package/src/draft-ghost.jsx +129 -0
  85. package/src/dualview.jsx +4 -3
  86. package/src/effects/EffectsLayer.jsx +112 -0
  87. package/src/effects/effect-meshes.js +232 -0
  88. package/src/effects/effect-record.js +103 -0
  89. package/src/effects/emitter.js +59 -0
  90. package/src/effects/simulate.js +261 -0
  91. package/src/effects/state.js +59 -0
  92. package/src/execution-telemetry.js +2 -3
  93. package/src/first-success-guide.jsx +5 -5
  94. package/src/ghost-set.js +257 -0
  95. package/src/held-object-edit.jsx +113 -0
  96. package/src/held-object-pose.js +175 -0
  97. package/src/hierarchy-model.js +1 -1
  98. package/src/hierarchy-panel.jsx +1 -1
  99. package/src/imported-mesh.jsx +103 -0
  100. package/src/kits/held.js +84 -0
  101. package/src/kits/index.js +70 -0
  102. package/src/kits/smoking.js +57 -0
  103. package/src/light-blockers.js +93 -0
  104. package/src/motion/beat-board-model.js +84 -0
  105. package/src/motion/beat-board.css +124 -0
  106. package/src/motion/beat-board.jsx +59 -0
  107. package/src/motion/center-of-mass.js +66 -0
  108. package/src/motion/fix-collisions.js +850 -0
  109. package/src/motion/physics-review.js +118 -0
  110. package/src/{ardy → motion}/physics-support.js +1 -76
  111. package/src/motion/rig-snapshot.js +22 -0
  112. package/src/motion/take-segments.jsx +140 -0
  113. package/src/{ardy → motion}/timeline-coordinates.js +0 -21
  114. package/src/{ardy → motion}/timeline.css +1 -2
  115. package/src/{ardy → motion}/timeline.jsx +42 -413
  116. package/src/motion/waypoints.js +76 -0
  117. package/src/motion3/beats.js +83 -0
  118. package/src/motion3/body.js +158 -0
  119. package/src/motion3/bones.js +47 -0
  120. package/src/motion3/carried.js +47 -0
  121. package/src/motion3/compile-job.js +75 -0
  122. package/src/motion3/compile-worker.js +10 -0
  123. package/src/motion3/compiler/anchors.js +105 -0
  124. package/src/motion3/compiler/arm-orient.js +128 -0
  125. package/src/motion3/compiler/channels.js +125 -0
  126. package/src/motion3/compiler/compile.js +205 -0
  127. package/src/motion3/compiler/gait-rhythm.js +97 -0
  128. package/src/motion3/compiler/grip.js +171 -0
  129. package/src/motion3/compiler/head-steady.js +48 -0
  130. package/src/motion3/compiler/hold.js +134 -0
  131. package/src/motion3/compiler/idle.js +113 -0
  132. package/src/motion3/compiler/limbs.js +170 -0
  133. package/src/motion3/compiler/math.js +72 -0
  134. package/src/motion3/compiler/pose.js +162 -0
  135. package/src/motion3/compiler/quat.js +49 -0
  136. package/src/motion3/compiler/skeleton.js +74 -0
  137. package/src/motion3/compiler/validate-common.js +32 -0
  138. package/src/motion3/compiler/validate-goals.js +118 -0
  139. package/src/motion3/compiler/validate-objects.js +143 -0
  140. package/src/motion3/compiler/validate-parts.js +116 -0
  141. package/src/motion3/compiler/validate.js +122 -0
  142. package/src/motion3/compiler/vocab.js +73 -0
  143. package/src/motion3/compiler/walk-pelvis.js +275 -0
  144. package/src/motion3/compiler/walk.js +207 -0
  145. package/src/motion3/compiler/wrist-clamp.js +32 -0
  146. package/src/motion3/delta.js +47 -0
  147. package/src/motion3/describe.js +174 -0
  148. package/src/motion3/docs.js +27 -0
  149. package/src/motion3/edit-carry.js +21 -0
  150. package/src/motion3/fix/ease.js +16 -0
  151. package/src/motion3/fix/ground.js +37 -0
  152. package/src/motion3/fix/index.js +52 -0
  153. package/src/motion3/fix/push.js +71 -0
  154. package/src/motion3/fix/reach.js +46 -0
  155. package/src/motion3/fix/retime.js +79 -0
  156. package/src/motion3/fix/self.js +211 -0
  157. package/src/motion3/fix/slide.js +70 -0
  158. package/src/motion3/fix/turn.js +51 -0
  159. package/src/motion3/follow.js +39 -0
  160. package/src/motion3/free-motion.js +173 -0
  161. package/src/motion3/grip-choice.js +130 -0
  162. package/src/motion3/hold-plan.js +157 -0
  163. package/src/motion3/json-patch.js +77 -0
  164. package/src/motion3/key-aim.js +105 -0
  165. package/src/motion3/key-edit.js +82 -0
  166. package/src/motion3/lenient.js +127 -0
  167. package/src/motion3/object-keys.js +236 -0
  168. package/src/motion3/object-pose.js +182 -0
  169. package/src/motion3/object-tracks.js +12 -0
  170. package/src/motion3/pipeline.js +102 -0
  171. package/src/motion3/playback.js +51 -0
  172. package/src/motion3/probes.js +91 -0
  173. package/src/motion3/qa/body-checks.js +34 -0
  174. package/src/motion3/qa/check-names.js +14 -0
  175. package/src/motion3/qa/checks.js +121 -0
  176. package/src/motion3/qa/contact-checks.js +51 -0
  177. package/src/motion3/qa/explain.js +80 -0
  178. package/src/motion3/qa/fmt.js +5 -0
  179. package/src/motion3/qa/foot-checks.js +72 -0
  180. package/src/motion3/qa/geometry.js +77 -0
  181. package/src/motion3/qa/goals.js +191 -0
  182. package/src/motion3/qa/ground.js +60 -0
  183. package/src/motion3/qa/hints-contact.js +179 -0
  184. package/src/motion3/qa/hints-goal.js +160 -0
  185. package/src/motion3/qa/hints-knee.js +24 -0
  186. package/src/motion3/qa/hints-motion.js +209 -0
  187. package/src/motion3/qa/hints-object.js +91 -0
  188. package/src/motion3/qa/index.js +42 -0
  189. package/src/motion3/qa/key-checks.js +82 -0
  190. package/src/motion3/qa/near.js +96 -0
  191. package/src/motion3/qa/object-checks.js +135 -0
  192. package/src/motion3/qa/patch.js +161 -0
  193. package/src/motion3/qa/severity.js +38 -0
  194. package/src/motion3/qa/spec-checks.js +65 -0
  195. package/src/motion3/qa/style.js +218 -0
  196. package/src/motion3/qa/wrist-checks.js +69 -0
  197. package/src/motion3/qa/wrist-obs.js +60 -0
  198. package/src/motion3/refs.js +120 -0
  199. package/src/motion3/repair-tune.js +121 -0
  200. package/src/motion3/repair.js +83 -0
  201. package/src/motion3/report.js +67 -0
  202. package/src/motion3/rigs/index.js +14 -0
  203. package/src/motion3/rigs/x-bot-tpose-body.json +1 -0
  204. package/src/motion3/rigs/x-bot-tpose.json +1 -0
  205. package/src/motion3/rigs/y-bot-tpose-body.json +1 -0
  206. package/src/motion3/rigs/y-bot-tpose.json +1 -0
  207. package/src/motion3/scene.js +60 -0
  208. package/src/motion3/schema/brief.js +89 -0
  209. package/src/motion3/schema/fields.js +44 -0
  210. package/src/motion3/schema/spec-examples.js +46 -0
  211. package/src/motion3/schema/topics.js +111 -0
  212. package/src/motion3/schema/types.js +147 -0
  213. package/src/motion3/search.js +117 -0
  214. package/src/motion3/second-hand.js +112 -0
  215. package/src/motion3/skills/basic.js +82 -0
  216. package/src/motion3/skills/book.js +76 -0
  217. package/src/motion3/skills/car.js +145 -0
  218. package/src/motion3/skills/frames-auto.js +29 -0
  219. package/src/motion3/skills/index.js +83 -0
  220. package/src/motion3/skills/info.js +65 -0
  221. package/src/motion3/skills/smoke-kit.js +109 -0
  222. package/src/motion3/skills/smoke.js +160 -0
  223. package/src/motion3/spec-store.js +59 -0
  224. package/src/motion3/take-edit.js +155 -0
  225. package/src/motion3/take.js +63 -0
  226. package/src/motion3/timeline.js +127 -0
  227. package/src/motion3/views.js +121 -0
  228. package/src/motion3/walk-plan.js +119 -0
  229. package/src/object-catalog.jsx +7 -1
  230. package/src/object-colors.js +98 -0
  231. package/src/object-gizmo-parts.js +115 -0
  232. package/src/object-gizmo.jsx +37 -122
  233. package/src/object-glow.js +139 -0
  234. package/src/object-hidden.js +33 -0
  235. package/src/object-look.js +196 -0
  236. package/src/object-size.js +10 -0
  237. package/src/object-travel.js +71 -6
  238. package/src/panels/CameraPanel.jsx +7 -7
  239. package/src/panels/HdrEnvironmentControls.jsx +59 -0
  240. package/src/panels/LightPanel.jsx +25 -4
  241. package/src/panels/ObjectLookPanel.jsx +88 -0
  242. package/src/panels/PosePanel.jsx +2 -17
  243. package/src/panels/RigControlPanel.jsx +5 -53
  244. package/src/panels/SubjectBox.jsx +4 -1
  245. package/src/panels/details.css +14 -0
  246. package/src/posestudio.jsx +4 -27
  247. package/src/project-browser.jsx +3 -4
  248. package/src/project-resources.js +18 -31
  249. package/src/project.js +13 -104
  250. package/src/props.jsx +25 -280
  251. package/src/rail-dot-drag.js +23 -0
  252. package/src/render-passes.js +10 -0
  253. package/src/resource-status.jsx +1 -0
  254. package/src/result-modal.jsx +4 -19
  255. package/src/room.jsx +20 -12
  256. package/src/scene-assets.js +8 -6
  257. package/src/scene-light-place.js +72 -0
  258. package/src/scene-lights.jsx +148 -0
  259. package/src/scene-objects.js +31 -139
  260. package/src/scenes.js +15 -105
  261. package/src/semantic-edit.js +2 -5
  262. package/src/set-pieces.jsx +183 -0
  263. package/src/settings-menu.jsx +3 -11
  264. package/src/shell/BottomDock.jsx +9 -51
  265. package/src/shell/DetailsSlot.jsx +16 -200
  266. package/src/shell/GhostsMenuGroup.jsx +74 -0
  267. package/src/shell/LibrarySlot.jsx +1 -3
  268. package/src/shell/MenuBar.jsx +3 -3
  269. package/src/shell/PreferencesDialog.jsx +15 -264
  270. package/src/shell/PreferencesSlot.jsx +2 -8
  271. package/src/shell/TopBar.jsx +5 -84
  272. package/src/shell/ViewportToolbar.jsx +10 -70
  273. package/src/shell/agent-glass.css +19 -6
  274. package/src/shell/details-selection.js +42 -0
  275. package/src/shell/ghosts-menu.css +22 -0
  276. package/src/shell/glass-regions.css +29 -3
  277. package/src/shell/preferences.css +6 -1
  278. package/src/shot-authoring.js +4 -7
  279. package/src/soft-shadows.js +37 -0
  280. package/src/stage-attach-frames.js +12 -12
  281. package/src/stage-canvas-rigs.jsx +3 -3
  282. package/src/stage-capture.jsx +42 -8
  283. package/src/stage-characters.jsx +22 -216
  284. package/src/stage-editor-gizmos.jsx +6 -6
  285. package/src/stage-environment.js +93 -0
  286. package/src/stage-hdr-builtins.js +107 -0
  287. package/src/stage-hdr-image.js +116 -0
  288. package/src/stage-hdr-library.js +70 -0
  289. package/src/stage-hdr-map.js +94 -0
  290. package/src/stage-hdr.js +130 -0
  291. package/src/stage-lighting.js +149 -0
  292. package/src/stage-occlusion.js +210 -0
  293. package/src/stage-sky.jsx +159 -0
  294. package/src/stage-startup-defaults.js +5 -9
  295. package/src/studio-actions.js +12 -14
  296. package/src/studio-agent-commands.js +19 -16
  297. package/src/studio-agent-motion.js +9 -9
  298. package/src/studio-agent-protocol.js +41 -71
  299. package/src/studio-app-binding.js +25 -45
  300. package/src/studio-asset-list.js +17 -0
  301. package/src/studio-contact-sheet.js +37 -5
  302. package/src/studio-elements.js +27 -7
  303. package/src/studio-entity-bounds.js +18 -0
  304. package/src/studio-error-codes.js +8 -0
  305. package/src/studio-export-framing.js +152 -0
  306. package/src/studio-lighting-check.js +103 -0
  307. package/src/studio-motion-visual.js +150 -0
  308. package/src/studio-schema-errors.js +80 -0
  309. package/src/studio-support-gap.js +74 -0
  310. package/src/styles.css +1 -19
  311. package/src/timeline-defaults.js +5 -0
  312. package/src/timeline-extent.js +8 -20
  313. package/src/tutorial-walk.js +17 -0
  314. package/src/ui-scrub.js +1 -1
  315. package/src/use-shot-camera-tracks.js +12 -0
  316. package/src/view-ghosts.js +97 -0
  317. package/src/view-ghosts.jsx +49 -0
  318. package/src/workflow/AgentPanel.jsx +47 -166
  319. package/src/workflow/AgentProgressCard.jsx +107 -0
  320. package/src/workflow/AgentReplyText.jsx +37 -0
  321. package/src/workflow/AgentTurnSteps.jsx +84 -0
  322. package/src/workflow/LoginPanel.jsx +140 -0
  323. package/src/workflow/WorkflowBuilder.jsx +7 -23
  324. package/src/workflow/agent-client.js +54 -51
  325. package/src/workflow/agent-panel.css +71 -1
  326. package/src/workflow/agent-progress-card.css +167 -0
  327. package/src/workflow/agent-progress-view.js +47 -0
  328. package/src/workflow/agent-turn-steps.css +113 -0
  329. package/src/workflow/agent-turn.js +191 -0
  330. package/src/workflow/login-client.js +34 -0
  331. package/src/workflow/motion-input.js +18 -31
  332. package/src/workflow/node-schema.js +1 -1
  333. package/src/workflow/reply-markdown.js +69 -0
  334. package/src/workflow/scene-asset-sync.js +8 -8
  335. package/tools/agent-session-stats.mjs +61 -0
  336. package/tools/dev-full.mjs +5 -56
  337. package/tools/eslint-baseline.json +12 -13
  338. package/tools/knip-baseline.json +0 -179
  339. package/tools/loc-baseline.json +31 -81
  340. package/tools/motion3/extract-body.mjs +103 -0
  341. package/tools/motion3/extract-rig.mjs +85 -0
  342. package/tools/process-supervisor.mjs +0 -88
  343. package/tools/run-tests.mjs +8 -124
  344. package/bin/agent/motion-runtime.mjs +0 -76
  345. package/dist/ardy/cskel27-rest.json +0 -557
  346. package/dist/assets/analytics-C9pMpW7s.js +0 -1
  347. package/dist/assets/app-B_2XB9rw.css +0 -1
  348. package/dist/assets/app-DAuvKPP8.js +0 -4920
  349. package/dist/assets/config-Bx7pYsD9.js +0 -1
  350. package/dist/assets/config-DeoFTYzb.css +0 -1
  351. package/dist/assets/demo-CvKuBgy5.js +0 -1
  352. package/dist/assets/scene-asset-sync-DKkpjKBV.js +0 -19
  353. package/dist/assets/scene-asset-sync-DjJ_cJ2R.css +0 -1
  354. package/dist/assets/ticket-BPk2Ni95.js +0 -1
  355. package/dist/assets/workflow-jJRBR94Z.js +0 -179
  356. package/dist/demo/walk-then-stop.npz +0 -0
  357. package/mcp/ardy-prompts.mjs +0 -218
  358. package/src/ardy/auto-fix-panel.css +0 -108
  359. package/src/ardy/auto-physics.js +0 -1194
  360. package/src/ardy/client.js +0 -153
  361. package/src/ardy/convert.js +0 -313
  362. package/src/ardy/cskel27-neutral.js +0 -39
  363. package/src/ardy/cskel27.js +0 -77
  364. package/src/ardy/export.js +0 -165
  365. package/src/ardy/fix-collisions.js +0 -2257
  366. package/src/ardy/foot-lock.js +0 -230
  367. package/src/ardy/motion-calibration.js +0 -118
  368. package/src/ardy/motion-edit.js +0 -213
  369. package/src/ardy/motion-url.js +0 -55
  370. package/src/ardy/mp4-duration.js +0 -102
  371. package/src/ardy/npz.js +0 -595
  372. package/src/ardy/physics-panel.css +0 -21
  373. package/src/ardy/physics-panel.jsx +0 -89
  374. package/src/ardy/physics-refine.js +0 -111
  375. package/src/ardy/physics-review.js +0 -567
  376. package/src/ardy/physics-temporal.js +0 -54
  377. package/src/ardy/platform-fit-panel.css +0 -64
  378. package/src/ardy/platform-fit-panel.jsx +0 -41
  379. package/src/ardy/platform-fit.js +0 -326
  380. package/src/ardy/playback.js +0 -711
  381. package/src/ardy/pose-pin.js +0 -97
  382. package/src/ardy/prompt-clips.js +0 -30
  383. package/src/ardy/retime.js +0 -211
  384. package/src/ardy/root-drop.js +0 -369
  385. package/src/ardy/timeline-resize.js +0 -13
  386. package/src/ardy/to-cskel27.js +0 -225
  387. package/src/ardy/trim.js +0 -33
  388. package/src/ardy/waypoints.js +0 -369
  389. package/src/ardy-bridge-contract.js +0 -281
  390. package/src/domains/motion/motion-video-capture.js +0 -664
  391. package/src/i2v-motion-client.js +0 -77
  392. package/src/i2v-motion-studio.jsx +0 -180
  393. package/src/line-edit.js +0 -1348
  394. package/src/motion/generation.js +0 -105
  395. package/src/motion-readiness-ui.jsx +0 -67
  396. package/src/motion-readiness.js +0 -40
  397. package/src/motion-resources.js +0 -177
  398. package/src/motion-store.js +0 -65
  399. package/src/motion-trail.js +0 -601
  400. package/src/multimodel-ingest.js +0 -394
  401. package/src/panels/PromptBlocksPanel.jsx +0 -444
  402. package/src/panels/VideoCapturePanel.jsx +0 -181
  403. package/src/pose-extract/browser.js +0 -42
  404. package/src/pose-extract/detector.js +0 -100
  405. package/src/pose-extract/fit.js +0 -227
  406. package/src/pose-extract/image-frame.js +0 -51
  407. package/src/pose-extract/index.js +0 -58
  408. package/src/pose-extract/keyframes.js +0 -74
  409. package/src/pose-extract/landmarks.js +0 -162
  410. package/src/pose-extract/mirror.js +0 -153
  411. package/src/pose-extract/take.js +0 -131
  412. package/src/pose-extract/video-frames.js +0 -91
  413. package/src/take-recipe.js +0 -165
  414. package/src/trail-key-conflicts.js +0 -39
  415. package/src/trail-pick.js +0 -59
  416. package/src/use-i2v-capture-modal.js +0 -15
  417. package/tools/ardy/BRIDGE.md +0 -37
  418. package/tools/ardy/GVHMR-NPZ.md +0 -68
  419. package/tools/ardy/README.md +0 -124
  420. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  421. package/tools/ardy/artifacts.mjs +0 -34
  422. package/tools/ardy/bridge.mjs +0 -1732
  423. package/tools/ardy/bvh-cskel27.mjs +0 -1265
  424. package/tools/ardy/cclay_constrained_generate.py +0 -561
  425. package/tools/ardy/cclay_gvhmr_worker.py +0 -264
  426. package/tools/ardy/cclay_motion_edit.py +0 -510
  427. package/tools/ardy/cclay_sequence_generate.py +0 -705
  428. package/tools/ardy/cclay_worker.py +0 -304
  429. package/tools/ardy/dump-npz.py +0 -205
  430. package/tools/ardy/extract-rest.mjs +0 -299
  431. package/tools/ardy/extract.mjs +0 -507
  432. package/tools/ardy/footage.mjs +0 -467
  433. package/tools/ardy/gvhmr-floor.mjs +0 -102
  434. package/tools/ardy/gvhmr-to-cskel27.mjs +0 -21
  435. package/tools/ardy/gvhmr_fastpath.py +0 -168
  436. package/tools/ardy/gvhmr_trajectory.py +0 -240
  437. package/tools/ardy/merge-text-encoder.py +0 -160
  438. package/tools/ardy/mocap-metrics.mjs +0 -200
  439. package/tools/ardy/mocap-quality-gate.mjs +0 -110
  440. package/tools/ardy/motion-stabilize.mjs +0 -348
  441. package/tools/ardy/npz.mjs +0 -414
  442. package/tools/ardy/pose-to-npz.mjs +0 -106
  443. package/tools/ardy/prompt-limits.mjs +0 -5
  444. package/tools/ardy/run-edit-on-box.sh +0 -73
  445. package/tools/ardy/run-local.mjs +0 -233
  446. package/tools/ardy/run-on-box.sh +0 -593
  447. package/tools/ardy/run-sequence-on-box.sh +0 -177
  448. package/tools/ardy/runners/gvhmr-worker.mjs +0 -206
  449. package/tools/ardy/runners/index.mjs +0 -18
  450. package/tools/ardy/runners/local.mjs +0 -455
  451. package/tools/ardy/runners/proc.mjs +0 -125
  452. package/tools/ardy/runners/remote.mjs +0 -252
  453. package/tools/ardy/setup-local.mjs +0 -189
  454. package/tools/ardy/setup-text-encoder-on-box.sh +0 -93
  455. package/tools/ardy/setup-text-encoder.py +0 -306
  456. package/tools/ardy/smpl-cskel27.mjs +0 -178
  457. package/tools/ardy/visual-qa.mjs +0 -196
  458. package/tools/ardy/vq-car.mjs +0 -52
  459. package/tools/bench/EXP3.md +0 -61
  460. package/tools/bench/cclay_bench_extract_incam.py +0 -125
  461. package/tools/bench/cclay_bench_extract_obs.py +0 -204
  462. package/tools/bench/cclay_bench_runner.py +0 -42
  463. package/tools/bench/cube-contact.mjs +0 -162
  464. package/tools/bench/exp3.mjs +0 -152
  465. package/tools/bench/extract-bench-lib.mjs +0 -216
  466. package/tools/bench/extract-bench.mjs +0 -302
  467. package/tools/bench/fal-generate.mjs +0 -88
  468. package/tools/bench/fit/README.md +0 -189
  469. package/tools/bench/fit/camera.mjs +0 -59
  470. package/tools/bench/fit/contact.mjs +0 -419
  471. package/tools/bench/fit/footlock.mjs +0 -234
  472. package/tools/bench/fit/motion.mjs +0 -85
  473. package/tools/bench/fit/pin.mjs +0 -27
  474. package/tools/bench/fit/remote.mjs +0 -105
  475. package/tools/bench/fit-bench.mjs +0 -126
  476. package/tools/bench/fit-sanity.mjs +0 -45
  477. package/tools/bench/metrics.mjs +0 -313
  478. package/tools/bench/obs/depth.mjs +0 -258
  479. package/tools/bench/obs/extrinsics.mjs +0 -175
  480. package/tools/bench/obs/fit_mannequin_betas.py +0 -340
  481. package/tools/bench/obs/ground.mjs +0 -294
  482. package/tools/bench/obs/heading.mjs +0 -151
  483. package/tools/bench/obs/ladder.mjs +0 -555
  484. package/tools/bench/obs/mannequin-betas.json +0 -124
  485. package/tools/bench/obs/remote.mjs +0 -83
  486. package/tools/bench/obs/rest_joints.py +0 -52
  487. package/tools/bench/obs/ybot-targets.mjs +0 -49
  488. package/tools/bench/obs-bench.mjs +0 -520
  489. package/tools/bench/score.mjs +0 -343
  490. package/tools/bench/summarize.mjs +0 -86
  491. package/tools/demo-worker/README.md +0 -137
  492. package/tools/demo-worker/api-client.mjs +0 -233
  493. package/tools/demo-worker/generate.mjs +0 -301
  494. package/tools/demo-worker/index.mjs +0 -466
  495. package/tools/demo-worker/sign-core.mjs +0 -112
  496. package/tools/demo-worker/sign.mjs +0 -161
  497. package/tools/gt-render/browser.mjs +0 -182
  498. package/tools/gt-render/camera-math.mjs +0 -276
  499. package/tools/gt-render/page.mjs +0 -267
  500. package/tools/gt-render/render.mjs +0 -514
  501. package/tools/gt-render/scene-box.mjs +0 -31
  502. package/tools/gt-render/take-transform.mjs +0 -87
  503. package/tools/kimodo/compare-seam.mjs +0 -108
  504. package/tools/kimodo/constraints.mjs +0 -244
  505. package/tools/kimodo/edit.mjs +0 -259
  506. package/tools/kimodo/effector-constraints.mjs +0 -200
  507. package/tools/kimodo/generate.mjs +0 -646
  508. package/tools/kimodo/local-output.mjs +0 -265
  509. package/tools/kimodo/measure-pose.mjs +0 -65
  510. package/tools/kimodo/measure-preserve.mjs +0 -899
  511. package/tools/kimodo/measure-waypoints.mjs +0 -55
  512. package/tools/kimodo/mlx-generate.py +0 -111
  513. package/tools/kimodo/pose-constraints.mjs +0 -248
  514. package/tools/kimodo/preserve-mask.mjs +0 -543
  515. package/tools/kimodo/read-npz.mjs +0 -189
  516. package/tools/kimodo/run-edit-on-box.mjs +0 -155
  517. package/tools/kimodo/run-sequence-on-box.mjs +0 -178
  518. package/tools/kimodo/runner.mjs +0 -284
  519. package/tools/kimodo/seam-batch.mjs +0 -77
  520. package/tools/kimodo/setup-local.mjs +0 -30
  521. package/tools/kimodo/setup-on-box.sh +0 -339
  522. package/tools/kimodo/soma77-to-cskel27.mjs +0 -305
  523. package/tools/morphgs/assets/gen_truth.py +0 -39
  524. package/tools/morphgs/assets/mesh_ori_rig.txt +0 -27
  525. package/tools/morphgs/assets/playback-check.mjs +0 -30
  526. package/tools/morphgs/demo-gate.sh +0 -26
  527. package/tools/morphgs/fbx2morphgs.mjs +0 -68
  528. package/tools/morphgs/morphgs-to-cskel27.mjs +0 -105
  529. package/tools/morphgs/patches/preprocess_src-none-mode.patch +0 -76
  530. package/tools/morphgs/setup-on-cluster.sh +0 -126
  531. package/tools/projflow/driver.py +0 -1069
  532. package/tools/projflow/generate.mjs +0 -805
  533. package/tools/projflow/hml22-to-cskel27.mjs +0 -735
  534. package/tools/projflow/line-edit-job.mjs +0 -547
  535. package/tools/projflow/replay.mjs +0 -452
  536. package/tools/projflow/runner.mjs +0 -269
  537. package/tools/projflow/service.mjs +0 -742
  538. package/tools/track/DESIGN.md +0 -513
  539. package/tools/track/backfill-provenance.mjs +0 -116
  540. package/tools/track/budget.mjs +0 -8
  541. package/tools/track/check-rig.mjs +0 -209
  542. package/tools/track/diagnostics.schema.json +0 -60
  543. package/tools/track/export-rig.mjs +0 -256
  544. package/tools/track/fallback.mjs +0 -7
  545. package/tools/track/fk-parity-fixture.mjs +0 -161
  546. package/tools/track/gate.mjs +0 -349
  547. package/tools/track/masks.mjs +0 -318
  548. package/tools/track/metrics.mjs +0 -201
  549. package/tools/track/publish-obs.mjs +0 -108
  550. package/tools/track/py/check_env.py +0 -61
  551. package/tools/track/py/eval_lr.py +0 -229
  552. package/tools/track/py/lr_viterbi.py +0 -294
  553. package/tools/track/py/masks.py +0 -405
  554. package/tools/track/py/objective.py +0 -382
  555. package/tools/track/py/rig.py +0 -508
  556. package/tools/track/py/scene.py +0 -326
  557. package/tools/track/py/test_joint_indices.py +0 -123
  558. package/tools/track/py/test_lr_viterbi.py +0 -195
  559. package/tools/track/py/test_masks.py +0 -229
  560. package/tools/track/py/test_rig.py +0 -288
  561. package/tools/track/py/test_scene.py +0 -354
  562. package/tools/track/py/test_track.py +0 -328
  563. package/tools/track/py/track.py +0 -411
  564. package/tools/track/remote.mjs +0 -140
  565. package/tools/track/rig-dump.mjs +0 -227
  566. package/tools/track/run-box-tests.mjs +0 -14
  567. package/tools/track/run-box.mjs +0 -168
  568. package/tools/track/setup-box.sh +0 -59
  569. package/tools/track/study-2d.mjs +0 -728
  570. /package/src/{ardy → motion}/collision-blockers.js +0 -0
  571. /package/src/{ardy → motion}/ground.js +0 -0
  572. /package/src/{ardy → motion}/ik-drag.js +0 -0
  573. /package/src/{ardy → motion}/ik-key-json.js +0 -0
  574. /package/src/{ardy → motion}/ik.js +0 -0
  575. /package/src/{ardy → motion}/physics-surface.js +0 -0
  576. /package/src/{ardy → motion}/playback-clock.js +0 -0
  577. /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
- }