cozyclay 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (583) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +12 -30
  3. package/bin/agent/agent-routes.mjs +40 -113
  4. package/bin/agent/agent-runner.mjs +9 -10
  5. package/bin/agent/cliproxy-provider.mjs +135 -0
  6. package/bin/agent/credential-store.mjs +30 -7
  7. package/bin/agent/pi-tools.mjs +4 -1
  8. package/bin/agent/provider-keys.mjs +32 -0
  9. package/bin/agent/provider-login.mjs +110 -0
  10. package/bin/agent/provider-routes.mjs +99 -0
  11. package/bin/agent/providers.mjs +19 -97
  12. package/bin/agent/script-tool.mjs +17 -4
  13. package/bin/agent/studio-motion-reference.mjs +23 -0
  14. package/bin/agent/studio-prompt.mjs +1 -1
  15. package/bin/agent/studio-rebase-text.mjs +32 -0
  16. package/bin/agent/studio-rebase.mjs +26 -39
  17. package/bin/agent/studio-tools.mjs +9 -28
  18. package/bin/agent/tool-arguments.mjs +49 -0
  19. package/bin/agent/turn-frames.mjs +66 -0
  20. package/bin/codex-auth.mjs +9 -2
  21. package/bin/cozyclay.mjs +10 -116
  22. package/bin/live/cli.mjs +22 -6
  23. package/bin/mcp-runtime.mjs +2 -12
  24. package/bin/package-signature.mjs +0 -4
  25. package/dist/app/index.html +7 -6
  26. package/dist/assets/EXRLoader-CvVIvGVE.js +1 -0
  27. package/dist/assets/analytics-C9sUnndd.js +1 -0
  28. package/dist/assets/app-CCh--1Hc.js +5087 -0
  29. package/dist/assets/app-f-fkV5uP.css +1 -0
  30. package/dist/assets/compile-worker-CkxiD6lH.js +5 -0
  31. package/dist/assets/fflate.module-KbGj5-H6.js +5 -0
  32. package/dist/assets/{first-shot-handoff-BlT33CGf.js → first-shot-handoff-CC4xe4HM.js} +1 -1
  33. package/dist/assets/{landing-BSesrCpL.js → landing-DjHD8u7k.js} +1 -1
  34. package/dist/assets/scene-asset-sync-Hx_FzdOH.js +20 -0
  35. package/dist/assets/scene-asset-sync-_S8NQl5B.css +1 -0
  36. package/dist/assets/workflow-C4yKH1p4.js +179 -0
  37. package/dist/cozyclay-package.json +1 -1
  38. package/dist/index.html +3 -3
  39. package/dist/llms.txt +14 -0
  40. package/dist/sw.js +3 -6
  41. package/dist/workflow/index.html +5 -4
  42. package/mcp/LIVE-PROTOCOL.md +7 -47
  43. package/mcp/README.md +16 -26
  44. package/mcp/live-hub.mjs +6 -10
  45. package/mcp/package.json +1 -3
  46. package/mcp/tool-handlers.mjs +4 -168
  47. package/package.json +3 -22
  48. package/src/App.jsx +174 -2639
  49. package/src/agent-progress.js +79 -0
  50. package/src/agent-turn-mark.js +30 -0
  51. package/src/analytics.js +1 -19
  52. package/src/app-context.js +5 -0
  53. package/src/app-stage.jsx +2 -34
  54. package/src/asset-pane.jsx +6 -5
  55. package/src/camera-block.js +21 -0
  56. package/src/camera-direction.js +120 -0
  57. package/src/character-kind.js +3 -9
  58. package/src/command-bus.js +8 -6
  59. package/src/commands/cast.js +5 -34
  60. package/src/commands/elements/character.js +1 -1
  61. package/src/commands/elements/motion.js +1 -6
  62. package/src/commands/elements/object.js +6 -2
  63. package/src/commands/elements/shot.js +4 -1
  64. package/src/commands/elements.js +38 -14
  65. package/src/commands/index.js +3 -1
  66. package/src/commands/kits.js +37 -0
  67. package/src/commands/motion-timing.js +57 -0
  68. package/src/commands/motion.js +9 -152
  69. package/src/commands/motion3-compile.js +65 -0
  70. package/src/commands/motion3.js +207 -0
  71. package/src/commands/objects.js +6 -4
  72. package/src/commands/shot.js +49 -11
  73. package/src/commands/stage.js +14 -1
  74. package/src/commands/view.js +4 -1
  75. package/src/document-mirrors.js +42 -0
  76. package/src/document-store.js +4 -0
  77. package/src/domains/cast.js +19 -194
  78. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  79. package/src/domains/motion/take-source.js +51 -0
  80. package/src/domains/motion.js +60 -1838
  81. package/src/domains/scenes.js +12 -55
  82. package/src/domains/shots.js +2 -2
  83. package/src/domains/stage.js +4 -2
  84. package/src/draft-ghost.js +46 -0
  85. package/src/draft-ghost.jsx +129 -0
  86. package/src/dualview.jsx +4 -3
  87. package/src/effects/EffectsLayer.jsx +112 -0
  88. package/src/effects/effect-meshes.js +232 -0
  89. package/src/effects/effect-record.js +103 -0
  90. package/src/effects/emitter.js +59 -0
  91. package/src/effects/simulate.js +261 -0
  92. package/src/effects/state.js +59 -0
  93. package/src/execution-telemetry.js +2 -3
  94. package/src/first-success-guide.jsx +5 -5
  95. package/src/ghost-set.js +257 -0
  96. package/src/held-object-edit.jsx +113 -0
  97. package/src/held-object-pose.js +175 -0
  98. package/src/hierarchy-model.js +1 -1
  99. package/src/hierarchy-panel.jsx +1 -1
  100. package/src/imported-mesh.jsx +103 -0
  101. package/src/kits/held.js +84 -0
  102. package/src/kits/index.js +70 -0
  103. package/src/kits/smoking.js +57 -0
  104. package/src/light-blockers.js +93 -0
  105. package/src/motion/beat-board-model.js +84 -0
  106. package/src/motion/beat-board.css +124 -0
  107. package/src/motion/beat-board.jsx +59 -0
  108. package/src/motion/center-of-mass.js +66 -0
  109. package/src/motion/fix-collisions.js +850 -0
  110. package/src/motion/physics-review.js +118 -0
  111. package/src/{ardy → motion}/physics-support.js +1 -76
  112. package/src/motion/rig-snapshot.js +22 -0
  113. package/src/motion/take-segments.jsx +140 -0
  114. package/src/{ardy → motion}/timeline-coordinates.js +0 -21
  115. package/src/{ardy → motion}/timeline.css +1 -2
  116. package/src/{ardy → motion}/timeline.jsx +48 -419
  117. package/src/motion/waypoints.js +76 -0
  118. package/src/motion3/beats.js +83 -0
  119. package/src/motion3/body.js +158 -0
  120. package/src/motion3/bones.js +47 -0
  121. package/src/motion3/carried.js +47 -0
  122. package/src/motion3/compile-job.js +75 -0
  123. package/src/motion3/compile-worker.js +10 -0
  124. package/src/motion3/compiler/anchors.js +105 -0
  125. package/src/motion3/compiler/arm-orient.js +128 -0
  126. package/src/motion3/compiler/channels.js +125 -0
  127. package/src/motion3/compiler/compile.js +205 -0
  128. package/src/motion3/compiler/gait-rhythm.js +97 -0
  129. package/src/motion3/compiler/grip.js +171 -0
  130. package/src/motion3/compiler/head-steady.js +48 -0
  131. package/src/motion3/compiler/hold.js +134 -0
  132. package/src/motion3/compiler/idle.js +113 -0
  133. package/src/motion3/compiler/limbs.js +170 -0
  134. package/src/motion3/compiler/math.js +72 -0
  135. package/src/motion3/compiler/pose.js +162 -0
  136. package/src/motion3/compiler/quat.js +49 -0
  137. package/src/motion3/compiler/skeleton.js +74 -0
  138. package/src/motion3/compiler/validate-common.js +32 -0
  139. package/src/motion3/compiler/validate-goals.js +118 -0
  140. package/src/motion3/compiler/validate-objects.js +143 -0
  141. package/src/motion3/compiler/validate-parts.js +116 -0
  142. package/src/motion3/compiler/validate.js +122 -0
  143. package/src/motion3/compiler/vocab.js +73 -0
  144. package/src/motion3/compiler/walk-pelvis.js +275 -0
  145. package/src/motion3/compiler/walk.js +207 -0
  146. package/src/motion3/compiler/wrist-clamp.js +32 -0
  147. package/src/motion3/delta.js +47 -0
  148. package/src/motion3/describe.js +174 -0
  149. package/src/motion3/docs.js +27 -0
  150. package/src/motion3/edit-carry.js +21 -0
  151. package/src/motion3/fix/ease.js +16 -0
  152. package/src/motion3/fix/ground.js +37 -0
  153. package/src/motion3/fix/index.js +52 -0
  154. package/src/motion3/fix/push.js +71 -0
  155. package/src/motion3/fix/reach.js +46 -0
  156. package/src/motion3/fix/retime.js +79 -0
  157. package/src/motion3/fix/self.js +211 -0
  158. package/src/motion3/fix/slide.js +70 -0
  159. package/src/motion3/fix/turn.js +51 -0
  160. package/src/motion3/follow.js +39 -0
  161. package/src/motion3/free-motion.js +173 -0
  162. package/src/motion3/grip-choice.js +130 -0
  163. package/src/motion3/hold-plan.js +157 -0
  164. package/src/motion3/json-patch.js +77 -0
  165. package/src/motion3/key-aim.js +105 -0
  166. package/src/motion3/key-edit.js +82 -0
  167. package/src/motion3/lenient.js +127 -0
  168. package/src/motion3/object-keys.js +236 -0
  169. package/src/motion3/object-pose.js +182 -0
  170. package/src/motion3/object-tracks.js +12 -0
  171. package/src/motion3/pipeline.js +102 -0
  172. package/src/motion3/playback.js +51 -0
  173. package/src/motion3/probes.js +91 -0
  174. package/src/motion3/qa/body-checks.js +34 -0
  175. package/src/motion3/qa/check-names.js +14 -0
  176. package/src/motion3/qa/checks.js +121 -0
  177. package/src/motion3/qa/contact-checks.js +51 -0
  178. package/src/motion3/qa/explain.js +80 -0
  179. package/src/motion3/qa/fmt.js +5 -0
  180. package/src/motion3/qa/foot-checks.js +72 -0
  181. package/src/motion3/qa/geometry.js +77 -0
  182. package/src/motion3/qa/goals.js +191 -0
  183. package/src/motion3/qa/ground.js +60 -0
  184. package/src/motion3/qa/hints-contact.js +179 -0
  185. package/src/motion3/qa/hints-goal.js +160 -0
  186. package/src/motion3/qa/hints-knee.js +24 -0
  187. package/src/motion3/qa/hints-motion.js +209 -0
  188. package/src/motion3/qa/hints-object.js +91 -0
  189. package/src/motion3/qa/index.js +42 -0
  190. package/src/motion3/qa/key-checks.js +82 -0
  191. package/src/motion3/qa/near.js +96 -0
  192. package/src/motion3/qa/object-checks.js +135 -0
  193. package/src/motion3/qa/patch.js +161 -0
  194. package/src/motion3/qa/severity.js +38 -0
  195. package/src/motion3/qa/spec-checks.js +65 -0
  196. package/src/motion3/qa/style.js +218 -0
  197. package/src/motion3/qa/wrist-checks.js +69 -0
  198. package/src/motion3/qa/wrist-obs.js +60 -0
  199. package/src/motion3/refs.js +120 -0
  200. package/src/motion3/repair-tune.js +121 -0
  201. package/src/motion3/repair.js +83 -0
  202. package/src/motion3/report.js +67 -0
  203. package/src/motion3/rigs/index.js +14 -0
  204. package/src/motion3/rigs/x-bot-tpose-body.json +1 -0
  205. package/src/motion3/rigs/x-bot-tpose.json +1 -0
  206. package/src/motion3/rigs/y-bot-tpose-body.json +1 -0
  207. package/src/motion3/rigs/y-bot-tpose.json +1 -0
  208. package/src/motion3/scene.js +60 -0
  209. package/src/motion3/schema/brief.js +89 -0
  210. package/src/motion3/schema/fields.js +44 -0
  211. package/src/motion3/schema/spec-examples.js +46 -0
  212. package/src/motion3/schema/topics.js +111 -0
  213. package/src/motion3/schema/types.js +147 -0
  214. package/src/motion3/search.js +117 -0
  215. package/src/motion3/second-hand.js +112 -0
  216. package/src/motion3/skills/basic.js +82 -0
  217. package/src/motion3/skills/book.js +76 -0
  218. package/src/motion3/skills/car.js +145 -0
  219. package/src/motion3/skills/frames-auto.js +29 -0
  220. package/src/motion3/skills/index.js +83 -0
  221. package/src/motion3/skills/info.js +65 -0
  222. package/src/motion3/skills/smoke-kit.js +109 -0
  223. package/src/motion3/skills/smoke.js +160 -0
  224. package/src/motion3/spec-store.js +59 -0
  225. package/src/motion3/take-edit.js +155 -0
  226. package/src/motion3/take.js +63 -0
  227. package/src/motion3/timeline.js +127 -0
  228. package/src/motion3/views.js +121 -0
  229. package/src/motion3/walk-plan.js +119 -0
  230. package/src/object-catalog.jsx +7 -1
  231. package/src/object-colors.js +98 -0
  232. package/src/object-gizmo-parts.js +115 -0
  233. package/src/object-gizmo.jsx +37 -122
  234. package/src/object-glow.js +139 -0
  235. package/src/object-hidden.js +33 -0
  236. package/src/object-look.js +196 -0
  237. package/src/object-size.js +10 -0
  238. package/src/object-travel.js +71 -6
  239. package/src/panels/CameraPanel.jsx +7 -7
  240. package/src/panels/HdrEnvironmentControls.jsx +59 -0
  241. package/src/panels/LightPanel.jsx +25 -4
  242. package/src/panels/ObjectLookPanel.jsx +88 -0
  243. package/src/panels/PoseImagePanel.jsx +1 -1
  244. package/src/panels/PosePanel.jsx +2 -17
  245. package/src/panels/RigControlPanel.jsx +5 -53
  246. package/src/panels/SubjectBox.jsx +4 -1
  247. package/src/panels/details.css +14 -0
  248. package/src/posestudio.jsx +4 -27
  249. package/src/project-browser.jsx +3 -4
  250. package/src/project-resources.js +18 -31
  251. package/src/project.js +13 -104
  252. package/src/props.jsx +25 -280
  253. package/src/rail-dot-drag.js +23 -0
  254. package/src/render-passes.js +10 -0
  255. package/src/resource-status.jsx +1 -0
  256. package/src/result-modal.jsx +4 -19
  257. package/src/room.jsx +20 -12
  258. package/src/scene-assets.js +8 -6
  259. package/src/scene-light-place.js +72 -0
  260. package/src/scene-lights.jsx +148 -0
  261. package/src/scene-objects.js +31 -139
  262. package/src/scenes.js +15 -105
  263. package/src/semantic-edit.js +2 -5
  264. package/src/set-pieces.jsx +183 -0
  265. package/src/settings-menu.jsx +3 -11
  266. package/src/shell/BottomDock.jsx +11 -53
  267. package/src/shell/CompositionGuideButton.jsx +20 -0
  268. package/src/shell/DetailsSlot.jsx +16 -200
  269. package/src/shell/GhostsMenuGroup.jsx +74 -0
  270. package/src/shell/LibrarySlot.jsx +1 -3
  271. package/src/shell/MenuBar.jsx +3 -3
  272. package/src/shell/PreferencesDialog.jsx +15 -264
  273. package/src/shell/PreferencesSlot.jsx +2 -8
  274. package/src/shell/ShotPreviewHeader.jsx +17 -0
  275. package/src/shell/TopBar.jsx +5 -83
  276. package/src/shell/ViewportToolbar.jsx +23 -93
  277. package/src/shell/agent-glass.css +19 -6
  278. package/src/shell/details-selection.js +42 -0
  279. package/src/shell/ghosts-menu.css +22 -0
  280. package/src/shell/glass-regions.css +29 -3
  281. package/src/shell/preferences.css +6 -1
  282. package/src/shell/viewport.css +12 -0
  283. package/src/shot-authoring.js +4 -7
  284. package/src/soft-shadows.js +37 -0
  285. package/src/stage-attach-frames.js +12 -12
  286. package/src/stage-canvas-rigs.jsx +3 -3
  287. package/src/stage-capture.jsx +42 -8
  288. package/src/stage-characters.jsx +22 -216
  289. package/src/stage-editor-gizmos.jsx +6 -6
  290. package/src/stage-environment.js +93 -0
  291. package/src/stage-hdr-builtins.js +107 -0
  292. package/src/stage-hdr-image.js +116 -0
  293. package/src/stage-hdr-library.js +70 -0
  294. package/src/stage-hdr-map.js +94 -0
  295. package/src/stage-hdr.js +130 -0
  296. package/src/stage-lighting.js +149 -0
  297. package/src/stage-occlusion.js +210 -0
  298. package/src/stage-sky.jsx +159 -0
  299. package/src/stage-startup-defaults.js +5 -9
  300. package/src/studio-actions.js +12 -14
  301. package/src/studio-agent-commands.js +46 -16
  302. package/src/studio-agent-motion.js +9 -9
  303. package/src/studio-agent-protocol.js +46 -73
  304. package/src/studio-app-binding.js +29 -45
  305. package/src/studio-asset-list.js +17 -0
  306. package/src/studio-contact-sheet.js +37 -5
  307. package/src/studio-elements.js +27 -7
  308. package/src/studio-entity-bounds.js +18 -0
  309. package/src/studio-error-codes.js +8 -0
  310. package/src/studio-export-framing.js +152 -0
  311. package/src/studio-lighting-check.js +103 -0
  312. package/src/studio-motion-visual.js +150 -0
  313. package/src/studio-schema-errors.js +80 -0
  314. package/src/studio-support-gap.js +74 -0
  315. package/src/styles.css +1 -19
  316. package/src/timeline-defaults.js +5 -0
  317. package/src/timeline-extent.js +8 -20
  318. package/src/tutorial-walk.js +17 -0
  319. package/src/ui-scrub.js +1 -1
  320. package/src/use-shot-camera-tracks.js +12 -0
  321. package/src/view-ghosts.js +97 -0
  322. package/src/view-ghosts.jsx +49 -0
  323. package/src/workflow/AgentPanel.jsx +47 -166
  324. package/src/workflow/AgentProgressCard.jsx +107 -0
  325. package/src/workflow/AgentReplyText.jsx +37 -0
  326. package/src/workflow/AgentTurnSteps.jsx +84 -0
  327. package/src/workflow/LoginPanel.jsx +140 -0
  328. package/src/workflow/WorkflowBuilder.jsx +7 -23
  329. package/src/workflow/agent-client.js +54 -51
  330. package/src/workflow/agent-panel.css +71 -1
  331. package/src/workflow/agent-progress-card.css +167 -0
  332. package/src/workflow/agent-progress-view.js +47 -0
  333. package/src/workflow/agent-turn-steps.css +113 -0
  334. package/src/workflow/agent-turn.js +191 -0
  335. package/src/workflow/login-client.js +34 -0
  336. package/src/workflow/motion-input.js +18 -31
  337. package/src/workflow/node-schema.js +1 -1
  338. package/src/workflow/reply-markdown.js +69 -0
  339. package/src/workflow/scene-asset-sync.js +8 -8
  340. package/tools/agent-session-stats.mjs +61 -0
  341. package/tools/dev-full.mjs +5 -56
  342. package/tools/eslint-baseline.json +12 -13
  343. package/tools/headless-studio.mjs +85 -0
  344. package/tools/knip-baseline.json +0 -179
  345. package/tools/loc-baseline.json +33 -83
  346. package/tools/motion3/extract-body.mjs +103 -0
  347. package/tools/motion3/extract-rig.mjs +85 -0
  348. package/tools/process-supervisor.mjs +0 -88
  349. package/tools/run-tests.mjs +9 -124
  350. package/bin/agent/motion-runtime.mjs +0 -76
  351. package/dist/ardy/cskel27-rest.json +0 -557
  352. package/dist/assets/analytics-B41FmPYI.js +0 -1
  353. package/dist/assets/app-Do-Og5Pu.css +0 -1
  354. package/dist/assets/app-DwP-tl2x.js +0 -4920
  355. package/dist/assets/config-Bx7pYsD9.js +0 -1
  356. package/dist/assets/config-DeoFTYzb.css +0 -1
  357. package/dist/assets/demo-Dt0qZfS7.js +0 -1
  358. package/dist/assets/scene-asset-sync-BCWDJB1d.js +0 -19
  359. package/dist/assets/scene-asset-sync-DjJ_cJ2R.css +0 -1
  360. package/dist/assets/ticket-jqMhqL88.js +0 -1
  361. package/dist/assets/workflow-Br-K7Srs.js +0 -179
  362. package/dist/demo/walk-then-stop.npz +0 -0
  363. package/mcp/ardy-prompts.mjs +0 -218
  364. package/src/ardy/auto-fix-panel.css +0 -108
  365. package/src/ardy/auto-physics.js +0 -1194
  366. package/src/ardy/client.js +0 -153
  367. package/src/ardy/convert.js +0 -313
  368. package/src/ardy/cskel27-neutral.js +0 -39
  369. package/src/ardy/cskel27.js +0 -77
  370. package/src/ardy/export.js +0 -165
  371. package/src/ardy/fix-collisions.js +0 -2257
  372. package/src/ardy/foot-lock.js +0 -230
  373. package/src/ardy/motion-calibration.js +0 -118
  374. package/src/ardy/motion-edit.js +0 -213
  375. package/src/ardy/motion-url.js +0 -55
  376. package/src/ardy/mp4-duration.js +0 -102
  377. package/src/ardy/npz.js +0 -595
  378. package/src/ardy/physics-panel.css +0 -21
  379. package/src/ardy/physics-panel.jsx +0 -89
  380. package/src/ardy/physics-refine.js +0 -111
  381. package/src/ardy/physics-review.js +0 -567
  382. package/src/ardy/physics-temporal.js +0 -54
  383. package/src/ardy/platform-fit-panel.css +0 -64
  384. package/src/ardy/platform-fit-panel.jsx +0 -41
  385. package/src/ardy/platform-fit.js +0 -326
  386. package/src/ardy/playback.js +0 -711
  387. package/src/ardy/pose-pin.js +0 -97
  388. package/src/ardy/prompt-clips.js +0 -30
  389. package/src/ardy/retime.js +0 -211
  390. package/src/ardy/root-drop.js +0 -369
  391. package/src/ardy/timeline-resize.js +0 -13
  392. package/src/ardy/to-cskel27.js +0 -225
  393. package/src/ardy/trim.js +0 -33
  394. package/src/ardy/waypoints.js +0 -369
  395. package/src/ardy-bridge-contract.js +0 -281
  396. package/src/domains/motion/motion-video-capture.js +0 -664
  397. package/src/i2v-motion-client.js +0 -77
  398. package/src/i2v-motion-studio.jsx +0 -180
  399. package/src/line-edit.js +0 -1348
  400. package/src/motion/generation.js +0 -105
  401. package/src/motion-readiness-ui.jsx +0 -67
  402. package/src/motion-readiness.js +0 -40
  403. package/src/motion-resources.js +0 -177
  404. package/src/motion-store.js +0 -65
  405. package/src/motion-trail.js +0 -601
  406. package/src/multimodel-ingest.js +0 -394
  407. package/src/panels/PromptBlocksPanel.jsx +0 -444
  408. package/src/panels/VideoCapturePanel.jsx +0 -181
  409. package/src/pose-extract/browser.js +0 -42
  410. package/src/pose-extract/detector.js +0 -100
  411. package/src/pose-extract/fit.js +0 -227
  412. package/src/pose-extract/image-frame.js +0 -51
  413. package/src/pose-extract/index.js +0 -58
  414. package/src/pose-extract/keyframes.js +0 -74
  415. package/src/pose-extract/landmarks.js +0 -162
  416. package/src/pose-extract/mirror.js +0 -153
  417. package/src/pose-extract/take.js +0 -131
  418. package/src/pose-extract/video-frames.js +0 -91
  419. package/src/take-recipe.js +0 -165
  420. package/src/trail-key-conflicts.js +0 -39
  421. package/src/trail-pick.js +0 -59
  422. package/src/use-i2v-capture-modal.js +0 -15
  423. package/tools/ardy/BRIDGE.md +0 -37
  424. package/tools/ardy/GVHMR-NPZ.md +0 -68
  425. package/tools/ardy/README.md +0 -124
  426. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  427. package/tools/ardy/artifacts.mjs +0 -34
  428. package/tools/ardy/bridge.mjs +0 -1732
  429. package/tools/ardy/bvh-cskel27.mjs +0 -1265
  430. package/tools/ardy/cclay_constrained_generate.py +0 -561
  431. package/tools/ardy/cclay_gvhmr_worker.py +0 -264
  432. package/tools/ardy/cclay_motion_edit.py +0 -510
  433. package/tools/ardy/cclay_sequence_generate.py +0 -705
  434. package/tools/ardy/cclay_worker.py +0 -304
  435. package/tools/ardy/dump-npz.py +0 -205
  436. package/tools/ardy/extract-rest.mjs +0 -299
  437. package/tools/ardy/extract.mjs +0 -507
  438. package/tools/ardy/footage.mjs +0 -467
  439. package/tools/ardy/gvhmr-floor.mjs +0 -102
  440. package/tools/ardy/gvhmr-to-cskel27.mjs +0 -21
  441. package/tools/ardy/gvhmr_fastpath.py +0 -168
  442. package/tools/ardy/gvhmr_trajectory.py +0 -240
  443. package/tools/ardy/merge-text-encoder.py +0 -160
  444. package/tools/ardy/mocap-metrics.mjs +0 -200
  445. package/tools/ardy/mocap-quality-gate.mjs +0 -110
  446. package/tools/ardy/motion-stabilize.mjs +0 -348
  447. package/tools/ardy/npz.mjs +0 -414
  448. package/tools/ardy/pose-to-npz.mjs +0 -106
  449. package/tools/ardy/prompt-limits.mjs +0 -5
  450. package/tools/ardy/run-edit-on-box.sh +0 -73
  451. package/tools/ardy/run-local.mjs +0 -233
  452. package/tools/ardy/run-on-box.sh +0 -593
  453. package/tools/ardy/run-sequence-on-box.sh +0 -177
  454. package/tools/ardy/runners/gvhmr-worker.mjs +0 -206
  455. package/tools/ardy/runners/index.mjs +0 -18
  456. package/tools/ardy/runners/local.mjs +0 -455
  457. package/tools/ardy/runners/proc.mjs +0 -125
  458. package/tools/ardy/runners/remote.mjs +0 -252
  459. package/tools/ardy/setup-local.mjs +0 -189
  460. package/tools/ardy/setup-text-encoder-on-box.sh +0 -93
  461. package/tools/ardy/setup-text-encoder.py +0 -306
  462. package/tools/ardy/smpl-cskel27.mjs +0 -178
  463. package/tools/ardy/visual-qa.mjs +0 -196
  464. package/tools/ardy/vq-car.mjs +0 -52
  465. package/tools/bench/EXP3.md +0 -61
  466. package/tools/bench/cclay_bench_extract_incam.py +0 -125
  467. package/tools/bench/cclay_bench_extract_obs.py +0 -204
  468. package/tools/bench/cclay_bench_runner.py +0 -42
  469. package/tools/bench/cube-contact.mjs +0 -162
  470. package/tools/bench/exp3.mjs +0 -152
  471. package/tools/bench/extract-bench-lib.mjs +0 -216
  472. package/tools/bench/extract-bench.mjs +0 -302
  473. package/tools/bench/fal-generate.mjs +0 -88
  474. package/tools/bench/fit/README.md +0 -189
  475. package/tools/bench/fit/camera.mjs +0 -59
  476. package/tools/bench/fit/contact.mjs +0 -419
  477. package/tools/bench/fit/footlock.mjs +0 -234
  478. package/tools/bench/fit/motion.mjs +0 -85
  479. package/tools/bench/fit/pin.mjs +0 -27
  480. package/tools/bench/fit/remote.mjs +0 -105
  481. package/tools/bench/fit-bench.mjs +0 -126
  482. package/tools/bench/fit-sanity.mjs +0 -45
  483. package/tools/bench/metrics.mjs +0 -313
  484. package/tools/bench/obs/depth.mjs +0 -258
  485. package/tools/bench/obs/extrinsics.mjs +0 -175
  486. package/tools/bench/obs/fit_mannequin_betas.py +0 -340
  487. package/tools/bench/obs/ground.mjs +0 -294
  488. package/tools/bench/obs/heading.mjs +0 -151
  489. package/tools/bench/obs/ladder.mjs +0 -555
  490. package/tools/bench/obs/mannequin-betas.json +0 -124
  491. package/tools/bench/obs/remote.mjs +0 -83
  492. package/tools/bench/obs/rest_joints.py +0 -52
  493. package/tools/bench/obs/ybot-targets.mjs +0 -49
  494. package/tools/bench/obs-bench.mjs +0 -520
  495. package/tools/bench/score.mjs +0 -343
  496. package/tools/bench/summarize.mjs +0 -86
  497. package/tools/demo-worker/README.md +0 -137
  498. package/tools/demo-worker/api-client.mjs +0 -233
  499. package/tools/demo-worker/generate.mjs +0 -301
  500. package/tools/demo-worker/index.mjs +0 -466
  501. package/tools/demo-worker/sign-core.mjs +0 -112
  502. package/tools/demo-worker/sign.mjs +0 -161
  503. package/tools/gt-render/browser.mjs +0 -159
  504. package/tools/gt-render/camera-math.mjs +0 -276
  505. package/tools/gt-render/page.mjs +0 -267
  506. package/tools/gt-render/render.mjs +0 -514
  507. package/tools/gt-render/scene-box.mjs +0 -31
  508. package/tools/gt-render/take-transform.mjs +0 -87
  509. package/tools/kimodo/compare-seam.mjs +0 -108
  510. package/tools/kimodo/constraints.mjs +0 -244
  511. package/tools/kimodo/edit.mjs +0 -259
  512. package/tools/kimodo/effector-constraints.mjs +0 -200
  513. package/tools/kimodo/generate.mjs +0 -646
  514. package/tools/kimodo/local-output.mjs +0 -265
  515. package/tools/kimodo/measure-pose.mjs +0 -65
  516. package/tools/kimodo/measure-preserve.mjs +0 -899
  517. package/tools/kimodo/measure-waypoints.mjs +0 -55
  518. package/tools/kimodo/mlx-generate.py +0 -111
  519. package/tools/kimodo/pose-constraints.mjs +0 -248
  520. package/tools/kimodo/preserve-mask.mjs +0 -543
  521. package/tools/kimodo/read-npz.mjs +0 -189
  522. package/tools/kimodo/run-edit-on-box.mjs +0 -155
  523. package/tools/kimodo/run-sequence-on-box.mjs +0 -178
  524. package/tools/kimodo/runner.mjs +0 -284
  525. package/tools/kimodo/seam-batch.mjs +0 -77
  526. package/tools/kimodo/setup-local.mjs +0 -30
  527. package/tools/kimodo/setup-on-box.sh +0 -339
  528. package/tools/kimodo/soma77-to-cskel27.mjs +0 -305
  529. package/tools/morphgs/assets/gen_truth.py +0 -39
  530. package/tools/morphgs/assets/mesh_ori_rig.txt +0 -27
  531. package/tools/morphgs/assets/playback-check.mjs +0 -30
  532. package/tools/morphgs/demo-gate.sh +0 -26
  533. package/tools/morphgs/fbx2morphgs.mjs +0 -68
  534. package/tools/morphgs/morphgs-to-cskel27.mjs +0 -105
  535. package/tools/morphgs/patches/preprocess_src-none-mode.patch +0 -76
  536. package/tools/morphgs/setup-on-cluster.sh +0 -126
  537. package/tools/projflow/driver.py +0 -1069
  538. package/tools/projflow/generate.mjs +0 -805
  539. package/tools/projflow/hml22-to-cskel27.mjs +0 -735
  540. package/tools/projflow/line-edit-job.mjs +0 -547
  541. package/tools/projflow/replay.mjs +0 -452
  542. package/tools/projflow/runner.mjs +0 -269
  543. package/tools/projflow/service.mjs +0 -742
  544. package/tools/track/DESIGN.md +0 -513
  545. package/tools/track/backfill-provenance.mjs +0 -116
  546. package/tools/track/budget.mjs +0 -8
  547. package/tools/track/check-rig.mjs +0 -209
  548. package/tools/track/diagnostics.schema.json +0 -60
  549. package/tools/track/export-rig.mjs +0 -256
  550. package/tools/track/fallback.mjs +0 -7
  551. package/tools/track/fk-parity-fixture.mjs +0 -161
  552. package/tools/track/gate.mjs +0 -349
  553. package/tools/track/masks.mjs +0 -318
  554. package/tools/track/metrics.mjs +0 -201
  555. package/tools/track/publish-obs.mjs +0 -108
  556. package/tools/track/py/check_env.py +0 -61
  557. package/tools/track/py/eval_lr.py +0 -229
  558. package/tools/track/py/lr_viterbi.py +0 -294
  559. package/tools/track/py/masks.py +0 -405
  560. package/tools/track/py/objective.py +0 -382
  561. package/tools/track/py/rig.py +0 -508
  562. package/tools/track/py/scene.py +0 -326
  563. package/tools/track/py/test_joint_indices.py +0 -123
  564. package/tools/track/py/test_lr_viterbi.py +0 -195
  565. package/tools/track/py/test_masks.py +0 -229
  566. package/tools/track/py/test_rig.py +0 -288
  567. package/tools/track/py/test_scene.py +0 -354
  568. package/tools/track/py/test_track.py +0 -328
  569. package/tools/track/py/track.py +0 -411
  570. package/tools/track/remote.mjs +0 -140
  571. package/tools/track/rig-dump.mjs +0 -227
  572. package/tools/track/run-box-tests.mjs +0 -14
  573. package/tools/track/run-box.mjs +0 -168
  574. package/tools/track/setup-box.sh +0 -59
  575. package/tools/track/study-2d.mjs +0 -728
  576. /package/src/{ardy → motion}/collision-blockers.js +0 -0
  577. /package/src/{ardy → motion}/ground.js +0 -0
  578. /package/src/{ardy → motion}/ik-drag.js +0 -0
  579. /package/src/{ardy → motion}/ik-key-json.js +0 -0
  580. /package/src/{ardy → motion}/ik.js +0 -0
  581. /package/src/{ardy → motion}/physics-surface.js +0 -0
  582. /package/src/{ardy → motion}/playback-clock.js +0 -0
  583. /package/src/{ardy → motion}/range-pin.js +0 -0
