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
package/src/line-edit.js DELETED
@@ -1,1348 +0,0 @@
1
- /**
2
- * Line editing (ProjFlow, contract C6) — the pure half.
3
- *
4
- * The joint's EXISTING screen trajectory is drawn over the viewport and the
5
- * user grabs a point on it and pulls; the curve deforms under a Gaussian
6
- * falloff and the deformed curve is what the box is made to follow EXACTLY.
7
- * (This is the Disney scheduled-inpainting figure-1 interaction: drag the dots
8
- * on the motion curve.) Everything in this file is the part of that feature
9
- * that has no DOM, no React and no three.js in it: a projected curve in, a wire
10
- * payload out. It is kept separate from App.jsx for two reasons —
11
- *
12
- * 1. the camera maths below is the one place where a sign error produces a
13
- * silently mirrored edit rather than a crash, so it has to be testable
14
- * without a browser;
15
- * 2. App.jsx is 11k lines and the feature's diff there should be wiring,
16
- * not arithmetic.
17
- *
18
- * NOTHING here imports three.js. Matrices arrive as plain 16-element arrays in
19
- * three's own column-major `elements` order, which is what `Matrix4.elements`
20
- * already is — the caller passes `[...m.elements]` and no adapter is needed.
21
- */
22
-
23
- /**
24
- * The 15 track ids a line edit may name. Deliberately spelled out rather than
25
- * imported from ./ardy/ik.js: that module pulls in three.js for its contact
26
- * -radius measurement, and this one must stay loadable in a bare node test.
27
- *
28
- * The list IS the union of ik.js's three tables — IK_TRACKS (hands/feet),
29
- * MID_TRACKS (elbows/knees) and FK_TRACKS (hips/spine/chest/neck/head/
30
- * shoulders) — and it is also exactly the key set of TRACK_GROUPS in
31
- * tools/kimodo/preserve-mask.mjs, which is the bridge's own validation table.
32
- * App.jsx builds the picker's LABELS by looking each id up in those ik.js
33
- * tables, so a track added there without being added here shows up as a
34
- * missing option rather than as a silently wrong wire value.
35
- */
36
- export const LINE_EDIT_TRACK_IDS = Object.freeze([
37
- // IK_TRACKS — effectors
38
- "leftHand",
39
- "rightHand",
40
- "leftFoot",
41
- "rightFoot",
42
- // MID_TRACKS — mid-chain handles
43
- "leftElbow",
44
- "rightElbow",
45
- "leftKnee",
46
- "rightKnee",
47
- // FK_TRACKS — torso and head
48
- "hips",
49
- "spine",
50
- "chest",
51
- "neck",
52
- "head",
53
- "leftShoulder",
54
- "rightShoulder",
55
- ]);
56
-
57
- /** Upper bound on the polyline the app sends.
58
- *
59
- * The box builds TWO affine rows per constrained sample and then factorises an
60
- * m x m system (Cholesky, per the C7 amendment). 64 points is 128 rows, which
61
- * is comfortably inside what the sampler solves per ODE step, and it is far
62
- * more resolution than the deformed trajectory carries. A 200-frame range is
63
- * 200 curve points; sending them all would cost solve time for detail the
64
- * Gaussian falloff made smooth on purpose. */
65
- export const MAX_LINE_POINTS = 64;
66
-
67
- /** A polyline needs at least a start and an end (C6: `>= 2 points`). */
68
- export const MIN_LINE_POINTS = 2;
69
-
70
- /** How many curve points at EACH end are hard-pinned to the original
71
- * trajectory — never moved by a drag, never grabbable.
72
- *
73
- * This is the seam fix, and it is the reason this interaction replaced
74
- * freehand drawing. Gate GP2 measured it: an arbitrary drawn line pops the
75
- * take 3.9x/8.0x its own median frame delta at the range edges (the joint
76
- * teleports to wherever the stroke started), while a line whose ENDPOINTS sit
77
- * on the joint's own trajectory collapses that to 1.67x/1.09x. A Gaussian
78
- * never actually reaches zero — at 6 sigma it is still ~1e-8, and at the more
79
- * realistic "grab three frames from the end with radius 40" it is a very
80
- * visible fraction — so relying on the falloff alone would leave the endpoint
81
- * OFF the original trajectory by an amount that depends on where the user
82
- * happened to grab. Pinning makes the seam property structural instead of
83
- * probabilistic: points [0, 1] and [n-2, n-1] are returned byte-identical by
84
- * dragCurve, so the edit's first and last constrained frames are always
85
- * exactly where the take already put the joint. Two rather than one because a
86
- * single pinned frame still lets the SLOPE jump at the seam. */
87
- export const PINNED_CURVE_ENDS = 2;
88
-
89
- /** Influence-radius bounds, in FRAMES, for the panel's slider. 2 is a local
90
- * nudge (one gesture beat); 40 at 20 fps is two seconds, past which a drag
91
- * stops being an edit and becomes a different motion. */
92
- export const DRAG_RADIUS_MIN = 2;
93
- export const DRAG_RADIUS_MAX = 40;
94
- export const DRAG_RADIUS_DEFAULT = 8;
95
-
96
- /** Below this weight a point is not being dragged AT ALL: dragWeight returns
97
- * exactly 0 there, so dragCurve hands the point back by reference and the
98
- * overlay's influenced-span highlight, the identity-based curvesEqual test and
99
- * changedFrameRange (which names the frames a pull actually touched) all agree
100
- * on one boundary. Before the cutoff the Gaussian tail brushed every frame of
101
- * the clip with sub-5% weights, which made "the frames the pull touched" the
102
- * whole clip — and a whole-clip edit range leaves the box zero preserve rows. */
103
- export const DRAG_WEIGHT_EPSILON = 0.05;
104
-
105
- /** Grab tolerance for the hit test, in CSS pixels. */
106
- export const CURVE_GRAB_RADIUS_PX = 14;
107
-
108
- /** How far the camera may drift under a pulled curve before the curve stops
109
- * being DRAWABLE in the live view. The captured extrinsics and the authored uv
110
- * are a MATCHED PAIR: once the user orbits, the same uv names a different ray,
111
- * so the line can no longer be painted where it belongs and a second gesture
112
- * would author points through a lens the first ones never saw.
113
- *
114
- * What it does NOT invalidate is the edit: the committed curve carries its own
115
- * camera and that snapshot is what goes on the wire, so a pending edit survives
116
- * any amount of drift (App.jsx paints it detached and refuses new gestures
117
- * until the view comes back). 1e-4 is tighter than any deliberate nudge and
118
- * looser than float noise from re-deriving the same matrix. */
119
- const CAMERA_DRIFT_EPSILON = 1e-4;
120
-
121
- /**
122
- * The rendering camera -> the C6 `camera` block: { fx, fy, cx, cy, R, t }.
123
- *
124
- * ============================ CONVENTION (read this) ======================
125
- * This is the contract the box-side driver builds its affine rows against
126
- * [fx*R0 - (u - cx)*R2] X = -[fx*t0 - (u - cx)*t2]
127
- * [fy*R1 - (v - cy)*R2] X = -[fy*t1 - (v - cy)*t2]
128
- * so every symbol below has to mean exactly one thing.
129
- *
130
- * UNITS. points2d are normalized 0..1 across the render viewport, so fx, fy,
131
- * cx, cy are in THOSE SAME uv units, not pixels. That is forced, not stylistic:
132
- * C6's camera block carries no width/height, so the only way (u - cx) can be
133
- * dimensionally consistent with fx is for both to live in uv. Multiply fx and
134
- * cx by the viewport width (fy, cy by its height) to recover pixel intrinsics.
135
- * cx and cy are therefore always exactly 0.5 for a three.js PerspectiveCamera,
136
- * which has no principal-point offset; they are still emitted so the box never
137
- * has to assume a centred principal point.
138
- *
139
- * AXES. u runs left->right and v runs TOP->BOTTOM, matching both the DOM
140
- * pointer coordinates the curve is grabbed in and the OpenCV image
141
- * convention. three.js NDC has y running bottom->top, so v is the flipped one:
142
- * u = (x_ndc + 1) / 2
143
- * v = (1 - y_ndc) / 2
144
- *
145
- * FRAME. R and t map WORLD -> an OPENCV camera frame: x right, y DOWN, z
146
- * FORWARD (into the screen, positive depth in front of the lens). three.js
147
- * cameras look down their own -Z with +Y up, so this function applies the
148
- * standard diag(1, -1, -1) flip to `matrixWorldInverse` — negating rows 1 and
149
- * 2 of the rotation and components 1 and 2 of the translation. With that flip
150
- * both fx and fy come out POSITIVE, which is why the flip is done here rather
151
- * than pushed onto the box as a sign convention it would have to remember.
152
- * The projection is then plain pinhole:
153
- * Xc = R X + t ; u = cx + fx * Xc0 / Xc2 ; v = cy + fy * Xc1 / Xc2
154
- * with Xc2 > 0 for anything in front of the camera.
155
- *
156
- * R is 3x3 ROW-MAJOR as a flat 9-array of numbers (R[0..2] is the first row).
157
- * ==========================================================================
158
- *
159
- * @param {object} view
160
- * @param {number} view.fovDeg three's VERTICAL field of view, in degrees.
161
- * @param {number} view.aspect the camera's own `aspect` — the one baked into
162
- * its projection matrix, which is not necessarily width/height (the shot
163
- * camera is locked to the export aspect and letterboxed into its pane).
164
- * @param {number[]} view.matrixWorldInverse 16 numbers, three's column-major
165
- * `elements` order, world -> three-camera.
166
- * @param {number} view.width render viewport width in CSS px — the exact
167
- * @param {number} view.height rectangle the curve's uv are normalized by.
168
- * Only used to catch a caller that measured the curve against a different
169
- * rectangle than the one the camera actually drew into.
170
- */
171
- export function cameraToC6({ fovDeg, aspect, matrixWorldInverse, width, height }) {
172
- if (!Number.isFinite(fovDeg) || fovDeg <= 0 || fovDeg >= 180) {
173
- throw new Error(`cameraToC6: fovDeg must be a vertical FOV in (0, 180), got ${JSON.stringify(fovDeg)}`);
174
- }
175
- if (!Number.isFinite(aspect) || aspect <= 0) {
176
- throw new Error(`cameraToC6: aspect must be a positive number, got ${JSON.stringify(aspect)}`);
177
- }
178
- if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
179
- throw new Error(`cameraToC6: width/height must be positive, got ${JSON.stringify([width, height])}`);
180
- }
181
- const e = matrixWorldInverse;
182
- if (!e || e.length !== 16 || !Array.prototype.every.call(e, Number.isFinite)) {
183
- throw new Error("cameraToC6: matrixWorldInverse must be 16 finite numbers (three's column-major elements)");
184
- }
185
- // The image rectangle the caller normalized against must match the aspect
186
- // the projection matrix was built with, or u and v are measured in a
187
- // different space than fx and fy describe and the whole edit lands skewed.
188
- // The callers fit the pane to the camera's aspect (dualview's fitAspect),
189
- // so a mismatch here is a wiring bug, not a user action.
190
- if (Math.abs(width / height - aspect) > 1e-3 * aspect) {
191
- throw new Error(
192
- `cameraToC6: viewport aspect ${(width / height).toFixed(4)} does not match camera aspect ${aspect.toFixed(4)} — ` +
193
- "the curve was measured against a rectangle the camera did not draw into",
194
- );
195
- }
196
-
197
- // Vertical FOV -> uv intrinsics. Derivation, from three's own perspective
198
- // projection: x_ndc = Xc_x / (aspect * tan(fov/2) * -Zc) and
199
- // y_ndc = Yc_y / (tan(fov/2) * -Zc). Substituting u = (x_ndc + 1)/2 and
200
- // v = (1 - y_ndc)/2 gives the halves below.
201
- const tanHalfFov = Math.tan((fovDeg * Math.PI) / 360);
202
- const fx = 0.5 / (aspect * tanHalfFov);
203
- const fy = 0.5 / tanHalfFov;
204
-
205
- // three's Matrix4.elements is COLUMN-major: element(row, col) = e[col*4+row].
206
- const m = (row, col) => e[col * 4 + row];
207
- // diag(1, -1, -1) * [R | t] — the three-camera (y up, -z forward) to
208
- // OpenCV-camera (y down, +z forward) change of basis described above.
209
- const R = [
210
- m(0, 0), m(0, 1), m(0, 2),
211
- -m(1, 0), -m(1, 1), -m(1, 2),
212
- -m(2, 0), -m(2, 1), -m(2, 2),
213
- ];
214
- const t = [m(0, 3), -m(1, 3), -m(2, 3)];
215
- return { fx, fy, cx: 0.5, cy: 0.5, R, t };
216
- }
217
-
218
- /**
219
- * Project a world point through a C6 camera block into the uv the curve lives
220
- * in. It is what builds the editable trajectory below, and it is the direct
221
- * inverse-check of cameraToC6: if a projected joint does not land on the joint
222
- * the user sees, the convention above is wrong and the curve makes that visible
223
- * immediately.
224
- *
225
- * Returns null for points at or behind the lens (depth <= 0), which have no
226
- * image.
227
- */
228
- export function projectPointC6(camera, x, y, z) {
229
- const { fx, fy, cx, cy, R, t } = camera;
230
- const xc = R[0] * x + R[1] * y + R[2] * z + t[0];
231
- const yc = R[3] * x + R[4] * y + R[5] * z + t[1];
232
- const zc = R[6] * x + R[7] * y + R[8] * z + t[2];
233
- if (!(zc > 1e-6)) return null;
234
- return [cx + (fx * xc) / zc, cy + (fy * yc) / zc];
235
- }
236
-
237
- /**
238
- * A uv drag -> a WORLD-space delta in the plane through `point` that faces the
239
- * camera. The pin gesture's whole 2D-to-3D step.
240
- *
241
- * A screen drag names two of three degrees of freedom and the third has to come
242
- * from somewhere. Every DCC answers the same way for an unconstrained effector
243
- * drag: keep the depth the point already had and move it in the image plane. So
244
- * the point's camera-space depth zc is measured once, a uv delta becomes a
245
- * camera-space delta (du*zc/fx, dv*zc/fy, 0) — the exact inverse of the pinhole
246
- * projection at that depth — and R transposed carries it back to world. The
247
- * consequence is worth saying out loud: the pin lands exactly under the pointer
248
- * in the view it was authored in, and its depth is the take's own. Nothing here
249
- * guesses at depth, which is precisely the guess that would make a pin
250
- * unpredictable.
251
- *
252
- * R is world->camera and orthonormal, so its transpose is its inverse; that is
253
- * the one property this function leans on and it is guaranteed by cameraToC6.
254
- *
255
- * Returns null for a point at or behind the lens, which has no image and
256
- * therefore no image-plane to slide in.
257
- */
258
- export function unprojectDeltaC6(camera, point, du, dv) {
259
- if (!camera || !Array.isArray(point) || point.length !== 3) return null;
260
- if (!Number.isFinite(du) || !Number.isFinite(dv)) return null;
261
- const { fx, fy, R } = camera;
262
- const t = camera.t;
263
- const zc = R[6] * point[0] + R[7] * point[1] + R[8] * point[2] + t[2];
264
- if (!(zc > 1e-6)) return null;
265
- const dxc = (du * zc) / fx;
266
- const dyc = (dv * zc) / fy;
267
- // R^T @ [dxc, dyc, 0] — column j of R^T is row j of R.
268
- return [
269
- R[0] * dxc + R[3] * dyc,
270
- R[1] * dxc + R[4] * dyc,
271
- R[2] * dxc + R[5] * dyc,
272
- ];
273
- }
274
-
275
- /* ===================== the editable trajectory curve =======================
276
- * A "curve" throughout this file is a DENSE, FRAME-INDEXED array: one entry per
277
- * frame of the edit range, in order, either `{ frame, u, v }` or `null` for a
278
- * frame whose joint is behind the lens and therefore has no image.
279
- *
280
- * Dense-and-frame-indexed is the load-bearing property. C6 spreads points2d
281
- * across frameRange, so index i of the curve IS frame startFrame + i; that is
282
- * what lets a drag mean "move the joint at THIS moment" instead of "move this
283
- * point of a shape", and it is why the falloff below is measured in frames.
284
- * Nothing in here compacts the array — nulls keep their slot.
285
- * ========================================================================== */
286
-
287
- /** Is this curve point inside the drawable viewport?
288
- *
289
- * A point can leave 0..1 two ways: it was already offscreen when the trail was
290
- * projected (the joint is out of frame at that moment), or a drag pushed it
291
- * out. Both are the same fact for the UI — it cannot be grabbed and it cannot
292
- * be sent, because the bridge refuses points2d outside 0..1. */
293
- export function isCurvePointOnScreen(point) {
294
- return !!point && point.u >= 0 && point.u <= 1 && point.v >= 0 && point.v <= 1;
295
- }
296
-
297
- /** Is this index one of the hard-pinned ends? See PINNED_CURVE_ENDS. */
298
- export function isCurveEndPinned(index, length) {
299
- return index < PINNED_CURVE_ENDS || index >= length - PINNED_CURVE_ENDS;
300
- }
301
-
302
- /**
303
- * Gaussian falloff weight for a point `distanceFrames` away from the grab.
304
- *
305
- * sigma = radius/2, so `radiusFrames` reads as "the span I am pulling": the
306
- * weight is 1 at the grab, 0.61 at radius/2, 0.135 at the radius itself and
307
- * ~1e-4 at twice it. Exported (rather than inlined in dragCurve) because the
308
- * overlay paints the influenced span with the SAME formula — two independent
309
- * spellings of one falloff is how the drawn highlight drifts from the applied
310
- * deformation.
311
- *
312
- * NOT the smoothstep in motion-trail.js: that one is compact (exactly 0 at the
313
- * radius) because it edits 3D world positions where a hard boundary is fine.
314
- * Here a C1 tail is what keeps the deformed trajectory from developing a crease
315
- * the sampler would then have to follow exactly.
316
- */
317
- export function dragWeight(distanceFrames, radiusFrames) {
318
- const radius = Number(radiusFrames);
319
- const distance = Number(distanceFrames);
320
- if (!Number.isFinite(distance)) return 0;
321
- if (!(radius > 0)) return distance === 0 ? 1 : 0;
322
- const sigma = radius / 2;
323
- const weight = Math.exp(-(distance * distance) / (2 * sigma * sigma));
324
- // Hard zero past the tail (see DRAG_WEIGHT_EPSILON): a pull must touch a
325
- // bounded window of frames, not brush the whole clip with 1% ghosts.
326
- return weight < DRAG_WEIGHT_EPSILON ? 0 : weight;
327
- }
328
-
329
- /**
330
- * The joint's world-space trail -> the on-screen curve the user grabs.
331
- *
332
- * `trail` is the flat Float32Array from motion-trail's jointTrailPoints (3
333
- * floats per frame of the WHOLE take); `frameRange` is C6's half-open app-clip
334
- * range; `camera` is the C6 block the range will be sent with. Frames past the
335
- * end of the trail and frames whose joint is behind the lens come back as
336
- * null. Points OUTSIDE 0..1 are kept as they are — the joint really is over
337
- * there, just not in shot, and the caller wants to say so rather than pretend
338
- * the trajectory stops at the frame edge.
339
- */
340
- export function projectTrailCurve({ trail, frameRange, camera }) {
341
- if (!trail || !camera || !frameRange) return null;
342
- const start = Math.max(0, Math.trunc(frameRange.startFrame));
343
- const end = Math.trunc(frameRange.endFrame);
344
- if (!(end > start)) return null;
345
- const trailFrames = Math.floor(trail.length / 3);
346
- const curve = [];
347
- for (let frame = start; frame < end; frame += 1) {
348
- if (frame >= trailFrames) {
349
- curve.push(null);
350
- continue;
351
- }
352
- const uv = projectPointC6(camera, trail[frame * 3], trail[frame * 3 + 1], trail[frame * 3 + 2]);
353
- curve.push(uv ? { frame, u: uv[0], v: uv[1] } : null);
354
- }
355
- return curve;
356
- }
357
-
358
- /**
359
- * Re-anchor an authored curve in WORLD space and see it through another lens.
360
- *
361
- * A committed edit is uv through the camera it was authored in; painted
362
- * verbatim after the view moves, those uv land wherever the new lens happens
363
- * to point (the "line floats across the floor" bug). But the depth a naive
364
- * reprojection lacks is sitting in the take: each curve point names a frame,
365
- * and the joint's ORIGINAL world position at that frame is in `trail`. So per
366
- * point: measure the authored uv against the original point's image under
367
- * `fromCamera`, lift that uv offset into world with unprojectDeltaC6 (image-
368
- * plane at the joint's own depth — the same rule the pin gesture uses), and
369
- * project the resulting WORLD point through `toCamera`. An unedited point
370
- * therefore lands exactly on the trajectory under the new lens, and an edited
371
- * one shows its true world displacement from wherever the user now stands.
372
- *
373
- * Display only: the wire payload keeps the authored camera + uv untouched,
374
- * because a 2D constraint is a ray through ITS lens — rewriting it through
375
- * another lens would change what the sampler is asked to satisfy.
376
- *
377
- * `trail` is jointTrailPoints' flat Float32Array for the whole take. Points
378
- * that cannot make the trip — null in, frame off the trail's end, original or
379
- * edited point behind either lens — come back null, which the painter already
380
- * treats as "no image at this frame". Returns null (not a partial curve) when
381
- * the inputs themselves are unusable.
382
- */
383
- export function reprojectCurveWorld(curve, trail, fromCamera, toCamera) {
384
- if (!Array.isArray(curve) || !trail || !fromCamera || !toCamera) return null;
385
- const trailFrames = Math.floor(trail.length / 3);
386
- const out = [];
387
- for (const point of curve) {
388
- if (!point || !Number.isInteger(point.frame) || point.frame < 0 || point.frame >= trailFrames) {
389
- out.push(null);
390
- continue;
391
- }
392
- const x = trail[point.frame * 3];
393
- const y = trail[point.frame * 3 + 1];
394
- const z = trail[point.frame * 3 + 2];
395
- const authored = projectPointC6(fromCamera, x, y, z);
396
- if (!authored) {
397
- out.push(null);
398
- continue;
399
- }
400
- const delta = unprojectDeltaC6(fromCamera, [x, y, z], point.u - authored[0], point.v - authored[1]);
401
- if (!delta) {
402
- out.push(null);
403
- continue;
404
- }
405
- const uv = projectPointC6(toCamera, x + delta[0], y + delta[1], z + delta[2]);
406
- out.push(uv ? { frame: point.frame, u: uv[0], v: uv[1] } : null);
407
- }
408
- return out;
409
- }
410
-
411
- /**
412
- * Pull one point of the curve and let the neighbours follow — a new curve out,
413
- * the input untouched.
414
- *
415
- * `du`/`dv` are the pointer's total travel since the grab, in normalized uv,
416
- * and they are applied to the curve AS IT WAS AT GRAB TIME (the caller keeps
417
- * that snapshot). That makes a single drag idempotent: moving the pointer back
418
- * to where it started restores the curve exactly, and re-running the function
419
- * with a different radius mid-drag re-derives the whole deformation instead of
420
- * compounding it. Successive drags stack because each new grab snapshots the
421
- * curve the previous one produced.
422
- *
423
- * Null points stay null (no image, nothing to move) and the outermost
424
- * PINNED_CURVE_ENDS points on each side are returned by reference, unmoved —
425
- * the seam guarantee, argued at that constant.
426
- */
427
- export function dragCurve(curve, grabIndex, du, dv, radiusFrames) {
428
- if (!Array.isArray(curve)) return curve;
429
- const deltaU = Number(du);
430
- const deltaV = Number(dv);
431
- if (!Number.isFinite(deltaU) || !Number.isFinite(deltaV)) return curve;
432
- const length = curve.length;
433
- return curve.map((point, index) => {
434
- if (!point) return null;
435
- if (isCurveEndPinned(index, length)) return point;
436
- const weight = dragWeight(index - grabIndex, radiusFrames);
437
- // Not an approximation of zero — an untouched point must come back as
438
- // the SAME object so an unedited stretch of curve compares equal to the
439
- // original without a float epsilon.
440
- if (weight === 0) return point;
441
- return { frame: point.frame, u: point.u + deltaU * weight, v: point.v + deltaV * weight };
442
- });
443
- }
444
-
445
- /**
446
- * The half-open app-frame window a pull actually touched.
447
- *
448
- * dragCurve returns UNTOUCHED points by reference (weight 0 and the pinned
449
- * ends), so "touched" is object identity — no float epsilon, no threshold on a
450
- * Gaussian tail. This exists because a pull used to inherit the panel's range,
451
- * which defaults to the WHOLE CLIP: with no frames left outside the edit range
452
- * there were no preserve rows, and the sampler re-rolled the entire body of the
453
- * entire clip — a 25 px nudge moved frames four metres, eight seconds away. The
454
- * frames the falloff touched are the only frames the artist asked about; the
455
- * splice guards everything else.
456
- *
457
- * `pad` breathes a couple of frames so the seam ease has room; `minFrames`
458
- * keeps a one-frame flick from asking the sampler for a sub-perceptual window.
459
- * Returns null when nothing changed or the curve carries no frame numbers.
460
- */
461
- export function changedFrameRange(before, after, { pad = 2, minFrames = 8, clipFrames } = {}) {
462
- if (!Array.isArray(before) || !Array.isArray(after) || before.length !== after.length) return null;
463
- let first = -1;
464
- let last = -1;
465
- for (let index = 0; index < after.length; index += 1) {
466
- if (after[index] !== before[index]) {
467
- if (first < 0) first = index;
468
- last = index;
469
- }
470
- }
471
- if (first < 0) return null;
472
- const firstFrame = after[first]?.frame ?? before[first]?.frame;
473
- const lastFrame = after[last]?.frame ?? before[last]?.frame;
474
- if (!Number.isFinite(firstFrame) || !Number.isFinite(lastFrame)) return null;
475
- let start = Math.trunc(firstFrame) - pad;
476
- let end = Math.trunc(lastFrame) + 1 + pad;
477
- const grow = minFrames - (end - start);
478
- if (grow > 0) {
479
- start -= Math.ceil(grow / 2);
480
- end += Math.floor(grow / 2);
481
- }
482
- start = Math.max(0, start);
483
- if (Number.isFinite(clipFrames)) {
484
- end = Math.min(end, clipFrames);
485
- start = Math.max(0, Math.min(start, end - minFrames));
486
- }
487
- if (end - start < 2) return null;
488
- return { startFrame: start, endFrame: end };
489
- }
490
-
491
- /**
492
- * The curve's points whose frame lies inside the half-open range.
493
- *
494
- * The wire pairing is positional: driver.py spreads points2d across frameRange
495
- * by time, so the points MUST be exactly the range's own frames. A dragged
496
- * curve spans whatever window it was projected over (the whole clip by
497
- * default); once the committed range shrinks to the touched frames, sending the
498
- * unsliced curve would compress eight seconds of trail into a half-second
499
- * window. A curve that already spans the range comes back unchanged.
500
- */
501
- export function sliceCurveToRange(curve, frameRange) {
502
- if (!Array.isArray(curve) || !frameRange) return curve;
503
- const { startFrame, endFrame } = frameRange;
504
- if (!Number.isFinite(startFrame) || !Number.isFinite(endFrame)) return curve;
505
- // Nulls (frames behind the lens) carry no frame number, but the curve is
506
- // dense and index-aligned, so any named point anchors the index -> frame map
507
- // and the nulls inherit their frame from position.
508
- const anchor = curve.findIndex((point) => point && Number.isFinite(point.frame));
509
- if (anchor < 0) return curve;
510
- const base = curve[anchor].frame - anchor;
511
- const sliced = curve.filter((point, index) => {
512
- const frame = point && Number.isFinite(point.frame) ? point.frame : base + index;
513
- return frame >= startFrame && frame < endFrame;
514
- });
515
- return sliced.length === curve.length ? curve : sliced;
516
- }
517
-
518
- /** How long a freehand stroke has to be before it is a STROKE rather than a
519
- * click, in CSS pixels. Measured by the caller, which is the only side that
520
- * knows how big the pane is; a press-and-release with a few pixels of hand
521
- * tremor is a click, and a click must never wipe an edit that is already there
522
- * (that exact regression is why the drag commit compares curvesEqual). */
523
- export const DRAW_MIN_STROKE_PX = 8;
524
-
525
- /** Fewest samples a stroke needs before it names a shape at all. One point is a
526
- * dot: it has no direction, no length and nothing to parameterize. */
527
- export const DRAW_MIN_STROKE_POINTS = 2;
528
-
529
- /* ==================== the drawn stroke's frame association ==================
530
- * THE PROBLEM THE PAPER DOES NOT HAVE. ProjFlow's 2D-to-3D lifting (paper 4.3.2)
531
- * constrains frame n of the motion to the 2D point y_{n,j}, and it "follows the
532
- * 2D heart trajectory exactly at every frame" — but that per-frame association
533
- * is FREE in their setup, because their 2D condition is the reprojection of a
534
- * real motion under a known camera: point n exists because frame n exists. A
535
- * human drawing on a viewport supplies a SHAPE and nothing else. Every one of
536
- * the three ways v1 was unusable comes from inventing that association badly:
537
- *
538
- * 1. the stroke was spread over the panel's frameRange, which defaults to the
539
- * WHOLE CLIP, so a two-inch hook became an eight-second crawl;
540
- * 2. the ends were pinned to the trail 2 frames in, so a stroke drawn away
541
- * from the trail teleported the joint there inside 2 frames;
542
- * 3. arc length was spread UNIFORMLY over those frames, erasing the take's own
543
- * velocity profile (a fast fall + slow recovery became one constant glide).
544
- *
545
- * The fix for all three is to RECOVER the association the paper gets for free:
546
- * match the stroke back onto the joint's own projected trail. The trail is the
547
- * reprojection of the take under the live camera — exactly the object ProjFlow's
548
- * y is built from — so "where on the trail did this stroke start and end" is a
549
- * well-posed question with a screen-space answer, and answering it fixes the
550
- * range (matchStrokeWindow), the timing (baseArcFractions) and the seam
551
- * (seamEaseFrames) at once.
552
- * ========================================================================== */
553
-
554
- /** Shortest window, in frames, a matched stroke may name.
555
- *
556
- * Below this the two matched frames are not naming a stretch of path at all —
557
- * they are the two ends of a stroke drawn ACROSS a bunched trail, where the
558
- * whole visible path is a few pixels wide and both endpoints land on nearly the
559
- * same frame. Rerouting 3 frames of a 24 fps take is a twitch, not an edit. */
560
- export const DRAW_MIN_WINDOW_FRAMES = 8;
561
-
562
- /** The window a bunched match falls back to: one second at the timeline's 24
563
- * fps, centred on the frame both endpoints matched. "I drew across the path
564
- * here" can only sanely mean "around this moment", and a second is the smallest
565
- * span an artist reads as a gesture rather than a pop. */
566
- export const DRAW_BUNCHED_WINDOW_FRAMES = 24;
567
-
568
- /** How far apart, in CSS pixels, the two matched trail points must be before
569
- * the match is believed.
570
- *
571
- * A frame-count test alone is not enough, and the take that motivated drawing
572
- * proves it: a hand that travels two pixels across the image over 120 frames
573
- * still has a strictly increasing trail, so a stroke drawn ACROSS it matches
574
- * its ends to frames 0 and 119 and "reroute this part" silently becomes
575
- * "reroute the whole clip" — the original complaint, reintroduced through the
576
- * back door. The honest question is whether the stroke's two ends picked out
577
- * two distinguishable PLACES on the path, and that is a screen-distance
578
- * question. Twice the grab radius is the app's own scale for "the same spot":
579
- * inside it a user could not have aimed at one end rather than the other. */
580
- const DRAW_MATCH_MIN_SEPARATION_PX = 2 * CURVE_GRAB_RADIUS_PX;
581
-
582
- /** Seam easing, replacing the hard 2-frame pin on the DRAW path only.
583
- *
584
- * PINNED_CURVE_ENDS argues why a drag pins: a Gaussian never reaches zero, so
585
- * without a pin the drag's endpoint sits off the original trail by an amount
586
- * that depends on where the user grabbed. A DRAWN stroke has the opposite
587
- * problem — its endpoint is wherever the hand stopped, so a hard pin does not
588
- * remove the gap, it CONCENTRATES it into the two frames next to the pin. GP2
589
- * measured that: an arbitrary line pops the take 3.9x/8.0x its own median frame
590
- * delta at the range edges.
591
- *
592
- * So the draw path ramps instead. Over the outer `fraction` of the window the
593
- * curve is lerp(base, stroke, w) with w a smoothstep from 0 at the very edge to
594
- * 1 in the interior. Two properties matter and both are structural:
595
- * - w(edge) = 0 EXACTLY, so the first and last constrained frames are still
596
- * byte-identically on the original trail (the pin's guarantee, kept);
597
- * - w'(edge) = 0 too (smoothstep is flat at both ends), so the curve leaves
598
- * the seam along the ORIGINAL trail's own slope. That is strictly stronger
599
- * than the 2-frame pin, whose second point was there precisely because one
600
- * pinned frame still let the slope jump.
601
- * Because auto-ranging puts the stroke's ends near the trail to begin with, the
602
- * ramp usually has almost nothing to absorb; when it does, it converts a
603
- * teleport into a glide.
604
- *
605
- * 15% with a 12-frame cap is the documented GP2 follow-up ("free the first/last
606
- * ~15% of line frames") clipped to the paper's own temporal neighbourhood:
607
- * ProjFlow's dynamic masking uses l_max = 10 frames around a hard observation
608
- * (Table 5), so a dozen frames is the scale at which the sampler already thinks
609
- * about "near a constraint". The 2-frame floor keeps the shortest legal window
610
- * behaving exactly like the pin it replaces. */
611
- const SEAM_EASE_FRACTION = 0.15;
612
- export const SEAM_EASE_MIN = 2;
613
- export const SEAM_EASE_MAX = 12;
614
-
615
- /** How many frames at EACH end of a window of `length` frames are eased.
616
- * Capped at a third of the window so the two ramps can never meet and leave the
617
- * stroke with no interior of its own. */
618
- export function seamEaseFrames(length, { fraction = SEAM_EASE_FRACTION, min = SEAM_EASE_MIN, max = SEAM_EASE_MAX } = {}) {
619
- const n = Math.trunc(length);
620
- // A 2-frame window is nothing but its two edges: there is no interior for a
621
- // ramp to reach, and 0 hands the caller back to the pin path, which says the
622
- // same thing without pretending to ease anything.
623
- if (!(n >= 3)) return 0;
624
- const wanted = Math.round(n * fraction);
625
- const capped = Math.min(max, Math.max(min, wanted));
626
- return Math.max(1, Math.min(capped, Math.floor((n - 1) / 2)));
627
- }
628
-
629
- /** The ramp itself: 0 at either edge of the window, 1 once `easeFrames` in.
630
- *
631
- * Exported so the overlay and the tests read the SAME curve the blend applies —
632
- * a second spelling of a falloff is how a drawn highlight drifts from the
633
- * deformation it claims to describe (the lesson dragWeight already carries). */
634
- export function seamEaseWeight(index, length, easeFrames) {
635
- const n = Math.trunc(length);
636
- const ease = Math.trunc(easeFrames);
637
- if (!(n >= 2)) return 1;
638
- if (!(ease > 0)) return index <= 0 || index >= n - 1 ? 0 : 1;
639
- const distance = Math.min(index, n - 1 - index);
640
- if (distance <= 0) return 0;
641
- if (distance >= ease) return 1;
642
- const t = distance / ease;
643
- // smoothstep: flat at both ends, which is the slope-continuity claim above.
644
- return t * t * (3 - 2 * t);
645
- }
646
-
647
- /**
648
- * The BASE trail's cumulative screen arc-length fraction at each frame of a
649
- * window — the timing a drawn stroke is replayed with.
650
- *
651
- * This is redesign point (B), and it is what keeps a re-routed motion looking
652
- * like the same performance. Uniform spacing along the stroke says "the joint
653
- * crosses the new path at a constant speed", which is true of no motion anybody
654
- * animates: the take's hand falls fast and recovers slowly, and spreading the
655
- * stroke evenly turns both into one glide. Sampling the stroke at the fraction
656
- * of arc length the ORIGINAL had already covered by frame f keeps the fast part
657
- * fast and the slow part slow — the path changes, the phrasing does not.
658
- *
659
- * Screen-space arc length, not world: the stroke is a screen object, the trail
660
- * it is replacing was measured on the same screen, and the two are only
661
- * comparable there. Null frames (joint behind the lens) contribute zero length
662
- * and inherit their neighbour's fraction, which keeps the sequence monotone
663
- * without inventing a position for them.
664
- *
665
- * Returns an array of `curve.length` fractions rising from 0 to 1, or null when
666
- * the base is degenerate (a stationary joint, total length ~0) — the caller
667
- * falls back to uniform, which is the only honest reading of "no velocity
668
- * profile to preserve".
669
- */
670
- export function baseArcFractions(curve, { epsilon = 1e-9 } = {}) {
671
- if (!Array.isArray(curve) || curve.length < 2) return null;
672
- const cumulative = new Array(curve.length).fill(0);
673
- let previous = null;
674
- let total = 0;
675
- for (let i = 0; i < curve.length; i += 1) {
676
- const point = curve[i];
677
- if (point && previous) total += Math.hypot(point.u - previous.u, point.v - previous.v);
678
- if (point) previous = point;
679
- cumulative[i] = total;
680
- }
681
- if (!(total > epsilon)) return null;
682
- return cumulative.map((value) => value / total);
683
- }
684
-
685
- /**
686
- * Which stretch of the joint's own path did this stroke draw over?
687
- *
688
- * `fullCurve` is projectTrailCurve run over the WHOLE clip — the original trail,
689
- * never a previously drawn edit, so redrawing re-matches against the same
690
- * reference instead of chasing its own last answer. The stroke's first and last
691
- * samples are matched to their nearest trail frames in PIXELS (uv distance is
692
- * anisotropic; the same argument nearestCurvePoint makes), and the two matched
693
- * frames become the edit's range.
694
- *
695
- * A stroke drawn end-to-start is not an error and not a reversal of the take:
696
- * the artist traced the same stretch of path the other way round. The window is
697
- * ordered start < end regardless and `reversed` is reported, so the caller can
698
- * walk the stroke backwards and land a curve that runs forwards in time.
699
- *
700
- * Returns `{ startFrame, endFrame, reversed }` (endFrame EXCLUSIVE, like every
701
- * range on this wire) or null when nothing on the trail is on screen to match
702
- * against — the caller then keeps whatever range the panel already shows, which
703
- * is the only remaining meaning available.
704
- */
705
- export function matchStrokeWindow(stroke, fullCurve, {
706
- paneW,
707
- paneH,
708
- clipFrames,
709
- minWindowFrames = DRAW_MIN_WINDOW_FRAMES,
710
- bunchedWindowFrames = DRAW_BUNCHED_WINDOW_FRAMES,
711
- minSeparationPx = DRAW_MATCH_MIN_SEPARATION_PX,
712
- } = {}) {
713
- if (!Array.isArray(stroke) || !Array.isArray(fullCurve) || fullCurve.length < MIN_LINE_POINTS) return null;
714
- if (!(paneW > 0) || !(paneH > 0)) return null;
715
- const samples = stroke.filter((s) => Array.isArray(s) && s.length >= 2 && Number.isFinite(Number(s[0])) && Number.isFinite(Number(s[1])));
716
- if (samples.length < DRAW_MIN_STROKE_POINTS) return null;
717
-
718
- // Unlike nearestCurvePoint this accepts EVERY on-screen point, ends included:
719
- // matching is not grabbing, and the frame a stroke starts over is a perfectly
720
- // good answer even when that frame would be undraggable.
721
- const nearestFrame = (u, v) => {
722
- let best = null;
723
- for (const point of fullCurve) {
724
- if (!isCurvePointOnScreen(point)) continue;
725
- const dist = Math.hypot((point.u - u) * paneW, (point.v - v) * paneH);
726
- if (!best || dist < best.dist) best = { frame: point.frame, u: point.u, v: point.v, dist };
727
- }
728
- return best;
729
- };
730
- const head = samples[0];
731
- const tail = samples[samples.length - 1];
732
- const a = nearestFrame(Number(head[0]), Number(head[1]));
733
- const b = nearestFrame(Number(tail[0]), Number(tail[1]));
734
- if (!a || !b) return null;
735
-
736
- const reversed = b.frame < a.frame;
737
- let start = Math.min(a.frame, b.frame);
738
- let end = Math.max(a.frame, b.frame) + 1;
739
-
740
- // The clip is the only hard wall; without one, the trail's own extent is.
741
- const firstFrame = fullCurve[0]?.frame ?? 0;
742
- const limit = Number.isFinite(clipFrames)
743
- ? Math.trunc(clipFrames)
744
- : firstFrame + fullCurve.length;
745
- const floor = Math.max(0, firstFrame);
746
-
747
- const separationPx = Math.hypot((a.u - b.u) * paneW, (a.v - b.v) * paneH);
748
- const bunched = end - start < Math.max(MIN_LINE_POINTS, Math.trunc(minWindowFrames))
749
- || separationPx < minSeparationPx;
750
- if (bunched) {
751
- // BUNCHED TRAIL. The two ends landed on the same PLACE on the path — one
752
- // frame apart, or a couple of pixels apart on a trail that projects into
753
- // a dot — so the stroke crosses the path rather than running along it and
754
- // the frames it named are noise. Take a second around the moment it
755
- // crossed: sliding the window to keep its length when it hits a clip
756
- // edge, then clipping if the clip is shorter than a second.
757
- const want = Math.max(MIN_LINE_POINTS, Math.trunc(bunchedWindowFrames));
758
- const centre = (start + end) / 2;
759
- start = Math.round(centre - want / 2);
760
- end = start + want;
761
- if (start < floor) { start = floor; end = start + want; }
762
- if (end > limit) { end = limit; start = Math.max(floor, end - want); }
763
- }
764
- if (!(end - start >= MIN_LINE_POINTS)) return null;
765
- return { startFrame: start, endFrame: end, reversed };
766
- }
767
-
768
- /** The slice of a whole-clip curve a frame range names, with the frame numbers
769
- * respected rather than assumed equal to the index. Returns null when the range
770
- * is not fully covered — a half-covered window would silently shorten the edit
771
- * and the caller would never know which frames it actually sent. */
772
- export function curveWindow(curve, { startFrame, endFrame } = {}) {
773
- if (!Array.isArray(curve) || curve.length === 0) return null;
774
- const first = curve[0]?.frame;
775
- // Nulls carry no frame, so anchor on the first point that has one and count
776
- // from there — the array is dense in FRAMES by construction.
777
- let offset = null;
778
- for (let i = 0; i < curve.length; i += 1) {
779
- if (curve[i]) { offset = curve[i].frame - i; break; }
780
- }
781
- if (offset === null) offset = Number.isFinite(first) ? first : 0;
782
- const from = Math.trunc(startFrame) - offset;
783
- const to = Math.trunc(endFrame) - offset;
784
- if (!(from >= 0 && to <= curve.length && to - from >= MIN_LINE_POINTS)) return null;
785
- return curve.slice(from, to);
786
- }
787
-
788
- /**
789
- * A freehand stroke -> the same dense frame-indexed curve a drag produces.
790
- *
791
- * This is the OTHER half of the interaction: on takes whose projected trail
792
- * bunches into a handful of screen pixels there is nothing to grab, so a press
793
- * on empty space draws a new path instead of doing nothing. The result has to
794
- * be indistinguishable from a dragged curve — same shape, same nulls, same
795
- * pinned ends — because everything downstream (preview, undo, curveToPoints2d,
796
- * the drift watcher) is written against that one type.
797
- *
798
- * ARC LENGTH, NOT SAMPLE INDEX. Frame at position `index` of the range samples
799
- * the stroke at the point `index / (length - 1)` of the way along its TOTAL
800
- * LENGTH, walking the polyline and interpolating inside the segment it lands
801
- * in. That is what makes drawing SPEED irrelevant: a stroke drawn slowly at the
802
- * start and flicked at the end carries a crowd of samples near its beginning,
803
- * and sampling by sample index would hand the crowded part most of the frames
804
- * and re-time the take. Arc length reads the stroke as a SHAPE, which is the
805
- * only thing a hand-drawn line honestly carries. (Note the deliberate contrast
806
- * with curveToPoints2d, which downsamples by index precisely because the curve
807
- * it is given DOES carry timing — index i is frame startFrame + i.)
808
- *
809
- * The parameterization is endpoint-inclusive: index 0 sits at the stroke's
810
- * start and index length-1 at its end, so the whole drawn shape is used.
811
- *
812
- * PINNED ENDS ARE THE SEAM GUARANTEE — or `easeFrames` is, which is the same
813
- * guarantee said in a way a drawn line can keep. With `pinnedEnds` (the default,
814
- * and what a DRAG uses) the first and last entries come back BY REFERENCE from
815
- * `baseCurve`, exactly as dragCurve returns them. With `easeFrames` the outer
816
- * frames are BLENDED toward the stroke instead, w = 0 at the very edge rising by
817
- * smoothstep to 1 in the interior; see SEAM_EASE_FRACTION for why a drawn stroke
818
- * needs the ramp and a drag does not. The two are mutually exclusive: an eased
819
- * blend that also pinned would just move the discontinuity inward.
820
- *
821
- * TIMING. `arcFractions` (from baseArcFractions) replaces the uniform
822
- * index/(n-1) parameterization with the base trail's own cumulative arc-length
823
- * fraction, so the take's velocity profile survives the reroute. Absent, the
824
- * mapping is uniform — the honest reading when there is no profile to preserve.
825
- *
826
- * `reversed` walks the stroke from its last sample to its first, for a stroke
827
- * the artist traced end-to-start over the path.
828
- *
829
- * Nulls in `baseCurve` stay null, again like dragCurve: a frame whose joint is
830
- * behind the lens has no image, and a stroke cannot invent one.
831
- *
832
- * @param {Array<[number, number]>} stroke pointer samples in the SAME
833
- * viewport-normalized uv the curve lives in, in the order they were drawn.
834
- * @param {Array<{frame:number,u:number,v:number}|null>} baseCurve the dense
835
- * frame-indexed curve whose interior this stroke replaces (the curve as it is
836
- * right now, so drawing twice means the second drawing wins and a drag after
837
- * a draw refines the drawn curve).
838
- * @param {object} [options]
839
- * @param {number} [options.pinnedEnds] how many entries at each end stay put.
840
- * @param {number} [options.easeFrames] ramp width in frames at each end;
841
- * non-zero REPLACES pinnedEnds with a blend.
842
- * @param {number[]} [options.arcFractions] per-index position along the stroke
843
- * in 0..1; defaults to uniform.
844
- * @param {boolean} [options.reversed] walk the stroke backwards.
845
- * @param {number} [options.minLength] refuse strokes shorter than this in uv
846
- * units. 0 (the default) still refuses a stroke of zero length — the callers
847
- * measure their own threshold in PIXELS, where the user drew it.
848
- * @returns a new curve, or null when the stroke is a no-op click.
849
- */
850
- export function strokeToCurve(stroke, baseCurve, {
851
- pinnedEnds = PINNED_CURVE_ENDS,
852
- minLength = 0,
853
- easeFrames = 0,
854
- arcFractions = null,
855
- reversed = false,
856
- } = {}) {
857
- if (!Array.isArray(baseCurve) || baseCurve.length < MIN_LINE_POINTS) return null;
858
- if (!Array.isArray(stroke)) return null;
859
- // Non-finite samples are dropped rather than poisoning the arc length: a
860
- // pointer event outside a settled pane can produce one, and a single NaN
861
- // would otherwise turn the whole curve into NaN and be refused much later
862
- // with a message about the wire format.
863
- const points = [];
864
- for (const sample of stroke) {
865
- if (!Array.isArray(sample) || sample.length < 2) continue;
866
- const x = Number(sample[0]);
867
- const y = Number(sample[1]);
868
- if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
869
- points.push([x, y]);
870
- }
871
- if (points.length < DRAW_MIN_STROKE_POINTS) return null;
872
- // Reversing here rather than at the sampler keeps ONE monotone walk below:
873
- // a backwards stroke is just a forwards stroke over the flipped polyline,
874
- // and arc length is direction-agnostic so the cumulative table is unchanged
875
- // in meaning.
876
- if (reversed) points.reverse();
877
- const cumulative = [0];
878
- for (let i = 1; i < points.length; i += 1) {
879
- cumulative.push(cumulative[i - 1] + Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]));
880
- }
881
- const total = cumulative[cumulative.length - 1];
882
- // A stroke that never moved is a click, and a click changes nothing.
883
- if (!(total > 0) || total < minLength) return null;
884
-
885
- const length = baseCurve.length;
886
- // Never pin away the whole curve: a range shorter than 2 x pinnedEnds would
887
- // otherwise come back byte-identical and read as "the user drew nothing".
888
- const ease = Math.max(0, Math.trunc(easeFrames) || 0);
889
- const pinned = ease > 0 ? 0 : Math.max(0, Math.min(Math.trunc(pinnedEnds) || 0, Math.floor(length / 2)));
890
- const timing = Array.isArray(arcFractions) && arcFractions.length === length ? arcFractions : null;
891
- // One monotone walk along the stroke for the whole curve: the targets only
892
- // ever increase with index, so the cursor never rewinds and a 200-frame
893
- // range costs one pass over the samples rather than 200 searches. Velocity
894
- // timing keeps that property — arc-length fractions are non-decreasing.
895
- let cursor = 1;
896
- const out = new Array(length);
897
- for (let index = 0; index < length; index += 1) {
898
- const base = baseCurve[index];
899
- if (pinned > 0 && (index < pinned || index >= length - pinned)) {
900
- // BY REFERENCE, like dragCurve — an untouched point must compare
901
- // equal with no epsilon to choose.
902
- out[index] = base;
903
- continue;
904
- }
905
- if (!base) {
906
- out[index] = null;
907
- continue;
908
- }
909
- const along = timing ? timing[index] : index / (length - 1);
910
- const target = along * total;
911
- while (cursor < cumulative.length - 1 && cumulative[cursor] < target) cursor += 1;
912
- const span = cumulative[cursor] - cumulative[cursor - 1];
913
- const fraction = span > 0 ? Math.min(1, Math.max(0, (target - cumulative[cursor - 1]) / span)) : 0;
914
- const a = points[cursor - 1];
915
- const b = points[cursor];
916
- const u = a[0] + (b[0] - a[0]) * fraction;
917
- const v = a[1] + (b[1] - a[1]) * fraction;
918
- if (ease > 0) {
919
- const weight = seamEaseWeight(index, length, ease);
920
- // w === 0 returns the base point BY REFERENCE, so the outermost frame
921
- // of an eased window keeps the pin's exact-equality property and
922
- // curvesEqual still reads an untouched edge as untouched.
923
- if (weight === 0) { out[index] = base; continue; }
924
- out[index] = weight === 1
925
- ? { frame: base.frame, u, v }
926
- : { frame: base.frame, u: base.u + (u - base.u) * weight, v: base.v + (v - base.v) * weight };
927
- continue;
928
- }
929
- out[index] = { frame: base.frame, u, v };
930
- }
931
- return out;
932
- }
933
-
934
- /**
935
- * THE DRAW GESTURE, end to end: a raw stroke in, a committed edit out.
936
- *
937
- * One function because the three redesign decisions are one decision — the
938
- * matched window IS the range, the range's base trail IS the timing, and the
939
- * window's edges ARE the seam. Splitting them across the caller is how they
940
- * drift apart. App.jsx therefore hands over the stroke and the geometry it was
941
- * drawn on, and gets back everything a commit needs:
942
- *
943
- * { frameRange, base, curve, reversed, matched, easeFrames }
944
- *
945
- * `frameRange` is the panel's new range (redesign A) — drawing over a stretch of
946
- * the visible path means "reroute THIS part", with no numbers typed. `base` is
947
- * that window's slice of the original projected trail, i.e. the `original` half
948
- * of the committed `{ camera, original, edited }`; `curve` is the `edited` half.
949
- *
950
- * FALLBACKS, in the order the brief fixes them:
951
- * - nothing on the trail is on screen to match against -> keep the panel's
952
- * range (`fallbackCurve` / `fallbackRange`) and draw into it, so the gesture
953
- * still does something on a take framed out of shot;
954
- * - the matched window is not fully covered by `fullCurve` (a trail shorter
955
- * than the clip) -> same fallback, for the same reason;
956
- * - the base window has no measurable arc length (a stationary joint) ->
957
- * uniform timing, which is what baseArcFractions returning null means.
958
- *
959
- * Returns null for a no-op click, exactly as strokeToCurve does, so the caller's
960
- * "a stray click must never wipe an existing edit" rule is unchanged.
961
- */
962
- export function drawStrokeEdit(stroke, {
963
- fullCurve = null,
964
- fallbackCurve = null,
965
- fallbackRange = null,
966
- paneW,
967
- paneH,
968
- clipFrames,
969
- minLength = 0,
970
- minWindowFrames = DRAW_MIN_WINDOW_FRAMES,
971
- bunchedWindowFrames = DRAW_BUNCHED_WINDOW_FRAMES,
972
- minSeparationPx = DRAW_MATCH_MIN_SEPARATION_PX,
973
- } = {}) {
974
- const match = matchStrokeWindow(stroke, fullCurve, { paneW, paneH, clipFrames, minWindowFrames, bunchedWindowFrames, minSeparationPx });
975
- const window = match ? curveWindow(fullCurve, match) : null;
976
- const base = window ?? fallbackCurve;
977
- if (!Array.isArray(base) || base.length < MIN_LINE_POINTS) return null;
978
- const matched = !!window;
979
- const frameRange = matched
980
- ? { startFrame: match.startFrame, endFrame: match.endFrame }
981
- : (fallbackRange ?? null);
982
- const easeFrames = seamEaseFrames(base.length);
983
- const curve = strokeToCurve(stroke, base, {
984
- minLength,
985
- easeFrames,
986
- arcFractions: baseArcFractions(base),
987
- reversed: matched ? match.reversed : false,
988
- });
989
- if (!curve) return null;
990
- return { frameRange, base, curve, reversed: matched ? match.reversed : false, matched, easeFrames };
991
- }
992
-
993
- /* ========================= the third gesture: 3D pins =======================
994
- * "순간 찍기" — scrub to a moment, grab the joint, put it where it should be.
995
- *
996
- * WHY IT BELONGS BESIDE THE OTHER TWO, in the paper's own terms. ProjFlow's
997
- * entire spatial-control evaluation is SPARSE KEYFRAMES — 1, 2, 5, 49 and 196 of
998
- * them — with 0.0000 trajectory/location/average error at every density, and the
999
- * flow prior filling everything between. A drawn line is the dense end of that
1000
- * axis (one constraint per frame); a pin is the sparse end, and the sparse end
1001
- * is the one the method was measured on. Everything a stroke needs and a pin
1002
- * does not — a camera, a frame association, a timing rule, a seam ease — exists
1003
- * because a 2D stroke is an impoverished signal. A pin carries its own frame, is
1004
- * already in 3D, and needs no lens — which is why a camera move leaves a pin
1005
- * fully drawable (it is re-projected through the new lens and lands where the
1006
- * artist put it) while a drawn curve can only be shown detached until the view
1007
- * comes back. Both edits survive the move; only one of them can still be drawn.
1008
- *
1009
- * The exchange: a pin says nothing about the frames between pins, and the model
1010
- * answers for them. That is the deal the paper's numbers are about.
1011
- * ========================================================================== */
1012
-
1013
- /** How many pins one edit may carry. ONE is legal (a line needs two points to
1014
- * have a direction; a pin needs none); EIGHT is where keyframing by hand stops
1015
- * being a refinement and starts being the pose studio's job. Mirrored in
1016
- * tools/projflow/replay.mjs, which is the wire's own gate. */
1017
- export const LINE_EDIT_PINS_MIN = 1;
1018
- export const LINE_EDIT_PINS_MAX = 8;
1019
-
1020
- /** Frames of CONTEXT either side of the pinned span, at the app's 24 fps.
1021
- *
1022
- * A pin is a point, but an edit is a passage: the model needs room to get the
1023
- * limb into the pinned pose and back out of it, and pinning frame 100 with a
1024
- * range of exactly [100, 101) would ask for an instantaneous arrival. Half a
1025
- * second each way is the smallest span that reads as a movement rather than a
1026
- * snap, and it is also comfortably wider than the driver's seam pin (2
1027
- * generation frames), so the two never fight. */
1028
- export const PIN_CONTEXT_FRAMES = 12;
1029
-
1030
- /** A SINGLE pin has no span of its own, so it gets a window instead: 1.5 s
1031
- * centred on it. Wider than 2 x PIN_CONTEXT_FRAMES on purpose — with two pins
1032
- * the span between them already carries the edit, and with one there is nothing
1033
- * but context. */
1034
- export const PIN_SOLO_WINDOW_FRAMES = 36;
1035
-
1036
- /**
1037
- * The frames a pins-only edit owns, from the pins themselves.
1038
- *
1039
- * The pins ARE the edit, so the range is derived, never typed — the same
1040
- * "nobody sets the numeric range first" lesson matchStrokeWindow learned for
1041
- * strokes. Half-open and clamped to the clip, like every range on this wire.
1042
- *
1043
- * Returns null when there are no pins or no clip to place them in.
1044
- */
1045
- export function pinsFrameRange(pins, clipFrames, {
1046
- context = PIN_CONTEXT_FRAMES,
1047
- soloWindow = PIN_SOLO_WINDOW_FRAMES,
1048
- } = {}) {
1049
- if (!Array.isArray(pins) || pins.length === 0) return null;
1050
- const frames = pins.map((pin) => Math.trunc(pin?.frame)).filter(Number.isFinite);
1051
- if (frames.length === 0) return null;
1052
- const limit = Number.isFinite(clipFrames) ? Math.trunc(clipFrames) : 0;
1053
- if (!(limit >= MIN_LINE_POINTS)) return null;
1054
- const first = Math.min(...frames);
1055
- const last = Math.max(...frames);
1056
- let start;
1057
- let end;
1058
- if (first === last) {
1059
- const want = Math.max(MIN_LINE_POINTS, Math.trunc(soloWindow));
1060
- start = Math.round(first - want / 2);
1061
- end = start + want;
1062
- } else {
1063
- start = first - Math.trunc(context);
1064
- end = last + 1 + Math.trunc(context);
1065
- }
1066
- // Slide to keep the length when one edge hits the clip, then clip.
1067
- const span = end - start;
1068
- if (start < 0) { start = 0; end = Math.min(limit, span); }
1069
- if (end > limit) { end = limit; start = Math.max(0, end - span); }
1070
- // Every pin must stay INSIDE the half-open range the wire validates against.
1071
- start = Math.min(start, first);
1072
- end = Math.max(end, last + 1);
1073
- start = Math.max(0, start);
1074
- end = Math.min(limit, end);
1075
- if (end - start < MIN_LINE_POINTS) return null;
1076
- return { startFrame: start, endFrame: end };
1077
- }
1078
-
1079
- /**
1080
- * Add or replace a pin, keeping the list ascending and capped.
1081
- *
1082
- * PINNING THE SAME FRAME TWICE REPLACES, it does not stack — a frame has one
1083
- * answer to "where is the hand", and the second gesture is the artist changing
1084
- * their mind, exactly as drawing twice replaces a stroke. Over the cap the
1085
- * OLDEST pin is dropped rather than the new one refused: a gesture the artist
1086
- * just made must do something visible, and silently ignoring the ninth pin is
1087
- * the kind of nothing that made drawing feel broken.
1088
- *
1089
- * Returns a NEW array; the input is untouched.
1090
- */
1091
- export function upsertPin(pins, pin, { max = LINE_EDIT_PINS_MAX } = {}) {
1092
- if (!pin || !Number.isInteger(pin.frame) || !Array.isArray(pin.position) || pin.position.length !== 3) return pins;
1093
- if (!pin.position.every(Number.isFinite)) return pins;
1094
- const entry = { frame: pin.frame, position: [pin.position[0], pin.position[1], pin.position[2]] };
1095
- const kept = (Array.isArray(pins) ? pins : []).filter((existing) => existing.frame !== entry.frame);
1096
- kept.push(entry);
1097
- kept.sort((a, b) => a.frame - b.frame);
1098
- // Dropping from the FRONT keeps the pin just placed and the ones nearest it
1099
- // in time, which is what "I am working on this passage" looks like.
1100
- return kept.length > max ? kept.slice(kept.length - max) : kept;
1101
- }
1102
-
1103
- /**
1104
- * The edited curve -> the `points2d` C6 sends, or a refusal.
1105
- *
1106
- * Returns `{ points2d }` or `{ error }` where error is one of:
1107
- * "empty" — fewer than 2 points have an image at all
1108
- * "offscreen" — some point sits outside 0..1, which the bridge refuses
1109
- *
1110
- * DOWNSAMPLING IS EVEN BY INDEX, i.e. even IN TIME, and that is the opposite
1111
- * of what the old freehand path did (even by arc length). The reason is that
1112
- * the two inputs mean different things. A freehand stroke carries no timing —
1113
- * the only sane reading is "constant speed along the shape", so arc-length
1114
- * spacing was right there. This curve is the joint's own trajectory: index i is
1115
- * frame startFrame + i, and the box spreads the points evenly across
1116
- * frameRange. Arc-length resampling would therefore RE-TIME the take — it
1117
- * would hand the slow parts of the motion fewer samples and the box would
1118
- * stretch them back out to equal duration, turning a pause into a glide. Even
1119
- * index sampling keeps every sent point on the frame it was projected from.
1120
- *
1121
- * The first and last kept points are always included, so the pinned endpoints
1122
- * from dragCurve survive into the payload rather than being resampled away.
1123
- */
1124
- export function curveToPoints2d(curve) {
1125
- const visible = Array.isArray(curve) ? curve.filter(Boolean) : [];
1126
- if (visible.length < MIN_LINE_POINTS) return { error: "empty" };
1127
- for (const point of visible) {
1128
- if (!isCurvePointOnScreen(point)) return { error: "offscreen" };
1129
- }
1130
- const count = Math.min(MAX_LINE_POINTS, visible.length);
1131
- const last = visible.length - 1;
1132
- const points2d = [];
1133
- for (let i = 0; i < count; i += 1) {
1134
- // Rounded rather than floored so the sampling is symmetric about the
1135
- // middle; i = count-1 lands exactly on `last`, i = 0 exactly on 0.
1136
- const source = Math.round((i * last) / (count - 1));
1137
- const point = visible[source];
1138
- points2d.push([point.u, point.v]);
1139
- }
1140
- return { points2d };
1141
- }
1142
-
1143
- /**
1144
- * Which curve point is under the pointer? `{ index, dist }` or null.
1145
- *
1146
- * The hit test is in PIXELS, not uv, because uv distance is anisotropic: on a
1147
- * 16:9 pane one unit of u is nearly twice one unit of v, so a uv-radius grab
1148
- * zone is a visible ellipse and the curve is hardest to grab exactly where it
1149
- * runs vertically. paneW/paneH convert once, here.
1150
- *
1151
- * Points with no image, points outside the viewport and the pinned ends are
1152
- * all skipped — a marker the user can grab but that cannot move is worse than
1153
- * no marker, so the overlay does not draw those as draggable either.
1154
- */
1155
- export function nearestCurvePoint(curve, u, v, maxDistPx, paneW, paneH) {
1156
- if (!Array.isArray(curve) || !(paneW > 0) || !(paneH > 0)) return null;
1157
- let best = null;
1158
- for (let index = 0; index < curve.length; index += 1) {
1159
- const point = curve[index];
1160
- if (!isCurvePointOnScreen(point)) continue;
1161
- if (isCurveEndPinned(index, curve.length)) continue;
1162
- const dx = (point.u - u) * paneW;
1163
- const dy = (point.v - v) * paneH;
1164
- const dist = Math.hypot(dx, dy);
1165
- if (dist > maxDistPx) continue;
1166
- if (!best || dist < best.dist) best = { index, dist };
1167
- }
1168
- return best;
1169
- }
1170
-
1171
- /** Are these two curves the same curve? The "has the user actually pulled
1172
- * anything yet?" test, and the reason dragCurve returns untouched points by
1173
- * reference: an unmoved curve compares equal exactly, with no tolerance to
1174
- * choose. Compared on value anyway so a rebuilt-but-identical curve (a camera
1175
- * re-projection that landed in the same place) also reads as unedited. */
1176
- export function curvesEqual(a, b) {
1177
- if (a === b) return true;
1178
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
1179
- for (let i = 0; i < a.length; i += 1) {
1180
- const pa = a[i];
1181
- const pb = b[i];
1182
- if (pa === pb) continue;
1183
- if (!pa || !pb) return false;
1184
- if (pa.frame !== pb.frame || pa.u !== pb.u || pa.v !== pb.v) return false;
1185
- }
1186
- return true;
1187
- }
1188
-
1189
- /**
1190
- * Has the camera moved out from under the curve?
1191
- *
1192
- * Compared on the C6 block itself rather than on three's camera object, so the
1193
- * test is on exactly the numbers that were sent — a re-derived but identical
1194
- * pose reads as "not moved" even after a matrix rebuild.
1195
- *
1196
- * A true means the 2D offsets on this curve CANNOT BE RE-INTERPRETED through the
1197
- * new camera: there is no correct way to read offsets authored through one lens
1198
- * as offsets through another, and no depth to reproject them with. It does not
1199
- * mean the edit is void. An UNEDITED curve is simply re-projected from the new
1200
- * camera and follows the view; a COMMITTED one keeps the lens it was authored
1201
- * with (the caller stores `{ camera, original, edited }` and sends that camera),
1202
- * so drift makes it undrawable and un-extendable in the live view, not invalid.
1203
- */
1204
- export function cameraDrifted(a, b, epsilon = CAMERA_DRIFT_EPSILON) {
1205
- if (!a || !b) return true;
1206
- if (Math.abs(a.fx - b.fx) > epsilon || Math.abs(a.fy - b.fy) > epsilon) return true;
1207
- if (Math.abs(a.cx - b.cx) > epsilon || Math.abs(a.cy - b.cy) > epsilon) return true;
1208
- for (let i = 0; i < 9; i += 1) if (Math.abs(a.R[i] - b.R[i]) > epsilon) return true;
1209
- for (let i = 0; i < 3; i += 1) if (Math.abs(a.t[i] - b.t[i]) > epsilon) return true;
1210
- return false;
1211
- }
1212
-
1213
- /**
1214
- * Last gate before a line edit goes on the wire (contract C6).
1215
- *
1216
- * Returns `null` when the payload is good, otherwise
1217
- * `{ code, message }` — `code` is a stable token the UI maps to localized copy
1218
- * and `message` is the English detail worth putting in a log or a test name.
1219
- * Structured the same way as the bridge's own validateGenerate: one specific
1220
- * reason, named field first, and the first failure wins.
1221
- *
1222
- * `clipFrames` is the loaded take's frame count on the APP timeline clock —
1223
- * C6's frameRange is in app clip frames, and unlike waypoints/motionEdit it is
1224
- * NOT converted to the bridge clock here.
1225
- */
1226
- export function validateLineEdit(lineEdit, { clipFrames } = {}) {
1227
- const fail = (code, message) => ({ code, message });
1228
- if (!lineEdit || typeof lineEdit !== "object" || Array.isArray(lineEdit)) {
1229
- return fail("shape", "lineEdit must be an object");
1230
- }
1231
- if (typeof lineEdit.sourceMotion !== "string" || !lineEdit.sourceMotion) {
1232
- // A line edit REWRITES a take; without the bridge-side source npz there
1233
- // is nothing to rewrite and the box would silently generate from scratch.
1234
- return fail("sourceMotion", "field 'lineEdit.sourceMotion' must be a non-empty motion url");
1235
- }
1236
- if (!LINE_EDIT_TRACK_IDS.includes(lineEdit.track)) {
1237
- return fail("track", `field 'lineEdit.track' must be one of the ${LINE_EDIT_TRACK_IDS.length} ik track ids, got ${JSON.stringify(lineEdit.track)}`);
1238
- }
1239
- const range = lineEdit.frameRange;
1240
- if (!range || typeof range !== "object" || !Number.isInteger(range.startFrame) || !Number.isInteger(range.endFrame)) {
1241
- return fail("frameRange", "field 'lineEdit.frameRange' must be { startFrame, endFrame } integers");
1242
- }
1243
- if (range.startFrame < 0 || range.endFrame <= range.startFrame) {
1244
- return fail("frameRange", `field 'lineEdit.frameRange' must satisfy 0 <= startFrame < endFrame, got ${range.startFrame}..${range.endFrame}`);
1245
- }
1246
- // endFrame is EXCLUSIVE, like every other half-open range crossing this
1247
- // boundary (preserve.editRanges, the prompt schedule), so it may equal the
1248
- // clip length but never exceed it.
1249
- if (Number.isFinite(clipFrames) && range.endFrame > clipFrames) {
1250
- return fail("frameRange", `field 'lineEdit.frameRange.endFrame' must be <= the clip's ${clipFrames} frames, got ${range.endFrame}`);
1251
- }
1252
- // One constrained frame cannot follow a LINE; two points need two frames.
1253
- if (Number.isFinite(clipFrames) && range.endFrame - range.startFrame < MIN_LINE_POINTS) {
1254
- return fail("frameRange", "field 'lineEdit.frameRange' must span at least 2 frames");
1255
- }
1256
- // PINS — the camera-free gesture. Checked before points2d so a pins payload
1257
- // is never told it needs two points of a field it deliberately omitted; the
1258
- // wire's own gate (tools/projflow/replay.mjs validateLineEditFields) applies
1259
- // the identical rules, and this one exists so the panel can refuse in the
1260
- // artist's language before anything is sent.
1261
- if (lineEdit.pins3d !== undefined) {
1262
- if (lineEdit.points2d !== undefined) {
1263
- return fail("pins", "a line edit is one gesture: send either points2d or pins3d, not both");
1264
- }
1265
- if (lineEdit.camera !== undefined) {
1266
- return fail("pins", "field 'lineEdit.camera' must be omitted for a pins edit — pins are world-space");
1267
- }
1268
- const pins = lineEdit.pins3d;
1269
- if (!Array.isArray(pins) || pins.length < LINE_EDIT_PINS_MIN) {
1270
- return fail("pins", `field 'lineEdit.pins3d' needs at least ${LINE_EDIT_PINS_MIN} pin`);
1271
- }
1272
- if (pins.length > LINE_EDIT_PINS_MAX) {
1273
- return fail("pins", `field 'lineEdit.pins3d' is capped at ${LINE_EDIT_PINS_MAX} pins, got ${pins.length}`);
1274
- }
1275
- let previous = -1;
1276
- for (let i = 0; i < pins.length; i += 1) {
1277
- const pin = pins[i];
1278
- if (!pin || typeof pin !== "object" || Array.isArray(pin) || !Number.isInteger(pin.frame)) {
1279
- return fail("pins", `field 'lineEdit.pins3d[${i}]' must be { frame: integer, position: [x, y, z] }`);
1280
- }
1281
- if (pin.frame < range.startFrame || pin.frame >= range.endFrame) {
1282
- return fail("pins", `field 'lineEdit.pins3d[${i}].frame' ${pin.frame} is outside frameRange ${range.startFrame}..${range.endFrame}`);
1283
- }
1284
- if (pin.frame <= previous) {
1285
- return fail("pins", `field 'lineEdit.pins3d' frames must be strictly ascending, got ${pin.frame} after ${previous}`);
1286
- }
1287
- previous = pin.frame;
1288
- if (!Array.isArray(pin.position) || pin.position.length !== 3 || !pin.position.every(Number.isFinite)) {
1289
- return fail("pins", `field 'lineEdit.pins3d[${i}].position' must be [x, y, z] finite numbers`);
1290
- }
1291
- }
1292
- if (lineEdit.prompt !== undefined && typeof lineEdit.prompt !== "string") {
1293
- return fail("prompt", "field 'lineEdit.prompt' must be a string when present");
1294
- }
1295
- return null;
1296
- }
1297
-
1298
- const points = lineEdit.points2d;
1299
- if (!Array.isArray(points) || points.length < MIN_LINE_POINTS) {
1300
- return fail("points", `field 'lineEdit.points2d' needs at least ${MIN_LINE_POINTS} points`);
1301
- }
1302
- if (points.length > MAX_LINE_POINTS) {
1303
- return fail("points", `field 'lineEdit.points2d' is capped at ${MAX_LINE_POINTS} points, got ${points.length}`);
1304
- }
1305
- for (let i = 0; i < points.length; i += 1) {
1306
- const point = points[i];
1307
- if (!Array.isArray(point) || point.length !== 2 || !point.every(Number.isFinite)) {
1308
- return fail("points", `field 'lineEdit.points2d[${i}]' must be [u, v] finite numbers`);
1309
- }
1310
- if (point[0] < 0 || point[0] > 1 || point[1] < 0 || point[1] > 1) {
1311
- return fail("points", `field 'lineEdit.points2d[${i}]' must be viewport-normalized into 0..1, got ${JSON.stringify(point)}`);
1312
- }
1313
- }
1314
- const camera = lineEdit.camera;
1315
- if (!camera || typeof camera !== "object") return fail("camera", "field 'lineEdit.camera' is required");
1316
- for (const key of ["fx", "fy", "cx", "cy"]) {
1317
- if (!Number.isFinite(camera[key])) return fail("camera", `field 'lineEdit.camera.${key}' must be a finite number`);
1318
- }
1319
- // A non-positive focal length means the uv/NDC flip was applied twice (or
1320
- // not at all) and every solved position would be mirrored.
1321
- if (camera.fx <= 0 || camera.fy <= 0) {
1322
- return fail("camera", "field 'lineEdit.camera' has a non-positive focal length — the uv convention is inverted");
1323
- }
1324
- if (!Array.isArray(camera.R) || camera.R.length !== 9 || !camera.R.every(Number.isFinite)) {
1325
- return fail("camera", "field 'lineEdit.camera.R' must be 9 finite numbers (3x3 row-major)");
1326
- }
1327
- if (!Array.isArray(camera.t) || camera.t.length !== 3 || !camera.t.every(Number.isFinite)) {
1328
- return fail("camera", "field 'lineEdit.camera.t' must be 3 finite numbers");
1329
- }
1330
- if (lineEdit.prompt !== undefined && typeof lineEdit.prompt !== "string") {
1331
- return fail("prompt", "field 'lineEdit.prompt' must be a string when present");
1332
- }
1333
- return null;
1334
- }
1335
-
1336
- /**
1337
- * Does this generate failure mean "the bridge does not know about lineEdit
1338
- * yet"? Wave 2 lands the routing; until then a request either 400s on the
1339
- * unknown field or is refused by the capability preflight. Both deserve the
1340
- * same calm "not connected yet" answer rather than a red error card, so the
1341
- * classification lives here where it can be tested against real reason
1342
- * strings.
1343
- */
1344
- export function isLineEditUnsupported(message) {
1345
- if (typeof message !== "string" || !message) return false;
1346
- if (/HTTP\s*400/i.test(message)) return true;
1347
- return /line\s*edit/i.test(message) && /unknown|unsupported|not supported|unrecognis|unrecogniz|must not|invalid field/i.test(message);
1348
- }