@@ -1,1732 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * bridge.mjs - dev-only HTTP sidecar that drives the motion-generation loop
4
- * for the CozyClay SPA.
5
- *
6
- * CozyClay stays a static SPA: `vite build` emits a dist/ that needs no
7
- * server. The box work (pose -> npz -> remote constrained generation) cannot
8
- * run in a browser, so this sidecar exposes it on 127.0.0.1:5181 and the Vite
9
- * dev server proxies /ardy to it. The sidecar is OPTIONAL: when it is not
10
- * running the app behaves exactly as before, with the generate affordance
11
- * unavailable. The production build never depends on it.
12
- *
13
- * The heavy lifting is done by the Kimodo runner (tools/kimodo/runner.mjs),
14
- * never reimplemented here. Pose conversion, validation, streaming and motion
15
- * delivery remain backend-neutral so the browser contract stays stable.
16
- *
17
- * Security posture (detailed in BRIDGE.md):
18
- * - binds 127.0.0.1 only;
19
- * - every child is spawn()ed with an argv ARRAY; request data never reaches
20
- * a shell string (the only remote shell strings are built from the box's
21
- * own listing, regex-whitelisted, or from operator env vars);
22
- * - every request field is validated before use: prompt length-capped,
23
- * duration/dstFrame range-checked, base matched against the list the box
24
- * actually reported, waypoints bounds/order-checked, body size-capped;
25
- * preserve object/range-checked against the clip and refused alongside the
26
- * modes it cannot mean anything with;
27
- * posePin boolean-checked (default true = full-body pose constraint;
28
- * false = path/prompt only, pose ignored, base optional - with waypoints
29
- * and no base the box free-generates the base clip first, two-pass);
30
- * - generated npz files are served back through an in-memory allowlist
31
- * populated only after this process generated and verified the file; a
32
- * path is never accepted from the URL;
33
- * - a client disconnect kills the detached child process group instead of
34
- * orphaning an ssh session.
35
- */
36
-
37
- import { createServer } from "node:http";
38
- import { spawn } from "node:child_process";
39
- import { randomBytes } from "node:crypto";
40
- import { createReadStream, existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
41
- import { basename, dirname, join, resolve, sep } from "node:path";
42
- import { fileURLToPath, pathToFileURL } from "node:url";
43
- import { decodeMotionNpz } from "../../src/ardy/npz.js";
44
- import { motionArraysToNpzMembers, replaceMotionSegment, writeNpz } from "./npz.mjs";
45
- import { globalChildren, killGroup, runStreaming, track } from "./runners/proc.mjs";
46
- import { createRunner } from "./runners/index.mjs";
47
- import { DURATION_MAX, DURATION_MIN, PROMPT_MAX_CHARS } from "./prompt-limits.mjs";
48
- import { footagePath, handleFootage, serveFootage } from "./footage.mjs";
49
- import { EXTRACT_BACKEND, EXTRACT_BACKEND_SUPPORTED, handleExtract } from "./extract.mjs";
50
- import { createPrivateArtifactDir, evictPrivateArtifact, removePrivateArtifactDir } from "./artifacts.mjs";
51
- // The IK track ids a preserve edit range may scope itself to. Imported rather
52
- // than restated: tools/kimodo/preserve-mask.mjs is the single source of truth
53
- // for track -> mask-group, and a list copied here would drift the first time a
54
- // track is added. The module is pure (no side effects on import).
55
- import { TRACK_GROUPS } from "../kimodo/preserve-mask.mjs";
56
- // Line editing (contract C6). The projflow modules are imported DIRECTLY, not
57
- // spawned: the runner's lineEditCommand produces raw 22-joint positions, and a
58
- // take is only finished after the cskel27 lift, the 20 -> 24 retime and the
59
- // splice — all of which are pure functions over files this process already
60
- // holds. Running them in a child would mean a second argv contract and a second
61
- // copy of the frame arithmetic for no isolation gain, so the composition layer
62
- // is imported the way handleExtract already is, and the only child process on
63
- // this path is the ssh inside lineEditOnBox.
64
- import { createProjflowRunner } from "../projflow/runner.mjs";
65
- import { runLineEditJob } from "../projflow/line-edit-job.mjs";
66
- // Recipe replay (contract C10) and, with it, the per-field line-edit rules that
67
- // BOTH the C6 request and every C10 entry are checked against. They live in
68
- // tools/projflow/replay.mjs rather than here because a replay is pure
69
- // arithmetic over an injectable job runner, and the whole contract has to be
70
- // verifiable on a laptop with no GPU and no sidecar.
71
- import { blockBoundaries, runReplay, validateLineEditFields, validateReplay } from "../projflow/replay.mjs";
72
- const HERE = dirname(fileURLToPath(import.meta.url));
73
- const REPO = resolve(HERE, "../..");
74
- const OUT_DIR = join(HERE, "out");
75
- const POSE_TO_NPZ = join(HERE, "pose-to-npz.mjs");
76
-
77
- const BIND_HOST = "127.0.0.1"; // loopback only: this process shells out
78
- const DEFAULT_PORT = 5181;
79
- const HEALTH_TTL_MS = 5000; // the UI polls health; a cached answer avoids hammering ssh
80
- const BASES_TTL_MS = 120000; // the box's motion list changes rarely
81
- const MAX_BODY_BYTES = 1024 * 1024;
82
- const SEED_MAX = 2 ** 31 - 1; // optional request seed: an integer in 0..2**31-1 (bridge contract)
83
- // Kimodo is retimed directly to the app's 24 fps production clock.
84
- const FPS = 24;
85
- const ROOT_2D_RANGE_M = 20; // |x| and |z| cap, metres (Y-up, X/Z horizontal)
86
- const HEADING_RANGE_RAD = 2 * Math.PI; // |heading| cap, radians
87
- const WAYPOINTS_MAX = 32; // sparse authored root keys including frame 0
88
- const WAYPOINT_SPEED_MIN_MPS = 0.3; // dense-sample gait floor (authored floor 0.5, arcs dip through corners)
89
- const WAYPOINT_SPEED_MAX_MPS = 3.6; // dense-sample ceiling (authored ceiling 3.0)
90
- const WAYPOINT_HOLD_EPS_M = 0.06; // pairs closer than this are a deliberate hold, any duration
91
- const MOTION_ALLOWLIST_MAX = 64; // newest runs only; evicted ids become stale 404s
92
- // The bridge's own gen stamp (<epoch-ms>-<3 random bytes hex>); keeping the
93
- // motions URL id to that shape keeps /ardy/motions/<run-id> predictable and
94
- // path-free (the id is a lookup key, never a path).
95
- const MOTION_ID = /^[0-9]+-[0-9a-f]{6}$/;
96
- // The same id embedded in the URL a client hands back to address a take it was
97
- // given. Every field that names an earlier take (motionEdit.sourceMotion,
98
- // regenerateSegments' sourceMotion, preserve.sourceMotion) matches THIS, so a
99
- // take that is addressable by one of them is addressable by all of them.
100
- const MOTION_URL = /^\/ardy\/motions\/[0-9]+-[0-9a-f]{6}$/;
101
- // Scheduled inpainting, contract C3: strength s in (0,1] maps to the diffusion
102
- // -time schedule the box blends over. sigma_s is where blending starts (high
103
- // noise, so the base's structure dominates) and sigma_e where the model is
104
- // left alone to finish; the end cap generalises the paper's recommended
105
- // 500/50 pair to any strength.
106
- const PRESERVE_SIGMA_MAX = 1000;
107
- const PRESERVE_SIGMA_END_CAP = 50;
108
- // Round 2 (C3v2): an edit range may name the IK tracks it touched, and only the
109
- // mask groups those tracks map to are freed there. The valid ids are exactly the
110
- // keys of the mask builder's own table.
111
- const PRESERVE_TRACK_IDS = Object.keys(TRACK_GROUPS);
112
- // C6's per-field rules (track ids, points2d, camera, frameRange) now live in
113
- // tools/projflow/replay.mjs: contract C10 validates every replay entry with the
114
- // SAME rules, and two copies of them would drift on the first joint-mapping
115
- // change. Only the rules a line edit does not share with a replay entry —
116
- // exclusivity, posePin, sourceMotion, preview — are still spelled out below.
117
- //
118
- // Every field a line edit is exclusive with (contract C6: a line edit is its own
119
- // run mode). `poses`/`pose` are in the list because posePin defaults to true and
120
- // a line edit authors no poses at all.
121
- const LINE_EDIT_EXCLUSIVE = ["preserve", "waypoints", "segments", "regenerateSegments", "motionEdit", "poses", "pose"];
122
-
123
- // The runner is the ONLY part of the bridge that knows where generation
124
- // actually happens. Selection (runners/index.mjs) is Kimodo-only; everything
125
- // below that boundary — validation, caching, streaming and the motion
126
- // allowlist — is backend-agnostic.
127
- let runner; // assigned after CLI parsing, before the server starts
128
-
129
- // ...with ONE exception, and it is deliberate: line editing is an
130
- // ENGINE-PER-TASK (contract C6). A lineEdit request always runs on ProjFlow
131
- // whatever CCLAY_MOTION_BACKEND says, so this second runner exists beside the
132
- // selected one instead of replacing it. Construction only reads env — the box
133
- // is not touched until something probes or runs — and a box with no ProjFlow
134
- // configuration leaves it null, which reads as "the capability is off".
135
- let projflowRunner = null; // assigned beside `runner`
136
-
137
- // A base path is produced by the backend's own `ls outputs/*.npz` /
138
- // `ls outputs/omb/*.npz` and is still whitelisted before it is embedded in a
139
- // remote shell string: only a plain, metacharacter-free relative npz path is
140
- // ever allowed through.
141
- const SAFE_BASE_PATH = /^outputs\/(?:omb\/)?[A-Za-z0-9._-]+\.npz$/;
142
- const SAFE_BASE_ID = /^[A-Za-z0-9._-]+$/;
143
-
144
- // backend probes (health, bases) with caching
145
-
146
- // The raw listing comes from the runner (a box's `ls outputs/*.npz` over ssh,
147
- // or a local directory scan); the bridge sanitizes every entry the same way
148
- // regardless of backend before anything downstream can see it.
149
- async function fetchBases() {
150
- const entries = await runner.listBases();
151
- const bases = [];
152
- for (const entry of entries) {
153
- if (
154
- !entry ||
155
- typeof entry !== "object" ||
156
- typeof entry.id !== "string" ||
157
- typeof entry.path !== "string" ||
158
- !Number.isInteger(entry.frames) ||
159
- entry.frames < 0
160
- ) {
161
- console.error(`[bridge] skipping malformed bases entry: ${JSON.stringify(entry)}`);
162
- continue;
163
- }
164
- if (!SAFE_BASE_ID.test(entry.id) || !SAFE_BASE_PATH.test(entry.path)) {
165
- console.error(`[bridge] skipping unsafe bases entry: ${JSON.stringify(entry)}`);
166
- continue;
167
- }
168
- bases.push({ id: entry.id, path: entry.path, frames: entry.frames });
169
- }
170
- // Duplicate ids (same name under outputs/ and outputs/omb/) are kept as-is;
171
- // generate matches the first, which is exactly the lookup order the
172
- // generation path uses, so the two cannot pick different files.
173
- return { bases };
174
- }
175
-
176
- let healthCache = null;
177
- let healthInflight = null;
178
-
179
- // Successes AND failures are cached for the TTL so a dead box does not turn
180
- // the UI's health polling into an ssh stampede.
181
- async function getHealth() {
182
- const now = Date.now();
183
- if (healthCache && now - healthCache.at < HEALTH_TTL_MS) {
184
- if (healthCache.error) throw healthCache.error;
185
- return healthCache.value;
186
- }
187
- if (!healthInflight) {
188
- healthInflight = runner.probeHealth()
189
- .then((value) => {
190
- healthCache = { at: Date.now(), value };
191
- return value;
192
- })
193
- .catch((err) => {
194
- healthCache = { at: Date.now(), error: err };
195
- throw err;
196
- })
197
- .finally(() => {
198
- healthInflight = null;
199
- });
200
- }
201
- return healthInflight;
202
- }
203
-
204
- // Does this box actually have a ProjFlow line-edit backend?
205
- //
206
- // LAZY AND AWAITED, not probed at startup. The app gates the whole draw-a-line
207
- // affordance on `capabilities.lineEdit` in the health payload, so a startup
208
- // probe would have to finish before the first poll or the feature would be dark
209
- // on a perfectly good box until the next one — a race with a UI. Health already
210
- // awaits an ssh round trip for the selected backend; this one rides along beside
211
- // it under the same 5 s TTL, and the two probes run in parallel so a healthy box
212
- // costs no extra wall time.
213
- //
214
- // A FAILED probe is never an error here, only a false: the capability is an
215
- // advertisement, and a bridge whose ProjFlow env is missing must still serve
216
- // Kimodo health normally. That is also the "a box without the scout env must not
217
- // advertise" rule — no host, no repo, no checkpoint, or an ssh that cannot
218
- // connect all land in the same catch.
219
- let lineEditCapabilityCache = null;
220
- let lineEditCapabilityInflight = null;
221
-
222
- async function getLineEditCapability() {
223
- if (!projflowRunner) return false;
224
- const now = Date.now();
225
- if (lineEditCapabilityCache && now - lineEditCapabilityCache.at < HEALTH_TTL_MS) {
226
- return lineEditCapabilityCache.value;
227
- }
228
- if (!lineEditCapabilityInflight) {
229
- lineEditCapabilityInflight = projflowRunner.probeHealth()
230
- .then((probe) => Boolean(probe?.ok))
231
- .catch((err) => {
232
- console.error(`[bridge] line editing unavailable: ${err.message}`);
233
- return false;
234
- })
235
- .then((value) => {
236
- lineEditCapabilityCache = { at: Date.now(), value };
237
- return value;
238
- })
239
- .finally(() => {
240
- lineEditCapabilityInflight = null;
241
- });
242
- }
243
- return lineEditCapabilityInflight;
244
- }
245
-
246
- let basesCache = null;
247
- let basesInflight = null;
248
-
249
- async function getBases() {
250
- const now = Date.now();
251
- if (basesCache && now - basesCache.at < BASES_TTL_MS) {
252
- if (basesCache.error) throw basesCache.error;
253
- return basesCache.value;
254
- }
255
- if (!basesInflight) {
256
- basesInflight = fetchBases()
257
- .then((value) => {
258
- basesCache = { at: Date.now(), value };
259
- return value;
260
- })
261
- .catch((err) => {
262
- basesCache = { at: Date.now(), error: err };
263
- throw err;
264
- })
265
- .finally(() => {
266
- basesInflight = null;
267
- });
268
- }
269
- return basesInflight;
270
- }
271
-
272
- // request validation
273
-
274
- // Returns an error message naming the offending field, or null when valid.
275
- function validateGenerate(body) {
276
- if (!body || typeof body !== "object" || Array.isArray(body)) return "request body must be a JSON object";
277
- if (body.posePin !== undefined && typeof body.posePin !== "boolean") {
278
- return `field 'posePin' must be a boolean, got ${JSON.stringify(body.posePin)}`;
279
- }
280
- if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) return "field 'prompt' must be a non-empty string";
281
- if (body.prompt.length > PROMPT_MAX_CHARS) return `field 'prompt' is ${body.prompt.length} chars; the cap is ${PROMPT_MAX_CHARS}`;
282
- if (typeof body.duration !== "number" || !Number.isFinite(body.duration)) return `field 'duration' must be a finite number`;
283
- if (body.duration < DURATION_MIN || body.duration > DURATION_MAX) return `field 'duration' must be in ${DURATION_MIN}..${DURATION_MAX} seconds`;
284
- // Optional post-processing knobs, forwarded to the box generator.
285
- if (body.rootMargin !== undefined && (typeof body.rootMargin !== "number" || !Number.isFinite(body.rootMargin) || body.rootMargin < 0 || body.rootMargin > 1)) return `field 'rootMargin' must be a number in 0..1 (meters)`;
286
- if (body.contactThreshold !== undefined && (typeof body.contactThreshold !== "number" || !Number.isFinite(body.contactThreshold) || body.contactThreshold < 0 || body.contactThreshold > 1)) return `field 'contactThreshold' must be a number in 0..1`;
287
- // Optional history crop (per autoregressive step), forwarded to the box
288
- // generators; the box enforces its own token-size multiple.
289
- if (body.historyFrames !== undefined && (!Number.isInteger(body.historyFrames) || body.historyFrames <= 0 || body.historyFrames > 400)) return `field 'historyFrames' must be an integer in 1..400`;
290
- const clipFrames = Math.floor(body.duration * FPS);
291
- if (clipFrames < 3) return `field 'duration' yields fewer than 3 frames`;
292
-
293
- // Checked BEFORE the pose block: a line edit carries no poses on purpose, so
294
- // the posePin rule below would refuse it with a message about a field the
295
- // client never sent. C6's own refusals have to win.
296
- if (body.lineEdit !== undefined) {
297
- const error = validateLineEdit(body, clipFrames);
298
- if (error) return error;
299
- }
300
-
301
- const posePinned = body.posePin !== false;
302
- if (posePinned) {
303
- const poses = body.motionEdit
304
- ? body.motionEdit.edits
305
- : Array.isArray(body.poses)
306
- ? body.poses
307
- : body.pose
308
- ? [{ frame: body.dstFrame, pose: body.pose }]
309
- : null;
310
- if (!poses || poses.length === 0 || poses.length > 64) return `field 'poses' must have 1..64 entries when posePin is true`;
311
- let previous = -1;
312
- for (let i = 0; i < poses.length; i += 1) {
313
- const entry = poses[i];
314
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) return `field 'poses[${i}]' must be an object`;
315
- if (!Number.isInteger(entry.frame) || entry.frame < 0 || entry.frame >= clipFrames) return `field 'poses[${i}].frame' must be an integer in 0..${clipFrames - 1}`;
316
- if (entry.frame <= previous) return `field 'poses' frames must be strictly ascending and distinct`;
317
- previous = entry.frame;
318
- if (!entry.pose || typeof entry.pose !== "object" || Array.isArray(entry.pose)) return `field 'poses[${i}].pose' must be an object`;
319
- if (entry.pose.schema !== "cozyclay.pose.v1") return `field 'poses[${i}].pose.schema' must be "cozyclay.pose.v1"`;
320
- if (!Array.isArray(entry.pose.root) || entry.pose.root.length !== 3 || !entry.pose.root.every(Number.isFinite)) return `field 'poses[${i}].pose.root' must be [x, y, z] finite metres`;
321
- }
322
- }
323
- if (body.base !== undefined && (typeof body.base !== "string" || body.base.length === 0)) return "field 'base' must be a non-empty string";
324
- if (body.segments !== undefined) {
325
- const error = validateSegments(body.segments, clipFrames);
326
- if (error) return error;
327
- if (posePinned) return "field 'segments' uses autoregressive history and requires posePin:false";
328
- // segments + waypoints run TOGETHER on the sequence generator: the
329
- // Root2D constraint set is built over the whole rollout and each
330
- // chained call sees its own slice (the interactive demo's pattern).
331
- // The trained window then binds each segment call, not the total.
332
- // Kimodo owns the segment duration; no legacy trained-window cap applies.
333
- }
334
- if (body.regenerateSegments !== undefined) {
335
- const error = validateRegenerateSegments(body.regenerateSegments, clipFrames);
336
- if (error) return error;
337
- if (!posePinned) return "field 'regenerateSegments' requires posePin:true";
338
- if (body.segments !== undefined || body.waypoints !== undefined) {
339
- return "field 'regenerateSegments' cannot be combined with segments or waypoints";
340
- }
341
- if (typeof body.sourceMotion !== "string" || !MOTION_URL.test(body.sourceMotion)) {
342
- return "field 'sourceMotion' must be a generated /ardy/motions/<run-id> URL";
343
- }
344
- }
345
- if (body.motionEdit !== undefined) {
346
- const edit = body.motionEdit;
347
- if (!edit || typeof edit !== "object" || Array.isArray(edit)) return "field 'motionEdit' must be an object";
348
- if (!posePinned) return "field 'motionEdit' requires posePin:true";
349
- if (body.segments !== undefined || body.regenerateSegments !== undefined || body.waypoints !== undefined) {
350
- return "field 'motionEdit' cannot be combined with segments, regenerateSegments, or waypoints";
351
- }
352
- if (typeof edit.sourceMotion !== "string" || !MOTION_URL.test(edit.sourceMotion)) {
353
- return "field 'motionEdit.sourceMotion' must be a generated /ardy/motions/<run-id> URL";
354
- }
355
- if (
356
- !Number.isInteger(edit.startFrame) ||
357
- !Number.isInteger(edit.endFrame) ||
358
- edit.startFrame < 0 ||
359
- edit.endFrame > clipFrames ||
360
- edit.endFrame - edit.startFrame < 3
361
- ) {
362
- return `field 'motionEdit' range must be 3+ frames inside 0..${clipFrames}`;
363
- }
364
- if (!Array.isArray(edit.edits) || edit.edits.length < 1 || edit.edits.length > 64) {
365
- return "field 'motionEdit.edits' must have 1..64 entries";
366
- }
367
- for (let index = 0; index < edit.edits.length; index += 1) {
368
- const entry = edit.edits[index];
369
- if (!Array.isArray(entry.tracks) || entry.tracks.length < 1 || entry.tracks.some((track) => typeof track !== "string")) {
370
- return `field 'motionEdit.edits[${index}].tracks' must contain track names`;
371
- }
372
- }
373
- for (const key of ["contextBefore", "contextAfter"]) {
374
- if (!Number.isInteger(edit[key]) || edit[key] < 0 || edit[key] > 160) {
375
- return `field 'motionEdit.${key}' must be an integer in 0..160`;
376
- }
377
- }
378
- }
379
- if (body.waypoints !== undefined) {
380
- const error = validateWaypoints(body.waypoints, clipFrames);
381
- if (error) return error;
382
- }
383
- if (body.preserve !== undefined) {
384
- const error = validatePreserve(body, clipFrames);
385
- if (error) return error;
386
- }
387
- // Recipe replay (contract C10): the line edits to re-apply once this request's
388
- // take exists. Validated LAST of the run-mode fields because it rides ON one —
389
- // a replay never chooses the take, it only refines whatever plain generation,
390
- // prompt schedule or preserved run produced.
391
- if (body.replay !== undefined) {
392
- const error = validateReplay(body, clipFrames, { seedMax: SEED_MAX });
393
- if (error) return error;
394
- }
395
- if (body.seed !== undefined && (!Number.isInteger(body.seed) || body.seed < 0 || body.seed > SEED_MAX)) return `field 'seed' must be an integer in 0..${SEED_MAX}`;
396
- if (body.cpu !== undefined && typeof body.cpu !== "boolean") return `field 'cpu' must be a boolean`;
397
- return null;
398
- }
399
-
400
- // Line editing (contract C6): the artist draws a 2D polyline over the viewport
401
- // and ONE joint is made to follow it exactly.
402
- //
403
- // Style and posture match validatePreserve: one specific reason, the offending
404
- // field named first, first failure wins, and nothing here computes anything the
405
- // backend will compute again. In particular the 24 -> 20 fps conversion is NOT
406
- // done here — tools/projflow/line-edit-job.mjs owns that clock, because it is
407
- // also the layer that decides how long the resampled source is, and two places
408
- // rounding the same frame index is how they drift. This validator's frame
409
- // numbers are all on the app's 24 fps clip clock, exactly as sent.
410
- //
411
- // Returns an error message naming the offending field, or null when valid.
412
- function validateLineEdit(body, clipFrames) {
413
- const lineEdit = body.lineEdit;
414
- if (!lineEdit || typeof lineEdit !== "object" || Array.isArray(lineEdit)) {
415
- return "field 'lineEdit' must be an object";
416
- }
417
- // Exclusivity first: a request that also carries a prompt schedule or a
418
- // preserved take is not a line edit with an extra field, it is two run modes
419
- // in one body, and guessing which one the artist meant is not the bridge's
420
- // call. C6 is named so the refusal is traceable to the contract.
421
- for (const field of LINE_EDIT_EXCLUSIVE) {
422
- if (body[field] !== undefined) {
423
- return `field 'lineEdit' cannot be combined with ${field}: contract C6 makes a line edit its own run mode`;
424
- }
425
- }
426
- // posePin defaults to TRUE, which demands a pose array. A line edit authors
427
- // no poses, so the client must say so explicitly rather than have the bridge
428
- // infer it — the same request without lineEdit would then mean something
429
- // completely different.
430
- if (body.posePin !== false) {
431
- return "field 'lineEdit' requires posePin:false (a line edit constrains one joint's path, not a pose)";
432
- }
433
- if (typeof lineEdit.sourceMotion !== "string" || !MOTION_URL.test(lineEdit.sourceMotion)) {
434
- return "field 'lineEdit.sourceMotion' must be a generated /ardy/motions/<run-id> URL";
435
- }
436
- // The 20-step draft the interactive loop asks for (contract C10's preview
437
- // flag). A BOOLEAN and nothing else: the step count itself is the job's, not
438
- // the client's, or a request could ask the box for a 10000-step sample.
439
- if (lineEdit.preview !== undefined && typeof lineEdit.preview !== "boolean") {
440
- return "field 'lineEdit.preview' must be a boolean when present";
441
- }
442
- // Everything else — track, frameRange, points2d, camera, prompt — is the
443
- // shared rule set a C10 replay entry is held to as well.
444
- return validateLineEditFields(lineEdit, clipFrames, "lineEdit");
445
- }
446
-
447
- function validateSegments(segments, clipFrames) {
448
- if (!Array.isArray(segments) || segments.length < 2 || segments.length > 64) return "field 'segments' must have 2..64 entries";
449
- let cursor = 0;
450
- for (let i = 0; i < segments.length; i += 1) {
451
- const segment = segments[i];
452
- if (!segment || typeof segment !== "object" || Array.isArray(segment)) return `field 'segments[${i}]' must be an object`;
453
- if (!Number.isInteger(segment.startFrame) || !Number.isInteger(segment.endFrame)) return `field 'segments[${i}]' frames must be integers`;
454
- if (segment.startFrame !== cursor || segment.endFrame <= segment.startFrame || segment.endFrame > clipFrames) return `field 'segments' must be contiguous from frame 0 through ${clipFrames}`;
455
- if (segment.endFrame - segment.startFrame < 3) return `field 'segments[${i}]' must contain at least 3 frames`;
456
- if (typeof segment.prompt !== "string" || !segment.prompt.trim() || segment.prompt.length > PROMPT_MAX_CHARS) return `field 'segments[${i}].prompt' must be 1..${PROMPT_MAX_CHARS} characters`;
457
- cursor = segment.endFrame;
458
- }
459
- return cursor === clipFrames ? null : `field 'segments' must end at frame ${clipFrames}`;
460
- }
461
-
462
- function validateRegenerateSegments(segments, clipFrames) {
463
- if (!Array.isArray(segments) || segments.length < 1 || segments.length > 64) {
464
- return "field 'regenerateSegments' must have 1..64 entries";
465
- }
466
- let previousEnd = -1;
467
- for (let i = 0; i < segments.length; i += 1) {
468
- const segment = segments[i];
469
- if (!segment || typeof segment !== "object" || Array.isArray(segment)) {
470
- return `field 'regenerateSegments[${i}]' must be an object`;
471
- }
472
- if (
473
- !Number.isInteger(segment.startFrame) ||
474
- !Number.isInteger(segment.endFrame) ||
475
- segment.startFrame < 0 ||
476
- segment.endFrame > clipFrames ||
477
- segment.endFrame - segment.startFrame < 4
478
- ) {
479
- return `field 'regenerateSegments[${i}]' must be a 4+ frame range inside 0..${clipFrames}`;
480
- }
481
- if (segment.startFrame < previousEnd) {
482
- return "field 'regenerateSegments' must be sorted and non-overlapping";
483
- }
484
- if (typeof segment.prompt !== "string" || !segment.prompt.trim() || segment.prompt.length > PROMPT_MAX_CHARS) {
485
- return `field 'regenerateSegments[${i}].prompt' must be 1..${PROMPT_MAX_CHARS} characters`;
486
- }
487
- previousEnd = segment.endFrame;
488
- }
489
- return null;
490
- }
491
-
492
- // Scheduled inpainting (contract C3): reconstruct an existing take everywhere
493
- // the user did NOT edit, and regenerate only the edited spans.
494
- //
495
- // The bridge validates the request and maps `strength` onto the diffusion-time
496
- // schedule; it deliberately does NOT build the per-frame mask. That happens in
497
- // tools/kimodo/generate.mjs, the only layer that knows how many frames Kimodo
498
- // will actually produce (see preserveSigmas below and the note there).
499
- //
500
- // Returns an error message naming the offending field, or null when valid.
501
- function validatePreserve(body, clipFrames) {
502
- const preserve = body.preserve;
503
- if (!preserve || typeof preserve !== "object" || Array.isArray(preserve)) {
504
- return "field 'preserve' must be an object";
505
- }
506
- if (typeof preserve.sourceMotion !== "string" || !MOTION_URL.test(preserve.sourceMotion)) {
507
- return "field 'preserve.sourceMotion' must be a generated /ardy/motions/<run-id> URL";
508
- }
509
- // 0 is not "preserve nothing", it is "do not preserve": the field carries a
510
- // base motion the backend would still load and blend at sigma_s 0. The
511
- // caller drops the whole field instead, so the request says what it means.
512
- if (
513
- typeof preserve.strength !== "number" ||
514
- !Number.isFinite(preserve.strength) ||
515
- preserve.strength <= 0 ||
516
- preserve.strength > 1
517
- ) {
518
- return "field 'preserve.strength' must be a number greater than 0 and at most 1 (strength 0 is preserve off: omit 'preserve')";
519
- }
520
- if (!Array.isArray(preserve.editRanges)) {
521
- return "field 'preserve.editRanges' must be an array (an EMPTY array means preserve the whole take)";
522
- }
523
- for (let i = 0; i < preserve.editRanges.length; i += 1) {
524
- const range = preserve.editRanges[i];
525
- if (!range || typeof range !== "object" || Array.isArray(range)) {
526
- return `field 'preserve.editRanges[${i}]' must be an object`;
527
- }
528
- if (!Number.isInteger(range.startFrame) || !Number.isInteger(range.endFrame)) {
529
- return `field 'preserve.editRanges[${i}]' startFrame and endFrame must be integers`;
530
- }
531
- // Half-open, non-empty and inside the clip. That last bound is also what
532
- // makes every range intersect the clip: a range the take does not contain
533
- // cannot be scaled onto the generation clock without freeing frames at one
534
- // end that the user never edited, so the mask builder refuses it too.
535
- // Overlaps between ranges are NOT refused — the mask combines them by
536
- // minimum, so two overlapping edits mean the same thing as one.
537
- if (range.startFrame < 0 || range.endFrame <= range.startFrame || range.endFrame > clipFrames) {
538
- return `field 'preserve.editRanges[${i}]' must be a non-empty half-open range inside 0..${clipFrames}`;
539
- }
540
- // C3v2: an OPTIONAL `tracks` list scopes the range to the mask groups those
541
- // IK tracks map to (a hand edit frees the arm, not the body). Omitting the
542
- // key is the v1 whole-body edit.
543
- if (range.tracks !== undefined) {
544
- if (!Array.isArray(range.tracks)) {
545
- return `field 'preserve.editRanges[${i}].tracks' must be an array of IK track ids`;
546
- }
547
- // An EMPTY list is refused rather than read as either "whole body" or
548
- // "nothing": the two readings differ by the entire clip and the caller
549
- // meant one of them. Same rule, same wording as buildPreserveMask — a
550
- // request that got past here must never die inside the mask builder.
551
- if (range.tracks.length === 0) {
552
- return `field 'preserve.editRanges[${i}].tracks' is empty — omit the key entirely for a whole-body edit`;
553
- }
554
- for (let t = 0; t < range.tracks.length; t += 1) {
555
- const trackId = range.tracks[t];
556
- if (typeof trackId !== "string" || !Object.hasOwn(TRACK_GROUPS, trackId)) {
557
- return (
558
- `field 'preserve.editRanges[${i}].tracks[${t}]' ${JSON.stringify(trackId)} is not a known IK track id; ` +
559
- `valid ids are ${PRESERVE_TRACK_IDS.join(", ")}`
560
- );
561
- }
562
- }
563
- }
564
- }
565
- // Exclusivity. TWO fields are deliberately absent from this list:
566
- //
567
- // `motionEdit` — an IK edit regenerates a span of the take preserve is
568
- // reconstructing, which is exactly the combination scheduled inpainting
569
- // exists for.
570
- //
571
- // `waypoints` — ALLOWED since round 2 (contract C3v2, paper 4.4). Round 1
572
- // refused it on the reasoning that an authored root path "re-plans the whole
573
- // rollout, leaving nothing to preserve". That is true only of a mask that
574
- // preserves the root: with the grouped mask of C1v2 the bridge frees the
575
- // `root` group for the WHOLE clip (preserveParams.rootFree below) and keeps
576
- // everything else, so the drawn path owns the trajectory and heading while
577
- // the body still rides the preserved take — which is the feature. It stays
578
- // SINGLE-SEGMENT: `segments` is still refused just below, so a preserve +
579
- // waypoints run is always one prompt over one rollout.
580
- if (body.regenerateSegments !== undefined) {
581
- return "field 'preserve' cannot be combined with regenerateSegments: a regenerated block set replaces the take preserve would reconstruct";
582
- }
583
- // The box refuses this pairing too ("scheduled inpainting v1 supports a
584
- // single segment"), after loading a model and a base motion. Failing here
585
- // costs nothing and can name the version limit instead of a stack trace.
586
- if (body.segments !== undefined) {
587
- return "field 'preserve' cannot be combined with a prompt schedule: scheduled inpainting v1 supports a single segment";
588
- }
589
- return null;
590
- }
591
-
592
- // strength -> the (sigma_s, sigma_e) pair the backend blends between. This
593
- // mapping is server-side by contract so a client never speaks in diffusion-time
594
- // units. The mapping is INVERTED against sigma_s on purpose: alpha_time is 1
595
- // ABOVE sigma_s and fades to 0 at sigma_e, so a SMALLER sigma_s keeps the base
596
- // blended deeper into the low-noise steps and preserves more. Measured on the
597
- // box (gate G3, all-ones mask, seed 5678): sigma_s 200 -> L2P 0.00455,
598
- // 500 -> 0.00467, 800 -> 0.00480, 1000 -> 0.00487 — strictly looser as sigma_s
599
- // rises. The first draft mapped strength straight onto sigma_s and made the
600
- // slider work backwards. sigma_e is capped rather than scaled: below it the
601
- // model must always be free to finish, or the take is frozen onto the base and
602
- // the edit cannot happen. The floor at the cap keeps full strength a step
603
- // schedule (blend until sigma_e) instead of rounding sigma_s to 0, which the
604
- // backend reads as preserve fully off.
605
- function preserveSigmas(strength) {
606
- const sigmaS = Math.max(PRESERVE_SIGMA_END_CAP, Math.round(PRESERVE_SIGMA_MAX * (1 - strength)));
607
- return { sigmaS, sigmaE: Math.min(PRESERVE_SIGMA_END_CAP, sigmaS) };
608
- }
609
-
610
- // Returns an error message naming the offending field, or null when valid.
611
- // The fixed contract: 2..32 sparse {frame,x,z,heading} keys, starting at
612
- // frame 0 with strictly ascending frames. The backend generates every
613
- // in-between frame. x/z are finite metres in [-20,20], heading null or finite radians
614
- // in [-2π,2π].
615
- // Every rejection names 'waypoints' so the client can point at the offending
616
- // entry.
617
- function validateWaypoints(waypoints, clipFrames) {
618
- if (!Array.isArray(waypoints)) {
619
- return "field 'waypoints' must be an array";
620
- }
621
- if (waypoints.length < 2 || waypoints.length > WAYPOINTS_MAX) {
622
- return `field 'waypoints' must have 2..${WAYPOINTS_MAX} sparse entries, got ${waypoints.length}`;
623
- }
624
- let prevFrame = -1;
625
- for (let i = 0; i < waypoints.length; i += 1) {
626
- const wp = waypoints[i];
627
- if (!wp || typeof wp !== "object" || Array.isArray(wp)) {
628
- return `field 'waypoints[${i}]' must be an object`;
629
- }
630
- if (!Number.isInteger(wp.frame) || wp.frame < 0 || wp.frame >= clipFrames) {
631
- return `field 'waypoints[${i}].frame' must be an integer in 0..${clipFrames - 1}, got ${JSON.stringify(wp.frame)}`;
632
- }
633
- if (wp.frame <= prevFrame) {
634
- return `field 'waypoints' frames must be strictly ascending: frame ${wp.frame} duplicates or precedes frame ${prevFrame} (index ${i})`;
635
- }
636
- prevFrame = wp.frame;
637
- if (i === 0 && wp.frame !== 0) {
638
- return `field 'waypoints[0].frame' must be 0 (start + destination path), got ${wp.frame}`;
639
- }
640
- for (const axis of ["x", "z"]) {
641
- const value = wp[axis];
642
- if (typeof value !== "number" || !Number.isFinite(value)) {
643
- return `field 'waypoints[${i}].${axis}' must be a finite number, got ${JSON.stringify(value)}`;
644
- }
645
- if (value < -ROOT_2D_RANGE_M || value > ROOT_2D_RANGE_M) {
646
- return `field 'waypoints[${i}].${axis}' ${value} is outside -${ROOT_2D_RANGE_M}..${ROOT_2D_RANGE_M} meters`;
647
- }
648
- }
649
- if (!("heading" in wp) || wp.heading === undefined) {
650
- return `field 'waypoints[${i}].heading' is missing; must be null or a number of radians`;
651
- }
652
- if (wp.heading !== null) {
653
- if (typeof wp.heading !== "number" || !Number.isFinite(wp.heading)) {
654
- return `field 'waypoints[${i}].heading' must be null or a finite number of radians, got ${JSON.stringify(wp.heading)}`;
655
- }
656
- if (Math.abs(wp.heading) > HEADING_RANGE_RAD) {
657
- return `field 'waypoints[${i}].heading' ${wp.heading} rad is outside -2π..2π`;
658
- }
659
- }
660
- }
661
- // Root pins are inpainting observations the model cannot refuse, so a pin
662
- // pair demanding an inexpressible pace collapses the gait into
663
- // foot-sliding instead of erroring. The bands are wider than the authored
664
- // 0.5..3 m/s walk band because these are the dense C1 samples, which dip
665
- // through corners and pause dead in authored holds.
666
- // holdPair[i]: the pair ending at waypoint i is a deliberate hold.
667
- // Index 0 has no pair — false, so the first real leg gets no exemption.
668
- const holdPair = waypoints.map((wp, i) =>
669
- i === 0 ? false : Math.hypot(wp.x - waypoints[i - 1].x, wp.z - waypoints[i - 1].z) <= WAYPOINT_HOLD_EPS_M,
670
- );
671
- for (let i = 1; i < waypoints.length; i += 1) {
672
- if (holdPair[i]) continue; // a deliberate hold
673
- const prev = waypoints[i - 1];
674
- const wp = waypoints[i];
675
- const speed = Math.hypot(wp.x - prev.x, wp.z - prev.z) / ((wp.frame - prev.frame) / FPS);
676
- if (speed > WAYPOINT_SPEED_MAX_MPS) {
677
- return `field 'waypoints[${i}]' implies ${speed.toFixed(1)} m/s from waypoint ${i - 1} — faster than the ${WAYPOINT_SPEED_MAX_MPS} m/s locomotion ceiling`;
678
- }
679
- // Pairs beside a hold are the C1 ramp down to (or up from) zero and
680
- // legitimately pass under the floor; everywhere else, sub-gait creep
681
- // means foot-sliding.
682
- const besideHold = holdPair[i - 1] || (i + 1 < waypoints.length && holdPair[i + 1]);
683
- if (speed < WAYPOINT_SPEED_MIN_MPS && !besideHold) {
684
- return `field 'waypoints[${i}]' implies ${speed.toFixed(2)} m/s from waypoint ${i - 1} — below the ${WAYPOINT_SPEED_MIN_MPS} m/s gait floor (hold still or move at walking pace, nothing between)`;
685
- }
686
- }
687
- return null;
688
- }
689
-
690
- function readBody(req, limitBytes) {
691
- return new Promise((resolvePromise, reject) => {
692
- const chunks = [];
693
- let total = 0;
694
- req.on("data", (chunk) => {
695
- total += chunk.length;
696
- if (total > limitBytes) {
697
- reject(new Error(`request body exceeds ${limitBytes} bytes`));
698
- req.destroy();
699
- return;
700
- }
701
- chunks.push(chunk);
702
- });
703
- req.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8")));
704
- req.on("error", (err) => reject(new Error(`request aborted: ${err.message}`)));
705
- });
706
- }
707
-
708
- // endpoints
709
-
710
- function sendJson(res, status, obj) {
711
- res.writeHead(status, {
712
- "Content-Type": "application/json",
713
- "Cache-Control": "no-store",
714
- });
715
- res.end(`${JSON.stringify(obj)}\n`);
716
- }
717
-
718
- // Shared 500 handler for async route failures: if headers already went out
719
- // the only thing left is to drop the socket; either way the error is logged.
720
- function respondInternalError(res, error, route) {
721
- if (!res.headersSent) sendJson(res, 500, { ok: false, reason: `internal error: ${error.message}` });
722
- else {
723
- try {
724
- res.end();
725
- } catch {
726
- /* socket gone */
727
- }
728
- }
729
- console.error(`[bridge] ${route} threw: ${error.stack || error}`);
730
- log(500);
731
- }
732
-
733
- // The generator's single-line JSON report is the LAST stdout line of
734
- // cclay_constrained_generate.py and is compact-printed (starts with "{").
735
- // Constrained generation reports carry target_space; autoregressive sequence
736
- // reports carry a segment table and continuity metrics.
737
- function tryParseReport(line) {
738
- const trimmed = line.trimStart();
739
- if (!trimmed.startsWith("{")) return null;
740
- let parsed;
741
- try {
742
- parsed = JSON.parse(trimmed);
743
- } catch {
744
- return null;
745
- }
746
- const constrained = parsed && typeof parsed === "object" && typeof parsed.target_space === "string";
747
- const sequence =
748
- parsed &&
749
- typeof parsed === "object" &&
750
- Number.isInteger(parsed.frames) &&
751
- Number.isInteger(parsed.fps) &&
752
- Array.isArray(parsed.segments) &&
753
- parsed.continuity &&
754
- typeof parsed.continuity === "object";
755
- const motionEdit =
756
- parsed &&
757
- typeof parsed === "object" &&
758
- Array.isArray(parsed.edit_range) &&
759
- Array.isArray(parsed.history_range) &&
760
- Array.isArray(parsed.future_range);
761
- if (!constrained && !sequence && !motionEdit) {
762
- return null;
763
- }
764
- return parsed;
765
- }
766
-
767
- // generated-motion delivery
768
-
769
- // run-id -> absolute npz path, populated ONLY after this process generated
770
- // the file and verified it on disk (see handleGenerate's done branch). The
771
- // GET handler never accepts a path from the URL: an id that is not in this
772
- // map is 404 by definition. Capped so a long-lived dev sidecar cannot grow
773
- // without bound; evicted ids become stale and 404, which is documented.
774
- const motionAllowlist = new Map();
775
-
776
- function registerMotion(runId, absPath) {
777
- motionAllowlist.set(runId, absPath);
778
- if (motionAllowlist.size > MOTION_ALLOWLIST_MAX) {
779
- const oldest = motionAllowlist.keys().next().value;
780
- evictPrivateArtifact(motionAllowlist, oldest);
781
- }
782
- }
783
-
784
- // The Studio agent runtime pins the artifact URL to this bridge's own origin
785
- // (bin/agent/motion-runtime.mjs), so the editor page on the dev/launcher port
786
- // fetches it cross-origin. Only loopback pages are granted read access.
787
- const LOOPBACK_ORIGIN = /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d{1,5}$/;
788
-
789
- /** CORS headers for a generated-motion download requested from `origin`. */
790
- export function motionCorsHeaders(origin) {
791
- return LOOPBACK_ORIGIN.test(origin ?? "") ? { "Access-Control-Allow-Origin": origin } : {};
792
- }
793
-
794
- // Serves /ardy/motions/<run-id>; returns the HTTP status to log.
795
- function serveMotion(req, res, pathname) {
796
- const match = /^\/ardy\/motions\/([^/]+)$/.exec(pathname);
797
- if (!match) {
798
- sendJson(res, 404, { ok: false, reason: `not found: ${req.method} ${pathname}` });
799
- return 404;
800
- }
801
- const runId = match[1];
802
- if (!MOTION_ID.test(runId) || !motionAllowlist.has(runId)) {
803
- sendJson(res, 404, { ok: false, reason: `unknown or expired motion "${runId}"` });
804
- return 404;
805
- }
806
- const absPath = motionAllowlist.get(runId);
807
- // The map only ever holds paths this process joined under OUT_DIR, but
808
- // the check is re-applied at serve time: nothing outside tools/ardy/out
809
- // is ever served, no matter how it got in.
810
- if (!absPath.startsWith(`${OUT_DIR}${sep}`)) {
811
- sendJson(res, 404, { ok: false, reason: `motion "${runId}" is outside ${OUT_DIR}` });
812
- return 404;
813
- }
814
- let size;
815
- try {
816
- size = statSync(absPath).size;
817
- } catch {
818
- sendJson(res, 404, { ok: false, reason: `motion "${runId}" is no longer on disk` });
819
- return 404;
820
- }
821
- res.writeHead(200, {
822
- "Content-Type": "application/octet-stream",
823
- "Content-Length": size,
824
- "Content-Disposition": `attachment; filename="${basename(absPath)}"`,
825
- "Cache-Control": "no-store",
826
- Vary: "Origin",
827
- ...motionCorsHeaders(req.headers.origin),
828
- });
829
- createReadStream(absPath)
830
- .on("error", (err) => {
831
- console.error(`[bridge] error streaming ${absPath}: ${err.message}`);
832
- res.destroy();
833
- })
834
- .pipe(res);
835
- return 200;
836
- }
837
-
838
- async function handleGenerate(req, res) {
839
- const started = Date.now();
840
- const finish = (status) => {
841
- console.log(`[bridge] POST /ardy/generate -> ${status} (${Date.now() - started} ms)`);
842
- };
843
-
844
- let raw;
845
- try {
846
- raw = await readBody(req, MAX_BODY_BYTES);
847
- } catch (err) {
848
- sendJson(res, 400, { ok: false, reason: err.message });
849
- finish(400);
850
- return;
851
- }
852
- let body;
853
- try {
854
- body = JSON.parse(raw);
855
- } catch (err) {
856
- sendJson(res, 400, { ok: false, reason: `malformed JSON body: ${err.message}` });
857
- finish(400);
858
- return;
859
- }
860
- const invalid = validateGenerate(body);
861
- if (invalid) {
862
- sendJson(res, 400, { ok: false, reason: invalid });
863
- finish(400);
864
- return;
865
- }
866
- // A given base must be one of the ids the box actually reported, so a
867
- // typo or a guessed id never reaches the generator. posePin:false requests
868
- // may omit base entirely - with waypoints that is the two-pass mode
869
- // waypoints and without
870
- // waypoints it is free generation - so the listing is skipped there.
871
- const posePinned = body.posePin !== false;
872
- const requestedPoses = posePinned
873
- ? (body.motionEdit
874
- ? body.motionEdit.edits
875
- : Array.isArray(body.poses)
876
- ? body.poses
877
- : [{ frame: body.dstFrame, pose: body.pose }])
878
- : [];
879
- const baseLabel = body.base === undefined ? "internal-neutral" : body.base;
880
- const dstFrameLabel = requestedPoses.map((entry) => entry.frame).join(",") || "none";
881
- let match = null;
882
- let sourceMotionPath = null;
883
- if (body.regenerateSegments || body.motionEdit) {
884
- const sourceUrl = body.motionEdit?.sourceMotion || body.sourceMotion;
885
- const sourceId = sourceUrl.slice("/ardy/motions/".length);
886
- sourceMotionPath = motionAllowlist.get(sourceId) || null;
887
- if (!sourceMotionPath || !existsSync(sourceMotionPath)) {
888
- sendJson(res, 400, { ok: false, reason: `field 'sourceMotion': unknown or expired motion "${sourceId}"` });
889
- finish(400);
890
- return;
891
- }
892
- }
893
- // Scheduled inpainting needs the BACKEND's own artifact of the take being
894
- // preserved: Kimodo's --base_motion reads the npz its generator wrote, and
895
- // the cskel27 file served at /ardy/motions/<id> is a lossy conversion of it,
896
- // not that file. Which artifact that is stays a backend question, so the
897
- // runner answers it and the bridge stays backend-neutral.
898
- let preserveParams = null;
899
- let preserveSkipped = null;
900
- if (body.preserve) {
901
- const preserveId = body.preserve.sourceMotion.slice("/ardy/motions/".length);
902
- const takePath = motionAllowlist.get(preserveId) || null;
903
- if (!takePath || !existsSync(takePath)) {
904
- sendJson(res, 400, { ok: false, reason: `field 'preserve.sourceMotion': unknown or expired motion "${preserveId}"` });
905
- finish(400);
906
- return;
907
- }
908
- const basePath = runner.baseMotionFor ? runner.baseMotionFor(takePath) : null;
909
- if (basePath) {
910
- preserveParams = {
911
- basePath,
912
- // The raw slider value rides along beside the derived sigmas: the
913
- // schedule alone is nearly binary in practice (gate G3 measured a
914
- // 7% L2P spread across the whole sweep), so the generator also
915
- // scales the blend AMPLITUDE by it to give the dial real range.
916
- strength: body.preserve.strength,
917
- ...preserveSigmas(body.preserve.strength),
918
- // `tracks` (C3v2) rides along INSIDE each range: the mask builder is
919
- // the only layer that knows the generation clock, so ranges are
920
- // forwarded verbatim and never resolved to groups here.
921
- editRanges: body.preserve.editRanges,
922
- // preserve + waypoints (C3v2, paper 4.4): the authored path owns the
923
- // root for the WHOLE clip, so the mask's `root` group is zeroed end
924
- // to end and the drawn trajectory is not fighting a preserved one.
925
- // Everything else still rides the base take, including the groups the
926
- // edit ranges free. A FLAG, not a mask: generate.mjs builds the mask
927
- // in exactly one place and composes this onto it there.
928
- rootFree: body.waypoints !== undefined,
929
- };
930
- } else {
931
- // The take is real, but this backend kept no base motion for it:
932
- // motion edits and regenerated block sets are SPLICED from two
933
- // motions, so the backend's own artifact is not what the user is
934
- // looking at, and takes generated before scheduled inpainting landed
935
- // kept nothing at all. Preservation is a best-effort quality knob
936
- // that is ON by default, so a missing base degrades to the plain
937
- // generation this request would have been before the feature existed
938
- // — loudly, on the status stream the App logs — instead of failing a
939
- // run the operator did ask for.
940
- preserveSkipped =
941
- `[bridge] preserve SKIPPED: take "${preserveId}" has no ${runner.mode} base motion on disk ` +
942
- "(edited and spliced takes keep none); generating WITHOUT scheduled inpainting";
943
- }
944
- }
945
- // A line edit REWRITES an existing take, so the take it names is resolved the
946
- // same way preserve's is — through the allowlist this process populated, never
947
- // from a path in the request — and before anything opens a stream.
948
- let lineEditTakePath = null;
949
- if (body.lineEdit) {
950
- if (!projflowRunner) {
951
- sendJson(res, 503, {
952
- ok: false,
953
- reason: "line editing needs the ProjFlow backend: set CCLAY_PROJFLOW_HOST (or CCLAY_KIMODO_HOST)",
954
- });
955
- finish(503);
956
- return;
957
- }
958
- const lineEditId = body.lineEdit.sourceMotion.slice("/ardy/motions/".length);
959
- lineEditTakePath = motionAllowlist.get(lineEditId) || null;
960
- if (!lineEditTakePath || !existsSync(lineEditTakePath)) {
961
- sendJson(res, 400, { ok: false, reason: `field 'lineEdit.sourceMotion': unknown or expired motion "${lineEditId}"` });
962
- finish(400);
963
- return;
964
- }
965
- }
966
- // A replay needs the same backend a line edit does, and it needs it BEFORE the
967
- // stream opens: the generation itself would succeed, and refusing halfway
968
- // through would leave the client holding a take whose refinements silently
969
- // never ran. Checked here rather than in the validator because "is a box
970
- // configured" is process state, not a request rule.
971
- if (body.replay && body.replay.length > 0 && !projflowRunner) {
972
- sendJson(res, 503, {
973
- ok: false,
974
- reason: "recipe replay needs the ProjFlow backend: set CCLAY_PROJFLOW_HOST (or CCLAY_KIMODO_HOST)",
975
- });
976
- finish(503);
977
- return;
978
- }
979
- if (body.base !== undefined) {
980
- let bases;
981
- try {
982
- bases = await getBases();
983
- } catch (err) {
984
- sendJson(res, 503, { ok: false, reason: `cannot list base motions: ${err.message}` });
985
- finish(503);
986
- return;
987
- }
988
- match = bases.bases.find((entry) => entry.id === body.base);
989
- if (!match) {
990
- sendJson(res, 400, {
991
- ok: false,
992
- reason: `field 'base': unknown base "${body.base}" (the box reports ${bases.bases.length} base motion(s))`,
993
- });
994
- finish(400);
995
- return;
996
- }
997
- }
998
- res.writeHead(200, {
999
- "Content-Type": "application/x-ndjson",
1000
- "Cache-Control": "no-store",
1001
- });
1002
- const send = (obj) => {
1003
- if (res.writableEnded) return;
1004
- try {
1005
- res.write(`${JSON.stringify(obj)}\n`);
1006
- } catch {
1007
- /* socket gone; the close handler below kills the children */
1008
- }
1009
- };
1010
- const sendStatus = (message) => send({ event: "status", message });
1011
- const sendError = (message) => {
1012
- send({ event: "error", message });
1013
- res.end();
1014
- };
1015
- if (preserveSkipped) {
1016
- console.error(preserveSkipped);
1017
- sendStatus(preserveSkipped);
1018
- }
1019
-
1020
- // Children spawned for THIS request, killed on client disconnect or any
1021
- // terminal error. Detached groups, so killGroup takes down the whole tree
1022
- // (bash + the ssh session it waits on).
1023
- const children = new Set();
1024
- const spawnTracked = (command, args, options) => {
1025
- const child = spawn(command, args, options);
1026
- children.add(child);
1027
- track(child);
1028
- return child;
1029
- };
1030
- const killChildren = () => {
1031
- for (const child of children) killGroup(child);
1032
- children.clear();
1033
- };
1034
- let artifactDir = null;
1035
- const cleanupArtifacts = () => {
1036
- if (artifactDir) removePrivateArtifactDir(artifactDir);
1037
- };
1038
- let clientGone = false;
1039
- res.on("close", () => {
1040
- if (!res.writableEnded) {
1041
- clientGone = true;
1042
- console.error(
1043
- `[bridge] client disconnected mid-generate; killing ${children.size} child process group(s)`
1044
- );
1045
- killChildren();
1046
- cleanupArtifacts();
1047
- }
1048
- });
1049
-
1050
- const stamp = `${Date.now()}-${randomBytes(3).toString("hex")}`;
1051
- artifactDir = createPrivateArtifactDir(OUT_DIR, "generate");
1052
- const poseJsonPaths = requestedPoses.map((_, index) => join(artifactDir, `pose-${index}.json`));
1053
- const poseNpzPaths = requestedPoses.map((_, index) => join(artifactDir, `pose-${index}.npz`));
1054
- // posePin:false runs carry no pose, so the artifact name says what the
1055
- // run actually was: constrained (pose pinned) vs generated (no pose).
1056
- const outNpzPath = join(artifactDir, body.lineEdit ? "line-edit.npz" : posePinned ? "constrained.npz" : "generated.npz");
1057
-
1058
- // --- recipe replay (contract C10) ---------------------------------------
1059
- // A take is a RECIPE: seed + prompt blocks + the line edits drawn on top.
1060
- // Regenerating or extending it therefore has to re-apply those edits, or
1061
- // every "add a block" throws away the refinement work. The chain, the
1062
- // per-entry failure policy and the boundary rule live in
1063
- // tools/projflow/replay.mjs; what belongs HERE is only which take the chain
1064
- // starts from, where its intermediates go, and what reaches the client.
1065
- //
1066
- // Called after the generated take is written and verified and BEFORE
1067
- // anything is registered, so the motionUrl the client receives always names
1068
- // the LAST good file in the chain — the take with the refinements in it.
1069
- const replayEntries = Array.isArray(body.replay) ? body.replay : [];
1070
- // The boundaries are a fact about how this take was GENERATED, so they come
1071
- // from the request's prompt schedule and nowhere else. No `segments` field
1072
- // means a single block, which has no internal boundary to warn about.
1073
- const replayBoundaries = blockBoundaries(body.segments, Math.floor(body.duration * FPS));
1074
- let replayReport = null;
1075
- const applyReplay = async (takePath) => {
1076
- if (replayEntries.length === 0) return takePath;
1077
- sendStatus(
1078
- `[bridge] replaying ${replayEntries.length} stored line edit(s) onto the new take` +
1079
- (replayBoundaries.length ? ` (block boundaries at ${replayBoundaries.join(", ")})` : "")
1080
- );
1081
- const result = await runReplay({
1082
- entries: replayEntries,
1083
- takePath,
1084
- artifactDir,
1085
- boundaries: replayBoundaries,
1086
- appFps: FPS,
1087
- runJob: runLineEditJob,
1088
- onStatus: (line) => sendStatus(line),
1089
- });
1090
- replayReport = result.entries;
1091
- const failed = result.entries.filter((entry) => !entry.ok).length;
1092
- const warned = result.entries.filter((entry) => entry.boundaryWarning).length;
1093
- sendStatus(
1094
- `[bridge] replay finished: ${result.entries.length - failed}/${result.entries.length} applied` +
1095
- (failed ? `, ${failed} FAILED (drawn refinements missing from this take)` : "") +
1096
- (warned ? `, ${warned} near a block boundary` : "")
1097
- );
1098
- return result.takePath;
1099
- };
1100
- // One report event, whatever produced it. The replay table rides on the
1101
- // generator's own report when there is one and stands alone when there is
1102
- // not, so a client never has to guess whether the replay ran.
1103
- const sendReport = (report) => {
1104
- if (!report && !replayReport) return;
1105
- send({
1106
- event: "report",
1107
- report: { ...(report || {}), ...(replayReport ? { replay: replayReport } : {}) },
1108
- });
1109
- };
1110
- try {
1111
-
1112
- // --- line edit (contract C6) ----------------------------------------
1113
- // FIRST, and it returns: a line edit shares none of the machinery below.
1114
- // It is its own run mode (no poses, no base, no segments), it runs on the
1115
- // ProjFlow backend whatever CCLAY_MOTION_BACKEND says (engine-per-task),
1116
- // and it produces a take by SPLICING a converted edit into the source
1117
- // rather than by writing whatever the box returned.
1118
- //
1119
- // One honest limitation: the ssh this awaits belongs to lineEditOnBox, not
1120
- // to `children`, so a client that disconnects mid-run does not kill it —
1121
- // the sampling S1 measured is under a second, the wrapper removes its own
1122
- // run directory either way, and the alternative is a cancellation channel
1123
- // through three modules for a sub-second job.
1124
- if (body.lineEdit) {
1125
- sendStatus(
1126
- `[bridge] line-editing ${body.lineEdit.track} over frames ` +
1127
- `${body.lineEdit.frameRange.startFrame}..${body.lineEdit.frameRange.endFrame - 1} on the projflow backend` +
1128
- (body.lineEdit.preview === true ? " (20-step preview)" : "")
1129
- );
1130
- const meta = await runLineEditJob({
1131
- lineEdit: body.lineEdit,
1132
- takePath: lineEditTakePath,
1133
- outputPath: outNpzPath,
1134
- // C10's preview flag, forwarded verbatim. The job owns what "preview"
1135
- // costs (20 ODE steps instead of 100); the bridge only relays that the
1136
- // client asked for a draft. A preview take is registered like any
1137
- // other — the APP decides whether to keep it.
1138
- preview: body.lineEdit.preview === true,
1139
- seed: body.seed,
1140
- appFps: FPS,
1141
- onStatus: (line) => sendStatus(line),
1142
- });
1143
- // The seam numbers gate GP2 reads, on the status stream as well as in
1144
- // the report: a hard cut is what this pipeline ships, and an operator
1145
- // watching a run should see a pop without opening a file.
1146
- sendStatus(
1147
- `[bridge] line edit spliced: seams ${meta.seamStartDelta.toFixed(4)} / ${meta.seamEndDelta.toFixed(4)} m ` +
1148
- `vs the take's median ${meta.medianFrameDelta.toFixed(4)} m/frame`
1149
- );
1150
- send({
1151
- event: "report",
1152
- report: {
1153
- target_space: "skeleton_joint_center",
1154
- engine: "projflow",
1155
- ...meta,
1156
- },
1157
- });
1158
- const finalSize = statSync(outNpzPath).size;
1159
- // Registered exactly like every other generated take, so the app can
1160
- // load it from /ardy/motions/<id> and edit it AGAIN.
1161
- registerMotion(stamp, outNpzPath);
1162
- send({ event: "done", output: outNpzPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
1163
- res.end();
1164
- console.log(`[bridge] line edit finished: ${outNpzPath} (${body.lineEdit.track})`);
1165
- killChildren();
1166
- finish(200);
1167
- return;
1168
- }
1169
-
1170
- // --- pose -> motion npz (local, fast). Skipped when posePin is
1171
- // false: the pose constraint is dropped, so there is nothing to
1172
- // convert, dump a reference for, or push.
1173
- if (posePinned) {
1174
- for (let index = 0; index < requestedPoses.length; index += 1) {
1175
- const entry = requestedPoses[index];
1176
- const poseJsonPath = poseJsonPaths[index];
1177
- const poseNpzPath = poseNpzPaths[index];
1178
- writeFileSync(poseJsonPath, `${JSON.stringify(entry.pose, null, 2)}
1179
- `);
1180
- sendStatus(`[bridge] pose ${index + 1}/${requestedPoses.length} frame ${entry.frame} written`);
1181
- const conv = spawnTracked(
1182
- process.execPath,
1183
- [POSE_TO_NPZ, poseJsonPath, "--out", poseNpzPath],
1184
- { cwd: REPO, detached: true }
1185
- );
1186
- const convLast = { stderr: "", stdout: "" };
1187
- const convCode = await runStreaming(conv, (line, streamName) => {
1188
- convLast[streamName] = line;
1189
- sendStatus(line);
1190
- });
1191
- children.delete(conv);
1192
- if (convCode !== 0) {
1193
- killChildren();
1194
- sendError(`pose-to-npz failed (exit ${convCode}): ${convLast.stderr || convLast.stdout || "no output"}`);
1195
- cleanupArtifacts();
1196
- finish(200);
1197
- return;
1198
- }
1199
- }
1200
- }
1201
-
1202
- // --- generation on the box -----------------------------------------
1203
- const clipFrames = Math.floor(body.duration * FPS);
1204
- const segments = body.segments || [{ startFrame: 0, endFrame: clipFrames, prompt: body.prompt }];
1205
- if (body.motionEdit) {
1206
- const manifestPath = join(artifactDir, "edit-manifest.json");
1207
- const manifest = {
1208
- start_frame: body.motionEdit.startFrame,
1209
- end_frame: body.motionEdit.endFrame,
1210
- edits: body.motionEdit.edits.map((entry, index) => ({
1211
- frame: entry.frame,
1212
- tracks: entry.tracks,
1213
- root: entry.pose.root,
1214
- pose_path: `pose-${index}.npz`,
1215
- })),
1216
- };
1217
- writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
1218
- const cmd = await runner.editCommand({
1219
- source: sourceMotionPath,
1220
- manifest: manifestPath,
1221
- prompt: body.prompt,
1222
- contextBefore: body.motionEdit.contextBefore,
1223
- contextAfter: body.motionEdit.contextAfter,
1224
- seed: body.seed,
1225
- poseNpzPaths,
1226
- // An edit run regenerates the whole clip and splices only the
1227
- // edited span back in, so its backend artifact is NOT the take the
1228
- // user ends up with — it keeps no base motion of its own. It can
1229
- // still PRESERVE one: reconstructing the source outside the edit is
1230
- // what makes the regenerated span line up with it.
1231
- preserve: preserveParams,
1232
- output: outNpzPath,
1233
- });
1234
- sendStatus(
1235
- `[bridge] editing frames ${body.motionEdit.startFrame}..${body.motionEdit.endFrame - 1} ` +
1236
- "with history and sparse constraints"
1237
- );
1238
- const box = spawnTracked(cmd.command, cmd.args, { cwd: REPO, detached: true, env: cmd.env });
1239
- const last = { stderr: "", stdout: "" };
1240
- let report = null;
1241
- let done = null;
1242
- const code = await runStreaming(box, (line, streamName) => {
1243
- last[streamName] = line;
1244
- if (streamName === "stdout") {
1245
- const parsed = tryParseReport(line);
1246
- if (parsed) {
1247
- report = parsed;
1248
- return;
1249
- }
1250
- const marker = cmd.doneRe.exec(line);
1251
- if (marker) {
1252
- done = { path: marker[1], bytes: Number(marker[2]) };
1253
- return;
1254
- }
1255
- }
1256
- sendStatus(line);
1257
- });
1258
- children.delete(box);
1259
- if (code !== 0) throw new Error(`${cmd.label} failed (exit ${code}): ${last.stderr || last.stdout || "no output"}`);
1260
- if (!done || done.path !== outNpzPath) throw new Error(`${cmd.label} did not return the requested output`);
1261
- const finalSize = statSync(outNpzPath).size;
1262
- if (finalSize !== done.bytes) throw new Error(`${cmd.label} output size mismatch`);
1263
- if (report) send({ event: "report", report });
1264
- registerMotion(stamp, outNpzPath);
1265
- send({ event: "done", output: outNpzPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
1266
- res.end();
1267
- console.log(`[bridge] context-aware motion edit finished: ${outNpzPath}`);
1268
- killChildren();
1269
- finish(200);
1270
- return;
1271
- }
1272
- if (body.segments) {
1273
- // Root waypoints ride the same chained rollout (rollout-global
1274
- // frames): the sequence generator slices the constraint set per
1275
- // segment call, so a path and a prompt schedule coexist.
1276
- const cmd = await runner.sequenceCommand({
1277
- segments: segments.map((segment) => ({
1278
- prompt: segment.prompt,
1279
- durationS: (segment.endFrame - segment.startFrame) / FPS,
1280
- })),
1281
- waypoints: body.waypoints,
1282
- seed: body.seed,
1283
- cpu: body.cpu,
1284
- rootMargin: body.rootMargin,
1285
- contactThreshold: body.contactThreshold,
1286
- historyFrames: body.historyFrames,
1287
- // The written take IS this run's generation, retimed — so keep the
1288
- // backend's own npz beside it as a base a later preserve run can
1289
- // reconstruct. (A prompt schedule can never itself preserve: v1 is
1290
- // single-segment. Being preserved FROM is unrestricted.)
1291
- keepNative: true,
1292
- output: outNpzPath,
1293
- });
1294
- sendStatus(
1295
- `[bridge] generating ${segments.length} blocks in one autoregressive session` +
1296
- (body.waypoints?.length ? ` with a ${body.waypoints.length}-pin root path` : "")
1297
- );
1298
-
1299
- const box = spawnTracked(cmd.command, cmd.args, { cwd: REPO, detached: true, env: cmd.env });
1300
- const last = { stderr: "", stdout: "" };
1301
- let finalReport = null;
1302
- let done = null;
1303
- const code = await runStreaming(box, (line, streamName) => {
1304
- last[streamName] = line;
1305
- if (streamName === "stdout") {
1306
- const parsed = tryParseReport(line);
1307
- if (parsed) {
1308
- finalReport = parsed;
1309
- return;
1310
- }
1311
- const marker = cmd.doneRe.exec(line);
1312
- if (marker) {
1313
- done = { path: marker[1], bytes: Number(marker[2]) };
1314
- return;
1315
- }
1316
- }
1317
- sendStatus(line);
1318
- });
1319
- children.delete(box);
1320
- if (code !== 0) {
1321
- throw new Error(`${cmd.label} failed (exit ${code}): ${last.stderr || last.stdout || "no output"}`);
1322
- }
1323
- if (!done) throw new Error(`${cmd.label} exited 0 without a "done" marker`);
1324
- if (done.path !== outNpzPath) throw new Error(`${cmd.label} returned unexpected output ${done.path}`);
1325
- const finalSize = statSync(outNpzPath).size;
1326
- if (finalSize === 0 || finalSize !== done.bytes) {
1327
- throw new Error(`${cmd.label} output size mismatch for ${outNpzPath}`);
1328
- }
1329
- // The take exists and is verified; NOW the recipe's line edits go back
1330
- // on, and the file they leave behind is the one that gets registered.
1331
- const deliveredPath = await applyReplay(outNpzPath);
1332
- sendReport(finalReport);
1333
- const deliveredSize = statSync(deliveredPath).size;
1334
- registerMotion(stamp, deliveredPath);
1335
- send({ event: "done", output: deliveredPath, bytes: deliveredSize, motionUrl: `/ardy/motions/${stamp}` });
1336
- res.end();
1337
- console.log(`[bridge] sequence generation finished: ${deliveredPath} (${segments.length} blocks)`);
1338
- killChildren();
1339
- finish(200);
1340
- return;
1341
- }
1342
- const runSingle = async (segment, outputPath = outNpzPath) => {
1343
- const localPoses = requestedPoses
1344
- .map((entry, poseIndex) => ({ ...entry, poseIndex }))
1345
- .filter((entry) => entry.frame >= segment.startFrame && entry.frame < segment.endFrame);
1346
- if (posePinned && localPoses.length === 0) {
1347
- throw new Error("generation has no pose constraint");
1348
- }
1349
- const segmentFrames = segment.endFrame - segment.startFrame;
1350
- const cmd = await runner.singleCommand({
1351
- poseFroms: localPoses.map((entry) => ({
1352
- npz: poseNpzPaths[entry.poseIndex],
1353
- srcFrame: 0,
1354
- dstFrame: entry.frame - segment.startFrame,
1355
- })),
1356
- basePath: match ? match.path : null,
1357
- prompt: segment.prompt,
1358
- durationS: segmentFrames / FPS,
1359
- seed: body.seed,
1360
- cpu: body.cpu,
1361
- waypoints: body.waypoints,
1362
- rootMargin: body.rootMargin,
1363
- contactThreshold: body.contactThreshold,
1364
- historyFrames: body.historyFrames,
1365
- preserve: preserveParams,
1366
- // regenerateSegments calls this once per block and splices the
1367
- // results into the source take, so no single block's backend npz is
1368
- // the delivered take; only a whole-clip run keeps a base motion.
1369
- keepNative: body.regenerateSegments === undefined,
1370
- output: outputPath,
1371
- });
1372
- sendStatus(`[bridge] generating frames ${segment.startFrame}..${segment.endFrame - 1}`);
1373
-
1374
- const box = spawnTracked(cmd.command, cmd.args, { cwd: REPO, detached: true, env: cmd.env });
1375
- const last = { stderr: "", stdout: "" };
1376
- let report = null;
1377
- let done = null;
1378
- const code = await runStreaming(box, (line, streamName) => {
1379
- last[streamName] = line;
1380
- if (streamName === "stdout") {
1381
- const parsed = tryParseReport(line);
1382
- if (parsed) {
1383
- report = parsed;
1384
- return;
1385
- }
1386
- const marker = cmd.doneRe.exec(line);
1387
- if (marker) {
1388
- done = { path: marker[1], bytes: Number(marker[2]) };
1389
- return;
1390
- }
1391
- }
1392
- sendStatus(line);
1393
- });
1394
- children.delete(box);
1395
- if (code !== 0) throw new Error(`${cmd.label} failed (exit ${code}): ${last.stderr || last.stdout || "no output"}`);
1396
- if (!done) throw new Error(`${cmd.label} exited 0 without a "done" marker`);
1397
- if (done.path !== outputPath) throw new Error(`${cmd.label} returned unexpected output ${done.path}`);
1398
- const size = statSync(outputPath).size;
1399
- if (size === 0 || size !== done.bytes) throw new Error(`${cmd.label} output size mismatch for ${outputPath}`);
1400
- return report;
1401
- };
1402
-
1403
- if (body.regenerateSegments) {
1404
- const source = await decodeMotionNpz(new Uint8Array(readFileSync(sourceMotionPath)));
1405
- if (source.frames !== clipFrames || source.fps !== FPS) {
1406
- throw new Error(`source motion is ${source.frames} frames @ ${source.fps} fps; expected ${clipFrames} @ ${FPS}`);
1407
- }
1408
- let result = source;
1409
- const reports = [];
1410
- for (let index = 0; index < body.regenerateSegments.length; index += 1) {
1411
- const segment = body.regenerateSegments[index];
1412
- const segmentPath = join(artifactDir, `edit-${index}.npz`);
1413
- const report = await runSingle(segment, segmentPath);
1414
- const generated = await decodeMotionNpz(new Uint8Array(readFileSync(segmentPath)));
1415
- const segmentFrames = segment.endFrame - segment.startFrame;
1416
- if (generated.frames !== segmentFrames || generated.fps !== FPS) {
1417
- throw new Error(`edited block ${index + 1} returned ${generated.frames} frames @ ${generated.fps} fps`);
1418
- }
1419
- result = replaceMotionSegment(result, generated, segment.startFrame);
1420
- if (report) reports.push({ ...report, startFrame: segment.startFrame, endFrame: segment.endFrame });
1421
- }
1422
- writeNpz(outNpzPath, motionArraysToNpzMembers(result));
1423
- const boundaryJumps = [];
1424
- for (const segment of body.regenerateSegments) {
1425
- for (const frame of [segment.startFrame, segment.endFrame]) {
1426
- if (frame <= 0 || frame >= result.frames) continue;
1427
- let maxJump = 0;
1428
- for (let joint = 0; joint < 27; joint += 1) {
1429
- const current = (frame * 27 + joint) * 3;
1430
- const previous = current - 27 * 3;
1431
- maxJump = Math.max(
1432
- maxJump,
1433
- Math.hypot(
1434
- result.posedJoints[current] - result.posedJoints[previous],
1435
- result.posedJoints[current + 1] - result.posedJoints[previous + 1],
1436
- result.posedJoints[current + 2] - result.posedJoints[previous + 2]
1437
- )
1438
- );
1439
- }
1440
- boundaryJumps.push({ frame, max_joint_jump_m: maxJump });
1441
- }
1442
- }
1443
- send({
1444
- event: "report",
1445
- report: {
1446
- target_space: "skeleton_joint_center",
1447
- frames: result.frames,
1448
- fps: result.fps,
1449
- regenerated_segments: body.regenerateSegments,
1450
- segments: reports,
1451
- boundaries: boundaryJumps,
1452
- },
1453
- });
1454
- const finalSize = statSync(outNpzPath).size;
1455
- registerMotion(stamp, outNpzPath);
1456
- send({ event: "done", output: outNpzPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
1457
- res.end();
1458
- console.log(`[bridge] regenerated ${body.regenerateSegments.length} edited block(s): ${outNpzPath}`);
1459
- killChildren();
1460
- finish(200);
1461
- return;
1462
- }
1463
-
1464
- let finalReport;
1465
- try {
1466
- finalReport = await runSingle(segments[0]);
1467
- } catch (error) {
1468
- // The base take is a different length than this generation window —
1469
- // Kimodo's preserve prep refuses it outright (there is no principled
1470
- // resample). The app now avoids sending the pair, but any other client
1471
- // (MCP, replay, an older app) can still ask; degrade to a plain
1472
- // generation LOUDLY, the same policy as a missing base motion, instead
1473
- // of failing a run the operator did ask for.
1474
- if (!preserveParams || !/Base motion duration does not match/.test(String(error?.message ?? ""))) throw error;
1475
- const skipped =
1476
- "[bridge] preserve SKIPPED: the take being preserved is a different length than this " +
1477
- "generation window; generating WITHOUT scheduled inpainting";
1478
- console.error(skipped);
1479
- sendStatus(skipped);
1480
- preserveParams = null;
1481
- finalReport = await runSingle(segments[0]);
1482
- }
1483
- if (finalReport) {
1484
- const poseResults = finalReport.poses || [];
1485
- const worst = (key) => poseResults.length ? Math.max(...poseResults.map((pose) => pose[key] ?? 0)) : null;
1486
- finalReport.root_error_m = worst("root_error_m");
1487
- finalReport.shape_mean_error_m = worst("shape_mean_error_m");
1488
- finalReport.shape_max_error_m = worst("shape_max_error_m");
1489
- finalReport.base_root_error_m = worst("base_root_error_m");
1490
- finalReport.base_shape_mean_error_m = worst("base_shape_mean_error_m");
1491
- finalReport.base_shape_max_error_m = worst("base_shape_max_error_m");
1492
- }
1493
- // Same order as every other branch: generate, verify, replay, report,
1494
- // register, done. runSingle already checked the npz's size against the
1495
- // generator's own "done" marker, so the file the replay reads is real.
1496
- const deliveredPath = await applyReplay(outNpzPath);
1497
- sendReport(finalReport);
1498
- const finalSize = statSync(deliveredPath).size;
1499
- registerMotion(stamp, deliveredPath);
1500
- send({ event: "done", output: deliveredPath, bytes: finalSize, motionUrl: `/ardy/motions/${stamp}` });
1501
- res.end();
1502
- console.log(`[bridge] generate finished: ${deliveredPath} (base ${baseLabel}, ${body.duration}s, dstFrame ${dstFrameLabel})`);
1503
-
1504
- killChildren();
1505
- finish(200);
1506
- } catch (err) {
1507
- killChildren();
1508
- cleanupArtifacts();
1509
- if (!res.writableEnded) sendError(`generate failed: ${err.message}`);
1510
- // A run whose client already hung up dies of collateral damage — its
1511
- // artifact dir was just removed underneath it (see the close handler), so
1512
- // the ENOENT that follows is the abort working, not a generation failure.
1513
- if (clientGone) console.error(`[bridge] generate aborted by client disconnect (${err.message.split("\n")[0]})`);
1514
- else console.error(`[bridge] generate error: ${err.stack || err}`);
1515
- finish(200);
1516
- }
1517
- }
1518
-
1519
- // server
1520
-
1521
- function usage() {
1522
- console.log(`usage: node tools/ardy/bridge.mjs [--port <n>]
1523
-
1524
- Dev-only HTTP sidecar for motion generation. See tools/ardy/BRIDGE.md.
1525
-
1526
- --port <n> port to listen on (default 5181, loopback only)
1527
-
1528
- env:
1529
- COZYCLAY_BRIDGE_PORT same as --port (default 5181)
1530
- CCLAY_MOTION_BACKEND only "kimodo" is supported (default)
1531
- CCLAY_KIMODO_HOST ssh destination for the Kimodo host
1532
- CCLAY_KIMODO_REPO Kimodo checkout on the host (default $HOME/kimodo)
1533
- CCLAY_KIMODO_MODEL model id (default Kimodo-SOMA-RP-v1.1)
1534
-
1535
- line editing (contract C6) runs on the ProjFlow backend regardless of
1536
- CCLAY_MOTION_BACKEND, and advertises itself in /ardy/health as
1537
- capabilities.lineEdit once the box probes clean:
1538
- CCLAY_PROJFLOW_HOST ssh destination (defaults to CCLAY_KIMODO_HOST)
1539
- CCLAY_PROJFLOW_REPO ProjFlow checkout (default /home/yun/projflow-scout/repo)
1540
- CCLAY_PROJFLOW_PYTHON the scout venv's python
1541
- CCLAY_PROJFLOW_HOME HOME override for box runs
1542
- `);
1543
- }
1544
-
1545
- function die(message) {
1546
- console.error(`[bridge] ${message}`);
1547
- process.exit(2);
1548
- }
1549
-
1550
- function resolvePort(argv) {
1551
- for (let i = 0; i < argv.length; i += 1) {
1552
- if (argv[i] === "--help" || argv[i] === "-h") {
1553
- usage();
1554
- process.exit(0);
1555
- }
1556
- if (argv[i] === "--port") {
1557
- const value = argv[i + 1];
1558
- if (value === undefined) die("--port needs a value");
1559
- const port = Number(value);
1560
- if (!Number.isInteger(port) || port < 1 || port > 65535) die(`invalid --port value '${value}'`);
1561
- return port;
1562
- }
1563
- }
1564
- if (process.env.COZYCLAY_BRIDGE_PORT !== undefined) {
1565
- const port = Number(process.env.COZYCLAY_BRIDGE_PORT);
1566
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
1567
- die(`invalid COZYCLAY_BRIDGE_PORT value '${process.env.COZYCLAY_BRIDGE_PORT}'`);
1568
- }
1569
- return port;
1570
- }
1571
- return DEFAULT_PORT;
1572
- }
1573
-
1574
- // Importing the module (tests) must not bind a port or pick a runner.
1575
- function startBridge() {
1576
- const port = resolvePort(process.argv.slice(2));
1577
- try {
1578
- runner = createRunner();
1579
- } catch (err) {
1580
- die(err.message);
1581
- }
1582
- // Beside the selected backend, never instead of it. A missing ProjFlow
1583
- // configuration is NOT fatal — the bridge's other run modes do not need it —
1584
- // so the failure is reported once at startup and the capability stays off.
1585
- try {
1586
- projflowRunner = createProjflowRunner();
1587
- } catch (err) {
1588
- console.error(`[bridge] line editing is unavailable: ${err.message}`);
1589
- }
1590
-
1591
- const server = createServer((req, res) => {
1592
- const started = Date.now();
1593
- const pathname = (req.url || "/").split("?")[0];
1594
- const log = (status) => {
1595
- console.log(`[bridge] ${req.method} ${pathname} -> ${status} (${Date.now() - started} ms)`);
1596
- };
1597
-
1598
- if (req.method === "OPTIONS") {
1599
- // 204 with NO CORS headers on purpose: a cross-origin browser
1600
- // preflight must fail, and the same-origin Vite proxy never
1601
- // preflights the bridge (it forwards server-side).
1602
- res.writeHead(204);
1603
- res.end();
1604
- log(204);
1605
- return;
1606
- }
1607
- if (pathname === "/ardy/health" && req.method === "GET") {
1608
- // `capabilities` is how the app learns that an OPTIONAL run mode is wired
1609
- // on this box. The draw-a-line affordance is gated on capabilities.lineEdit
1610
- // (App.jsx accepts the object or an array spelling), so a bridge whose
1611
- // ProjFlow env is missing keeps the feature dark instead of letting the
1612
- // artist draw a stroke that would 503. Probed, never assumed.
1613
- Promise.all([getHealth(), getLineEditCapability()])
1614
- .then(([value, lineEdit]) => {
1615
- sendJson(res, 200, {
1616
- ...value,
1617
- extractionBackend: EXTRACT_BACKEND_SUPPORTED ? EXTRACT_BACKEND : "unsupported",
1618
- capabilities: { lineEdit, extractionBackend: EXTRACT_BACKEND_SUPPORTED ? EXTRACT_BACKEND : "unsupported" },
1619
- });
1620
- log(200);
1621
- })
1622
- .catch((err) => {
1623
- // The selected Kimodo route is configured even when its probe fails;
1624
- // expose only the safe backend bucket and boolean configuration state.
1625
- sendJson(res, 503, {
1626
- ok: false,
1627
- backend: "local_kimodo",
1628
- host_configured: Boolean(process.env.CCLAY_KIMODO_HOST?.trim()),
1629
- reason: err.message,
1630
- });
1631
- log(503);
1632
- });
1633
- return;
1634
- }
1635
- if (pathname === "/ardy/bases" && req.method === "GET") {
1636
- getBases()
1637
- .then((value) => {
1638
- sendJson(res, 200, value);
1639
- log(200);
1640
- })
1641
- .catch((err) => {
1642
- sendJson(res, 503, { ok: false, reason: err.message });
1643
- log(503);
1644
- });
1645
- return;
1646
- }
1647
- if (pathname === "/ardy/generate" && req.method === "POST") {
1648
- handleGenerate(req, res).catch((err) => respondInternalError(res, err, `${req.method} ${pathname}`));
1649
- return;
1650
- }
1651
- if (/^\/ardy\/motions\//.test(pathname) && req.method === "GET") {
1652
- log(serveMotion(req, res, pathname));
1653
- return;
1654
- }
1655
- if (pathname === "/ardy/footage" && req.method === "POST") {
1656
- handleFootage(req, res, (request) => readBody(request, MAX_BODY_BYTES)).catch((err) =>
1657
- respondInternalError(res, err, `${req.method} ${pathname}`),
1658
- );
1659
- return;
1660
- }
1661
- if (/^\/ardy\/footage\//.test(pathname) && req.method === "GET") {
1662
- log(serveFootage(req, res, pathname));
1663
- return;
1664
- }
1665
- if (pathname === "/ardy/extract" && req.method === "POST") {
1666
- handleExtract(req, res, {
1667
- readBody: (request) => readBody(request, MAX_BODY_BYTES),
1668
- footagePath,
1669
- registerMotion,
1670
- artifactRoot: OUT_DIR,
1671
- }).catch((err) => respondInternalError(res, err, `${req.method} ${pathname}`));
1672
- return;
1673
- }
1674
- if (
1675
- pathname === "/ardy/health" ||
1676
- pathname === "/ardy/bases" ||
1677
- pathname === "/ardy/generate" ||
1678
- pathname === "/ardy/footage" ||
1679
- pathname === "/ardy/extract" ||
1680
- /^\/ardy\/motions\//.test(pathname) ||
1681
- /^\/ardy\/footage\//.test(pathname)
1682
- ) {
1683
- sendJson(res, 405, { ok: false, reason: `method ${req.method} not allowed on ${pathname}` });
1684
- log(405);
1685
- return;
1686
- }
1687
- sendJson(res, 404, { ok: false, reason: `not found: ${req.method} ${pathname}` });
1688
- log(404);
1689
- });
1690
-
1691
- server.on("clientError", (err, socket) => {
1692
- if (socket.writable) {
1693
- socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
1694
- }
1695
- console.error(`[bridge] client error: ${err.message}`);
1696
- });
1697
-
1698
- server.listen(port, BIND_HOST, () => {
1699
- const address = server.address();
1700
- const boundPort = typeof address === "object" && address ? address.port : port;
1701
- process.send?.({ type: "cozyclay-bridge-ready", port: boundPort });
1702
- console.log(`[bridge] motion dev bridge listening on http://${BIND_HOST}:${boundPort}`);
1703
- console.log("[bridge] dev-only sidecar: the static dist/ build does not need it; stop with Ctrl-C");
1704
- console.log(`[bridge] ${runner.mode} backend: ${runner.describe()}`);
1705
- });
1706
-
1707
- server.on("error", (err) => {
1708
- const override = "set COZYCLAY_BRIDGE_PORT to a free port or pass --port <n>";
1709
- console.error(
1710
- `[bridge] cannot listen on ${BIND_HOST}:${port}: ${err.message}` +
1711
- (err?.code === "EADDRINUSE" ? `; ${override}` : ""),
1712
- );
1713
- if (process.send && process.connected) {
1714
- process.send({ type: "cozyclay-bridge-listen-error", port, code: err?.code }, () => process.exit(1));
1715
- } else {
1716
- process.exit(1);
1717
- }
1718
- });
1719
-
1720
- // Ctrl-C / SIGTERM: take the in-flight process groups down with us; ssh dies,
1721
- // sshd closes the session, and the remote generation is not orphaned.
1722
- for (const signal of ["SIGINT", "SIGTERM"]) {
1723
- process.on(signal, () => {
1724
- console.error(`[bridge] received ${signal}; killing ${globalChildren.size} in-flight child process group(s)`);
1725
- for (const child of globalChildren) killGroup(child);
1726
- process.exit(signal === "SIGINT" ? 130 : 143);
1727
- });
1728
- }
1729
- }
1730
-
1731
- const isEntry = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
1732
- if (isEntry) startBridge();