cozyclay 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (577) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +12 -30
  3. package/bin/agent/agent-routes.mjs +40 -113
  4. package/bin/agent/agent-runner.mjs +9 -10
  5. package/bin/agent/cliproxy-provider.mjs +135 -0
  6. package/bin/agent/credential-store.mjs +30 -7
  7. package/bin/agent/pi-tools.mjs +4 -1
  8. package/bin/agent/provider-keys.mjs +32 -0
  9. package/bin/agent/provider-login.mjs +110 -0
  10. package/bin/agent/provider-routes.mjs +99 -0
  11. package/bin/agent/providers.mjs +19 -97
  12. package/bin/agent/script-tool.mjs +17 -4
  13. package/bin/agent/studio-motion-reference.mjs +23 -0
  14. package/bin/agent/studio-prompt.mjs +1 -1
  15. package/bin/agent/studio-rebase-text.mjs +32 -0
  16. package/bin/agent/studio-rebase.mjs +26 -39
  17. package/bin/agent/studio-tools.mjs +9 -28
  18. package/bin/agent/tool-arguments.mjs +49 -0
  19. package/bin/agent/turn-frames.mjs +66 -0
  20. package/bin/codex-auth.mjs +9 -2
  21. package/bin/cozyclay.mjs +10 -116
  22. package/bin/live/cli.mjs +3 -3
  23. package/bin/mcp-runtime.mjs +2 -12
  24. package/bin/package-signature.mjs +0 -4
  25. package/dist/app/index.html +7 -6
  26. package/dist/assets/EXRLoader-CvVIvGVE.js +1 -0
  27. package/dist/assets/analytics-C9sUnndd.js +1 -0
  28. package/dist/assets/app-CCh--1Hc.js +5087 -0
  29. package/dist/assets/app-f-fkV5uP.css +1 -0
  30. package/dist/assets/compile-worker-CkxiD6lH.js +5 -0
  31. package/dist/assets/fflate.module-KbGj5-H6.js +5 -0
  32. package/dist/assets/{first-shot-handoff-msLI71g1.js → first-shot-handoff-CC4xe4HM.js} +1 -1
  33. package/dist/assets/{landing-ChUvB_zL.js → landing-DjHD8u7k.js} +1 -1
  34. package/dist/assets/scene-asset-sync-Hx_FzdOH.js +20 -0
  35. package/dist/assets/scene-asset-sync-_S8NQl5B.css +1 -0
  36. package/dist/assets/workflow-C4yKH1p4.js +179 -0
  37. package/dist/cozyclay-package.json +1 -1
  38. package/dist/index.html +3 -3
  39. package/dist/sw.js +3 -6
  40. package/dist/workflow/index.html +5 -4
  41. package/mcp/LIVE-PROTOCOL.md +7 -47
  42. package/mcp/README.md +16 -26
  43. package/mcp/live-hub.mjs +6 -10
  44. package/mcp/package.json +1 -3
  45. package/mcp/tool-handlers.mjs +4 -168
  46. package/package.json +3 -22
  47. package/src/App.jsx +150 -2624
  48. package/src/agent-progress.js +79 -0
  49. package/src/agent-turn-mark.js +30 -0
  50. package/src/analytics.js +1 -19
  51. package/src/app-context.js +5 -0
  52. package/src/app-stage.jsx +2 -34
  53. package/src/asset-pane.jsx +6 -5
  54. package/src/camera-block.js +21 -0
  55. package/src/camera-direction.js +120 -0
  56. package/src/character-kind.js +3 -9
  57. package/src/command-bus.js +8 -6
  58. package/src/commands/cast.js +5 -34
  59. package/src/commands/elements/character.js +1 -1
  60. package/src/commands/elements/motion.js +1 -6
  61. package/src/commands/elements/object.js +6 -2
  62. package/src/commands/elements/shot.js +4 -1
  63. package/src/commands/elements.js +38 -14
  64. package/src/commands/index.js +3 -1
  65. package/src/commands/kits.js +37 -0
  66. package/src/commands/motion-timing.js +57 -0
  67. package/src/commands/motion.js +9 -152
  68. package/src/commands/motion3-compile.js +65 -0
  69. package/src/commands/motion3.js +207 -0
  70. package/src/commands/objects.js +6 -4
  71. package/src/commands/shot.js +49 -11
  72. package/src/commands/stage.js +14 -1
  73. package/src/commands/view.js +4 -1
  74. package/src/document-mirrors.js +42 -0
  75. package/src/document-store.js +4 -0
  76. package/src/domains/cast.js +19 -194
  77. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  78. package/src/domains/motion/take-source.js +51 -0
  79. package/src/domains/motion.js +60 -1838
  80. package/src/domains/scenes.js +12 -55
  81. package/src/domains/shots.js +2 -2
  82. package/src/domains/stage.js +4 -2
  83. package/src/draft-ghost.js +46 -0
  84. package/src/draft-ghost.jsx +129 -0
  85. package/src/dualview.jsx +4 -3
  86. package/src/effects/EffectsLayer.jsx +112 -0
  87. package/src/effects/effect-meshes.js +232 -0
  88. package/src/effects/effect-record.js +103 -0
  89. package/src/effects/emitter.js +59 -0
  90. package/src/effects/simulate.js +261 -0
  91. package/src/effects/state.js +59 -0
  92. package/src/execution-telemetry.js +2 -3
  93. package/src/first-success-guide.jsx +5 -5
  94. package/src/ghost-set.js +257 -0
  95. package/src/held-object-edit.jsx +113 -0
  96. package/src/held-object-pose.js +175 -0
  97. package/src/hierarchy-model.js +1 -1
  98. package/src/hierarchy-panel.jsx +1 -1
  99. package/src/imported-mesh.jsx +103 -0
  100. package/src/kits/held.js +84 -0
  101. package/src/kits/index.js +70 -0
  102. package/src/kits/smoking.js +57 -0
  103. package/src/light-blockers.js +93 -0
  104. package/src/motion/beat-board-model.js +84 -0
  105. package/src/motion/beat-board.css +124 -0
  106. package/src/motion/beat-board.jsx +59 -0
  107. package/src/motion/center-of-mass.js +66 -0
  108. package/src/motion/fix-collisions.js +850 -0
  109. package/src/motion/physics-review.js +118 -0
  110. package/src/{ardy → motion}/physics-support.js +1 -76
  111. package/src/motion/rig-snapshot.js +22 -0
  112. package/src/motion/take-segments.jsx +140 -0
  113. package/src/{ardy → motion}/timeline-coordinates.js +0 -21
  114. package/src/{ardy → motion}/timeline.css +1 -2
  115. package/src/{ardy → motion}/timeline.jsx +42 -413
  116. package/src/motion/waypoints.js +76 -0
  117. package/src/motion3/beats.js +83 -0
  118. package/src/motion3/body.js +158 -0
  119. package/src/motion3/bones.js +47 -0
  120. package/src/motion3/carried.js +47 -0
  121. package/src/motion3/compile-job.js +75 -0
  122. package/src/motion3/compile-worker.js +10 -0
  123. package/src/motion3/compiler/anchors.js +105 -0
  124. package/src/motion3/compiler/arm-orient.js +128 -0
  125. package/src/motion3/compiler/channels.js +125 -0
  126. package/src/motion3/compiler/compile.js +205 -0
  127. package/src/motion3/compiler/gait-rhythm.js +97 -0
  128. package/src/motion3/compiler/grip.js +171 -0
  129. package/src/motion3/compiler/head-steady.js +48 -0
  130. package/src/motion3/compiler/hold.js +134 -0
  131. package/src/motion3/compiler/idle.js +113 -0
  132. package/src/motion3/compiler/limbs.js +170 -0
  133. package/src/motion3/compiler/math.js +72 -0
  134. package/src/motion3/compiler/pose.js +162 -0
  135. package/src/motion3/compiler/quat.js +49 -0
  136. package/src/motion3/compiler/skeleton.js +74 -0
  137. package/src/motion3/compiler/validate-common.js +32 -0
  138. package/src/motion3/compiler/validate-goals.js +118 -0
  139. package/src/motion3/compiler/validate-objects.js +143 -0
  140. package/src/motion3/compiler/validate-parts.js +116 -0
  141. package/src/motion3/compiler/validate.js +122 -0
  142. package/src/motion3/compiler/vocab.js +73 -0
  143. package/src/motion3/compiler/walk-pelvis.js +275 -0
  144. package/src/motion3/compiler/walk.js +207 -0
  145. package/src/motion3/compiler/wrist-clamp.js +32 -0
  146. package/src/motion3/delta.js +47 -0
  147. package/src/motion3/describe.js +174 -0
  148. package/src/motion3/docs.js +27 -0
  149. package/src/motion3/edit-carry.js +21 -0
  150. package/src/motion3/fix/ease.js +16 -0
  151. package/src/motion3/fix/ground.js +37 -0
  152. package/src/motion3/fix/index.js +52 -0
  153. package/src/motion3/fix/push.js +71 -0
  154. package/src/motion3/fix/reach.js +46 -0
  155. package/src/motion3/fix/retime.js +79 -0
  156. package/src/motion3/fix/self.js +211 -0
  157. package/src/motion3/fix/slide.js +70 -0
  158. package/src/motion3/fix/turn.js +51 -0
  159. package/src/motion3/follow.js +39 -0
  160. package/src/motion3/free-motion.js +173 -0
  161. package/src/motion3/grip-choice.js +130 -0
  162. package/src/motion3/hold-plan.js +157 -0
  163. package/src/motion3/json-patch.js +77 -0
  164. package/src/motion3/key-aim.js +105 -0
  165. package/src/motion3/key-edit.js +82 -0
  166. package/src/motion3/lenient.js +127 -0
  167. package/src/motion3/object-keys.js +236 -0
  168. package/src/motion3/object-pose.js +182 -0
  169. package/src/motion3/object-tracks.js +12 -0
  170. package/src/motion3/pipeline.js +102 -0
  171. package/src/motion3/playback.js +51 -0
  172. package/src/motion3/probes.js +91 -0
  173. package/src/motion3/qa/body-checks.js +34 -0
  174. package/src/motion3/qa/check-names.js +14 -0
  175. package/src/motion3/qa/checks.js +121 -0
  176. package/src/motion3/qa/contact-checks.js +51 -0
  177. package/src/motion3/qa/explain.js +80 -0
  178. package/src/motion3/qa/fmt.js +5 -0
  179. package/src/motion3/qa/foot-checks.js +72 -0
  180. package/src/motion3/qa/geometry.js +77 -0
  181. package/src/motion3/qa/goals.js +191 -0
  182. package/src/motion3/qa/ground.js +60 -0
  183. package/src/motion3/qa/hints-contact.js +179 -0
  184. package/src/motion3/qa/hints-goal.js +160 -0
  185. package/src/motion3/qa/hints-knee.js +24 -0
  186. package/src/motion3/qa/hints-motion.js +209 -0
  187. package/src/motion3/qa/hints-object.js +91 -0
  188. package/src/motion3/qa/index.js +42 -0
  189. package/src/motion3/qa/key-checks.js +82 -0
  190. package/src/motion3/qa/near.js +96 -0
  191. package/src/motion3/qa/object-checks.js +135 -0
  192. package/src/motion3/qa/patch.js +161 -0
  193. package/src/motion3/qa/severity.js +38 -0
  194. package/src/motion3/qa/spec-checks.js +65 -0
  195. package/src/motion3/qa/style.js +218 -0
  196. package/src/motion3/qa/wrist-checks.js +69 -0
  197. package/src/motion3/qa/wrist-obs.js +60 -0
  198. package/src/motion3/refs.js +120 -0
  199. package/src/motion3/repair-tune.js +121 -0
  200. package/src/motion3/repair.js +83 -0
  201. package/src/motion3/report.js +67 -0
  202. package/src/motion3/rigs/index.js +14 -0
  203. package/src/motion3/rigs/x-bot-tpose-body.json +1 -0
  204. package/src/motion3/rigs/x-bot-tpose.json +1 -0
  205. package/src/motion3/rigs/y-bot-tpose-body.json +1 -0
  206. package/src/motion3/rigs/y-bot-tpose.json +1 -0
  207. package/src/motion3/scene.js +60 -0
  208. package/src/motion3/schema/brief.js +89 -0
  209. package/src/motion3/schema/fields.js +44 -0
  210. package/src/motion3/schema/spec-examples.js +46 -0
  211. package/src/motion3/schema/topics.js +111 -0
  212. package/src/motion3/schema/types.js +147 -0
  213. package/src/motion3/search.js +117 -0
  214. package/src/motion3/second-hand.js +112 -0
  215. package/src/motion3/skills/basic.js +82 -0
  216. package/src/motion3/skills/book.js +76 -0
  217. package/src/motion3/skills/car.js +145 -0
  218. package/src/motion3/skills/frames-auto.js +29 -0
  219. package/src/motion3/skills/index.js +83 -0
  220. package/src/motion3/skills/info.js +65 -0
  221. package/src/motion3/skills/smoke-kit.js +109 -0
  222. package/src/motion3/skills/smoke.js +160 -0
  223. package/src/motion3/spec-store.js +59 -0
  224. package/src/motion3/take-edit.js +155 -0
  225. package/src/motion3/take.js +63 -0
  226. package/src/motion3/timeline.js +127 -0
  227. package/src/motion3/views.js +121 -0
  228. package/src/motion3/walk-plan.js +119 -0
  229. package/src/object-catalog.jsx +7 -1
  230. package/src/object-colors.js +98 -0
  231. package/src/object-gizmo-parts.js +115 -0
  232. package/src/object-gizmo.jsx +37 -122
  233. package/src/object-glow.js +139 -0
  234. package/src/object-hidden.js +33 -0
  235. package/src/object-look.js +196 -0
  236. package/src/object-size.js +10 -0
  237. package/src/object-travel.js +71 -6
  238. package/src/panels/CameraPanel.jsx +7 -7
  239. package/src/panels/HdrEnvironmentControls.jsx +59 -0
  240. package/src/panels/LightPanel.jsx +25 -4
  241. package/src/panels/ObjectLookPanel.jsx +88 -0
  242. package/src/panels/PosePanel.jsx +2 -17
  243. package/src/panels/RigControlPanel.jsx +5 -53
  244. package/src/panels/SubjectBox.jsx +4 -1
  245. package/src/panels/details.css +14 -0
  246. package/src/posestudio.jsx +4 -27
  247. package/src/project-browser.jsx +3 -4
  248. package/src/project-resources.js +18 -31
  249. package/src/project.js +13 -104
  250. package/src/props.jsx +25 -280
  251. package/src/rail-dot-drag.js +23 -0
  252. package/src/render-passes.js +10 -0
  253. package/src/resource-status.jsx +1 -0
  254. package/src/result-modal.jsx +4 -19
  255. package/src/room.jsx +20 -12
  256. package/src/scene-assets.js +8 -6
  257. package/src/scene-light-place.js +72 -0
  258. package/src/scene-lights.jsx +148 -0
  259. package/src/scene-objects.js +31 -139
  260. package/src/scenes.js +15 -105
  261. package/src/semantic-edit.js +2 -5
  262. package/src/set-pieces.jsx +183 -0
  263. package/src/settings-menu.jsx +3 -11
  264. package/src/shell/BottomDock.jsx +9 -51
  265. package/src/shell/DetailsSlot.jsx +16 -200
  266. package/src/shell/GhostsMenuGroup.jsx +74 -0
  267. package/src/shell/LibrarySlot.jsx +1 -3
  268. package/src/shell/MenuBar.jsx +3 -3
  269. package/src/shell/PreferencesDialog.jsx +15 -264
  270. package/src/shell/PreferencesSlot.jsx +2 -8
  271. package/src/shell/TopBar.jsx +5 -84
  272. package/src/shell/ViewportToolbar.jsx +10 -70
  273. package/src/shell/agent-glass.css +19 -6
  274. package/src/shell/details-selection.js +42 -0
  275. package/src/shell/ghosts-menu.css +22 -0
  276. package/src/shell/glass-regions.css +29 -3
  277. package/src/shell/preferences.css +6 -1
  278. package/src/shot-authoring.js +4 -7
  279. package/src/soft-shadows.js +37 -0
  280. package/src/stage-attach-frames.js +12 -12
  281. package/src/stage-canvas-rigs.jsx +3 -3
  282. package/src/stage-capture.jsx +42 -8
  283. package/src/stage-characters.jsx +22 -216
  284. package/src/stage-editor-gizmos.jsx +6 -6
  285. package/src/stage-environment.js +93 -0
  286. package/src/stage-hdr-builtins.js +107 -0
  287. package/src/stage-hdr-image.js +116 -0
  288. package/src/stage-hdr-library.js +70 -0
  289. package/src/stage-hdr-map.js +94 -0
  290. package/src/stage-hdr.js +130 -0
  291. package/src/stage-lighting.js +149 -0
  292. package/src/stage-occlusion.js +210 -0
  293. package/src/stage-sky.jsx +159 -0
  294. package/src/stage-startup-defaults.js +5 -9
  295. package/src/studio-actions.js +12 -14
  296. package/src/studio-agent-commands.js +19 -16
  297. package/src/studio-agent-motion.js +9 -9
  298. package/src/studio-agent-protocol.js +41 -71
  299. package/src/studio-app-binding.js +25 -45
  300. package/src/studio-asset-list.js +17 -0
  301. package/src/studio-contact-sheet.js +37 -5
  302. package/src/studio-elements.js +27 -7
  303. package/src/studio-entity-bounds.js +18 -0
  304. package/src/studio-error-codes.js +8 -0
  305. package/src/studio-export-framing.js +152 -0
  306. package/src/studio-lighting-check.js +103 -0
  307. package/src/studio-motion-visual.js +150 -0
  308. package/src/studio-schema-errors.js +80 -0
  309. package/src/studio-support-gap.js +74 -0
  310. package/src/styles.css +1 -19
  311. package/src/timeline-defaults.js +5 -0
  312. package/src/timeline-extent.js +8 -20
  313. package/src/tutorial-walk.js +17 -0
  314. package/src/ui-scrub.js +1 -1
  315. package/src/use-shot-camera-tracks.js +12 -0
  316. package/src/view-ghosts.js +97 -0
  317. package/src/view-ghosts.jsx +49 -0
  318. package/src/workflow/AgentPanel.jsx +47 -166
  319. package/src/workflow/AgentProgressCard.jsx +107 -0
  320. package/src/workflow/AgentReplyText.jsx +37 -0
  321. package/src/workflow/AgentTurnSteps.jsx +84 -0
  322. package/src/workflow/LoginPanel.jsx +140 -0
  323. package/src/workflow/WorkflowBuilder.jsx +7 -23
  324. package/src/workflow/agent-client.js +54 -51
  325. package/src/workflow/agent-panel.css +71 -1
  326. package/src/workflow/agent-progress-card.css +167 -0
  327. package/src/workflow/agent-progress-view.js +47 -0
  328. package/src/workflow/agent-turn-steps.css +113 -0
  329. package/src/workflow/agent-turn.js +191 -0
  330. package/src/workflow/login-client.js +34 -0
  331. package/src/workflow/motion-input.js +18 -31
  332. package/src/workflow/node-schema.js +1 -1
  333. package/src/workflow/reply-markdown.js +69 -0
  334. package/src/workflow/scene-asset-sync.js +8 -8
  335. package/tools/agent-session-stats.mjs +61 -0
  336. package/tools/dev-full.mjs +5 -56
  337. package/tools/eslint-baseline.json +12 -13
  338. package/tools/knip-baseline.json +0 -179
  339. package/tools/loc-baseline.json +31 -81
  340. package/tools/motion3/extract-body.mjs +103 -0
  341. package/tools/motion3/extract-rig.mjs +85 -0
  342. package/tools/process-supervisor.mjs +0 -88
  343. package/tools/run-tests.mjs +8 -124
  344. package/bin/agent/motion-runtime.mjs +0 -76
  345. package/dist/ardy/cskel27-rest.json +0 -557
  346. package/dist/assets/analytics-C9pMpW7s.js +0 -1
  347. package/dist/assets/app-B_2XB9rw.css +0 -1
  348. package/dist/assets/app-DAuvKPP8.js +0 -4920
  349. package/dist/assets/config-Bx7pYsD9.js +0 -1
  350. package/dist/assets/config-DeoFTYzb.css +0 -1
  351. package/dist/assets/demo-CvKuBgy5.js +0 -1
  352. package/dist/assets/scene-asset-sync-DKkpjKBV.js +0 -19
  353. package/dist/assets/scene-asset-sync-DjJ_cJ2R.css +0 -1
  354. package/dist/assets/ticket-BPk2Ni95.js +0 -1
  355. package/dist/assets/workflow-jJRBR94Z.js +0 -179
  356. package/dist/demo/walk-then-stop.npz +0 -0
  357. package/mcp/ardy-prompts.mjs +0 -218
  358. package/src/ardy/auto-fix-panel.css +0 -108
  359. package/src/ardy/auto-physics.js +0 -1194
  360. package/src/ardy/client.js +0 -153
  361. package/src/ardy/convert.js +0 -313
  362. package/src/ardy/cskel27-neutral.js +0 -39
  363. package/src/ardy/cskel27.js +0 -77
  364. package/src/ardy/export.js +0 -165
  365. package/src/ardy/fix-collisions.js +0 -2257
  366. package/src/ardy/foot-lock.js +0 -230
  367. package/src/ardy/motion-calibration.js +0 -118
  368. package/src/ardy/motion-edit.js +0 -213
  369. package/src/ardy/motion-url.js +0 -55
  370. package/src/ardy/mp4-duration.js +0 -102
  371. package/src/ardy/npz.js +0 -595
  372. package/src/ardy/physics-panel.css +0 -21
  373. package/src/ardy/physics-panel.jsx +0 -89
  374. package/src/ardy/physics-refine.js +0 -111
  375. package/src/ardy/physics-review.js +0 -567
  376. package/src/ardy/physics-temporal.js +0 -54
  377. package/src/ardy/platform-fit-panel.css +0 -64
  378. package/src/ardy/platform-fit-panel.jsx +0 -41
  379. package/src/ardy/platform-fit.js +0 -326
  380. package/src/ardy/playback.js +0 -711
  381. package/src/ardy/pose-pin.js +0 -97
  382. package/src/ardy/prompt-clips.js +0 -30
  383. package/src/ardy/retime.js +0 -211
  384. package/src/ardy/root-drop.js +0 -369
  385. package/src/ardy/timeline-resize.js +0 -13
  386. package/src/ardy/to-cskel27.js +0 -225
  387. package/src/ardy/trim.js +0 -33
  388. package/src/ardy/waypoints.js +0 -369
  389. package/src/ardy-bridge-contract.js +0 -281
  390. package/src/domains/motion/motion-video-capture.js +0 -664
  391. package/src/i2v-motion-client.js +0 -77
  392. package/src/i2v-motion-studio.jsx +0 -180
  393. package/src/line-edit.js +0 -1348
  394. package/src/motion/generation.js +0 -105
  395. package/src/motion-readiness-ui.jsx +0 -67
  396. package/src/motion-readiness.js +0 -40
  397. package/src/motion-resources.js +0 -177
  398. package/src/motion-store.js +0 -65
  399. package/src/motion-trail.js +0 -601
  400. package/src/multimodel-ingest.js +0 -394
  401. package/src/panels/PromptBlocksPanel.jsx +0 -444
  402. package/src/panels/VideoCapturePanel.jsx +0 -181
  403. package/src/pose-extract/browser.js +0 -42
  404. package/src/pose-extract/detector.js +0 -100
  405. package/src/pose-extract/fit.js +0 -227
  406. package/src/pose-extract/image-frame.js +0 -51
  407. package/src/pose-extract/index.js +0 -58
  408. package/src/pose-extract/keyframes.js +0 -74
  409. package/src/pose-extract/landmarks.js +0 -162
  410. package/src/pose-extract/mirror.js +0 -153
  411. package/src/pose-extract/take.js +0 -131
  412. package/src/pose-extract/video-frames.js +0 -91
  413. package/src/take-recipe.js +0 -165
  414. package/src/trail-key-conflicts.js +0 -39
  415. package/src/trail-pick.js +0 -59
  416. package/src/use-i2v-capture-modal.js +0 -15
  417. package/tools/ardy/BRIDGE.md +0 -37
  418. package/tools/ardy/GVHMR-NPZ.md +0 -68
  419. package/tools/ardy/README.md +0 -124
  420. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  421. package/tools/ardy/artifacts.mjs +0 -34
  422. package/tools/ardy/bridge.mjs +0 -1732
  423. package/tools/ardy/bvh-cskel27.mjs +0 -1265
  424. package/tools/ardy/cclay_constrained_generate.py +0 -561
  425. package/tools/ardy/cclay_gvhmr_worker.py +0 -264
  426. package/tools/ardy/cclay_motion_edit.py +0 -510
  427. package/tools/ardy/cclay_sequence_generate.py +0 -705
  428. package/tools/ardy/cclay_worker.py +0 -304
  429. package/tools/ardy/dump-npz.py +0 -205
  430. package/tools/ardy/extract-rest.mjs +0 -299
  431. package/tools/ardy/extract.mjs +0 -507
  432. package/tools/ardy/footage.mjs +0 -467
  433. package/tools/ardy/gvhmr-floor.mjs +0 -102
  434. package/tools/ardy/gvhmr-to-cskel27.mjs +0 -21
  435. package/tools/ardy/gvhmr_fastpath.py +0 -168
  436. package/tools/ardy/gvhmr_trajectory.py +0 -240
  437. package/tools/ardy/merge-text-encoder.py +0 -160
  438. package/tools/ardy/mocap-metrics.mjs +0 -200
  439. package/tools/ardy/mocap-quality-gate.mjs +0 -110
  440. package/tools/ardy/motion-stabilize.mjs +0 -348
  441. package/tools/ardy/npz.mjs +0 -414
  442. package/tools/ardy/pose-to-npz.mjs +0 -106
  443. package/tools/ardy/prompt-limits.mjs +0 -5
  444. package/tools/ardy/run-edit-on-box.sh +0 -73
  445. package/tools/ardy/run-local.mjs +0 -233
  446. package/tools/ardy/run-on-box.sh +0 -593
  447. package/tools/ardy/run-sequence-on-box.sh +0 -177
  448. package/tools/ardy/runners/gvhmr-worker.mjs +0 -206
  449. package/tools/ardy/runners/index.mjs +0 -18
  450. package/tools/ardy/runners/local.mjs +0 -455
  451. package/tools/ardy/runners/proc.mjs +0 -125
  452. package/tools/ardy/runners/remote.mjs +0 -252
  453. package/tools/ardy/setup-local.mjs +0 -189
  454. package/tools/ardy/setup-text-encoder-on-box.sh +0 -93
  455. package/tools/ardy/setup-text-encoder.py +0 -306
  456. package/tools/ardy/smpl-cskel27.mjs +0 -178
  457. package/tools/ardy/visual-qa.mjs +0 -196
  458. package/tools/ardy/vq-car.mjs +0 -52
  459. package/tools/bench/EXP3.md +0 -61
  460. package/tools/bench/cclay_bench_extract_incam.py +0 -125
  461. package/tools/bench/cclay_bench_extract_obs.py +0 -204
  462. package/tools/bench/cclay_bench_runner.py +0 -42
  463. package/tools/bench/cube-contact.mjs +0 -162
  464. package/tools/bench/exp3.mjs +0 -152
  465. package/tools/bench/extract-bench-lib.mjs +0 -216
  466. package/tools/bench/extract-bench.mjs +0 -302
  467. package/tools/bench/fal-generate.mjs +0 -88
  468. package/tools/bench/fit/README.md +0 -189
  469. package/tools/bench/fit/camera.mjs +0 -59
  470. package/tools/bench/fit/contact.mjs +0 -419
  471. package/tools/bench/fit/footlock.mjs +0 -234
  472. package/tools/bench/fit/motion.mjs +0 -85
  473. package/tools/bench/fit/pin.mjs +0 -27
  474. package/tools/bench/fit/remote.mjs +0 -105
  475. package/tools/bench/fit-bench.mjs +0 -126
  476. package/tools/bench/fit-sanity.mjs +0 -45
  477. package/tools/bench/metrics.mjs +0 -313
  478. package/tools/bench/obs/depth.mjs +0 -258
  479. package/tools/bench/obs/extrinsics.mjs +0 -175
  480. package/tools/bench/obs/fit_mannequin_betas.py +0 -340
  481. package/tools/bench/obs/ground.mjs +0 -294
  482. package/tools/bench/obs/heading.mjs +0 -151
  483. package/tools/bench/obs/ladder.mjs +0 -555
  484. package/tools/bench/obs/mannequin-betas.json +0 -124
  485. package/tools/bench/obs/remote.mjs +0 -83
  486. package/tools/bench/obs/rest_joints.py +0 -52
  487. package/tools/bench/obs/ybot-targets.mjs +0 -49
  488. package/tools/bench/obs-bench.mjs +0 -520
  489. package/tools/bench/score.mjs +0 -343
  490. package/tools/bench/summarize.mjs +0 -86
  491. package/tools/demo-worker/README.md +0 -137
  492. package/tools/demo-worker/api-client.mjs +0 -233
  493. package/tools/demo-worker/generate.mjs +0 -301
  494. package/tools/demo-worker/index.mjs +0 -466
  495. package/tools/demo-worker/sign-core.mjs +0 -112
  496. package/tools/demo-worker/sign.mjs +0 -161
  497. package/tools/gt-render/browser.mjs +0 -182
  498. package/tools/gt-render/camera-math.mjs +0 -276
  499. package/tools/gt-render/page.mjs +0 -267
  500. package/tools/gt-render/render.mjs +0 -514
  501. package/tools/gt-render/scene-box.mjs +0 -31
  502. package/tools/gt-render/take-transform.mjs +0 -87
  503. package/tools/kimodo/compare-seam.mjs +0 -108
  504. package/tools/kimodo/constraints.mjs +0 -244
  505. package/tools/kimodo/edit.mjs +0 -259
  506. package/tools/kimodo/effector-constraints.mjs +0 -200
  507. package/tools/kimodo/generate.mjs +0 -646
  508. package/tools/kimodo/local-output.mjs +0 -265
  509. package/tools/kimodo/measure-pose.mjs +0 -65
  510. package/tools/kimodo/measure-preserve.mjs +0 -899
  511. package/tools/kimodo/measure-waypoints.mjs +0 -55
  512. package/tools/kimodo/mlx-generate.py +0 -111
  513. package/tools/kimodo/pose-constraints.mjs +0 -248
  514. package/tools/kimodo/preserve-mask.mjs +0 -543
  515. package/tools/kimodo/read-npz.mjs +0 -189
  516. package/tools/kimodo/run-edit-on-box.mjs +0 -155
  517. package/tools/kimodo/run-sequence-on-box.mjs +0 -178
  518. package/tools/kimodo/runner.mjs +0 -284
  519. package/tools/kimodo/seam-batch.mjs +0 -77
  520. package/tools/kimodo/setup-local.mjs +0 -30
  521. package/tools/kimodo/setup-on-box.sh +0 -339
  522. package/tools/kimodo/soma77-to-cskel27.mjs +0 -305
  523. package/tools/morphgs/assets/gen_truth.py +0 -39
  524. package/tools/morphgs/assets/mesh_ori_rig.txt +0 -27
  525. package/tools/morphgs/assets/playback-check.mjs +0 -30
  526. package/tools/morphgs/demo-gate.sh +0 -26
  527. package/tools/morphgs/fbx2morphgs.mjs +0 -68
  528. package/tools/morphgs/morphgs-to-cskel27.mjs +0 -105
  529. package/tools/morphgs/patches/preprocess_src-none-mode.patch +0 -76
  530. package/tools/morphgs/setup-on-cluster.sh +0 -126
  531. package/tools/projflow/driver.py +0 -1069
  532. package/tools/projflow/generate.mjs +0 -805
  533. package/tools/projflow/hml22-to-cskel27.mjs +0 -735
  534. package/tools/projflow/line-edit-job.mjs +0 -547
  535. package/tools/projflow/replay.mjs +0 -452
  536. package/tools/projflow/runner.mjs +0 -269
  537. package/tools/projflow/service.mjs +0 -742
  538. package/tools/track/DESIGN.md +0 -513
  539. package/tools/track/backfill-provenance.mjs +0 -116
  540. package/tools/track/budget.mjs +0 -8
  541. package/tools/track/check-rig.mjs +0 -209
  542. package/tools/track/diagnostics.schema.json +0 -60
  543. package/tools/track/export-rig.mjs +0 -256
  544. package/tools/track/fallback.mjs +0 -7
  545. package/tools/track/fk-parity-fixture.mjs +0 -161
  546. package/tools/track/gate.mjs +0 -349
  547. package/tools/track/masks.mjs +0 -318
  548. package/tools/track/metrics.mjs +0 -201
  549. package/tools/track/publish-obs.mjs +0 -108
  550. package/tools/track/py/check_env.py +0 -61
  551. package/tools/track/py/eval_lr.py +0 -229
  552. package/tools/track/py/lr_viterbi.py +0 -294
  553. package/tools/track/py/masks.py +0 -405
  554. package/tools/track/py/objective.py +0 -382
  555. package/tools/track/py/rig.py +0 -508
  556. package/tools/track/py/scene.py +0 -326
  557. package/tools/track/py/test_joint_indices.py +0 -123
  558. package/tools/track/py/test_lr_viterbi.py +0 -195
  559. package/tools/track/py/test_masks.py +0 -229
  560. package/tools/track/py/test_rig.py +0 -288
  561. package/tools/track/py/test_scene.py +0 -354
  562. package/tools/track/py/test_track.py +0 -328
  563. package/tools/track/py/track.py +0 -411
  564. package/tools/track/remote.mjs +0 -140
  565. package/tools/track/rig-dump.mjs +0 -227
  566. package/tools/track/run-box-tests.mjs +0 -14
  567. package/tools/track/run-box.mjs +0 -168
  568. package/tools/track/setup-box.sh +0 -59
  569. package/tools/track/study-2d.mjs +0 -728
  570. /package/src/{ardy → motion}/collision-blockers.js +0 -0
  571. /package/src/{ardy → motion}/ground.js +0 -0
  572. /package/src/{ardy → motion}/ik-drag.js +0 -0
  573. /package/src/{ardy → motion}/ik-key-json.js +0 -0
  574. /package/src/{ardy → motion}/ik.js +0 -0
  575. /package/src/{ardy → motion}/physics-surface.js +0 -0
  576. /package/src/{ardy → motion}/playback-clock.js +0 -0
  577. /package/src/{ardy → motion}/range-pin.js +0 -0
package/src/App.jsx CHANGED
@@ -25,24 +25,8 @@ import {
25
25
  } from "@react-three/drei";
26
26
  import * as THREE from "three";
27
27
 
28
- import { checkBridge, generate as ardyGenerate } from "./ardy/client.js";
29
- import {
30
- loadMotionFromUrl,
31
- } from "./ardy/npz.js";
32
-
33
- import { motionUrlFromQuery } from "./ardy/motion-url.js";
34
-
35
- import {
36
- applyMotionFrame,
37
- restorePlaybackBones,
38
- snapshotPlaybackBones,
39
- } from "./ardy/playback.js";
40
-
41
- import {
42
- TRAIL_EFFECTOR_JOINTS,
43
- jointTrailPoints,
44
- worldPointToClip,
45
- } from "./motion-trail.js";
28
+ import { restorePlaybackBones, snapshotPlaybackBones } from "./motion/rig-snapshot.js";
29
+ import { applyTakeFrame } from "./motion3/playback.js";
46
30
 
47
31
  import { FlyControls, aimAt, forwardFrom } from "./controls.jsx";
48
32
  import { createLiveControl, loadLiveWorkspaceId, mintLiveWorkspaceId } from "./live-control.js";
@@ -61,7 +45,6 @@ import { useFirstSuccessPrompt } from "./use-first-success-prompt.js";
61
45
  import { useCameraTutorial } from "./use-camera-tutorial.js";
62
46
  import { useEmbedBridge } from "./use-embed-bridge.js";
63
47
  import { useAssetShelf } from "./use-asset-shelf.js";
64
- import { useI2vCaptureModal } from "./use-i2v-capture-modal.js";
65
48
  import { StudioProtocolError } from "./studio-agent-protocol.js";
66
49
  import { elementByPath } from "./studio-elements.js";
67
50
  import {
@@ -79,59 +62,15 @@ import { drawPlanDiagram } from "./plan-diagram.js";
79
62
  import { placementAt } from "./root-path.js";
80
63
  import { proxyYaw } from "./proxy-figure.jsx";
81
64
  import { autoColorHex, loadAutoColor } from "./auto-color.js";
82
- import { DualRender, fitAspect, GIZMO_LAYER } from "./dualview.jsx";
65
+ import { DualRender, GIZMO_LAYER } from "./dualview.jsx";
83
66
  import { GridFloor } from "./grid-floor.jsx";
84
- import { GRID_BACKGROUND, GRID_FOG, stageBackground, readStoredGridView, writeStoredGridView } from "./grid-view.js";
67
+ import { readStoredGridView, writeStoredGridView } from "./grid-view.js";
85
68
  import { useUiTheme } from "./theme.js";
86
- import {
87
- CURVE_GRAB_RADIUS_PX,
88
- DRAG_RADIUS_DEFAULT,
89
- DRAG_RADIUS_MAX,
90
- DRAG_RADIUS_MIN,
91
- DRAG_WEIGHT_EPSILON,
92
- DRAW_MIN_STROKE_POINTS,
93
- DRAW_MIN_STROKE_PX,
94
- MAX_LINE_POINTS,
95
- MIN_LINE_POINTS,
96
- cameraDrifted,
97
- cameraToC6,
98
- changedFrameRange,
99
- curveToPoints2d,
100
- curvesEqual,
101
- sliceCurveToRange,
102
- dragCurve,
103
- dragWeight,
104
- isCurveEndPinned,
105
- isCurvePointOnScreen,
106
- nearestCurvePoint,
107
- projectTrailCurve,
108
- projectPointC6,
109
- reprojectCurveWorld,
110
- drawStrokeEdit,
111
- unprojectDeltaC6,
112
- pinsFrameRange,
113
- upsertPin,
114
- validateLineEdit,
115
- } from "./line-edit.js";
116
-
117
- import { Room, StageLights } from "./room.jsx";
118
- import {
119
- createShotAuthoringDocument,
120
- } from "./shot-authoring.js";
121
- import {
122
- buildFollowTrack,
123
- buildRail,
124
- buildRailFollowTrack,
125
- simplifyStroke,
126
- } from "./camera-follow.js";
127
- import {
128
- createCameraBlock,
129
- } from "./camera-block.js";
130
- import {
131
- RAIL_SCHEDULE_LEGACY,
132
- RAIL_SCHEDULE_RANGE,
133
- resolveRailSchedule,
134
- } from "./camera-rail-schedule.js";
69
+
70
+ import { Room, StageLights, StageAtmosphere } from "./room.jsx";
71
+ import { createDocumentMirrors } from "./document-mirrors.js";
72
+ import { buildRail, simplifyStroke } from "./camera-follow.js";
73
+ import { useShotCameraTracks } from "./use-shot-camera-tracks.js";
135
74
  import { ObjectCarrier, SetProps } from "./props.jsx";
136
75
  import {
137
76
  CUTOUT_KIND,
@@ -172,7 +111,6 @@ import {
172
111
  } from "./scenes.js";
173
112
  import {
174
113
  hasFileSystemAccess,
175
- loadProjectSession,
176
114
  loadStoredProjectHandle,
177
115
  queryHandlePermission,
178
116
  requestHandlePermission,
@@ -186,17 +124,14 @@ import { CameraTutorial } from "./camera-tutorial.jsx";
186
124
  import { createTutorialAnalytics } from "./tutorial-analytics.js";
187
125
  import { cameraTutorialSuppressed, createFirstShotHandoff, rememberCameraTutorialTerminal } from "./first-shot-handoff.js";
188
126
  import ObjectGizmo from "./object-gizmo.jsx";
127
+ import { useHeldObjectEdit } from "./held-object-edit.jsx";
189
128
 
190
129
  import {
191
130
  SaveBlockedDialog,
192
131
  } from "./resource-status.jsx";
193
132
 
194
133
  import ResultModal from "./result-modal.jsx";
195
- import {
196
- I2vMotionModal,
197
- } from "./i2v-motion-studio.jsx";
198
134
 
199
- import { demoSeedGate, hasLineEditCapability, motionReadiness } from "./motion-readiness.js";
200
135
 
201
136
  import { PWA_UPDATE_EVENT } from "./pwa.js";
202
137
  import {
@@ -206,7 +141,7 @@ import {
206
141
  pathMetrics,
207
142
  strokeToPathPoints,
208
143
  } from "./object-path.js";
209
- import { carriedPointAt, sceneObjectsAt } from "./object-travel.js";
144
+ import { carriedPointAt, sceneObjectsAt } from "./object-travel.js"; import { objectTracksOf } from "./motion3/object-tracks.js"; import { tutorialWalkSpec } from "./tutorial-walk.js"; import { ViewGhosts } from "./view-ghosts.jsx"; import { DraftGhost } from "./draft-ghost.jsx";
210
145
  import { routeOwnerFor } from "./route-owner.js";
211
146
  import {
212
147
  analyticsActive,
@@ -243,19 +178,7 @@ import {
243
178
  ikSeedTargets,
244
179
  resolveIkRig,
245
180
  shareContactMeasurements,
246
- } from "./ardy/ik.js";
247
- import {
248
- buildCollisionCapsules,
249
- detectPenetrations,
250
- supportsCollisionCleanup,
251
- } from "./ardy/fix-collisions.js";
252
- import {
253
- blockerSummary,
254
- } from "./ardy/collision-blockers.js";
255
- import { computeCenterOfMass, markerPositions } from "./ardy/auto-physics.js";
256
- import {
257
- physicsKeyStamp,
258
- } from "./ardy/physics-review.js";
181
+ } from "./motion/ik.js";
259
182
 
260
183
  import {
261
184
  Toast,
@@ -287,7 +210,6 @@ import { buildZip } from "./zip-store.js";
287
210
  import { composeStoryboard } from "./storyboard.js";
288
211
  import { DEPTH_RANGE_M, depthRangeFromFrames, passFileName, renderPass } from "./render-passes.js";
289
212
 
290
- import { motionApiOrigin, I2V_MOTION_SHOT_ASPECT } from "./i2v-motion-client.js";
291
213
  import { serializeOtio } from "./otio.js";
292
214
  import "./shell/mode.css";
293
215
  import { readPoseFadeOpacity, withPoseFadeLifted, writePoseFadeOpacity } from "./pose-fade.js";
@@ -296,7 +218,6 @@ import {
296
218
  shotIndexAtFrame,
297
219
  } from "./cuts.js";
298
220
  import {
299
- BRIDGE_RECHECK_MS,
300
221
  CameraGlide,
301
222
  CameraRailScenePreview,
302
223
  CaptureRig,
@@ -305,20 +226,13 @@ import {
305
226
  CraneHandles,
306
227
  DEFAULT_CAMERA_POSITION,
307
228
  DEFAULT_DURATION_S,
308
- DEMO_MOTION_PROMPT,
309
- DEMO_MOTION_URL,
310
229
  EditorCamSeed,
311
230
  FollowCamRig,
312
231
  GIZMO_HOTKEYS,
313
232
  KeyLightPuck,
314
- LINE_CURVE_MARKER_STRIDE,
315
- LINE_CURVE_REFUSALS,
316
- LINE_EDIT_DEFAULT_TRACK,
317
- LINE_EDIT_REFUSALS,
318
- LINE_PREVIEW_DEBOUNCE_MS,
319
233
  MCP_CAPTURE_H,
320
234
  MCP_CAPTURE_W,
321
- MotionTrails,
235
+
322
236
  MoveRig,
323
237
  OBJECT_DELETE_UNDO_MS,
324
238
  ObjectPathHandles,
@@ -333,15 +247,12 @@ import {
333
247
  ViewportLayoutInvalidator,
334
248
  WORKSPACE_LAYOUT_KEY,
335
249
  attachFrameMatrix,
336
- buildPromptSchedule,
337
250
  captureMcpFrame,
338
251
  characterModelUrl,
339
252
  defaultCharacterTint,
340
- ikTracksInRange,
341
253
  loadSceneStartup,
342
254
  loadWorkspaceLayout,
343
255
  moveSequenceSlateKo,
344
- preserveTracksSummary,
345
256
  slateLineKo,
346
257
  useStageFilesDrop,
347
258
  } from "./app-stage.jsx";
@@ -375,10 +286,6 @@ function ShotGuideOverlay({ mode, aspect, className = "" }) {
375
286
  );
376
287
  }
377
288
 
378
- // Below this the mean landmark visibility is too low to claim the fit measured
379
- // the photograph rather than guessed at it. Same number the fit diagnostics are
380
- // scaled on (0..1 visibility), so it reads as "less than half seen".
381
- const PHOTO_POSE_LOW_CONFIDENCE = 0.5;
382
289
  const CHARACTER_POSITION_BOUNDS = elementByPath("character.position").gizmo;
383
290
  const CHARACTER_SCALE_BOUNDS = elementByPath("character.scale");
384
291
 
@@ -410,42 +317,12 @@ function storyboardCaption(prompt) {
410
317
  .join(" · "));
411
318
  }
412
319
 
413
- // cskel27 joint names are rig vocabulary — "RightForeArm" means nothing to
414
- // someone holding a photograph. Every joint collapses into one of six groups a
415
- // viewer can check against their own picture, ordered so the sentence always
416
- // reads limbs before body.
417
- const RELEASED_BONE_LABELS = [
418
- ["LeftArm", "left arm", "왼팔"],
419
- ["RightArm", "right arm", "오른팔"],
420
- ["LeftLeg", "left leg", "왼다리"],
421
- ["RightLeg", "right leg", "오른다리"],
422
- ["Torso", "torso", "몸통"],
423
- ["Head", "head", "머리"],
424
- ];
425
-
426
- function releasedBoneGroup(name) {
427
- const side = name.startsWith("Left") ? "Left" : name.startsWith("Right") ? "Right" : "";
428
- const part = side ? name.slice(side.length) : name;
429
- if (part === "UpLeg" || part === "Leg" || part === "Foot" || part === "ToeBase") return side + "Leg";
430
- if (part === "Shoulder" || part === "Arm" || part === "ForeArm" || part === "Hand" || part === "HandEnd") return side + "Arm";
431
- if (part === "Head" || part === "Neck") return "Head";
432
- // Hips and the Spine chain are the only names left, and they are the torso.
433
- return "Torso";
434
- }
435
-
436
- // 이/가 is fixed by the final syllable of the word it follows — 왼팔이 but
437
- // 왼다리가 — so it cannot be baked into the sentence template.
438
- function koSubjectParticle(word) {
439
- const syllable = word.charCodeAt(word.length - 1) - 0xac00;
440
- return syllable >= 0 && syllable < 11172 && syllable % 28 !== 0 ? "이" : "가";
441
- }
442
-
443
320
  /**
444
321
  * Pose ONE cast member's rig at an absolute timeline frame: its own clip first,
445
322
  * then its own IK correction layer on top. This is the single description of
446
323
  * "where is this character at frame N" — the viewport effect, the offscreen
447
- * recorder and the whole-clip collision pass all go through it, so a body used
448
- * as a collision blocker stands exactly where the render would draw it.
324
+ * recorder and the range-pin bake all go through it, so a body stands exactly
325
+ * where the render would draw it.
449
326
  *
450
327
  * Pure in the sense that matters here: it takes the rig, the clip and the layer
451
328
  * state and writes bones. No React, no refs, no knowledge of who is active — a
@@ -459,7 +336,7 @@ function poseMemberAtFrame(rig, clip, ikState, frame, blendFrames = 0, pose = nu
459
336
  if (!rig) return;
460
337
  if (clip) {
461
338
  const sampled = sampleAt({ frameCount: clip.frames, motion: clip }, null, frame);
462
- applyMotionFrame(rig, clip, sampled.motionFrame);
339
+ applyTakeFrame(rig, clip, sampled.motionFrame);
463
340
  } else if (pose) {
464
341
  applyPose(rig, pose);
465
342
  applyHipsOffset(rig, pose.rootY ?? 0);
@@ -668,7 +545,7 @@ export default function App() {
668
545
  get objects() { return storeRef.current.objects; },
669
546
  }));
670
547
  const {
671
- preset, shotAspectKey, environmentImage, cameraPresetId, sensorId, keyLight, changeKeyLight,
548
+ preset, shotAspectKey, environmentImage, cameraPresetId, sensorId, keyLight, lighting, changeKeyLight,
672
549
  resetKeyLight, changeEnvironmentImage, hasEnvSheet, environment, style, publishStudioStage,
673
550
  } = stageDomain;
674
551
  const shotsDomain = useShots(appContext.forRender({
@@ -684,7 +561,7 @@ export default function App() {
684
561
  get markSemanticEdit() { return markSemanticEdit; },
685
562
  get motionPos() { return motionPos; },
686
563
  get playMode() { return playMode; },
687
- get promptTextSessionRef() { return promptTextSessionRef; },
564
+
688
565
 
689
566
  get runStudioAction() { return runStudioAction; },
690
567
  get selectWorkflowMode() { return selectWorkflowMode; },
@@ -704,9 +581,8 @@ export default function App() {
704
581
  get executeExportRequest() { return executeExportRequest; },
705
582
  get exportRequest() { return exportRequest; },
706
583
  get motion() { return motion; },
707
- get multiModelFootage() { return multiModelFootage; },
708
584
  get playgroundMode() { return playgroundMode; },
709
- get promptClips() { return promptClips; },
585
+
710
586
  get recRef() { return recRef; },
711
587
  get runShotExport() { return runShotExport; },
712
588
  get setCameraPos() { return setCameraPos; },
@@ -1104,25 +980,21 @@ export default function App() {
1104
980
  get committedIkEdits() { return committedIkEdits; },
1105
981
  get environment() { return environment; },
1106
982
  get environmentImage() { return environmentImage; },
1107
- get exitLineEditMode() { return exitLineEditMode; },
1108
983
  get frameCountRef() { return frameCountRef; },
1109
984
  get gestureUndoRef() { return gestureUndoRef; },
1110
985
  get hasEnvSheet() { return hasEnvSheet; },
1111
986
  get ikStateRef() { return ikStateRef; },
1112
987
  get ikStatesRef() { return ikStatesRef; },
1113
988
  get keyLight() { return keyLight; },
1114
- get lineEditMode() { return lineEditMode; },
1115
989
  get loadedLayerCharRef() { return loadedLayerCharRef; },
1116
990
  get markSemanticEdit() { return markSemanticEdit; },
1117
991
  get motion() { return motion; },
1118
992
  get motionFullRef() { return motionFullRef; },
1119
- get promptTextSessionRef() { return promptTextSessionRef; },
993
+
1120
994
  get publishStudioCharacters() { return publishStudioCharacters; },
1121
995
  get rigReportersRef() { return rigReportersRef; },
1122
996
  get rigWaitersRef() { return rigWaitersRef; },
1123
997
  get runStudioAction() { return runStudioAction; },
1124
- get setArdyDuration() { return setArdyDuration; },
1125
- get setArdyPrompt() { return setArdyPrompt; },
1126
998
  get setCommittedIkEdits() { return setCommittedIkEdits; },
1127
999
  get setIkTick() { return setIkTick; },
1128
1000
  get setSelectedHierarchyId() { return setSelectedHierarchyId; },
@@ -1159,25 +1031,22 @@ export default function App() {
1159
1031
  get snapshotExportRig() { return snapshotExportRig; },
1160
1032
  get studioBindingRef() { return studioBindingRef; },
1161
1033
  get objectsDomain() { return objectsDomain; },
1162
- get restoreMotionRefs() { return restoreMotionRefs; },
1034
+
1163
1035
  get shotsDomain() { return shotsDomain; },
1164
1036
  }));
1165
1037
  const {
1166
- characters, customPoses, posing, setPosing, posingClosing, studioPick, setStudioPick,
1038
+ characters, customPoses, posing, posingClosing, studioPick, setStudioPick,
1167
1039
  rigs, rigMountEpoch, setRigMountEpoch, setPoseTick, charA, charB, showB, poseA, poseB, subject, subject2,
1168
1040
  updateCharacterAt, setShowB, moveCharacter, removeCharacter, reportRig, spawnCharacter,
1169
1041
  charKeyToHierarchyId, charIdFromHierarchyId, activeCharacterId, setActiveCharacterId, rowIdForCharIndex,
1170
1042
  activeChar, selectActiveCharacterInHierarchy, activeCharIndex, activeRig, waitForRig, ghostLayers,
1171
- snapshotCast, changeInspectorCharacter, hasCharSheet, setHasCharSheet, promptBlocksReveal,
1172
- setPromptBlocksReveal, revealPromptBlocks, waypointMode, setWaypointMode, waypoints, setWaypoints,
1173
- activeWaypointId, setActiveWaypointId, pendingWaypointFrame, setPendingWaypointFrame, promptClips,
1174
- setPromptClips, editPromptClips, selectedPromptId, setSelectedPromptId, photoPoseState, photoPoseError,
1175
- setPhotoPoseError, allPoses, selectablePoses, posingIndex, posingChar, posedRig, setPosed, rootStart, commitPosedPose,
1043
+ snapshotCast, changeInspectorCharacter, hasCharSheet, setHasCharSheet,
1044
+ waypointMode, setWaypointMode, waypoints, setWaypoints,
1045
+ activeWaypointId, setActiveWaypointId, pendingWaypointFrame, setPendingWaypointFrame, allPoses, selectablePoses, posingIndex, posingChar, posedRig, setPosed, rootStart, commitPosedPose,
1176
1046
  queueRootWaypointFrame, castMemberOf, addCharacterWaypoint, moveCharacterWaypoint,
1177
1047
  removeCharacterWaypoint, clearCharacterWaypoints, addFloorWaypoint, moveWaypoint, removeWaypoint,
1178
- toggleWaypointMode, openStudio, closeStudio, saveCurrentPose, savePose, posePhotoFile, removePose,
1179
- addPromptClip, changePromptClip, PROMPT_BLOCK_MAX_FRAMES, resizePromptClip, movePromptClip,
1180
- removePromptClip,
1048
+ toggleWaypointMode, openStudio, closeStudio, saveCurrentPose, savePose, removePose,
1049
+
1181
1050
  } = castDomain;
1182
1051
  const { publishStudioCharacters, syncStudioLayerBuffer, undoScene, redoScene, snapshotStudioDomain } = castDomain;
1183
1052
  // The cast as of this render, for async handlers: an extraction that
@@ -1185,28 +1054,6 @@ export default function App() {
1185
1054
  // not the one its closure captured.
1186
1055
  appContext.publishCharacters(characters);
1187
1056
 
1188
- const {
1189
- i2vMotionEnabled,
1190
- setI2vMotionEnabled,
1191
- i2vMotionMode,
1192
- setI2vMotionMode,
1193
- i2vMotionCameraUnlocked,
1194
- setI2vMotionCameraUnlocked,
1195
- } = useI2vCaptureModal();
1196
-
1197
- useEffect(() => {
1198
- let cancelled = false;
1199
- fetch(`${motionApiOrigin()}/v1/motion/me`, { credentials: "include" })
1200
- .then((response) => response.ok ? response.json() : null)
1201
- .then((payload) => {
1202
- if (cancelled || !payload) return;
1203
- setI2vMotionEnabled(payload.enabled === true);
1204
- if (Number.isFinite(payload.dailyRemaining)) motionDomain.updateI2vMotionQuota(payload.dailyRemaining);
1205
- })
1206
- .catch(() => { if (!cancelled) setI2vMotionEnabled(false); });
1207
- return () => { cancelled = true; };
1208
- }, []);
1209
-
1210
1057
  // Per-character rig report: stable callback identity per character so
1211
1058
  // the Character effect does not re-fire on every App render.
1212
1059
  const rigReportersRef = useRef(new Map());
@@ -1265,66 +1112,46 @@ export default function App() {
1265
1112
 
1266
1113
  const motionDomain = useMotion(appContext.forRender({
1267
1114
  get attachFrameRef() { return attachFrameRef; },
1268
- get PROMPT_BLOCK_MAX_FRAMES() { return PROMPT_BLOCK_MAX_FRAMES; },
1115
+
1269
1116
  get activeChar() { return activeChar; },
1270
1117
  get activeCharIndex() { return activeCharIndex; },
1271
1118
  get activeRig() { return activeRig; },
1272
- get addPromptClip() { return addPromptClip; },
1273
- get ardyAbortRef() { return ardyAbortRef; },
1274
- get autoPhysicsRunRef() { return autoPhysicsRunRef; },
1275
- get bridgeRefreshRef() { return bridgeRefreshRef; },
1119
+
1276
1120
  get bufferRef() { return bufferRef; },
1277
- get buildLineEditRequest() { return buildLineEditRequest; },
1278
1121
  get cameraPreviewEndRef() { return cameraPreviewEndRef; },
1279
- get cancelLinePreview() { return cancelLinePreview; },
1280
1122
  get castDomain() { return castDomain; },
1281
1123
  get castMemberOf() { return castMemberOf; },
1282
1124
  get charA() { return charA; },
1283
1125
  get characters() { return characters; },
1284
- get clearLineEdit() { return clearLineEdit; },
1285
- get collisionCleanupSupported() { return collisionCleanupSupported; },
1286
- get exitLineEditMode() { return exitLineEditMode; },
1287
1126
  get fovDeg() { return fovDeg; },
1288
1127
  get frameCountRef() { return frameCountRef; },
1289
- get genJobSeq() { return genJobSeq; },
1290
- get genRunningRef() { return genRunningRef; },
1291
- get generationPendingRef() { return generationPendingRef; },
1292
1128
  get ikBodyDragRef() { return ikBodyDragRef; },
1293
1129
  get ikFrames() { return ikFrames; },
1294
1130
  get ikStateRef() { return ikStateRef; },
1295
1131
  get ikStatesRef() { return ikStatesRef; },
1296
- get lineEditMode() { return lineEditMode; },
1297
- get lineEditPayload() { return lineEditPayload; },
1298
- get linePreviewSource() { return linePreviewSource; },
1299
- get linePreviewUrl() { return linePreviewUrl; },
1300
- get lineTrack() { return lineTrack; },
1301
1132
  get loadedLayerCharRef() { return loadedLayerCharRef; },
1302
1133
  get look() { return look; },
1303
1134
  get markSemanticEdit() { return markSemanticEdit; },
1304
1135
  get motionFullRef() { return motionFullRef; },
1305
- get multiModelObjectUrlRef() { return multiModelObjectUrlRef; },
1306
- get multiModelRunRef() { return multiModelRunRef; },
1307
- get physicsJobRef() { return physicsJobRef; },
1308
- get physicsSourceCacheRef() { return physicsSourceCacheRef; },
1309
1136
  get poseMemberAtFrame() { return poseMemberAtFrame; },
1310
1137
  get posedRig() { return posedRig; },
1311
1138
  get poserCamRef() { return poserCamRef; },
1312
1139
  get poserLook() { return poserLook; },
1313
1140
  get posing() { return posing; },
1314
1141
  get posingChar() { return posingChar; },
1315
- get projectMotionsRef() { return projectMotionsRef; },
1316
- get promptClips() { return promptClips; },
1142
+
1143
+
1317
1144
  get publishStudioCharacters() { return publishStudioCharacters; },
1318
- get restoreEpochRef() { return restoreEpochRef; },
1319
- get restoreRef() { return restoreRef; },
1320
- get revealPromptBlocks() { return revealPromptBlocks; },
1145
+
1146
+
1147
+
1321
1148
  get rigs() { return rigs; },
1322
1149
  get rowIdForCharIndex() { return rowIdForCharIndex; },
1323
1150
  get runStudioAction() { return runStudioAction; },
1324
1151
  get sceneObjects() { return sceneObjects; },
1325
1152
  get selectActiveCharacterInHierarchy() { return selectActiveCharacterInHierarchy; },
1326
1153
  get setProjectManifest() { return setProjectManifest; },
1327
- get setPromptClips() { return setPromptClips; },
1154
+
1328
1155
  get setSelectedHierarchyId() { return setSelectedHierarchyId; },
1329
1156
  get setTlFps() { return setTlFps; },
1330
1157
  get setTlFrame() { return setTlFrame; },
@@ -1332,31 +1159,24 @@ export default function App() {
1332
1159
  get setTlPlaying() { return setTlPlaying; },
1333
1160
  get shot() { return shot; },
1334
1161
  get shotCamRef() { return shotCamRef; },
1335
- get takeRecipeRef() { return takeRecipeRef; },
1336
- get takeSourceUrl() { return takeSourceUrl; },
1337
1162
  get tlFps() { return tlFps; },
1338
1163
  get tlFrame() { return tlFrame; },
1339
1164
  get tlFrameCount() { return tlFrameCount; },
1340
1165
  get tlFrameRef() { return tlFrameRef; },
1341
- get toggleLineEditMode() { return toggleLineEditMode; },
1342
- get trailBaseMotionRef() { return trailBaseMotionRef; },
1343
- get trailPreviewMotionRef() { return trailPreviewMotionRef; },
1166
+
1167
+
1344
1168
  get tutorialProjectEpochRef() { return tutorialProjectEpochRef; },
1345
1169
  get waitForRig() { return waitForRig; },
1346
1170
  get waypointMode() { return waypointMode; },
1347
1171
  get waypoints() { return waypoints; },
1348
1172
  get captureCurrentFraming() { return captureCurrentFraming; },
1349
1173
  get enterShotLook() { return enterShotLook; },
1350
- get i2vMotionEnabled() { return i2vMotionEnabled; },
1351
- get i2vMotionSegmentationReady() { return i2vMotionSegmentationReady; },
1352
1174
  get captureLiveFraming() { return liveQueries.capture_framing_png; },
1353
1175
  get lookThroughShot() { return lookThroughShot; },
1354
- get motionEncodingCacheRef() { return motionEncodingCacheRef; },
1176
+
1355
1177
  get readStudioState() { return readStudioState; },
1356
1178
  get restoreExportRig() { return restoreExportRig; },
1357
1179
  get setCameraPos() { return setCameraPos; },
1358
- get setI2vMotionCameraUnlocked() { return setI2vMotionCameraUnlocked; },
1359
- get setI2vMotionStudioOpen() { return setI2vMotionStudioOpen; },
1360
1180
  get setFovDeg() { return setFovDeg; },
1361
1181
  get setResult() { return setResult; },
1362
1182
  get setResultOpen() { return setResultOpen; },
@@ -1366,32 +1186,17 @@ export default function App() {
1366
1186
  }));
1367
1187
  const {
1368
1188
  ikMode, ikChains, setIkChains, ikFkJoints, setIkFkJoints, ikFocus, setIkFocus, footSnap, setFootSnap,
1369
- bodyContact, setBodyContact, IK_CORRECTION_BLEND_FRAMES, autoPhysicsRunning, setAutoPhysicsRunning,
1370
- physicsPreview, setPhysicsPreview, physicsShow, physicsProgress, physicsOptions, setPhysicsOptions,
1371
- ikTick, setIkTick, committedIkEdits, setCommittedIkEdits, trailFalloffS, setTrailFalloffS, showTrails,
1372
- setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailTrackFocus, selectTrailTrack, clearTrailTrackFocus, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
1189
+ bodyContact, setBodyContact, IK_CORRECTION_BLEND_FRAMES,
1190
+ ikTick, setIkTick, committedIkEdits, setCommittedIkEdits, ikEditTool, setIkEditTool, focusIkHandle, snapshotIkKeys,
1373
1191
  pendingIkEdit, applyPendingIkEdit, cancelPendingIkEdit,
1374
1192
  rangePins, rangePinResiduals, rangePinSelection, setRangePinSelection, rangePinPartPick, setRangePinPartPick, rangePinPreview, previewRangePinDraft, applyRangePinDraft, deleteRangePin,
1375
- setCharacterIkKey, removeCharacterIkKey, clearCharacterIkKeys, bridge, setBridge, bridgeChecking,
1376
- motionSetupReveal, motionSetupKind, setArdyPrompt, setArdyDuration, ardySeed, preserveStrength,
1377
- setPreserveStrength, takeRecipe, takeVersions, replayNotices, sceneMenuOpen, setSceneMenuOpen,
1378
- ardyRunning, ardyStatus, ardyOutcome, lineEditBackend, setLineEditBackend, motion, motionBusy,
1379
- multiModelUrl, setMultiModelUrl, multiModelSource, setMultiModelSource, multiModelStatus,
1380
- multiModelStage, multiModelProgress, multiModelFootage, multiModelError, multiModelTake,
1381
- multiModelExtract, multiModelExtractProgress, multiModelExtractError, advanceFrame, stepFrame,
1382
- leaveIkMode, beginPlaybackOn, chooseMultiModelFile, pasteMultiModelUrl, useMultiModelUrl, ingestFootage,
1383
- extractMultiModelMotion, loadMotion, clearMotion, applyMotionTrim, resetMotionTrim, cutMotionAtPlayhead,
1384
- changeMotionSegmentSpeed, removeMotionSegmentById, poseOtherCastMembers, toggleIkMode, ikSolve,
1385
- ikDragEnd, ikAddKeyframe, externalBlockers, runFixCollisions, runFixCollisionsRange,
1386
- changePhysicsOptions, showPhysicsPreview, cancelPhysicsPreview, applyPhysicsPreview, runAutoPhysics,
1387
- platformFitRunning, platformFitLast, platformFitProgress, platformFitApplied,
1388
- ikDeleteKeyframe, ikApplyPoseAsKey, recheckMotionHealth, changeArdySeed, takeSeed, runLineEdit,
1389
- runAllPromptBlocks, runArdy, onTrailDragStart, onTrailDragPreview, onTrailDragEnd, runTrailRegeneration,
1390
- genQueue, setGenQueue, executeMotionJob, seedLoadedTake, loadTakeVersion, selectedMotionReadiness,
1391
- generationBusy, openMotionSetup, refineDisabledReason, sceneDisabledReason, sceneGenerateDisabledReason,
1392
- sceneAgainDisabledReason, enterRefineMode, runSceneAgain, addSceneBlock, restoreMotionRefs, cancelArdy,
1193
+ setCharacterIkKey, removeCharacterIkKey, clearCharacterIkKeys,
1194
+ motion,
1195
+ advanceFrame, stepFrame,
1196
+ leaveIkMode, clearMotion, poseOtherCastMembers, toggleIkMode, ikSolve,
1197
+ ikDragEnd, ikAddKeyframe,
1198
+ ikDeleteKeyframe, ikApplyPoseAsKey,
1393
1199
  } = motionDomain;
1394
- const { i2vMotion, captureI2vStill, enterI2vFraming, markI2vPose, clearI2vPose, clearI2vMotion, restoreI2vCamera, framingDistance, showI2vMotionLock, generateI2vMotion, i2vMotionUnavailable, generateI2vMotionFromUi } = motionDomain;
1395
1200
  const [partColoursEnabled, setPartColoursEnabled] = useState(false);
1396
1201
  const [partColoursMode, setPartColoursMode] = useState("shaded");
1397
1202
 
@@ -1429,9 +1234,7 @@ export default function App() {
1429
1234
  // renders for a specific cast member, so selecting the group used to
1430
1235
  // drop the operator into Motion with nothing to drag.
1431
1236
  setSelectedHierarchyId(rowIdForCharIndex(activeCharIndex));
1432
- // Selecting Motion should land on its first useful control rather than
1433
- // leaving the operator to hunt through a long inspector column.
1434
- setPromptBlocksReveal((signal) => signal + 1);
1237
+
1435
1238
  }
1436
1239
  // Pose lands on the active character's rig: its Rig Control panel holds
1437
1240
  // the IK tools that W/E/R switch between.
@@ -1598,14 +1401,14 @@ export default function App() {
1598
1401
  get loadedLayerCharRef() { return loadedLayerCharRef; },
1599
1402
  get manualCameraOverrideRef() { return manualCameraOverrideRef; },
1600
1403
  get markSemanticEdit() { return markSemanticEdit; },
1601
- get motionEncodingCacheRef() { return motionEncodingCacheRef; },
1404
+
1602
1405
  get motionFullRef() { return motionFullRef; },
1603
1406
  get playgroundMode() { return playgroundMode; },
1604
1407
  get projectHandleRef() { return projectHandleRef; },
1605
- get projectMotionsRef() { return projectMotionsRef; },
1408
+
1606
1409
  get projectSnapshotRef() { return projectSnapshotRef; },
1607
1410
  get projectStateRef() { return projectStateRef; },
1608
- get restoreMotionRefs() { return restoreMotionRefs; },
1411
+
1609
1412
  get runStudioAction() { return runStudioAction; },
1610
1413
  get saveBlockedRef() { return saveBlockedRef; },
1611
1414
  get saveFailureToastRef() { return saveFailureToastRef; },
@@ -1617,11 +1420,11 @@ export default function App() {
1617
1420
  get setHasCharSheet() { return setHasCharSheet; },
1618
1421
  get setMovePlaying() { return setMovePlaying; },
1619
1422
  get setPendingWaypointFrame() { return setPendingWaypointFrame; },
1620
- get setPromptClips() { return setPromptClips; },
1423
+
1621
1424
  get setRailDraw() { return setRailDraw; },
1622
1425
  get setRigMountEpoch() { return setRigMountEpoch; },
1623
1426
  get setSelectedHierarchyId() { return setSelectedHierarchyId; },
1624
- get setSelectedPromptId() { return setSelectedPromptId; },
1427
+
1625
1428
  get setShots() { return setShots; },
1626
1429
  get setTlFrame() { return setTlFrame; },
1627
1430
  get setTlFrameCount() { return setTlFrameCount; },
@@ -1676,10 +1479,6 @@ export default function App() {
1676
1479
  const ikBodyDragRef = useRef(false); // true while a body drag is active
1677
1480
 
1678
1481
  const ikStateRef = useRef(createIkState());
1679
- const autoPhysicsRunRef = useRef(null);
1680
-
1681
- const physicsJobRef = useRef(0);
1682
- const physicsSourceCacheRef = useRef({ value: null });
1683
1482
 
1684
1483
  // Sorted full-body key frames for the timeline markers. Derived from the
1685
1484
  // ref state; ikTick re-derives after every key add/remove.
@@ -1687,9 +1486,7 @@ export default function App() {
1687
1486
  // eslint-disable-next-line react-hooks/exhaustive-deps
1688
1487
  [ikTick]);
1689
1488
 
1690
- // {track, grabFrame, radiusFrames, clipDelta}
1691
- const trailBaseMotionRef = useRef(null);
1692
- const trailPreviewMotionRef = useRef(null);
1489
+
1693
1490
 
1694
1491
  function selectHierarchy(id) {
1695
1492
  // A selection switch is the user starting something else: settle any open
@@ -1834,10 +1631,7 @@ export default function App() {
1834
1631
  camera.rotation.set(angles.pitch, angles.yaw, 0);
1835
1632
  }
1836
1633
 
1837
- // Prompt-block text (inspector field AND the timeline chip both land in
1838
- // changePromptClip) and viewport/plan camera framing are the two streams
1839
- // that would otherwise push an entry per keystroke / per pointermove.
1840
- const promptTextSessionRef = useRef(null);
1634
+ // Viewport/plan camera framing would otherwise push an entry per pointermove.
1841
1635
  const framingSessionRef = useRef(null);
1842
1636
  // A colour picker streams values for as long as its dialog is open.
1843
1637
  const tintSessionRef = useRef(null);
@@ -1924,21 +1718,15 @@ export default function App() {
1924
1718
  requestSculptView({ KeyW: "front", KeyE: "side", KeyR: "back" }[event.code]);
1925
1719
  return;
1926
1720
  }
1927
- // Pose mode's W/E/R are the IK tools, gated like their Rig Control
1928
- // buttons: the trail tool needs visible trails, the range pin a take.
1721
+ // Pose mode's W/R are the IK tools, gated like their Rig Control
1722
+ // buttons: the range pin needs a take.
1929
1723
  if (workflowMode === "pose" && GIZMO_HOTKEYS[event.code]) {
1930
1724
  event.preventDefault();
1931
1725
  if (event.code === "KeyW") setIkEditTool("ik");
1932
- else if (event.code === "KeyE" && showTrails) setIkEditTool("trail");
1726
+
1933
1727
  else if (event.code === "KeyR" && motion) { setIkEditTool("pin"); setRangePinPartPick(null); }
1934
1728
  return;
1935
1729
  }
1936
- // Motion mode's E is Refine: drag the take's path to re-route it.
1937
- if (workflowMode === "motion" && event.code === "KeyE") {
1938
- event.preventDefault();
1939
- enterRefineMode();
1940
- return;
1941
- }
1942
1730
  if (GIZMO_HOTKEYS[event.code]) {
1943
1731
  event.preventDefault();
1944
1732
  setGizmoMode(GIZMO_HOTKEYS[event.code]);
@@ -2067,7 +1855,6 @@ export default function App() {
2067
1855
  const liveWorkspaceIdRef = useRef("");
2068
1856
  if (!liveWorkspaceIdRef.current) liveWorkspaceIdRef.current = loadLiveWorkspaceId();
2069
1857
  const [result, setResult] = useState(null);
2070
- const [i2vMotionStudioOpen, setI2vMotionStudioOpen] = useState(false);
2071
1858
  const [resultOpen, setResultOpen] = useState(false);
2072
1859
  const [copied, setCopied] = useState(false);
2073
1860
  const [recordedVideoName, setRecordedVideoName] = useState(null);
@@ -2100,13 +1887,10 @@ export default function App() {
2100
1887
  }, []);
2101
1888
  const [glContextLost, setGlContextLost] = useState(false);
2102
1889
 
2103
- const bridgeRefreshRef = useRef(null);
2104
1890
 
2105
- const generationPendingRef = useRef(false);
2106
1891
 
2107
1892
  // default clip length in seconds; aligned with the recommended 3-5 s block range
2108
1893
 
2109
- const takeRecipeRef = useRef(null);
2110
1894
 
2111
1895
  const [bottomTab, setBottomTab] = useState("timeline");
2112
1896
  const { shelfImageIds, setShelfImageIds, shelfMeshIds, setShelfMeshIds, manageAssetStorage, setManageAssetStorage, unusedAssetIds, setUnusedAssetIds, usedAssetIds, setUsedAssetIds, usageCounts, setUsageCounts, assetShelfScanTokenRef, legacyDerivedIdsRef, assetTrash, setAssetTrash, assetUndoOffered, setAssetUndoOffered, deletingAssetId, setDeletingAssetId, cutoutLineage, meshLineage, projectCutoutLineage, projectAssetGraphSignature } = useAssetShelf({ scenes, activeSceneId, sceneObjects });
@@ -2273,7 +2057,6 @@ export default function App() {
2273
2057
  }
2274
2058
  }
2275
2059
 
2276
- const ardyAbortRef = useRef(null);
2277
2060
 
2278
2061
  const renderActive = useRenderActivity(tlPlaying || movePlaying);
2279
2062
  // the clip length on the production clock
@@ -2282,10 +2065,12 @@ export default function App() {
2282
2065
  // the export overwrites it per captured frame and restores nothing, which
2283
2066
  // is correct — the next render puts it back.
2284
2067
  propFrameRef.current = tlFrame;
2068
+ // A held object's gizmo drag edits its motion; its live preview rides the take tracks (held-object-edit.jsx).
2069
+ const heldEdit = useHeldObjectEdit({ selected: selectedSceneObject, objects: sceneObjects, tracks: objectTracksOf(appContext.storeDomain("motion"), appContext.live.characters), frame: tlFrame, frameCount: tlFrameCount, fps: tlFps, motion: appContext.storeDomain("motion"), run: runStudioAction });
2285
2070
  // Routed records and everything grouped under them (object-travel.js).
2286
- const animatedSceneObjects = useMemo(
2287
- () => sceneObjectsAt(sceneObjects, tlFrame, { frameCount: tlFrameCount, fps: tlFps }),
2288
- [sceneObjects, tlFrame, tlFrameCount, tlFps],
2071
+ const objectTracks = heldEdit.tracks, animatedSceneObjects = useMemo(
2072
+ () => sceneObjectsAt(sceneObjects, tlFrame, { frameCount: tlFrameCount, fps: tlFps, objectTracks }),
2073
+ [sceneObjects, tlFrame, tlFrameCount, tlFps, objectTracks],
2289
2074
  );
2290
2075
 
2291
2076
  // The selection as it is DRAWN at this frame, for the transform gizmo to sit
@@ -2293,10 +2078,8 @@ export default function App() {
2293
2078
  // routed descendant all stand away from their authored numbers while the
2294
2079
  // playhead is off frame 0. Drags still read the authored records (the gizmo
2295
2080
  // is handed those separately), so they stay delta-based on authored numbers.
2296
- const gizmoSceneObject = useMemo(
2297
- () => (selectedSceneObject ? animatedSceneObjects.find((object) => object.id === selectedSceneObject.id) ?? selectedSceneObject : null),
2298
- [selectedSceneObject, animatedSceneObjects],
2299
- );
2081
+ // A take-drawn object also gets `heldEdit.gizmo`: the exact drawn centre and rotation.
2082
+ const gizmoSceneObject = selectedSceneObject ? animatedSceneObjects.find((object) => object.id === selectedSceneObject.id) ?? selectedSceneObject : null;
2300
2083
 
2301
2084
  // Auto color: Blender's viewport "Random" mode. A DISPLAY-ONLY marker rides
2302
2085
  // each non-cutout object into the renderers; the authored `color`, the scene
@@ -2376,167 +2159,6 @@ export default function App() {
2376
2159
  const frameCountRef = useRef(DEFAULT_DURATION_S * TIMELINE_FPS);
2377
2160
  frameCountRef.current = tlFrameCount;
2378
2161
 
2379
- /* ------------------------------ line editing ---------------------------
2380
- * Contract C6. A modal editing surface exactly like waypointMode above,
2381
- * but it works in SCREEN space on a 2D overlay canvas rather than raycast
2382
- * onto the floor — depth is the model's problem, not the UI's.
2383
- *
2384
- * TWO GESTURES, ONE CURVE. The primary interaction is TRAJECTORY DRAGGING:
2385
- * the joint's existing path is projected onto the viewport and the user
2386
- * grabs a point on it and pulls, with a Gaussian falloff carrying the
2387
- * neighbours along. A press that lands nowhere near the path instead DRAWS
2388
- * one freehand (drawStrokeEdit), because a trail can project into a few
2389
- * screen pixels — a person backing up and falling barely moves the hand
2390
- * across the image — and with nothing grabbable the whole mode reads as
2391
- * dead. Drawing is not the old freehand tool coming back, though: that one
2392
- * popped the take 8x its own frame delta at the range edges because the
2393
- * joint teleported to wherever the stroke began (gate GP2). A stroke here is
2394
- * MATCHED BACK ONTO THE TRAIL — its endpoints pick the frames it was drawn
2395
- * over, and those frames become the edit's range — so it reroutes the stretch
2396
- * of path it covers, replays it with that stretch's own velocity profile, and
2397
- * eases out of the original trajectory at both seams. See the block comment
2398
- * above matchStrokeWindow in line-edit.js for why all three are one decision.
2399
- * A drag pins its ends instead (its Gaussian already eases), and both
2400
- * gestures produce the SAME dense frame-indexed curve, so everything
2401
- * downstream (preview, undo, camera drift, the wire payload) cannot tell them
2402
- * apart — except that a drawn curve also carries the `frameRange` it chose.
2403
- *
2404
- * `lineCurve` is non-null EXACTLY WHEN THERE IS AN EDIT, and that invariant
2405
- * is the whole camera policy in one sentence. Null means the curve is
2406
- * re-projected from the LIVE camera on every repaint, so orbiting the view
2407
- * carries the path along and navigation never fights the mode. Non-null is
2408
- * `{ camera, original, edited }` — one object, because a pull is a 2D offset
2409
- * that only means something through the lens it was authored with, so the
2410
- * camera is frozen beside the points and travels with them onto the wire.
2411
- * Moving the view therefore does NOT drop the pull; it only stops the line
2412
- * being drawable here (see lineDrifted just below, and the watcher further
2413
- * down).
2414
- *
2415
- * The four refs never re-render: `lineDragRef` is the in-flight grab and
2416
- * carries the live deformed curve, `lineDrawRef` is the in-flight freehand
2417
- * stroke, `lineLiveRef` caches the last projection the painter made so the
2418
- * hit test does not redo it, and `lineHoverRef` is the marker under the
2419
- * pointer. A pointermove must repaint the overlay
2420
- * without re-rendering an 11k-line component 200 times a second, so the
2421
- * painter reads the refs and only pointerup commits to state. */
2422
- const [lineEditMode, setLineEditMode] = useState(false);
2423
- const [lineTrack, setLineTrack] = useState(LINE_EDIT_DEFAULT_TRACK);
2424
- // null means "the whole clip" — resolved against the loaded take at use
2425
- // time so loading a different clip cannot leave a stale range behind.
2426
- const [lineRange, setLineRange] = useState(null);
2427
- const [lineCurve, setLineCurve] = useState(null);
2428
- /* THE VIEW HAS MOVED, AND THE EDIT IS STILL HERE.
2429
- *
2430
- * A committed curve carries its OWN camera (the `{ camera, original, edited }`
2431
- * above), and buildLineEditRequest sends that snapshot — so a later orbit,
2432
- * fly or shot switch cannot invalidate the pending edit, its preview or the
2433
- * confirming run. What it invalidates is only the ALIGNMENT of the painted
2434
- * overlay: the uv were authored through one lens, there is no depth to
2435
- * reproject them with, and drawing them over a different view would put the
2436
- * line somewhere the artist never aimed. So drift is a PRESENTATION state,
2437
- * not a destruction trigger — the curve is painted detached (ghosted, no
2438
- * grab handles), the panel says so, and a NEW gesture is refused because it
2439
- * would mix two cameras into one curve. Come back toward the snapshot and the
2440
- * line paints normally again.
2441
- *
2442
- * The ref is what the pointer handlers and the painter read (a poll is 250 ms
2443
- * stale and a press must not be); the state is what re-renders the panel. */
2444
- const [lineDrifted, setLineDrifted] = useState(false);
2445
- const lineDriftRef = useRef(false);
2446
- /* ------------------------------ 3D pins --------------------------------
2447
- * The THIRD gesture: scrub to a moment, grab the joint where it is, put it
2448
- * where it should be. Entries are `{ frame, position: [x, y, z] }` in the
2449
- * TAKE's own clip space (worldPointToClip converts on commit), ascending by
2450
- * frame, capped at LINE_EDIT_PINS_MAX — exactly the C6 `pins3d` payload, so
2451
- * the panel state and the wire object are the same value.
2452
- *
2453
- * ONE EDIT IS ONE GESTURE (v1). A pin clears the curve and a stroke or drag
2454
- * clears the pins, because a 2D path and a set of 3D points are two answers
2455
- * to "where does this joint go" and the box would be asked to average them.
2456
- * Combining them is a real feature (pin the extremes, draw the arc between)
2457
- * and it needs a story about which one owns a frame they both name — that
2458
- * story is not written, so the modes are exclusive and say so.
2459
- *
2460
- * `linePinMode` is the stage's gesture selector rather than a modifier key:
2461
- * pressing on the joint's own marker is ALSO how a curve drag starts, so the
2462
- * two cannot share a press, and a modal toggle beside the joint picker is
2463
- * discoverable in a way a chord is not. */
2464
- const [linePins, setLinePins] = useState([]);
2465
- const [linePinMode, setLinePinMode] = useState(false);
2466
- // The in-flight pin drag. Same no-re-render discipline as lineDragRef: a
2467
- // pointermove writes the ref and repaints the overlay, only pointerup
2468
- // commits to state.
2469
- const linePinDragRef = useRef(null);
2470
- const [lineRadius, setLineRadius] = useState(DRAG_RADIUS_DEFAULT);
2471
- const lineDragRef = useRef(null);
2472
- // The in-flight freehand STROKE, when the press missed the curve. Same
2473
- // no-re-render discipline as lineDragRef: pointermove appends a uv sample and
2474
- // repaints the overlay, and only pointerup turns the stroke into a curve (via
2475
- // strokeToCurve) and commits it through the drag's own pipeline.
2476
- const lineDrawRef = useRef(null);
2477
- // The range a DRAW just auto-matched into the panel. "Switching the range
2478
- // drops the pull in hand" is the right rule for a range the ARTIST typed —
2479
- // the pull was authored against a trajectory that is no longer on screen —
2480
- // but a drawn stroke authors its range and its curve in the same gesture,
2481
- // and letting that rule fire on the range the draw itself installed would
2482
- // wipe the drawing on commit and cancel its preview. One-shot: the effect
2483
- // consumes it.
2484
- const lineAutoRangeRef = useRef(null);
2485
- const lineLiveRef = useRef(null);
2486
- const lineHoverRef = useRef(null);
2487
- // Undo stack for committed pulls: each entry is the WHOLE lineCurve value
2488
- // that a commit replaced (null = "no edit yet"), so Ctrl/Cmd+Z is a plain
2489
- // pop-and-restore. A camera move no longer touches it: an edit survives the
2490
- // view moving, so the pulls behind it are still meaningful too, and undo is
2491
- // one of the three things (with reset and Generate) that must keep working
2492
- // while the view has drifted away from the line.
2493
- const lineUndoRef = useRef([]);
2494
- const lineOverlayRef = useRef(null);
2495
-
2496
- /* ------------------ live preview of a pull (contracts C10/C11) ------------
2497
- * Releasing the drag fires a FULL-QUALITY run of the same edit (same steps
2498
- * and session seed as Generate, ~2 s on the warm resident, so the draft and
2499
- * the confirmed take are bit-identical) and swaps the VIEWPORT to it, so
2500
- * the artist judges the correction by watching it move instead of by
2501
- * reading a curve. Three rules make that honest:
2502
- *
2503
- * 1. A PREVIEW IS A PICTURE, NOT A TAKE. It never pushes a version, never
2504
- * touches the recipe and never becomes anyone's sourceMotion. The take
2505
- * being edited is `takeSourceUrl` — remembered here the moment a
2506
- * preview starts — and `motion.url` is merely what is on screen.
2507
- * 2. ONE SEED PER EDITING SESSION. A draft rendered with a different seed
2508
- * predicts nothing, so the seed is rolled once (first preview or the
2509
- * confirming run, whichever comes first), reused by every preview, SENT
2510
- * by the full-quality run, and only then re-rolled. A typed seed is
2511
- * already constant, so the rule costs nothing there.
2512
- * 3. SUPERSEDE, NEVER STACK. At most one request is in flight; a drag
2513
- * that lands while one is out replaces the pending curve, and the older
2514
- * answer is dropped on arrival. Queueing them would make the viewport
2515
- * replay a history the artist has already moved past.
2516
- *
2517
- * Undo, reset and leaving the mode all revert the viewport to the source take
2518
- * and discard whatever is in flight. A CAMERA MOVE does not: the draft is a
2519
- * picture of an edit that survives the view moving, so it stays on screen and
2520
- * stays confirmable. */
2521
- const [linePreviewSource, setLinePreviewSource] = useState(null);
2522
- const linePreviewSourceRef = useRef(null);
2523
- // The preview motionUrl currently ON SCREEN — null means the source take is.
2524
- const [linePreviewUrl, setLinePreviewUrl] = useState(null);
2525
- const [linePreviewBusy, setLinePreviewBusy] = useState(false);
2526
- const [linePreviewError, setLinePreviewError] = useState("");
2527
- // Round trip of the last preview, in ms. Surfaced because "is this loop
2528
- // actually live?" is the question the number answers in one glance.
2529
- const [linePreviewMs, setLinePreviewMs] = useState(0);
2530
- const linePreviewSeedRef = useRef(null);
2531
- // Monotonic: a result whose token is stale was superseded or cancelled, and
2532
- // is discarded without ever reaching the viewport.
2533
- const linePreviewTokenRef = useRef(0);
2534
- const linePreviewAbortRef = useRef(null);
2535
- const linePreviewPendingRef = useRef(null);
2536
- const linePreviewTimerRef = useRef(0);
2537
- // The draft that actually REACHED the viewport, so a cancel knows whether
2538
- // there is anything to put back.
2539
- const linePreviewShownRef = useRef(null);
2540
2162
  useEffect(castDomain.removeLegacyRootWaypoint, []);
2541
2163
 
2542
2164
  // Shot trims also trim their local Rail Follow card once. Growing a shot
@@ -2548,31 +2170,22 @@ export default function App() {
2548
2170
  // its latest objects and camera department envelope before React opens the
2549
2171
  // destination room.
2550
2172
  const activeSceneIdRef = useRef(activeSceneId);
2551
- const shotDocumentRef = useRef(null);
2552
- const actorStageRef = useRef(null);
2173
+ // The shot document and the stage's cast are read from the stores when used,
2174
+ // not rebuilt here: a render-time copy lagged a synchronous command by one
2175
+ // render and moved the scene revision after its receipt (document-mirrors.js).
2176
+ const [{ shotDocumentRef, actorStageRef }] = useState(() => createDocumentMirrors(appContext));
2553
2177
  appContext.publishScenes(scenes);
2554
2178
  activeSceneIdRef.current = activeSceneId;
2555
- shotDocumentRef.current = createShotAuthoringDocument({ shots, waypoints, frameCount: tlFrameCount });
2556
- // The motion layers: their IK edits (Pose mode, IK keys, range pins) ride
2557
- // into the saved stage below with each character, and a change to them is
2558
- // a reason to autosave.
2179
+ // The motion layers (IK edits ride into the saved stage with each
2180
+ // character): a change to them is a reason to autosave.
2559
2181
  const motionLayers = appContext.storeDomain("motion")?.read() ?? null;
2560
- const ikEditsOf = (id) => {
2561
- const row = motionLayers?.find((layer) => layer.id === id);
2562
- if (!row || (!row.ikKeys?.length && !row.ikPins?.length)) return null;
2563
- return { keys: row.ikKeys, pins: row.ikPins ?? [], pinResiduals: row.ikPinResiduals ?? [] };
2564
- };
2565
- // sessionMotion is stripped: generated clips are session-only and far too
2566
- // heavy for the stage envelope; paths, prompt blocks and IK edits persist.
2567
- const stageCharacters = characters.map(({ sessionMotion, ...entry }) => ({ ...entry, ikEdits: ikEditsOf(entry.id) }));
2568
2182
  actorStageRef.current = {
2569
- characters: stageCharacters,
2570
2183
  hasCharSheet,
2571
2184
  environmentImage,
2572
2185
  shotAspect: shotAspectKey,
2573
2186
  cameraPresetId,
2574
2187
  sensorId,
2575
- keyLight,
2188
+ keyLight, lighting,
2576
2189
  // What this location IS and how it should look. Session state until #345:
2577
2190
  // a reopened scene came back with another room's description.
2578
2191
  environment,
@@ -2716,11 +2329,7 @@ export default function App() {
2716
2329
  return () => { window.removeEventListener("cozyclay:agent-panel-toggle", toggle); window.removeEventListener("keydown", key); };
2717
2330
  }, [embedMode]);
2718
2331
  const projectHandleRef = useRef(null);
2719
- const projectMotionsRef = useRef(new Map());
2720
- // Loaded clips keep the same Uint8Array identity while they remain active.
2721
- // Reuse the expensive encoded record until a new byte buffer is supplied.
2722
- const motionEncodingCacheRef = useRef(new WeakMap());
2723
- const restoreEpochRef = useRef(0);
2332
+
2724
2333
 
2725
2334
  const projectSnapshotRef = useRef("");
2726
2335
  const projectStateRef = useRef(null);
@@ -2798,7 +2407,6 @@ export default function App() {
2798
2407
  && tutorialInitialSnapshotRef.current === collectProjectSnapshot("Tutorial");
2799
2408
  if (seed) {
2800
2409
  tutorialLoadingRef.current = true;
2801
- demoSeeded.current = true;
2802
2410
  try {
2803
2411
  const opened = await openStarterScene("city-block", "tutorial");
2804
2412
  if (opened) {
@@ -2893,7 +2501,7 @@ export default function App() {
2893
2501
  filmback,
2894
2502
  // keyLight rides the live stage envelope: it is authored, undoable and
2895
2503
  // patchable state, so every reader sees the body the save path writes.
2896
- stage: { shotAspect: shotAspectKey, cameraPresetId, sensorId, hasCharSheet, environmentImage, environment, style, hasEnvSheet, keyLight },
2504
+ stage: { shotAspect: shotAspectKey, cameraPresetId, sensorId, hasCharSheet, environmentImage, environment, style, hasEnvSheet, keyLight, lighting },
2897
2505
  timeline: { currentFrame: tlFrame, frameCount: tlFrameCount, fps: tlFps },
2898
2506
  activeCharacterId,
2899
2507
  partColours: partColoursEnabled ? PART_COLOURS : null,
@@ -2905,7 +2513,6 @@ export default function App() {
2905
2513
  removeCharacter,
2906
2514
  persistScenes,
2907
2515
  openScene,
2908
- loadMotion,
2909
2516
  // The framing-capture pair the agent commands call. Both are per-render
2910
2517
  // closures (captureFramingPng sizes its canvas off the render's own
2911
2518
  // shotOutput), so the ref must always hold THIS render's instance — a
@@ -3095,10 +2702,10 @@ export default function App() {
3095
2702
 
3096
2703
  // These hooks are declared after the liveStateRef assignment above runs, so
3097
2704
  // they join the live read model here — same render, no TDZ.
3098
- appContext.patchLive({ promptClips, setPromptClips, editPromptClips, setTlFrameCount });
2705
+ appContext.patchLive({ setTlFrameCount });
3099
2706
 
3100
2707
  // Dirty tracking: any divergence from the last saved file lights the dot.
3101
- useEffect(scenesDomain.refreshProjectDirty, [scenes, activeSceneId, workspaceLayout, customPoses, characters, shots, waypoints, promptClips, projectName, keyLight, sceneObjects, shotAspectKey, environmentImage, environment, style, hasEnvSheet, sensorId, tlFrameCount, workflowRevision]);
2708
+ useEffect(scenesDomain.refreshProjectDirty, [scenes, activeSceneId, workspaceLayout, customPoses, characters, shots, waypoints, projectName, keyLight, sceneObjects, shotAspectKey, environmentImage, environment, style, hasEnvSheet, sensorId, tlFrameCount, workflowRevision]);
3102
2709
 
3103
2710
  // The untrimmed take per CHARACTER. Trims are non-destructive views of the
3104
2711
  // full take, so re-trimming and "restore full" always cut from the
@@ -3106,35 +2713,8 @@ export default function App() {
3106
2713
  // ref would hand Subject 2's take to Subject 1 on the next switch.
3107
2714
  const motionFullRef = useRef(new Map()); // charId -> untrimmed take
3108
2715
 
3109
- // Which ik tracks a preserve run would name in its editRanges, derived the
3110
- // same way runArdy derives them: the prompt-block schedule, the blocks that
3111
- // contain authored IK keys, the tracks keyed inside those blocks. The blocks
3112
- // TILE 0..clipFrames, so the union over the edited ones is just the union
3113
- // over the whole clip — blocks without keys contribute nothing either way.
3114
- // Empty means the request carries no `tracks` at all and the panel says
3115
- // nothing extra. Regenerating a take is the only clock that matters here, so
3116
- // clipFrames comes from the loaded take exactly as runArdy takes it.
3117
- const preserveEditedTracks = useMemo(() => {
3118
- if (!motion?.url || ikFrames.length === 0) return [];
3119
- const sourcePromptClips = promptClips
3120
- .filter((clip) => clip.text.trim())
3121
- .sort((a, b) => a.startFrame - b.startFrame);
3122
- if (sourcePromptClips.length === 0) return [];
3123
- const clipFrames = (motion.frames / motion.fps) * TIMELINE_FPS;
3124
- // A single block spanning the whole take is not a schedule, and without a
3125
- // schedule there are no edited blocks to attribute (runArdy's own rule).
3126
- if (buildPromptSchedule(sourcePromptClips, clipFrames, sourcePromptClips[0].text).length < 2) return [];
3127
- return ikTracksInRange(ikStateRef.current, ikFrames, 0, clipFrames);
3128
- }, [motion, promptClips, ikFrames]);
3129
- const preserveTracksLine = preserveTracksSummary(preserveEditedTracks);
3130
-
3131
- const multiModelFileRef = useRef(null);
3132
- const multiModelRunRef = useRef(0);
3133
- const multiModelObjectUrlRef = useRef(null);
3134
-
3135
- // idle | running | done | error
3136
-
3137
- const photoPoseFileRef = useRef(null);
2716
+
2717
+
3138
2718
 
3139
2719
  // Cast render props, memoized with Character itself (React.memo): during
3140
2720
  // playback the playhead ticks 24 times a second, and a character whose
@@ -3203,20 +2783,18 @@ export default function App() {
3203
2783
  }, [gizmoView, characters, activeChar.id]);
3204
2784
 
3205
2785
  /* --------------------- per-character layer buffers ---------------------
3206
- * waypoints / promptClips / motion above are the EDITING BUFFER of the
2786
+ * waypoints / motion above are the EDITING BUFFER of the
3207
2787
  * active character's animation layer. On a character switch the buffer is
3208
2788
  * committed back into the previous character's entry and the new one's
3209
2789
  * layer is loaded, so each cast member keeps its own schedule. The
3210
2790
  * generated clip is session-only; paths and prompt blocks persist in the
3211
2791
  * stage envelope via the characters array. */
3212
- const bufferRef = useRef({ waypoints: [], promptClips: [], motion: null, ik: null });
3213
- bufferRef.current = { waypoints, promptClips, motion, ik: ikStateRef.current };
2792
+ const bufferRef = useRef({ waypoints: [], motion: null, ik: null });
2793
+ bufferRef.current = { waypoints, motion, ik: ikStateRef.current };
3214
2794
  const loadedLayerCharRef = useRef(activeChar.id);
3215
2795
  const ikStatesRef = useRef(new Map()); // charId -> ikState, one per layer
3216
2796
  useEffect(castDomain.switchActiveCharacterLayer, [activeChar.id]);
3217
- // Pre-playback bone snapshot; restoring it (after Character's pose effect
3218
- // has re-applied poseA) puts the rig back exactly where it was.
3219
- const restoreRef = useRef(null);
2797
+
3220
2798
 
3221
2799
  const shotCamRef = useRef(null);
3222
2800
  const captureRef = useRef(null);
@@ -3569,11 +3147,10 @@ export default function App() {
3569
3147
  characters,
3570
3148
  activeChar.id,
3571
3149
  motion,
3572
- promptClips,
3573
- multiModelFootage?.frames,
3150
+ null,
3574
3151
  );
3575
3152
  // Camera-only scenes still use their authored production duration. Once
3576
- // motion or prompt content exists, content is the authoritative record
3153
+ // motion exists, content is the authoritative record
3577
3154
  // range even if an older timeline count was left behind.
3578
3155
  return contentExtent > 0 ? contentExtent : tlFrameCount;
3579
3156
  }
@@ -4124,10 +3701,6 @@ export default function App() {
4124
3701
  return captureFraming({ pos: { x: pos.x, y: pos.y, z: pos.z }, yaw: look.current.yaw, pitch: look.current.pitch, fovDeg });
4125
3702
  }
4126
3703
 
4127
- /** The agent panel's Generate motion chip: the motion.generateFromVideo
4128
- * action. Locked, the action is unavailable and never runs, so the chip shows
4129
- * the AI motion card's lock line itself, as it always did. */
4130
-
4131
3704
  // What a framing capture says about the shot it came from: lens, delivery
4132
3705
  // aspect, cast, cut range and the video model the shot is aimed at. Both
4133
3706
  // capture paths (the live command and the embed message) attach this, so a
@@ -4171,7 +3744,6 @@ export default function App() {
4171
3744
  // sidebar tabs. Every panel belongs to the thing that owns it: the scene
4172
3745
  // owns what gets generated, the camera owns the lens, and a character owns
4173
3746
  // its pose and its motion.
4174
- const isSceneSelection = selectedHierarchyId === "shot";
4175
3747
  const isCameraSelection = selectedHierarchyId === "camera";
4176
3748
  const isCharacterSelection = selectedHierarchyId === "characters"
4177
3749
  || selectedHierarchyId === "characterA"
@@ -4181,64 +3753,18 @@ export default function App() {
4181
3753
  // for the part colours, and a dot on the trigger whenever the viewport is
4182
3754
  // showing something other than the plain stage.
4183
3755
  const partColoursChoice = partColoursEnabled ? partColoursMode : "off";
4184
- // Shaded part colours keep the stable per-part hues while preserving the
4185
- // surface lighting H3 uses to infer the character's volume and pose.
4186
- const i2vMotionSegmentationReady = partColoursEnabled && partColoursMode === "shaded";
4187
- const i2vMotionHasA = Boolean(i2vMotion.a);
4188
- const i2vMotionHasB = Boolean(i2vMotion.b);
4189
- const i2vMotionCameraMatch = i2vMotionHasA && i2vMotionHasB && framingDistance(i2vMotion.a.framing, i2vMotion.b.framing) <= 0.001;
4190
- const i2vMotionCameraLocked = i2vMotionHasA && !i2vMotionCameraUnlocked;
4191
- const i2vMotionCurrentCameraMatch = !i2vMotionHasA || framingDistance(i2vMotion.a.framing, captureCurrentFraming()) <= 0.001;
4192
- const i2vMotionStep = !i2vMotionSegmentationReady ? 1 : !i2vMotionHasA ? 2 : i2vMotionMode === "interpolate" && !i2vMotionHasB ? 3 : 4;
4193
- // The AI motion workflow is split into a viewport-side capture card and a wide
4194
- // authoring modal (i2v-motion-studio.jsx); both render from this one model
4195
- // and action set so they cannot drift.
4196
- const i2vMotionModel = {
4197
- i2vMotion,
4198
- mode: i2vMotionMode,
4199
- enabled: i2vMotionEnabled,
4200
- segmentationReady: i2vMotionSegmentationReady,
4201
- step: i2vMotionStep,
4202
- hasA: i2vMotionHasA,
4203
- hasB: i2vMotionHasB,
4204
- cameraMatch: i2vMotionCameraMatch,
4205
- cameraUnlocked: i2vMotionCameraUnlocked,
4206
- currentCameraMatch: i2vMotionCurrentCameraMatch,
4207
- framingActive: lookThroughShot && shotAspectKey === I2V_MOTION_SHOT_ASPECT,
4208
- };
4209
- const i2vMotionActions = {
4210
- setI2vMotion: motionDomain.setI2vMotion,
4211
- setMode: setI2vMotionMode,
4212
- enterFraming: enterI2vFraming,
4213
- markPose: markI2vPose,
4214
- clearPose: clearI2vPose,
4215
- clear: clearI2vMotion,
4216
- toggleCameraLock: () => setI2vMotionCameraUnlocked((value) => !value),
4217
- restoreCamera: restoreI2vCamera,
4218
- generate: (kind) => void generateI2vMotion(kind),
4219
- enableShaded: () => runStudioAction("view.setPartColours", { mode: "shaded" }),
4220
- };
4221
3756
  const viewLooksActive = gridView || autoColor || partColoursEnabled || guideMode !== "off";
4222
3757
  const rigSelection = parseRigNodeId(selectedHierarchyId);
4223
3758
  const isRigSelection = rigSelection !== null;
4224
- const inspectorHasContent = isSceneSelection || isCameraSelection || isCharacterSelection || isRigSelection
3759
+ // The scene row owns no settings of its own, so it leaves the empty-panel hint up rather than a blank column.
3760
+ const inspectorHasContent = isCameraSelection || isCharacterSelection || isRigSelection
4225
3761
  || selectedHierarchyId === "environment" || selectedHierarchyId === "props" || selectedHierarchyId === "light" || Boolean(selectedSceneObject) || sculptMode;
4226
3762
 
4227
- useEffect(() => () => {
4228
- if (multiModelObjectUrlRef.current) URL.revokeObjectURL(multiModelObjectUrlRef.current);
4229
- }, []);
4230
-
4231
- // Hosted-demo seed. A build served as static files has no ARDY sidecar, so
4232
- // a first-time visitor would otherwise land on a character standing still
4233
- // with no way to see generated motion. The clip below ships with the build
4234
- // and is loaded once, only when the bridge is absent and nothing has been
4235
- // loaded or generated yet. A local session with the bridge running is
4236
- // untouched.
4237
- const motionRefsRestored = useRef(false);
3763
+ // Startup idle work, once.
3764
+ const startupWarmed = useRef(false);
4238
3765
  useEffect(() => {
4239
- if (motionRefsRestored.current) return;
4240
- motionRefsRestored.current = true;
4241
- restoreMotionRefs(startupStage.characters);
3766
+ if (startupWarmed.current) return;
3767
+ startupWarmed.current = true;
4242
3768
  // Parse the thumbnail rigs during startup idle so the first pose-studio
4243
3769
  // open starts rendering immediately instead of paying a 100ms+ FBX parse.
4244
3770
  warmPoseThumbnails();
@@ -4246,64 +3772,21 @@ export default function App() {
4246
3772
  // eslint-disable-next-line react-hooks/exhaustive-deps
4247
3773
  }, []);
4248
3774
 
4249
- const demoSeeded = useRef(false);
4250
- // Latched on the first healthy probe: a session that has seen the sidecar is
4251
- // not the hosted demo, and a later failed probe is a blip, not "no bridge".
4252
- const bridgeSeenOk = useRef(false);
3775
+ // The camera tutorial's seed (#209). startCameraTutorial arms
3776
+ // tutorialSeedPending once the starter scene is applied, and a compiled walk
3777
+ // (take v3) goes on as soon as that scene's character has a parsed rig: a
3778
+ // state condition, not a timer. The compile runs in the worker; an edit that lands
3779
+ // before it answers makes the job's fence refuse the install (set dressing, no error).
4253
3780
  useEffect(() => {
4254
- const demoSeed = demoSeedGate(bridge, bridgeSeenOk.current);
4255
- bridgeSeenOk.current = demoSeed.bridgeSeenOk;
4256
- if (demoSeeded.current) return;
4257
- if (!activeRig || motion || motionBusy) return;
4258
- // A hosted-demo result link opens the app with ?motion=<url>. The value
4259
- // is gated by motionUrlFromQuery (same-origin or allowlisted https host
4260
- // only) and, unlike the shipped seed below, loads regardless of bridge
4261
- // state — the visitor followed a link whose whole point is this clip.
4262
- const queryMotion = motionUrlFromQuery(window.location.search, window.location.origin);
4263
- if (queryMotion) {
4264
- demoSeeded.current = true;
4265
- loadMotion(queryMotion, "").catch(() => {
4266
- /* a dead link degrades to the normal empty stage, not an error */
4267
- });
4268
- return;
4269
- }
4270
- // The seed dresses a visitor's first, unnamed stage and the starter set a
4271
- // `?scene=` launch always re-opens ("one character mid-walk"). A named
4272
- // project is the author's document: reloading it must never put the demo
4273
- // walk back on a character they deliberately left without a take.
4274
- if (loadProjectSession()?.name && !new URLSearchParams(window.location.search).get("scene")) { demoSeeded.current = true; return; }
4275
- if (!demoSeed.seed) return;
4276
- demoSeeded.current = true;
4277
- // Loaded, not played: the clip walks the subject out of the default
4278
- // framing, so autoplay would greet a first-time visitor with an empty
4279
- // room. Frame 0 is composed; PLAYBACK is one click away.
4280
- loadMotion(DEMO_MOTION_URL, DEMO_MOTION_PROMPT).catch(() => {
4281
- /* the seed is a nicety, never a failure the visitor must act on */
4282
- });
4283
- }, [bridge, activeRig, motion, motionBusy]);
4284
-
4285
- // The camera tutorial's seed (#209). The hosted-demo seed above is gated on
4286
- // a missing bridge, which is why the tutorial used to open on an empty room
4287
- // in a local session. startCameraTutorial arms tutorialSeedPending once the
4288
- // starter scene is applied, and the same clip goes on regardless of bridge
4289
- // state as soon as that scene's character has a parsed rig — a state
4290
- // condition, not a timer, exactly like the seed above.
4291
- useEffect(() => {
4292
- if (!tutorialSeedPending || !activeRig || motionBusy) return;
3781
+ if (!tutorialSeedPending || !activeRig) return;
4293
3782
  const seedEpoch = tutorialSeedEpochRef.current;
4294
- if (seedEpoch === null || seedEpoch !== tutorialProjectEpochRef.current) {
4295
- tutorialSeedEpochRef.current = null;
4296
- setTutorialSeedPending(false);
4297
- return;
4298
- }
4299
3783
  tutorialSeedEpochRef.current = null;
4300
3784
  setTutorialSeedPending(false);
4301
- demoSeeded.current = true; // one seeded clip per session, whichever got here first
4302
- loadMotion(DEMO_MOTION_URL, DEMO_MOTION_PROMPT, undefined, null, activeChar.id, null, { tutorialEpoch: seedEpoch }).catch(() => {
4303
- /* the take is the tutorial's set dressing, never an error to act on */
4304
- });
3785
+ if (seedEpoch === null || seedEpoch !== tutorialProjectEpochRef.current) return;
3786
+ const spec = tutorialWalkSpec(activeChar); // the take is set dressing, never an error to act on
3787
+ if (spec && runStudioAction("motion.compile", { characterId: activeChar.id, spec })) { setTlFrame(0); setTlPlaying(false); }
4305
3788
  // eslint-disable-next-line react-hooks/exhaustive-deps
4306
- }, [tutorialSeedPending, activeRig, motionBusy]);
3789
+ }, [tutorialSeedPending, activeRig]);
4307
3790
 
4308
3791
  // Drive every cast member from ITS OWN clip on the shared playhead. The
4309
3792
  // active character's buffer motion and the stored session motions of the
@@ -4322,7 +3805,7 @@ export default function App() {
4322
3805
  // clock. Recomputing both directions matters: deleting Subject 2 or
4323
3806
  // shortening its take must pull the end back in instead of leaving the
4324
3807
  // recorder with frozen tail frames (#80).
4325
- useEffect(shotsDomain.syncTimelineExtent, [characters, activeChar.id, motion, promptClips, multiModelFootage?.frames, shots]);
3808
+ useEffect(shotsDomain.syncTimelineExtent, [characters, activeChar.id, motion, shots]);
4326
3809
 
4327
3810
  /* ------------------------------ IK logic ------------------------------ */
4328
3811
 
@@ -4342,32 +3825,6 @@ export default function App() {
4342
3825
  if (!chains) leaveIkMode();
4343
3826
  }, [activeRig, activeCapabilities.ik]);
4344
3827
 
4345
- // Whether the collision capsules can be built for this rig at all. Bone
4346
- // lookups only — no mesh measurement — so it is cheap enough to hang off
4347
- // the rig identity and read straight in the render. A rig that resolves
4348
- // for IK can still miss the toes/spine the capsule table needs, so this is
4349
- // a SEPARATE question from `ikChains`: the two collision buttons disable
4350
- // on it rather than offering a click whose only possible answer is "not
4351
- // supported".
4352
- const collisionCleanupSupported = useMemo(() => activeCapabilities.physics && supportsCollisionCleanup(activeRig), [activeRig, activeCapabilities.physics]);
4353
-
4354
- //
4355
-
4356
- useEffect(() => {
4357
- physicsJobRef.current += 1;
4358
- physicsSourceCacheRef.current.value = null;
4359
- setPhysicsPreview(null);
4360
- setPhysicsOptions({ overrides: [], protectedFrames: [], strength: 1 });
4361
- setAutoPhysicsRunning(false);
4362
- autoPhysicsRunRef.current = null;
4363
- return () => { physicsJobRef.current += 1; };
4364
- }, [activeRig, motion, activeChar.x, activeChar.y, activeChar.z, activeChar.rot, activeChar.scale]);
4365
- useEffect(() => {
4366
- if (physicsPreview && physicsPreview.sourceStamp !== physicsKeyStamp(ikStateRef.current.keys)) {
4367
- setPhysicsPreview(null);
4368
- }
4369
- }, [ikTick, physicsPreview]);
4370
-
4371
3828
  // Keyed-pose playback: the IK layer's keyed bone rotations apply at the
4372
3829
  // current frame whether or not IK edit mode is on — IK-authored keys are
4373
3830
  // the source of truth (the user designs first/end keys and ARDY in-
@@ -4386,12 +3843,11 @@ export default function App() {
4386
3843
  // Preview changes may re-run this effect without a playhead change.
4387
3844
  // Always re-establish the motion base before adding a correction.
4388
3845
  if (motion) poseMemberAtFrame(activeRig, motion, null, tlFrame);
4389
- const layer = pendingIkEdit?.characterId === activeChar.id ? pendingIkEdit.candidate
4390
- : physicsPreview && physicsShow ? physicsPreview.candidate : ikStateRef.current;
3846
+ const layer = pendingIkEdit?.characterId === activeChar.id ? pendingIkEdit.candidate : ikStateRef.current;
4391
3847
  if (ikMode || layer.keys.size > 0) {
4392
3848
  ikEvaluate(ikChains, layer, tlFrame, ikFkJoints, motion ? IK_CORRECTION_BLEND_FRAMES : 0);
4393
3849
  }
4394
- }, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints, physicsPreview, physicsShow, pendingIkEdit, activeChar.id]);
3850
+ }, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints, pendingIkEdit, activeChar.id]);
4395
3851
 
4396
3852
  // Re-seat the handles on the keyed pose when the FRAME changes with IK
4397
3853
  // on — scrubbing to frame 39 shows that frame's interpolated pose AND
@@ -4409,11 +3865,10 @@ export default function App() {
4409
3865
  }, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints]);
4410
3866
 
4411
3867
  // QA hook: lets headless visual checks read the live rig/motion state
4412
- // (tools/ardy/visual-qa.mjs). Harmless in normal use.
3868
+ // (test/motion3/qa-motion3-*-browser.mjs). Harmless in normal use.
4413
3869
  useEffect(() => {
4414
3870
  window.__cozyclay = {
4415
- runStudioAction,
4416
- runArdy: (options) => appContext.live.state.runArdy(options),
3871
+ runStudioAction, setFrame: setTlFrame,
4417
3872
  rigA: activeRig, motion, tlFrame, frameCount: tlFrameCount, playing: tlPlaying, ikMode, ikChains, ikFocus, contactRadii: ikChains?.values().next().value?.contactRadii ?? null, ik: ikStateRef.current,
4418
3873
  rangePins, rangePinResiduals,
4419
3874
  rangePinApplySpec: motionDomain.rangePinApplySpec,
@@ -4526,16 +3981,7 @@ export default function App() {
4526
3981
  addSceneObject: (kind, placement = {}) => addSceneObject(kind, placement),
4527
3982
  scrub: (frame) => setTlFrame(Math.max(0, Math.min(tlFrameCount - 1, Math.round(frame)))),
4528
3983
  pause: () => setTlPlaying(false),
4529
- // Motion-trail QA surface: read the current trail policy and drive the
4530
- // same drag -> preview -> pending-edit path headless checks cannot reach
4531
- // through synthetic pointers reliably.
4532
- trail: { falloffFrames: trailFalloffFrames, falloffS: trailFalloffS, edit: trailEdit, tool: ikEditTool, visible: showTrails, activeTrackId: trailTrackFocus, visibleTracks: trailTrackFocus ? [trailTrackFocus] : null },
4533
- trailPoints: (jointName = "Hips") => jointTrailPoints(motion, jointName, { baseY: activeChar.y ?? 0, scale: activeChar.scale ?? 1 }),
4534
- trailEditApply: (grabFrame, delta) => {
4535
- onTrailDragStart({ track: "hips", grabFrame });
4536
- onTrailDragPreview({ track: "hips", grabFrame, delta });
4537
- onTrailDragEnd({ track: "hips", grabFrame, delta });
4538
- },
3984
+
4539
3985
  // QA-only IK drag seam: resolves the same production solve/end path as
4540
3986
  // the viewport manipulator, with a deterministic world-space offset.
4541
3987
  ikPreviewApply: (track = "leftHand", offset = { x: 0.02, y: 0, z: 0 }) => {
@@ -4546,53 +3992,11 @@ export default function App() {
4546
3992
  ikDragEnd();
4547
3993
  return true;
4548
3994
  },
4549
- trailRegenerate: runTrailRegeneration,
4550
- // Fix-collisions QA surface: live penetration readout for headless
4551
- // checks; the fix itself runs through the buttons / runFixCollisions.
4552
- // null (not []) on an unsupported rig, so a check can tell "the tool
4553
- // cannot describe this skeleton" from "this pose is clean".
4554
- // EXTERNAL blockers ride this readout too, so a headless check can see
4555
- // `leftHand×obj:chair` / `leftHand×char:subject-2:torso` and not just
4556
- // self-collisions. A blocker hit has no `def` on its side of the pair
4557
- // — it is not a capsule of THIS rig — so the label falls back to the
4558
- // blocker's own namespaced id.
4559
- fcDetect: () => {
4560
- const capsules = activeRig ? buildCollisionCapsules(activeRig) : null;
4561
- if (!capsules) return null;
4562
- const label = (side) => side?.def?.id ?? side?.id ?? "?";
4563
- return detectPenetrations(capsules, { blockers: externalBlockers(tlFrame) })
4564
- .map((p) => ({ pair: `${label(p.a)}×${label(p.b)}`, depth: p.depth }));
4565
- },
4566
- // What those `obj:` / `char:` names stand for, as plain numbers: the
4567
- // boxes and capsules the fixer is being asked to keep the body out of.
4568
- fcBlockers: () => blockerSummary(externalBlockers(tlFrame)),
4569
- // AutoPhysics QA surface: the centre of mass of the CURRENT pose, so
4570
- // a headless check can sample the arc before/after the button press.
4571
- apCom: () => {
4572
- const com = activeRig ? computeCenterOfMass(activeRig) : null;
4573
- return com ? { x: com.x, y: com.y, z: com.z } : null;
4574
- },
4575
- apFeet: () => {
4576
- const points = activeRig ? markerPositions(activeRig) : null;
4577
- return points ? Object.fromEntries(Object.entries(points).map(([name, point]) => [name, { x: point.x, y: point.y, z: point.z }])) : null;
4578
- },
4579
- apRun: runAutoPhysics,
4580
- physics: { preview: physicsPreview, show: physicsShow, running: autoPhysicsRunning, options: physicsOptions },
4581
- platformFit: {
4582
- running: platformFitRunning, applied: platformFitApplied, last: platformFitLast,
4583
- run: () => Promise.resolve(motionDomain.run('motion.platformFit.run', { characterId: activeChar?.id })).then(receipt => receipt.output),
4584
- remove: () => Promise.resolve(motionDomain.run('motion.platformFit.remove', { characterId: activeChar?.id })).then(receipt => receipt.output),
4585
- },
4586
- apOptions: changePhysicsOptions,
4587
3995
  preview,
4588
3996
  pathDraw,
4589
3997
  };
4590
3998
  // eslint-disable-next-line react-hooks/exhaustive-deps
4591
- // sceneObjects/rigs/characters ride the deps because fcDetect/fcBlockers
4592
- // close over them: a stale closure would report the set as it was two
4593
- // edits ago — and, after an undo that removes a subject, would keep
4594
- // reporting the ghost's capsules.
4595
- }, [activeRig, motion, tlFrame, tlFrameCount, ikMode, ikChains, ikFocus, ikTick, charA, rangePins, rangePinResiduals, committedIkEdits, waypoints, lookThroughShot, selectedSceneObject, sceneObjects, rigs, characters, pathPointIndex, preview, posing, playMode, pathDraw, trailEdit, trailTrackFocus, pendingIkEdit, trailFalloffFrames, trailFalloffS, ikEditTool, showTrails, physicsPreview, physicsShow, physicsOptions, autoPhysicsRunning, platformFitRunning, platformFitLast, platformFitApplied]);
3999
+ }, [activeRig, motion, tlFrame, tlFrameCount, ikMode, ikChains, ikFocus, ikTick, charA, rangePins, rangePinResiduals, committedIkEdits, waypoints, lookThroughShot, selectedSceneObject, sceneObjects, rigs, characters, pathPointIndex, preview, posing, playMode, pathDraw, pendingIkEdit, ikEditTool]);
4596
4000
  // QA hook (plan §6.5): exposes history depth and the present === objects
4597
4001
  // invariant so the browser suite can assert undo entry counts directly.
4598
4002
  // Reads live store state at call time; re-registered after every render.
@@ -4621,16 +4025,7 @@ export default function App() {
4621
4025
  };
4622
4026
  });
4623
4027
 
4624
- // On clear, restore the exact pre-playback bone rotations. This runs in
4625
- // the parent AFTER Character's pose effect (children flush first), so even
4626
- // a pose change made during playback cannot leak into the restored rig.
4627
- useEffect(() => {
4628
- if (motion) return;
4629
- const saved = restoreRef.current;
4630
- if (!saved) return;
4631
- restoreRef.current = null;
4632
- restorePlaybackBones(saved.rig, saved.bones);
4633
- }, [motion]);
4028
+
4634
4029
 
4635
4030
  const playbackSceneBase = useMemo(() => ({
4636
4031
  frameCount: tlFrameCount,
@@ -4677,32 +4072,8 @@ export default function App() {
4677
4072
  // the follow controller and the Top-View display.
4678
4073
  const railCurve = useMemo(() => (cameraRail ? buildRail(cameraRail) : null), [cameraRail]);
4679
4074
 
4680
- const followTrack = useMemo(() => {
4681
- if (!subjectTrack) return null;
4682
- const yaw = (charA.rot * Math.PI) / 180;
4683
- const combined = new Array(subjectTrack.length).fill(null);
4684
- for (let index = 0; index < shots.length; index += 1) {
4685
- const shot = shots[index];
4686
- const camera = createCameraBlock(shot.camera);
4687
- if (camera.mode === "keys") continue;
4688
- const start = shot.startFrame;
4689
- const end = Math.min(subjectTrack.length, shot.endFrame + 1);
4690
- const subjectSlice = subjectTrack.slice(start, end);
4691
- const params = { ...camera.followCam, craneHeight: camera.craneHeight, dollyTiming: camera.dollyTiming, initialDir: { x: Math.sin(yaw), z: Math.cos(yaw) } };
4692
- const rail = camera.mode === "rail" ? buildRail(camera.cameraRail) : null;
4693
- if (rail) {
4694
- const schedule = resolveRailSchedule({ railFollow: camera.railFollow, cameraRail: camera.cameraRail, frameCount: subjectSlice.length });
4695
- if (schedule.kind !== RAIL_SCHEDULE_RANGE && schedule.kind !== RAIL_SCHEDULE_LEGACY) continue;
4696
- const railSlice = subjectSlice.slice(schedule.startFrame, schedule.endFrame + 1);
4697
- const track = buildRailFollowTrack(railSlice, tlFps, rail, params);
4698
- track.forEach((sample, offset) => { combined[start + schedule.startFrame + offset] = sample; });
4699
- } else {
4700
- const track = buildFollowTrack(subjectSlice, tlFps, params);
4701
- track.forEach((sample, offset) => { combined[start + offset] = sample; });
4702
- }
4703
- }
4704
- return combined;
4705
- }, [shots, subjectTrack, tlFps, charA.rot]);
4075
+ // A rail shot with an aim holds its cast (or a world point) instead of the subject.
4076
+ const followTrack = useShotCameraTracks({ shots, subjectTrack, subjectId: charA.id, fps: tlFps, yawDeg: charA.rot, filmback, characters, objects: sceneObjects, frameCount: tlFrameCount, activeId: activeChar.id, motion });
4706
4077
 
4707
4078
  const playbackScene = useMemo(() => ({
4708
4079
  ...playbackSceneBase,
@@ -4774,13 +4145,8 @@ export default function App() {
4774
4145
  return { min, max, warn: min < 0.5 || max > 3 };
4775
4146
  }, [activeChar.x, activeChar.z, tlFps, waypoints]);
4776
4147
 
4777
- const stateBadge = ardyRunning
4778
- ? { label: ko("GENERATING", "생성 중"), kind: "generating" }
4779
- : motion
4780
- ? { label: ko("PLAYBACK", "재생"), kind: "playback" }
4781
- : waypointMode
4782
- ? { label: ko("ROOT PATH", "루트 경로"), kind: "root" }
4783
- : null;
4148
+ // A loaded take needs no badge: its clip on the Body lane already says so.
4149
+ const stateBadge = !motion && waypointMode ? { label: ko("ROOT PATH", "루트 경로"), kind: "root" } : null;
4784
4150
 
4785
4151
  function bufferToPng(buffer, output = shotOutput, decorate = null) {
4786
4152
  const canvas = document.createElement("canvas");
@@ -5050,1783 +4416,6 @@ export default function App() {
5050
4416
  return executeExportRequest(frameJob.request);
5051
4417
  }
5052
4418
 
5053
- // Keep the optional sidecar live instead of freezing its startup state.
5054
- // Developers commonly open the studio first and start `npm run bridge`
5055
- // second; a one-shot failed probe left Generate disabled until reload.
5056
- useEffect(() => {
5057
- let alive = true;
5058
- let inflight = null;
5059
- // The poll answer is almost always identical to the last one; keeping the
5060
- // previous object identity skips a full App re-render per poll. Each of
5061
- // those renders costs ~80ms of main thread on this tree, which read as a
5062
- // periodic hitch while orbiting/flying the camera.
5063
- const refreshBridge = () => {
5064
- if (inflight) return inflight;
5065
- inflight = checkBridge().then((state) => {
5066
- if (!alive) return;
5067
- if (state.ok) trackFeature("mcp_connected");
5068
- setBridge((previous) => JSON.stringify(previous) === JSON.stringify(state) ? previous : state);
5069
- setLineEditBackend(hasLineEditCapability(state));
5070
- }).finally(() => { inflight = null; });
5071
- return inflight;
5072
- };
5073
- bridgeRefreshRef.current = refreshBridge;
5074
- refreshBridge();
5075
- const id = window.setInterval(refreshBridge, BRIDGE_RECHECK_MS);
5076
- window.addEventListener("focus", refreshBridge);
5077
- return () => {
5078
- alive = false;
5079
- window.clearInterval(id);
5080
- window.removeEventListener("focus", refreshBridge);
5081
- };
5082
- }, []);
5083
-
5084
- // QA/programmatic requests must use the current render's same generation path.
5085
- appContext.patchLive({ runArdy: runArdy });
5086
-
5087
- /* ======================= line editing (contract C6) =======================
5088
- * Grab the joint's own motion path on the viewport and pull it; the joint
5089
- * then follows the pulled path exactly.
5090
- *
5091
- * Four invariants hold this together and every function below serves one
5092
- * of them:
5093
- * 1. THE CURVE AND THE CAMERA ARE ONE OBJECT. uv only mean something
5094
- * through the lens they were projected with, so the camera is captured
5095
- * when the curve is built, stored next to it, and the pair is rebuilt
5096
- * together when the view moves. Nothing re-reads old uv through a new
5097
- * lens.
5098
- * 2. THE EDIT IS A DEFORMATION OF THE TAKE, NOT A REPLACEMENT OF IT. The
5099
- * curve starts as the joint's current trajectory and the falloff pins
5100
- * its ends there, so the first and last constrained frames always
5101
- * agree with the take. That is the seam fix GP2 measured: 8.0x median
5102
- * frame delta for an arbitrary line, 1.09x when the ends sit on the
5103
- * joint's own path.
5104
- * 3. THE OVERLAY NEVER TOUCHES THE SCENE GRAPH. It is a plain 2D canvas
5105
- * stacked on the stage, so an edit cannot disturb playback, picking
5106
- * or the render loop, and it works identically in every view mode.
5107
- * 4. THE REQUEST IS ITS OWN RUN. C6 makes lineEdit exclusive with
5108
- * preserve/waypoints/segments/motionEdit, so runLineEdit builds a
5109
- * dedicated body instead of decorating runArdy's. The WIRE SHAPE is
5110
- * untouched by the interaction change: points2d is still a <=64-point
5111
- * viewport-normalized polyline. */
5112
-
5113
- /** The loaded take's length on the TIMELINE clock — C6's frameRange is in
5114
- * app clip frames and, unlike waypoints or motionEdit, is never converted
5115
- * to the bridge clock. */
5116
- const lineClipFrames = motion?.frames ?? 0;
5117
- /** THE TAKE, as opposed to what is on screen. While a line-edit preview is
5118
- * showing, `motion` is the draft and this is still the take the draft was
5119
- * drafted FROM — so an edit sources the take, the version strip keeps
5120
- * highlighting the take's own chip, and a preview can never quietly become
5121
- * the thing everything else is built on. With no preview the two are the
5122
- * same url, which is why every call site can read this one unconditionally. */
5123
- const takeSourceUrl = linePreviewSource?.url ?? motion?.url ?? null;
5124
- /** The authored range, or the whole clip when the user has not narrowed it.
5125
- * Re-derived rather than stored so loading a different take cannot leave a
5126
- * range pointing past the end of the new one. */
5127
- const lineEditRange = useMemo(() => {
5128
- if (!(lineClipFrames > 1)) return null;
5129
- const start = Math.max(0, Math.min(lineRange?.startFrame ?? 0, lineClipFrames - 2));
5130
- const end = Math.max(start + 2, Math.min(lineRange?.endFrame ?? lineClipFrames, lineClipFrames));
5131
- return { startFrame: start, endFrame: end };
5132
- }, [lineRange, lineClipFrames]);
5133
-
5134
- /* ---------------------------- the pins edit -----------------------------
5135
- * A pins edit is derived, not stored: the pins ARE the payload and the range
5136
- * follows from them (pinsFrameRange), so there is no second copy of either
5137
- * to keep in sync. `lineEditPayload` is what every downstream call site
5138
- * takes — Generate, the preview, the request builder — and it is the one
5139
- * place the two gestures meet. */
5140
- const linePinRange = useMemo(
5141
- () => (linePins.length ? pinsFrameRange(linePins, lineClipFrames) : null),
5142
- [linePins, lineClipFrames],
5143
- );
5144
- const linePinEdit = useMemo(
5145
- () => (linePins.length && linePinRange ? { pins: linePins, frameRange: linePinRange } : null),
5146
- [linePins, linePinRange],
5147
- );
5148
- /** The edit in hand, whichever gesture made it. Null means "nothing to
5149
- * send", which is exactly what the Generate gate asks. */
5150
- const lineEditPayload = lineCurve ?? linePinEdit;
5151
-
5152
- /** The joint's WORLD position at one frame, from the same trail sampler the
5153
- * curve is projected from — so a pin and the curve can never disagree about
5154
- * where the joint is. Null when there is no take, no trail or no such
5155
- * frame. */
5156
- function lineJointWorldAt(frame) {
5157
- const jointName = TRAIL_EFFECTOR_JOINTS[lineTrack];
5158
- if (!motion || !jointName) return null;
5159
- const trail = jointTrailPoints(motion, jointName, { baseY: activeChar.y ?? 0, scale: activeChar.scale ?? 1 });
5160
- const index = Math.max(0, Math.min(Math.trunc(frame) || 0, motion.frames - 1));
5161
- if (!trail || index * 3 + 2 >= trail.length) return null;
5162
- return [trail[index * 3], trail[index * 3 + 1], trail[index * 3 + 2]];
5163
- }
5164
-
5165
- /** Every pin as a WORLD point, for the overlay. Pins are stored in clip
5166
- * space (that is what the wire wants); drawing them means undoing the
5167
- * conversion, which is jointTrailPoints' own transform applied to one
5168
- * point. Kept here rather than storing both spellings: two stored copies of
5169
- * a coordinate is how a pin ends up drawn somewhere it was not placed. */
5170
- function linePinWorld(pin) {
5171
- if (!motion) return null;
5172
- const basis = { baseY: activeChar.y ?? 0, scale: activeChar.scale ?? 1 };
5173
- // Forward transform, mirroring motion-trail's jointTrailPoints.
5174
- const clipToWorld = (p) => {
5175
- const radians = ((Number.isFinite(motion.rotationDeg) ? motion.rotationDeg : 0) * Math.PI) / 180;
5176
- const cos = Math.cos(radians);
5177
- const sin = Math.sin(radians);
5178
- const anchorFrame = Math.max(0, Math.min(motion.anchorFrame || 0, Math.max(0, motion.frames - 1)));
5179
- const rootX = motion.posedJoints?.[anchorFrame * 27 * 3] ?? 0;
5180
- const rootZ = motion.posedJoints?.[anchorFrame * 27 * 3 + 2] ?? 0;
5181
- const anchorX = Number.isFinite(motion.anchorX) ? motion.anchorX : 0;
5182
- const anchorZ = Number.isFinite(motion.anchorZ) ? motion.anchorZ : 0;
5183
- const dx = p[0] - rootX;
5184
- const dz = p[2] - rootZ;
5185
- return [
5186
- anchorX + (dx * cos + dz * sin) * basis.scale,
5187
- basis.baseY + p[1] * basis.scale,
5188
- anchorZ + (-dx * sin + dz * cos) * basis.scale,
5189
- ];
5190
- };
5191
- return clipToWorld(pin.position);
5192
- }
5193
-
5194
- /** Drop the pins, and say so. Called when a stroke or a drag commits — the
5195
- * two gestures are exclusive, and a silently discarded pin is worse than a
5196
- * refused one. */
5197
- function clearLinePins({ toast = true } = {}) {
5198
- if (!linePins.length) return;
5199
- setLinePins([]);
5200
- if (toast) {
5201
- setToast(ko(
5202
- "Pins cleared — one edit is one gesture, and this one is now the path",
5203
- "찍은 순간을 지웠어요 — 한 번의 편집은 한 가지 방식이라, 지금은 궤적 편집이에요",
5204
- ));
5205
- }
5206
- }
5207
-
5208
- /** Has the user pulled anything yet? The Generate gate, and the reason the
5209
- * panel can say "pull first" instead of shipping a no-op edit. It is just
5210
- * the state's existence: pointerup installs a curve only when the pull
5211
- * actually moved something and clears it otherwise, so "there is a curve
5212
- * object" and "there is an edit" are the same fact. */
5213
- const lineCurveDirty = !!lineEditPayload;
5214
- /** Frames of the range whose joint has no image (behind the lens or out of
5215
- * frame). Those points cannot be grabbed and are dropped from the payload,
5216
- * so the count is worth showing rather than leaving as a mystery gap. */
5217
- const lineCurveHidden = useMemo(
5218
- () => (lineCurve ? lineCurve.original.reduce((count, point) => count + (isCurvePointOnScreen(point) ? 0 : 1), 0) : 0),
5219
- [lineCurve],
5220
- );
5221
- /** How many points the box will actually receive — the curve is downsampled
5222
- * to MAX_LINE_POINTS, and seeing the number keeps "64" from being a
5223
- * surprise buried in the contract. */
5224
- const lineCurvePointCount = useMemo(
5225
- () => (lineCurve ? Math.min(MAX_LINE_POINTS, lineCurve.original.length - lineCurveHidden) : 0),
5226
- [lineCurve, lineCurveHidden],
5227
- );
5228
- /** The frames the edit in hand actually covers. A DRAWN curve carries the
5229
- * window its stroke matched (which is also what goes on the wire); a dragged
5230
- * one has always covered the panel's range. Shown rather than left implicit
5231
- * because auto-ranging means the artist did not choose these numbers and
5232
- * deserves to see what the stroke was read as. endFrame is exclusive on the
5233
- * wire and inclusive to a human, hence the -1. */
5234
- const lineEditFrom = lineCurve?.frameRange?.startFrame ?? lineEditRange?.startFrame ?? 0;
5235
- const lineEditTo = (lineCurve?.frameRange?.endFrame ?? lineEditRange?.endFrame ?? 0) - 1;
5236
-
5237
- /** Change the influence radius. A live drag is re-derived from its snapshot
5238
- * rather than left showing the old falloff, which is what makes the slider
5239
- * legible: drag, then widen, and the same pull spreads under the finger. */
5240
- function changeLineRadius(next) {
5241
- const radius = Math.max(DRAG_RADIUS_MIN, Math.min(DRAG_RADIUS_MAX, Math.round(Number(next) || 0)));
5242
- setLineRadius(radius);
5243
- const drag = lineDragRef.current;
5244
- if (!drag) return;
5245
- // The drag carries its own radius so the window-level pointermove (which
5246
- // closed over the value at grab time) keeps agreeing with what is drawn.
5247
- drag.radius = radius;
5248
- drag.live = dragCurve(drag.snapshot, drag.index, drag.du, drag.dv, radius);
5249
- }
5250
-
5251
- /** Which camera actually draws the MAIN pane right now, and the exact
5252
- * rectangle it draws into.
5253
- *
5254
- * This mirrors DualRender's own branch order on purpose: the pane holds
5255
- * different cameras in different modes, and the letterboxed shot views draw
5256
- * into a fitAspect sub-rect rather than the whole pane. Measuring the curve
5257
- * against the pane instead of the IMAGE would shift every point by the
5258
- * width of the black bars. Plan and IK views return null — the plan camera
5259
- * is orthographic (no pinhole intrinsics to send) and IK mode is mutually
5260
- * exclusive with this one anyway. Rects are in CLIENT coordinates, which is
5261
- * what pointer events speak. */
5262
- function lineEditPane() {
5263
- const pane = mainPaneRef.current;
5264
- if (!pane) return null;
5265
- const box = pane.getBoundingClientRect();
5266
- if (!(box.width >= 2 && box.height >= 2)) return null;
5267
- const rect = { x: box.left, y: box.top, w: box.width, h: box.height };
5268
- if (planIsMain || ikMode) return null;
5269
- if (playMode || lookThroughShot) {
5270
- const camera = shotCamRef.current;
5271
- return camera ? { camera, rect: fitAspect(rect, shotOutput.aspect) } : null;
5272
- }
5273
- const camera = editorCamRef.current;
5274
- return camera ? { camera, rect } : null;
5275
- }
5276
-
5277
- /** Freeze the pane's camera into the C6 block. Returns null rather than
5278
- * throwing when the camera is not a settled perspective camera — a refusal
5279
- * here means "do not send", never "send something approximate". */
5280
- function captureLineCamera(pane) {
5281
- const camera = pane?.camera;
5282
- if (!camera?.isPerspectiveCamera) return null;
5283
- camera.updateMatrixWorld();
5284
- const inverse = new THREE.Matrix4().copy(camera.matrixWorld).invert();
5285
- try {
5286
- return cameraToC6({
5287
- fovDeg: camera.fov,
5288
- aspect: camera.aspect,
5289
- matrixWorldInverse: [...inverse.elements],
5290
- width: pane.rect.w,
5291
- height: pane.rect.h,
5292
- });
5293
- } catch (err) {
5294
- // The only way here is a pane whose aspect disagrees with the
5295
- // projection matrix, i.e. a frame drawn before DualRender settled.
5296
- // Refusing is correct; the next paint or pointerup retries.
5297
- console.warn("line edit: camera capture refused —", err.message);
5298
- return null;
5299
- }
5300
- }
5301
-
5302
- /** Has the live view moved out from under a COMMITTED edit?
5303
- *
5304
- * The comparison is the same cameraDrifted the old watcher used; what changed
5305
- * is what a `true` MEANS. It is not "this edit is invalid" — the edit carries
5306
- * the lens it was authored through and goes on the wire with it, so it stays
5307
- * exactly as applicable as it was. It is "the painted line can no longer be
5308
- * drawn where it belongs", because reprojecting the edited uv through the new
5309
- * camera would need a depth that a 2D edit does not have.
5310
- *
5311
- * Called both from the 4 Hz watcher (which owns the panel's state) and
5312
- * synchronously at pointerdown (which cannot afford to be a quarter second
5313
- * behind) — one spelling, so the paint, the hint and the refusal can never
5314
- * disagree about which state the mode is in. */
5315
- function lineCurveDrifted(pane, edit = lineCurve) {
5316
- if (!edit) return false;
5317
- const live = captureLineCamera(pane);
5318
- // An unmeasurable pane is not a moved view — a frame drawn before
5319
- // DualRender settled must not flicker the line out or refuse a press.
5320
- if (!live) return lineDriftRef.current;
5321
- return cameraDrifted(live, edit.camera);
5322
- }
5323
-
5324
- /** The one sentence the drifted state says — in the panel, and in the toast
5325
- * that refuses a new gesture. ONE spelling, because a hint that disagreed
5326
- * with the refusal would read as two different problems. */
5327
- function lineDriftHint() {
5328
- return ko(
5329
- "The view moved — the pending edit still applies; the dashed line is that same edit seen from here. Return toward the original view to grab it again, or Generate/undo from here.",
5330
- "시점이 움직였어요 — 편집한 궤적은 그대로 적용되며, 점선은 같은 궤적을 지금 시점에서 본 모습입니다. 다시 잡으려면 원래 시점 쪽으로 돌아가고, 지금 이 상태에서 생성하거나 되돌려도 됩니다.",
5331
- );
5332
- }
5333
-
5334
- /** The drifted ghost, RE-ANCHORED: lift the committed edit into world space
5335
- * with the trail's own per-frame depth and see it through the LIVE lens, so
5336
- * the dashed line hugs the trajectory instead of floating wherever the old
5337
- * uv happen to land in the new view. Returns null when the trip cannot be
5338
- * made (no live lens yet, no motion, a pins-only edit with no curves) — the
5339
- * caller then paints the authored uv as before, which is at least honest
5340
- * about being stale. Runs per frame like the rest of the painter; it is the
5341
- * same few hundred pinhole projections projectLineCurve already pays. */
5342
- function reprojectDriftedEdit(pane, edit) {
5343
- const live = captureLineCamera(pane);
5344
- const jointName = TRAIL_EFFECTOR_JOINTS[lineTrack];
5345
- if (!live || !jointName || !motion) return null;
5346
- const trail = jointTrailPoints(motion, jointName, { baseY: activeChar.y ?? 0, scale: activeChar.scale ?? 1 });
5347
- if (!trail) return null;
5348
- const original = reprojectCurveWorld(edit.original, trail, edit.camera, live);
5349
- const edited = reprojectCurveWorld(edit.edited, trail, edit.camera, live);
5350
- if (!original || !edited) return null;
5351
- return { original, edited };
5352
- }
5353
-
5354
- /** Project the joint's trail for the current track and range through the
5355
- * pane's LIVE camera. `{ camera, curve }`, or null when the view cannot
5356
- * supply a usable lens right now — a refusal means "no curve", never "a
5357
- * curve through some other camera".
5358
- *
5359
- * Deliberately the same sampler and the same arguments the old ghost line
5360
- * used (jointTrailPoints + TRAIL_EFFECTOR_JOINTS), so the curve the user
5361
- * grabs is the trajectory they were previously told to trace by hand.
5362
- *
5363
- * Cheap ON PURPOSE: while nothing has been pulled this runs once per
5364
- * animation frame, and that is exactly what lets the path FOLLOW the camera
5365
- * instead of fighting it. A few hundred pinhole projections is nothing
5366
- * beside the WebGL frame it is drawn over. */
5367
- function projectLineCurve(pane, { camera: reuseCamera = null, frameRange = null } = {}) {
5368
- if (!pane || !motion) return null;
5369
- const range = frameRange ?? lineEditRange;
5370
- if (!range) return null;
5371
- const camera = reuseCamera ?? captureLineCamera(pane);
5372
- const jointName = TRAIL_EFFECTOR_JOINTS[lineTrack];
5373
- if (!camera || !jointName) return null;
5374
- const trail = jointTrailPoints(motion, jointName, { baseY: activeChar.y ?? 0, scale: activeChar.scale ?? 1 });
5375
- if (!trail) return null;
5376
- const curve = projectTrailCurve({ trail, frameRange: range, camera });
5377
- if (!curve || curve.length < MIN_LINE_POINTS) return null;
5378
- return { camera, curve };
5379
- }
5380
-
5381
- /** Repaint the whole overlay from scratch: the drawable frame, the joint's
5382
- * ORIGINAL trajectory as a faint reference once there is something to
5383
- * compare it against, the EDITED (or live) curve as the hero with its
5384
- * draggable markers, and — while a grab is live — the span the falloff is
5385
- * actually moving. Cheap enough to run per pointermove and stateless, so
5386
- * there is no partial-repaint bug class to have.
5387
- *
5388
- * This is also where the "follow the camera" half of the camera policy
5389
- * lives: with no edit in hand the curve is re-projected from the CURRENT
5390
- * lens every frame, so orbiting drags the path around with the render. The
5391
- * projection is cached in lineLiveRef for the hit test, which therefore
5392
- * never has to re-derive what was just drawn. */
5393
- function paintLineOverlay() {
5394
- const canvas = lineOverlayRef.current;
5395
- const stage = stageRef.current;
5396
- if (!canvas || !stage) return;
5397
- const stageBox = stage.getBoundingClientRect();
5398
- const dpr = Math.min(2, window.devicePixelRatio || 1);
5399
- const width = Math.max(1, Math.round(stageBox.width));
5400
- const height = Math.max(1, Math.round(stageBox.height));
5401
- if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
5402
- canvas.width = Math.round(width * dpr);
5403
- canvas.height = Math.round(height * dpr);
5404
- }
5405
- const ctx = canvas.getContext("2d");
5406
- if (!ctx) return;
5407
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
5408
- ctx.clearRect(0, 0, width, height);
5409
- const pane = lineEditPane();
5410
- if (!pane) {
5411
- lineLiveRef.current = null;
5412
- return;
5413
- }
5414
- // Stage-relative, because the canvas is stretched over the stage while
5415
- // the pane rect is measured in client coordinates.
5416
- const ox = pane.rect.x - stageBox.left;
5417
- const oy = pane.rect.y - stageBox.top;
5418
-
5419
- // The drawable frame: the letterboxed shot views draw into less than the
5420
- // full pane, and a curve point outside this box is outside 0..1 and
5421
- // cannot be sent. Saying so with a border beats clamping silently.
5422
- ctx.save();
5423
- ctx.setLineDash([6, 5]);
5424
- ctx.strokeStyle = "rgba(255, 255, 255, .35)";
5425
- ctx.lineWidth = 1;
5426
- ctx.strokeRect(ox + .5, oy + .5, pane.rect.w - 1, pane.rect.h - 1);
5427
- ctx.restore();
5428
-
5429
- // Which curve is on screen right now, in priority order: the one under
5430
- // the finger, the committed edit, or a fresh projection through the live
5431
- // camera. The live deformation has to win over the committed one because
5432
- // a pointermove writes only lineDragRef — reading state here would
5433
- // freeze the curve under the finger until pointerup.
5434
- const drag = lineDragRef.current;
5435
- const edit = drag ? { camera: drag.camera, original: drag.snapshot, edited: drag.live } : lineCurve;
5436
- let original = null;
5437
- let edited = null;
5438
- // DETACHED. The committed edit was authored through its own lens; once the
5439
- // live view has left that lens the same uv name different rays, so the
5440
- // line is painted as a ghost — dashed, dim, no halo and no grab handles —
5441
- // rather than drawn confidently in the wrong place or silently reprojected
5442
- // (there is no depth to reproject it with). Nothing is discarded: the edit,
5443
- // its preview and the Generate button all still run off the snapshot.
5444
- // Computed HERE, per frame, rather than read off the 4 Hz watcher's state,
5445
- // so the line detaches the instant the view moves instead of a quarter
5446
- // second later. Never while a gesture is live — a drag paints its own
5447
- // snapshot under the finger and mid-gesture drift is a different rule.
5448
- let ghost = false;
5449
- if (edit) {
5450
- // An edit pins the curve to the lens it was authored through; the
5451
- // cached live projection would disagree with it, so it is dropped
5452
- // rather than left to go stale.
5453
- lineLiveRef.current = null;
5454
- original = edit.original;
5455
- edited = edit.edited;
5456
- ghost = !drag && lineCurveDrifted(pane, edit);
5457
- // A drifted line is still WORLD-anchored through the trail's depth, so
5458
- // draw it where the edit actually sits under the live lens rather than
5459
- // at its stale authored uv. Falls back to the authored uv when the
5460
- // re-anchoring cannot run (see reprojectDriftedEdit).
5461
- if (ghost) {
5462
- const anchored = reprojectDriftedEdit(pane, edit);
5463
- if (anchored) ({ original, edited } = anchored);
5464
- }
5465
- } else {
5466
- const live = projectLineCurve(pane);
5467
- lineLiveRef.current = live;
5468
- if (!live) { delete stage.dataset.lineDrift; return; }
5469
- edited = live.curve;
5470
- }
5471
- // The CDP/e2e-visible handle for "the line is detached from this view".
5472
- // On the stage rather than in React state because it has to be true on
5473
- // the frame it becomes true, and this painter is the only thing that runs
5474
- // on every frame.
5475
- if (ghost) stage.dataset.lineDrift = "true";
5476
- else delete stage.dataset.lineDrift;
5477
- const pointPx = (point) => [ox + point.u * pane.rect.w, oy + point.v * pane.rect.h];
5478
- /** Trace a frame-indexed curve, breaking the path at every null — a
5479
- * frame whose joint is behind the lens has no image, and joining across
5480
- * it would draw a straight line through the middle of the screen. */
5481
- const traceCurve = (curve, from = 0, to = curve.length - 1) => {
5482
- ctx.beginPath();
5483
- let started = false;
5484
- for (let index = Math.max(0, from); index <= Math.min(curve.length - 1, to); index += 1) {
5485
- const point = curve[index];
5486
- if (!point) { started = false; continue; }
5487
- const [px, py] = pointPx(point);
5488
- if (started) ctx.lineTo(px, py);
5489
- else ctx.moveTo(px, py);
5490
- started = true;
5491
- }
5492
- ctx.stroke();
5493
- };
5494
-
5495
- ctx.save();
5496
- ctx.lineJoin = "round";
5497
- ctx.lineCap = "round";
5498
- // Everything from here down to the pins is the CURVE, and the curve is the
5499
- // one thing a moved view makes unplaceable, so the whole block fades
5500
- // together. The pins below deliberately do not: they are world-space and
5501
- // are re-projected through the live lens on every repaint.
5502
- if (ghost) ctx.globalAlpha = .3;
5503
-
5504
- // (1) The ORIGINAL trajectory, faint, and ONLY while there is an edit to
5505
- // compare it with. It used to be the instruction ("draw something like
5506
- // this"); now it is a reference — how far the pull has taken the joint
5507
- // from where the take put it. Unedited it would sit exactly under the
5508
- // hero and just fatten the line.
5509
- if (original) {
5510
- ctx.setLineDash([7, 6]);
5511
- ctx.strokeStyle = "rgba(0, 20, 40, .45)";
5512
- ctx.lineWidth = 3;
5513
- traceCurve(original);
5514
- ctx.strokeStyle = "rgba(120, 205, 255, .5)";
5515
- ctx.lineWidth = 1.5;
5516
- traceCurve(original);
5517
- ctx.setLineDash([]);
5518
- }
5519
-
5520
- // (2) The curve in hand — the hero, and literally what gets sent. Two
5521
- // passes: a dark halo so it reads on bright floors and skin tones alike,
5522
- // then the glowing yellow the eye follows. GHOSTED the halo and the glow
5523
- // both go: a detached line must not look like something you can grab, and
5524
- // the dash says "this is where it was, not where it is".
5525
- if (!ghost) {
5526
- ctx.strokeStyle = "rgba(40, 24, 0, .8)";
5527
- ctx.lineWidth = 9;
5528
- traceCurve(edited);
5529
- ctx.shadowColor = "rgba(255, 210, 61, .9)";
5530
- ctx.shadowBlur = 10;
5531
- } else {
5532
- ctx.setLineDash([3, 7]);
5533
- }
5534
- ctx.strokeStyle = "#ffd23d";
5535
- ctx.lineWidth = ghost ? 2 : 4.5;
5536
- traceCurve(edited);
5537
- ctx.shadowBlur = 0;
5538
- ctx.setLineDash([]);
5539
-
5540
- // (4) The influenced span, while a grab is live: the stretch the falloff
5541
- // is actually moving, drawn thicker and hotter. It is the only honest
5542
- // picture of what the influence slider does, and it walks out with the
5543
- // SAME dragWeight the deformation used rather than a redrawn guess.
5544
- if (drag) {
5545
- let from = drag.index;
5546
- let to = drag.index;
5547
- while (from > 0 && dragWeight(from - 1 - drag.index, drag.radius) > DRAG_WEIGHT_EPSILON) from -= 1;
5548
- while (to < edited.length - 1 && dragWeight(to + 1 - drag.index, drag.radius) > DRAG_WEIGHT_EPSILON) to += 1;
5549
- ctx.strokeStyle = "rgba(255, 245, 200, .95)";
5550
- ctx.lineWidth = 6;
5551
- traceCurve(edited, from, to);
5552
- }
5553
-
5554
- // (3) The grab handles. Every 4th frame keeps a 200-frame range from
5555
- // turning into a solid bead of dots while still leaving a target within
5556
- // a couple of frames of wherever the pointer lands (the hit test
5557
- // tolerates 14 px anyway, so the dots are an affordance, not the
5558
- // geometry). Pinned ends and offscreen frames are deliberately NOT drawn
5559
- // as handles: nearestCurvePoint refuses them, and a dot that cannot be
5560
- // grabbed is worse than no dot. The hovered one swells, because the
5561
- // scene's own cursor is already `grab` and a cursor alone cannot say
5562
- // "this pointer would pick up the path rather than orbit the view".
5563
- // GHOSTED THERE ARE NO HANDLES AT ALL. A dot is a promise that pressing it
5564
- // picks the path up, and while the view has drifted that press is refused
5565
- // — and would be aiming at a place the point is not, anyway.
5566
- const hovered = ghost ? null : (drag ? drag.index : lineHoverRef.current);
5567
- for (let index = 0; !ghost && index < edited.length; index += 1) {
5568
- const point = edited[index];
5569
- if (!isCurvePointOnScreen(point)) continue;
5570
- if (isCurveEndPinned(index, edited.length)) continue;
5571
- const active = hovered === index;
5572
- if (!active && index % LINE_CURVE_MARKER_STRIDE !== 0) continue;
5573
- const [px, py] = pointPx(point);
5574
- const radius = active ? (drag ? 7 : 5.5) : 3.2;
5575
- ctx.fillStyle = "rgba(40, 24, 0, .85)";
5576
- ctx.beginPath();
5577
- ctx.arc(px, py, radius + 1.6, 0, Math.PI * 2);
5578
- ctx.fill();
5579
- ctx.fillStyle = active ? "#fff6d0" : "#ffe27a";
5580
- ctx.beginPath();
5581
- ctx.arc(px, py, radius, 0, Math.PI * 2);
5582
- ctx.fill();
5583
- }
5584
-
5585
- // The pinned ends, drawn in the REFERENCE colour rather than the edit
5586
- // colour, because that is exactly what they are: the frames at each edge
5587
- // that stay on the original trajectory no matter how hard the middle is
5588
- // pulled. Seeing them anchored is the whole explanation of why this edit
5589
- // does not pop at the seams.
5590
- for (const index of ghost ? [] : [0, edited.length - 1]) {
5591
- const point = edited[index];
5592
- if (!isCurvePointOnScreen(point)) continue;
5593
- const [px, py] = pointPx(point);
5594
- ctx.fillStyle = "rgba(0, 20, 40, .8)";
5595
- ctx.beginPath();
5596
- ctx.arc(px, py, 5.5, 0, Math.PI * 2);
5597
- ctx.fill();
5598
- ctx.fillStyle = "#78cdff";
5599
- ctx.beginPath();
5600
- ctx.arc(px, py, 3.2, 0, Math.PI * 2);
5601
- ctx.fill();
5602
- }
5603
- // The curve's fade ends here; the pins below are world-space and paint at
5604
- // full strength through the live lens whatever the curve's lens is doing.
5605
- ctx.globalAlpha = 1;
5606
-
5607
- // (4b) THE PINS, and — in pin mode — the joint's own handle at the
5608
- // playhead. Drawn in the pose-studio's effector green rather than the
5609
- // path's yellow, because a pin is a different KIND of statement: the path
5610
- // says "go this way", a pin says "be exactly here at this instant". Each
5611
- // pin carries a leader line back to where the take put the joint at that
5612
- // frame, which is the only honest picture of what was asked for; without
5613
- // it a pin is just a dot floating in space.
5614
- if (linePinMode || linePins.length) {
5615
- const pinDrag = linePinDragRef.current;
5616
- // ONE capture for the whole block: pins are world-space, so unlike the
5617
- // curve they are re-projected through the LIVE lens on every repaint
5618
- // and an orbit carries them along, at full strength. The curve above
5619
- // can only ghost — that is the camera-policy difference between the two
5620
- // gestures, and it is a difference in what can be DRAWN, not in what
5621
- // survives: both edits outlive the move.
5622
- const pinCam = pinDrag?.camera ?? captureLineCamera(pane);
5623
- const marks = [];
5624
- for (const pin of linePins) {
5625
- const world = pinDrag && pinDrag.frame === pin.frame ? pinDrag.world : linePinWorld(pin);
5626
- if (world) marks.push({ frame: pin.frame, world, placed: true });
5627
- }
5628
- if (pinDrag && !linePins.some((pin) => pin.frame === pinDrag.frame)) {
5629
- marks.push({ frame: pinDrag.frame, world: pinDrag.world, placed: false });
5630
- }
5631
- // The grabbable handle: the joint where the take currently puts it at
5632
- // the playhead. Only in pin mode, and only when that frame is not
5633
- // already pinned — otherwise the pin IS the handle.
5634
- const playhead = Math.max(0, Math.min(Math.trunc(tlFrame) || 0, Math.max(0, lineClipFrames - 1)));
5635
- if (linePinMode && !pinDrag && !linePins.some((pin) => pin.frame === playhead)) {
5636
- const here = lineJointWorldAt(playhead);
5637
- if (here) marks.push({ frame: playhead, world: here, placed: false, handle: true });
5638
- }
5639
- for (const mark of pinCam ? marks : []) {
5640
- const uv = projectPointC6(pinCam, mark.world[0], mark.world[1], mark.world[2]);
5641
- if (!uv) continue;
5642
- const px = ox + uv[0] * pane.rect.w;
5643
- const py = oy + uv[1] * pane.rect.h;
5644
- if (mark.placed) {
5645
- const origin = lineJointWorldAt(mark.frame);
5646
- const originUv = origin ? projectPointC6(pinCam, origin[0], origin[1], origin[2]) : null;
5647
- if (originUv) {
5648
- ctx.save();
5649
- ctx.setLineDash([4, 4]);
5650
- ctx.strokeStyle = "rgba(120, 255, 190, .55)";
5651
- ctx.lineWidth = 1.5;
5652
- ctx.beginPath();
5653
- ctx.moveTo(ox + originUv[0] * pane.rect.w, oy + originUv[1] * pane.rect.h);
5654
- ctx.lineTo(px, py);
5655
- ctx.stroke();
5656
- ctx.restore();
5657
- }
5658
- }
5659
- const radius = mark.handle ? 6 : 7;
5660
- ctx.fillStyle = "rgba(0, 30, 20, .85)";
5661
- ctx.beginPath();
5662
- ctx.arc(px, py, radius + 2, 0, Math.PI * 2);
5663
- ctx.fill();
5664
- ctx.fillStyle = mark.placed ? "#5cffb0" : "rgba(92, 255, 176, .5)";
5665
- ctx.beginPath();
5666
- ctx.arc(px, py, radius, 0, Math.PI * 2);
5667
- ctx.fill();
5668
- if (mark.placed) {
5669
- ctx.fillStyle = "rgba(0, 30, 20, .9)";
5670
- ctx.font = "600 10px system-ui, sans-serif";
5671
- ctx.textAlign = "center";
5672
- ctx.textBaseline = "middle";
5673
- ctx.fillText(String(mark.frame), px, py);
5674
- }
5675
- }
5676
- }
5677
-
5678
- // (5) The stroke under the finger, while one is being drawn. Same family
5679
- // as the hero curve — dark halo, warm core — but PALE and dashed rather
5680
- // than the solid yellow, because it is not the curve yet: it is a shape
5681
- // that becomes the curve's interior on release, and the two ends it will
5682
- // be pinned to are the blue dots already drawn above. Painted last so it
5683
- // sits over everything, with a dot at the head so a stroke that is only
5684
- // beginning is still visible.
5685
- const draw = lineDrawRef.current;
5686
- if (draw && draw.points.length) {
5687
- ctx.save();
5688
- ctx.lineJoin = "round";
5689
- ctx.lineCap = "round";
5690
- ctx.beginPath();
5691
- draw.points.forEach(([su, sv], index) => {
5692
- const px = ox + su * pane.rect.w;
5693
- const py = oy + sv * pane.rect.h;
5694
- if (index === 0) ctx.moveTo(px, py);
5695
- else ctx.lineTo(px, py);
5696
- });
5697
- ctx.strokeStyle = "rgba(40, 24, 0, .75)";
5698
- ctx.lineWidth = 8;
5699
- ctx.stroke();
5700
- ctx.setLineDash([9, 6]);
5701
- ctx.shadowColor = "rgba(255, 246, 208, .9)";
5702
- ctx.shadowBlur = 10;
5703
- ctx.strokeStyle = "#fff6d0";
5704
- ctx.lineWidth = 3.5;
5705
- ctx.stroke();
5706
- ctx.shadowBlur = 0;
5707
- ctx.setLineDash([]);
5708
- const head = draw.points[draw.points.length - 1];
5709
- const hx = ox + head[0] * pane.rect.w;
5710
- const hy = oy + head[1] * pane.rect.h;
5711
- ctx.fillStyle = "rgba(40, 24, 0, .85)";
5712
- ctx.beginPath();
5713
- ctx.arc(hx, hy, 6, 0, Math.PI * 2);
5714
- ctx.fill();
5715
- ctx.fillStyle = "#fff6d0";
5716
- ctx.beginPath();
5717
- ctx.arc(hx, hy, 4, 0, Math.PI * 2);
5718
- ctx.fill();
5719
- ctx.restore();
5720
- }
5721
- ctx.restore();
5722
- }
5723
-
5724
- /** Client pointer -> viewport-normalized uv. Unlike the old freehand path
5725
- * this does NOT reject coordinates outside the image: a pull that leaves the
5726
- * frame is a real gesture and the deformation it implies is real too. It is
5727
- * refused later, at curveToPoints2d, with a message that says which problem
5728
- * it is — the bridge rejects points2d outside 0..1. */
5729
- function lineUvFromPointer(pane, event) {
5730
- return [
5731
- (event.clientX - pane.rect.x) / pane.rect.w,
5732
- (event.clientY - pane.rect.y) / pane.rect.h,
5733
- ];
5734
- }
5735
-
5736
- /** The curve a pointer could grab this instant, WITHOUT side effects — the
5737
- * committed edit if there is one, otherwise whatever the last repaint
5738
- * projected. Used by the hover cursor, which must never change state. */
5739
- function lineHoverCurve() {
5740
- // A drifted curve is painted detached and has no handles, so it has no
5741
- // hover targets either — swelling a marker the press would refuse is the
5742
- // cursor telling a lie. The ref (rather than a fresh capture) because
5743
- // hover fires on every pointermove and 250 ms of staleness on a cursor
5744
- // shape is not worth a matrix inversion per move.
5745
- if (lineCurve) return lineDriftRef.current ? null : lineCurve.edited;
5746
- return lineLiveRef.current?.curve ?? null;
5747
- }
5748
-
5749
- /** Side-effect-free "does this press belong to line editing?" — handed to
5750
- * ObjectGizmo as `claimPointer` so its WINDOW-capture selection handler
5751
- * yields the press. Without the yield the gizmo's stopPropagation kills the
5752
- * event above the stage and the gesture never reaches the stage listener at
5753
- * all; that swallowing is the entire reason claimPointer exists.
5754
- *
5755
- * IT CLAIMS EVERY PLAIN-LEFT PRESS ON THE IMAGE, not only the ones that hit
5756
- * the curve. It has to: a press on EMPTY space is now a freehand stroke, so
5757
- * "missed the curve" is no longer "not ours" — it is the other half of the
5758
- * mode, and a probe that answered false there would let the gizmo eat exactly
5759
- * the presses drawing depends on. The cost is explicit and deliberate: while
5760
- * the mode is ON, plain-left no longer picks a cast member on the deck. What
5761
- * is NOT claimed is anything the camera owns — alt+left orbits, middle pans,
5762
- * right flies (see controls.jsx), so navigation is untouched — nor a press
5763
- * outside the drawn image, nor a press on the HUD chrome stacked over the
5764
- * stage, nor anything at all outside line-edit mode: App only passes this
5765
- * probe to the gizmos while lineEditMode is true.
5766
- *
5767
- * Must never mutate state, and must agree exactly with what
5768
- * onLineStagePointerDown will do with the same event — the two conditions
5769
- * below are the same ones that handler opens with. */
5770
- function lineGrabProbe(event) {
5771
- if (event.button !== 0 || event.altKey) return false;
5772
- if (!(event.target instanceof HTMLCanvasElement)) return false;
5773
- const pane = lineEditPane();
5774
- if (!pane) return false;
5775
- // No curve to grab AND nothing to draw onto: without a projected
5776
- // trajectory there is no base curve for a stroke to replace the interior
5777
- // of, so the press is not ours and the gizmo keeps it.
5778
- //
5779
- // A COMMITTED EDIT CLAIMS THE PRESS EVEN WHILE THE VIEW HAS DRIFTED, which
5780
- // is why the test is on lineCurve rather than on lineHoverCurve alone
5781
- // (that one goes null under drift, deliberately — no handles to hover).
5782
- // The stage handler refuses that press with the drift hint; yielding it
5783
- // instead would hand it to the gizmo, which would quietly select a cast
5784
- // member and never say why the gesture did nothing.
5785
- if (!lineCurve && !lineHoverCurve()) return false;
5786
- const [u, v] = lineUvFromPointer(pane, event);
5787
- // Outside the drawable image (the letterbox bars of a shot view) a stroke
5788
- // could only author points the bridge refuses, so it is not a stroke.
5789
- return u >= 0 && u <= 1 && v >= 0 && v <= 1;
5790
- }
5791
-
5792
- /** What a grab picks up, `{ camera, curve }`, and the ONE place the camera
5793
- * is snapshotted: the pair returned here is what the drag deforms and what
5794
- * the eventual C6 request is built against, so the uv and the lens can never
5795
- * come from different moments.
5796
- *
5797
- * A committed edit is handed back with ITS OWN lens, and a drifted one is
5798
- * never reached at all — onLineStagePointerDown refuses the press above,
5799
- * synchronously, before this function is called. That refusal (rather than
5800
- * the old discard) is the whole policy change: a second gesture through a
5801
- * different lens would mix two cameras into one curve, which is a real
5802
- * problem; the edit already in hand is not. */
5803
- function lineGrabSource(pane) {
5804
- if (lineCurve) return { camera: lineCurve.camera, curve: lineCurve.edited };
5805
- const cached = lineLiveRef.current;
5806
- if (cached) return cached;
5807
- const fresh = projectLineCurve(pane);
5808
- lineLiveRef.current = fresh;
5809
- return fresh;
5810
- }
5811
-
5812
- /** The stage's CAPTURE-phase pointerdown: which of the two gestures this is.
5813
- *
5814
- * The overlay canvas is `pointer-events: none` and only paints; this listener
5815
- * sits on the stage container instead, ahead of r3f and the fly controls, and
5816
- * asks one question: did the pointer land within CURVE_GRAB_RADIUS_PX of a
5817
- * draggable point?
5818
- *
5819
- * YES -> a PULL. The curve is snapshotted and deformed under the finger.
5820
- * NO -> a DRAW. The press starts a freehand stroke on empty space, which
5821
- * on release picks its own frame window and becomes the same kind of
5822
- * curve (drawStrokeEdit), committed through the identical pipeline.
5823
- * This used to be the
5824
- * do-nothing path, and on a take whose trail projects into a few
5825
- * pixels that made the mode feel broken: nothing to grab, nothing
5826
- * happens, no way in.
5827
- *
5828
- * Either way the event is consumed exclusively (preventDefault +
5829
- * stopPropagation) so one gesture cannot also select or orbit. The presses
5830
- * this handler deliberately never takes are the camera's — alt+left orbits,
5831
- * middle pans, right flies — so judging a correction from another angle
5832
- * still works and the mode never takes the viewport hostage. Plain-left
5833
- * click-to-select IS given up for as long as the mode is on; that is the
5834
- * price of an empty-space gesture, and the mode is modal and escapable.
5835
- *
5836
- * Capture phase rather than bubble because the decision has to be made before
5837
- * the controls start an orbit; a bubble listener would arrive after they had
5838
- * already latched on (FlyControls binds pointerdown on gl.domElement, which
5839
- * is a descendant of the stage, so stopping here is enough — nothing binds
5840
- * this gesture at the window). Nothing is re-dispatched. */
5841
- function onLineStagePointerDown(event) {
5842
- // Alt+left is the orbit gesture and belongs to the camera, never to a
5843
- // stroke — the one plain-left press this mode must still let through.
5844
- if (event.button !== 0 || event.altKey) return;
5845
- // Only the render surface itself. The stage also carries HUD chrome — the
5846
- // inset's drag chip, the plan pane, overlay buttons — and a control that
5847
- // happens to sit within 14 px of the curve must keep its own click. The
5848
- // overlay canvas cannot be the target (pointer-events: none), so a canvas
5849
- // target is always the WebGL one.
5850
- if (!(event.target instanceof HTMLCanvasElement)) return;
5851
- const pane = lineEditPane();
5852
- if (!pane) return;
5853
- // THE VIEW HAS DRIFTED AWAY FROM THE EDIT IN HAND — refuse the gesture,
5854
- // keep the edit. A pull, a stroke or a pin started now would be authored
5855
- // through THIS lens and committed onto a curve authored through another
5856
- // one, and there is no honest way to merge two cameras into a single set
5857
- // of uv. So the press is consumed (so it cannot orbit or select behind the
5858
- // refusal) and answered with the same sentence the panel is showing. Undo,
5859
- // reset, Generate and Esc all still work — none of them authors anything.
5860
- //
5861
- // Checked synchronously rather than off the 4 Hz watcher's state: the
5862
- // window between polls is exactly where a fly-then-press lands.
5863
- if (lineCurve && lineCurveDrifted(pane)) {
5864
- event.preventDefault();
5865
- event.stopPropagation();
5866
- setToast(lineDriftHint());
5867
- return;
5868
- }
5869
- // PIN MODE takes the press before the curve does: in this mode the marker
5870
- // under the pointer is the joint AT THE PLAYHEAD, not a point of a path,
5871
- // and the two gestures start on the same pixels.
5872
- if (linePinMode) {
5873
- if (beginLinePinDrag(event, pane)) return;
5874
- // A press that missed the handle in pin mode does nothing rather than
5875
- // falling through to a stroke: the artist asked for pins, and a
5876
- // surprise reroute is exactly the "I did not mean that" the redesign
5877
- // is about.
5878
- return;
5879
- }
5880
- const source = lineGrabSource(pane);
5881
- if (!source) return;
5882
- const [u, v] = lineUvFromPointer(pane, event);
5883
- const hit = nearestCurvePoint(source.curve, u, v, CURVE_GRAB_RADIUS_PX, pane.rect.w, pane.rect.h);
5884
- // MISSED THE CURVE — draw a new path instead of doing nothing.
5885
- if (!hit) {
5886
- if (!(u >= 0 && u <= 1 && v >= 0 && v <= 1)) return;
5887
- beginLineDraw(event, pane, source, u, v);
5888
- return;
5889
- }
5890
- event.preventDefault();
5891
- event.stopPropagation();
5892
- const stage = stageRef.current;
5893
- if (stage) stage.dataset.lineGrab = "drag";
5894
- lineHoverRef.current = null;
5895
- // SNAPSHOT. Every pointermove re-derives the deformation from this exact
5896
- // curve, so one drag is idempotent (return the pointer to the grab point
5897
- // and the curve is restored) and changing the radius mid-drag re-deforms
5898
- // rather than compounding. Successive drags stack because each new grab
5899
- // snapshots what the previous one committed.
5900
- lineDragRef.current = {
5901
- index: hit.index,
5902
- prev: lineCurve,
5903
- u0: u,
5904
- v0: v,
5905
- du: 0,
5906
- dv: 0,
5907
- radius: lineRadius,
5908
- camera: source.camera,
5909
- snapshot: source.curve,
5910
- live: source.curve,
5911
- };
5912
- // Window-level move/up, the same idiom beginInsetDrag uses above: a pull
5913
- // that leaves the stage still tracks, and a pointerup anywhere ends it.
5914
- const onMove = (moveEvent) => {
5915
- const drag = lineDragRef.current;
5916
- const livePane = lineEditPane();
5917
- if (!drag || !livePane) return;
5918
- const [mu, mv] = lineUvFromPointer(livePane, moveEvent);
5919
- if (!Number.isFinite(mu) || !Number.isFinite(mv)) return;
5920
- drag.du = mu - drag.u0;
5921
- drag.dv = mv - drag.v0;
5922
- drag.live = dragCurve(drag.snapshot, drag.index, drag.du, drag.dv, drag.radius);
5923
- paintLineOverlay();
5924
- };
5925
- const onUp = () => {
5926
- window.removeEventListener("pointermove", onMove);
5927
- window.removeEventListener("pointerup", onUp);
5928
- window.removeEventListener("pointercancel", onUp);
5929
- const drag = lineDragRef.current;
5930
- lineDragRef.current = null;
5931
- const endStage = stageRef.current;
5932
- if (endStage) delete endStage.dataset.lineGrab;
5933
- if (!drag) return;
5934
- // A pull that went nowhere changes NOTHING — including an edit that
5935
- // was already there (wiping it on a stray click was a bug). A real
5936
- // pull commits with the lens it was authored through and records
5937
- // what it replaced, which is what Ctrl/Cmd+Z restores.
5938
- const changed = !curvesEqual(drag.live, drag.snapshot);
5939
- // ONE EDIT IS ONE GESTURE: a real pull makes this a path edit, so any
5940
- // pins in hand are dropped (with a toast, never silently).
5941
- if (changed) { clearLinePins(); lineUndoRef.current.push(drag.prev ?? null); }
5942
- // A FRESH PULL EDITS ONLY THE FRAMES THE FALLOFF TOUCHED. Inheriting
5943
- // the panel's whole-clip default meant zero preserve rows on the box,
5944
- // and the sampler re-rolled the entire body of the entire clip — a
5945
- // 25 px nudge measured 4.6 m of drift eight seconds away. A pull that
5946
- // refines an existing edit keeps that edit's window instead: shrinking
5947
- // it would drop the drawn reroute outside the newly touched frames.
5948
- const inherited = drag.prev?.frameRange ?? null;
5949
- const pulledRange = changed && !inherited
5950
- ? changedFrameRange(drag.snapshot, drag.live, { clipFrames: lineClipFrames })
5951
- : null;
5952
- const committed = changed
5953
- ? {
5954
- camera: drag.camera,
5955
- original: drag.snapshot,
5956
- edited: drag.live,
5957
- frameRange: inherited ?? pulledRange ?? lineEditRange,
5958
- }
5959
- : (drag.prev ?? null);
5960
- // Same panel feedback as a drawn stroke: the numbers the artist never
5961
- // typed show up filled in.
5962
- if (changed && pulledRange && (
5963
- pulledRange.startFrame !== lineEditRange?.startFrame || pulledRange.endFrame !== lineEditRange?.endFrame
5964
- )) {
5965
- lineAutoRangeRef.current = pulledRange;
5966
- setLineRange(pulledRange);
5967
- }
5968
- setLineCurve(committed);
5969
- // RELEASE FIRES A DRAFT. A pull that changed nothing asks for nothing:
5970
- // the viewport already shows what it would show.
5971
- if (changed) scheduleLinePreview(committed);
5972
- paintLineOverlay();
5973
- };
5974
- window.addEventListener("pointermove", onMove);
5975
- window.addEventListener("pointerup", onUp);
5976
- window.addEventListener("pointercancel", onUp);
5977
- paintLineOverlay();
5978
- }
5979
-
5980
- /** The DRAW half: a press on empty space collects a freehand stroke and, on
5981
- * release, hands it to drawStrokeEdit, which decides WHICH FRAMES the stroke
5982
- * was drawn over, replays it with those frames' own timing, and eases it into
5983
- * the original trajectory at both ends.
5984
- *
5985
- * The commit below therefore does one thing the drag's does not: it writes
5986
- * the matched window back into the panel's range inputs, so "frames 30-66"
5987
- * appears filled in without anyone having typed a number. That is the entire
5988
- * fix for the complaint that a short hook became an eight-second crawl — the
5989
- * range used to default to the whole clip and nobody ever narrowed it.
5990
- *
5991
- * Why drawing exists beside dragging at all: the grab is a hit test against
5992
- * the joint's PROJECTED trail, and on a take where the joint barely moves
5993
- * across the image — the person who backs up and falls, whose hand travels a
5994
- * few screen pixels over 192 frames — that trail is a dot. There is nothing
5995
- * to grab, so the mode did nothing, so the mode was broken. Drawing needs no
5996
- * target.
5997
- *
5998
- * The commit below is deliberately the SAME six lines the drag's pointerup
5999
- * runs: compare against the snapshot, push the replaced curve on the undo
6000
- * stack, install `{ camera, original, edited }`, schedule a preview. Every
6001
- * downstream behaviour — Ctrl/Cmd+Z, the reset button, the detached paint a
6002
- * moved view puts it in, the wire payload — therefore treats a drawn curve as
6003
- * an ordinary one, because it IS one. */
6004
- function beginLineDraw(event, pane, source, u, v) {
6005
- event.preventDefault();
6006
- event.stopPropagation();
6007
- const stage = stageRef.current;
6008
- if (stage) stage.dataset.lineGrab = "draw";
6009
- lineHoverRef.current = null;
6010
- lineDrawRef.current = {
6011
- prev: lineCurve,
6012
- camera: source.camera,
6013
- snapshot: source.curve,
6014
- // THE WHOLE CLIP'S trail, through the SAME lens the stroke is being
6015
- // drawn with, captured once at press. This is what the stroke's
6016
- // endpoints are matched against on release (redesign A), and it has to
6017
- // be the ORIGINAL trail rather than whatever is currently edited so
6018
- // that redrawing re-matches against a fixed reference instead of
6019
- // walking away from it one stroke at a time. Capturing at press rather
6020
- // than at release means a camera that drifts mid-stroke cannot leave
6021
- // the match and the stroke measured through different lenses.
6022
- fullCurve: lineClipFrames > 1
6023
- ? projectLineCurve(pane, { camera: source.camera, frameRange: { startFrame: 0, endFrame: lineClipFrames } })?.curve ?? null
6024
- : null,
6025
- // Pixel size of the pane the stroke is being drawn on, captured once:
6026
- // the "is this a stroke or a click?" threshold is in CSS pixels, where
6027
- // the hand drew it, and uv distance is anisotropic so it cannot be
6028
- // judged in uv without this pair.
6029
- paneW: pane.rect.w,
6030
- paneH: pane.rect.h,
6031
- points: [[u, v]],
6032
- // Running length in PIXELS, accumulated as the stroke is drawn rather
6033
- // than re-measured at release.
6034
- lengthPx: 0,
6035
- };
6036
- const onMove = (moveEvent) => {
6037
- const draw = lineDrawRef.current;
6038
- const livePane = lineEditPane();
6039
- if (!draw || !livePane) return;
6040
- const [mu, mv] = lineUvFromPointer(livePane, moveEvent);
6041
- if (!Number.isFinite(mu) || !Number.isFinite(mv)) return;
6042
- const last = draw.points[draw.points.length - 1];
6043
- const stepPx = Math.hypot((mu - last[0]) * draw.paneW, (mv - last[1]) * draw.paneH);
6044
- // Sub-pixel jitter carries no shape and only costs arc-length samples.
6045
- if (!(stepPx > 0.5)) return;
6046
- draw.points.push([mu, mv]);
6047
- draw.lengthPx += stepPx;
6048
- paintLineOverlay();
6049
- };
6050
- const onUp = () => {
6051
- window.removeEventListener("pointermove", onMove);
6052
- window.removeEventListener("pointerup", onUp);
6053
- window.removeEventListener("pointercancel", onUp);
6054
- const draw = lineDrawRef.current;
6055
- lineDrawRef.current = null;
6056
- const endStage = stageRef.current;
6057
- if (endStage) delete endStage.dataset.lineGrab;
6058
- if (!draw) return;
6059
- // A CLICK IS NOT A STROKE, and a click must change nothing — including
6060
- // an edit that is already there. Same rule the drag's commit enforces
6061
- // with curvesEqual; here the test is the length of what was drawn.
6062
- const edit = draw.points.length >= DRAW_MIN_STROKE_POINTS && draw.lengthPx >= DRAW_MIN_STROKE_PX
6063
- ? drawStrokeEdit(draw.points, {
6064
- fullCurve: draw.fullCurve,
6065
- fallbackCurve: draw.snapshot,
6066
- fallbackRange: lineEditRange,
6067
- paneW: draw.paneW,
6068
- paneH: draw.paneH,
6069
- clipFrames: lineClipFrames,
6070
- })
6071
- : null;
6072
- if (!edit) {
6073
- paintLineOverlay();
6074
- return;
6075
- }
6076
- const drawn = edit.curve;
6077
- const changed = !curvesEqual(drawn, edit.base);
6078
- if (changed) { clearLinePins(); lineUndoRef.current.push(draw.prev ?? null); }
6079
- // The matched window rides ON the committed curve, not only in
6080
- // `lineRange` state. setLineRange is asynchronous and the preview below
6081
- // fires from a timeout that closed over THIS render's lineEditRange, so
6082
- // a request built from state alone would ship the old range with the
6083
- // new curve — the exact mismatch that would splice a re-routed stretch
6084
- // into the wrong frames. buildLineEditRequest reads curve.frameRange.
6085
- const committed = changed
6086
- ? { camera: draw.camera, original: edit.base, edited: drawn, frameRange: edit.frameRange ?? lineEditRange }
6087
- : (draw.prev ?? null);
6088
- // PANEL FEEDBACK (redesign D): the numbers the artist never typed show
6089
- // up filled in, so "frames 30-66" is visible confirmation of what the
6090
- // stroke was read as. Only on a real match — a fallback draw is still
6091
- // in whatever range the panel already showed.
6092
- if (changed && edit.matched && edit.frameRange && (
6093
- edit.frameRange.startFrame !== lineEditRange?.startFrame || edit.frameRange.endFrame !== lineEditRange?.endFrame
6094
- )) {
6095
- lineAutoRangeRef.current = edit.frameRange;
6096
- setLineRange(edit.frameRange);
6097
- }
6098
- setLineCurve(committed);
6099
- if (changed) scheduleLinePreview(committed);
6100
- paintLineOverlay();
6101
- };
6102
- window.addEventListener("pointermove", onMove);
6103
- window.addEventListener("pointerup", onUp);
6104
- window.addEventListener("pointercancel", onUp);
6105
- paintLineOverlay();
6106
- }
6107
-
6108
- /** THE PIN GESTURE (순간 찍기): grab the joint at the playhead and put it
6109
- * where it should be.
6110
- *
6111
- * Returns true when the press was taken. The handle is the joint's own
6112
- * position at the CURRENT FRAME — the artist scrubbed there, so that is the
6113
- * moment they are talking about — plus any pin already placed, which can be
6114
- * re-grabbed to adjust it (grabbing one moves the playhead to its frame, so
6115
- * the viewport shows the pose being pinned).
6116
- *
6117
- * The 2D-to-3D step is unprojectDeltaC6: the joint slides in the image plane
6118
- * at its own depth, which is the effector-drag mechanic the pose studio's
6119
- * gizmo uses and the only reading of a screen drag that does not invent a
6120
- * depth the artist could not see. THE DEVIATION IS DELIBERATE and worth
6121
- * naming: this is not posestudio's TransformControls gizmo, because that one
6122
- * requires ikMode, which is mutually exclusive with this mode. What is
6123
- * reused is the MECHANIC, not the widget.
6124
- *
6125
- * On release the world point is converted to the take's own clip space
6126
- * (worldPointToClip) and stored — pins go on the wire in the space the npz
6127
- * is written in, which is the only space a "within 2 cm of the target" claim
6128
- * can be checked in. */
6129
- function beginLinePinDrag(event, pane) {
6130
- const camera = captureLineCamera(pane);
6131
- if (!camera) return false;
6132
- const frame = Math.max(0, Math.min(Math.trunc(tlFrame) || 0, Math.max(0, lineClipFrames - 1)));
6133
- const [u, v] = lineUvFromPointer(pane, event);
6134
- const reach = (candidate) => {
6135
- const uv = projectPointC6(camera, candidate.world[0], candidate.world[1], candidate.world[2]);
6136
- if (!uv) return null;
6137
- const dist = Math.hypot((uv[0] - u) * pane.rect.w, (uv[1] - v) * pane.rect.h);
6138
- return dist > CURVE_GRAB_RADIUS_PX * 2 ? null : { ...candidate, dist };
6139
- };
6140
- // THE PLAYHEAD WINS TIES, and not by accident. The obvious rule — nearest
6141
- // candidate in pixels — was measured wrong on the take this mode exists
6142
- // for: the head's whole trail is ~100 px, so a pin already placed at frame
6143
- // 90 sits within the grab radius of the joint at frame 112, and scrubbing
6144
- // forward to add a second pin instead re-grabbed the first one and dragged
6145
- // the playhead back to it. The artist scrubbed somewhere on purpose; that
6146
- // moment is what the press means. Existing pins are only considered when
6147
- // the playhead's own handle is out of reach.
6148
- const here = lineJointWorldAt(frame);
6149
- let best = here && !linePins.some((pin) => pin.frame === frame)
6150
- ? reach({ frame, world: here })
6151
- : null;
6152
- if (!best) {
6153
- for (const pin of linePins) {
6154
- const world = linePinWorld(pin);
6155
- if (!world) continue;
6156
- const hit = reach({ frame: pin.frame, world });
6157
- if (hit && (!best || hit.dist < best.dist)) best = hit;
6158
- }
6159
- }
6160
- // A press on the joint at a frame that is ALREADY pinned re-places that
6161
- // pin, which is the same "the second gesture wins" rule upsertPin applies.
6162
- if (!best && here) best = reach({ frame, world: here });
6163
- if (!best) return false;
6164
- event.preventDefault();
6165
- event.stopPropagation();
6166
- const stage = stageRef.current;
6167
- if (stage) stage.dataset.lineGrab = "pin";
6168
- // Re-grabbing an existing pin moves the playhead to it, so the body on
6169
- // screen is the pose the pin belongs to rather than whatever frame the
6170
- // timeline happened to be parked on.
6171
- if (best.frame !== frame) setTlFrame(best.frame);
6172
- linePinDragRef.current = {
6173
- frame: best.frame,
6174
- camera,
6175
- origin: best.world,
6176
- world: best.world,
6177
- u0: u,
6178
- v0: v,
6179
- moved: false,
6180
- };
6181
- const onMove = (moveEvent) => {
6182
- const drag = linePinDragRef.current;
6183
- const livePane = lineEditPane();
6184
- if (!drag || !livePane) return;
6185
- const [mu, mv] = lineUvFromPointer(livePane, moveEvent);
6186
- if (!Number.isFinite(mu) || !Number.isFinite(mv)) return;
6187
- const delta = unprojectDeltaC6(drag.camera, drag.origin, mu - drag.u0, mv - drag.v0);
6188
- if (!delta) return;
6189
- drag.world = [drag.origin[0] + delta[0], drag.origin[1] + delta[1], drag.origin[2] + delta[2]];
6190
- // Sub-pixel tremor is not a placement, exactly as a few pixels of
6191
- // wobble is not a stroke (DRAW_MIN_STROKE_PX).
6192
- drag.moved = Math.hypot((mu - drag.u0) * livePane.rect.w, (mv - drag.v0) * livePane.rect.h) >= DRAW_MIN_STROKE_PX;
6193
- paintLineOverlay();
6194
- };
6195
- const onUp = () => {
6196
- window.removeEventListener("pointermove", onMove);
6197
- window.removeEventListener("pointerup", onUp);
6198
- window.removeEventListener("pointercancel", onUp);
6199
- const drag = linePinDragRef.current;
6200
- linePinDragRef.current = null;
6201
- const endStage = stageRef.current;
6202
- if (endStage) delete endStage.dataset.lineGrab;
6203
- if (!drag || !drag.moved) { paintLineOverlay(); return; }
6204
- const clip = worldPointToClip(motion, { x: drag.world[0], y: drag.world[1], z: drag.world[2] }, {
6205
- baseY: activeChar.y ?? 0,
6206
- scale: activeChar.scale ?? 1,
6207
- });
6208
- const next = upsertPin(linePins, { frame: drag.frame, position: [clip.x, clip.y, clip.z] });
6209
- if (next === linePins) { paintLineOverlay(); return; }
6210
- // A pin and a curve cannot coexist (see the linePins comment). The
6211
- // curve goes on the undo stack first, so Ctrl/Cmd+Z brings it back.
6212
- if (lineCurve) {
6213
- lineUndoRef.current.push(lineCurve);
6214
- setLineCurve(null);
6215
- }
6216
- const range = pinsFrameRange(next, lineClipFrames);
6217
- if (range) {
6218
- lineAutoRangeRef.current = range;
6219
- setLineRange(range);
6220
- }
6221
- setLinePins(next);
6222
- // Same debounce, same session seed, same supersede rule as a stroke:
6223
- // the preview loop does not care which gesture asked for it.
6224
- if (range) scheduleLinePreview({ pins: next, frameRange: range });
6225
- paintLineOverlay();
6226
- };
6227
- window.addEventListener("pointermove", onMove);
6228
- window.addEventListener("pointerup", onUp);
6229
- window.addEventListener("pointercancel", onUp);
6230
- paintLineOverlay();
6231
- return true;
6232
- }
6233
-
6234
- /** Bubble-phase hover: the cursor and the swollen marker, nothing else. It
6235
- * never stops the event and never writes state, so it cannot interfere with
6236
- * the orbit the same pointermove is probably driving. */
6237
- function onLineStageHover(event) {
6238
- const stage = stageRef.current;
6239
- if (!stage || lineDragRef.current || lineDrawRef.current || linePinDragRef.current) return;
6240
- const pane = event.target instanceof HTMLCanvasElement ? lineEditPane() : null;
6241
- const curve = pane ? lineHoverCurve() : null;
6242
- let hit = null;
6243
- if (curve) {
6244
- const [u, v] = lineUvFromPointer(pane, event);
6245
- hit = nearestCurvePoint(curve, u, v, CURVE_GRAB_RADIUS_PX, pane.rect.w, pane.rect.h);
6246
- }
6247
- lineHoverRef.current = hit ? hit.index : null;
6248
- if (hit) stage.dataset.lineGrab = "hover";
6249
- else delete stage.dataset.lineGrab;
6250
- }
6251
-
6252
- function onLineStageHoverEnd() {
6253
- const stage = stageRef.current;
6254
- if (lineDragRef.current || lineDrawRef.current || linePinDragRef.current) return;
6255
- lineHoverRef.current = null;
6256
- if (stage) delete stage.dataset.lineGrab;
6257
- }
6258
-
6259
- /** Back to the take's own trajectory — and, because an unedited curve
6260
- * follows the live camera, back to a path that tracks the view. */
6261
- function resetLineCurve() {
6262
- lineDragRef.current = null;
6263
- lineDrawRef.current = null;
6264
- linePinDragRef.current = null;
6265
- setLinePins([]);
6266
- // The button is itself undoable: resetting a curve you spent five pulls
6267
- // on should not be a cliff.
6268
- if (lineCurve) lineUndoRef.current.push(lineCurve);
6269
- // The draft on screen was a picture of the pull that is being thrown
6270
- // away, so it goes with it — but the SESSION does not end, and the seed
6271
- // survives, because the artist is still editing the same take.
6272
- cancelLinePreview();
6273
- setLineCurve(null);
6274
- }
6275
-
6276
- /** Internal clear — mode entry/exit and enqueue. (A camera move used to come
6277
- * through here too; it no longer clears anything.) Unlike the
6278
- * reset BUTTON this also empties the undo stack: those transitions change
6279
- * what the stack's entries were authored against. It is also where a
6280
- * preview SESSION ends, which is what re-rolls the seed: every one of these
6281
- * transitions means the next pull is a different piece of work. */
6282
- function clearLineEdit() {
6283
- lineDragRef.current = null;
6284
- lineDrawRef.current = null;
6285
- linePinDragRef.current = null;
6286
- setLinePins([]);
6287
- setLinePinMode(false);
6288
- lineUndoRef.current = [];
6289
- cancelLinePreview();
6290
- linePreviewSeedRef.current = null;
6291
- setLineCurve(null);
6292
- }
6293
-
6294
- function undoLineCurve() {
6295
- const previous = lineUndoRef.current.pop();
6296
- if (previous === undefined) return false;
6297
- lineDragRef.current = null;
6298
- lineDrawRef.current = null;
6299
- linePinDragRef.current = null;
6300
- // Undo restores a CURVE, so it also un-does whatever pins replaced it —
6301
- // the two are exclusive and the stack only ever stores curves.
6302
- setLinePins([]);
6303
- cancelLinePreview();
6304
- setLineCurve(previous);
6305
- return true;
6306
- }
6307
-
6308
- /* --------------------- the live preview loop (C10/C11) --------------------
6309
- * Everything below is the machinery behind "release the drag, watch it move".
6310
- * It is deliberately kept apart from enqueueMotionJob: that queue exists to
6311
- * produce TAKES — it delivers to a character layer, commits a recipe and
6312
- * pushes a version — and a preview must do none of those things. So a
6313
- * preview is its own bare request, its own AbortController, and one
6314
- * viewport swap that anything can undo. */
6315
-
6316
- /** Pin (or release) the take a preview is drafted from. Mirrored into a ref
6317
- * because the async completion below reads it after several awaits, where a
6318
- * render-time closure would be pointing at the previous take. */
6319
- function setPreviewSource(next) {
6320
- linePreviewSourceRef.current = next;
6321
- setLinePreviewSource(next);
6322
- }
6323
-
6324
- /** THE SESSION SEED (rule 2 above). Rolled at most once per editing session
6325
- * and handed to every preview AND to the confirming full-quality run, so the
6326
- * draft the artist accepted is the draft they get. Returns null when the
6327
- * typed seed is invalid — takeSeed has already said so. */
6328
- function lineSessionSeed() {
6329
- if (Number.isInteger(linePreviewSeedRef.current)) return linePreviewSeedRef.current;
6330
- const seed = takeSeed();
6331
- if (seed === null) return null;
6332
- linePreviewSeedRef.current = seed;
6333
- return seed;
6334
- }
6335
-
6336
- /** The C6 body for a curve — ONE builder for the preview and the confirm, so
6337
- * the only difference between what the artist watched and what they get is
6338
- * the step count. Returns `{ ok: false, message }` with copy already
6339
- * localized, or `{ ok: true, body, lineEdit, seed }`. */
6340
- function buildLineEditRequest(curve, { preview = false } = {}) {
6341
- const refuse = (copy) => ({ ok: false, message: ko(copy[0], copy[1]) });
6342
- const sourceUrl = takeSourceUrl;
6343
- if (!sourceUrl) return refuse(LINE_EDIT_REFUSALS.sourceMotion);
6344
- if (!curve) {
6345
- return refuse(["Pull the path first — grab a dot on it and drag", "커브를 먼저 잡아당겨 주세요"]);
6346
- }
6347
- // PINS take the other branch of C6 entirely: no points2d, no camera. The
6348
- // prompt and the seed rule below are shared, because those belong to the
6349
- // RUN, not to the gesture.
6350
- if (curve.pins) {
6351
- const prompt = ((linePreviewSource?.prompt ?? motion?.prompt) || "").trim() || "A person continues the motion naturally.";
6352
- const lineEdit = {
6353
- sourceMotion: sourceUrl,
6354
- track: lineTrack,
6355
- frameRange: curve.frameRange,
6356
- pins3d: curve.pins.map((pin) => ({ frame: pin.frame, position: [...pin.position] })),
6357
- prompt,
6358
- };
6359
- const refusal = validateLineEdit(lineEdit, { clipFrames: lineClipFrames });
6360
- if (refusal) return refuse(LINE_EDIT_REFUSALS[refusal.code] ?? LINE_EDIT_REFUSALS.shape);
6361
- const body = { prompt, duration: lineClipFrames / TIMELINE_FPS, posePin: false, lineEdit };
6362
- const seed = lineSessionSeed();
6363
- if (seed === null) return { ok: false, message: "" };
6364
- body.seed = seed;
6365
- if (preview) lineEdit.preview = true;
6366
- return { ok: true, body, lineEdit, seed };
6367
- }
6368
- // The wire pairing is positional (driver.py spreads points2d across
6369
- // frameRange by time), so the points sent are exactly the range's own
6370
- // slice of the curve — a whole-clip dragged curve paired with a touched-
6371
- // frames range would compress the full trail into the window.
6372
- const requestRange = curve.frameRange ?? lineEditRange;
6373
- const built = curveToPoints2d(sliceCurveToRange(curve.edited, requestRange));
6374
- if (built.error) return refuse(LINE_CURVE_REFUSALS[built.error] ?? LINE_EDIT_REFUSALS.shape);
6375
- // The take's own prompt is the text condition: a line edit changes WHERE
6376
- // a joint goes, not what the shot is about. The fallback keeps the
6377
- // bridge's non-empty-prompt contract satisfiable for takes imported
6378
- // without one.
6379
- const prompt = ((linePreviewSource?.prompt ?? motion?.prompt) || "").trim() || "A person continues the motion naturally.";
6380
- const lineEdit = {
6381
- sourceMotion: sourceUrl,
6382
- track: lineTrack,
6383
- // THE CURVE'S OWN RANGE FIRST. A drawn stroke picks its window from
6384
- // where it was drawn (drawStrokeEdit) and a fresh pull from the frames
6385
- // its falloff touched; the window is stamped on the committed curve
6386
- // precisely so a request can never pair one gesture's points with
6387
- // another render's range — `lineEditRange` is re-derived from state a
6388
- // just-committed gesture has not landed in yet.
6389
- frameRange: requestRange,
6390
- points2d: built.points2d,
6391
- camera: curve.camera,
6392
- prompt,
6393
- };
6394
- const refusal = validateLineEdit(lineEdit, { clipFrames: lineClipFrames });
6395
- if (refusal) return refuse(LINE_EDIT_REFUSALS[refusal.code] ?? LINE_EDIT_REFUSALS.shape);
6396
- // posePin:false is mandatory, not decorative: the bridge demands a
6397
- // `poses` array whenever posePin is not explicitly false, and a line
6398
- // edit authors no poses at all.
6399
- const body = { prompt, duration: lineClipFrames / TIMELINE_FPS, posePin: false, lineEdit };
6400
- // THE SEED RULE (C9). An edit creates a take, so it may not leave the
6401
- // seed to chance. The seed rides on the BODY, never inside `lineEdit` —
6402
- // C6 validates that object field by field and an unknown key there is a
6403
- // 400. `preview` is the one exception the bridge added for this loop.
6404
- const seed = lineSessionSeed();
6405
- if (seed === null) return { ok: false, message: "" };
6406
- body.seed = seed;
6407
- if (preview) lineEdit.preview = true;
6408
- return { ok: true, body, lineEdit, seed };
6409
- }
6410
-
6411
- /** Put a draft on screen without letting it become the take. */
6412
- async function showLinePreview(url) {
6413
- const source = linePreviewSourceRef.current;
6414
- if (!source) return;
6415
- try {
6416
- await loadMotion(url, source.prompt, source.rotationDeg, null, source.charId, null, { preview: true });
6417
- linePreviewShownRef.current = url;
6418
- setLinePreviewUrl(url);
6419
- } catch {
6420
- setLinePreviewError(ko("The preview could not be read back", "미리보기를 읽지 못했어요"));
6421
- await revertLinePreview();
6422
- }
6423
- }
6424
-
6425
- /** Back to the take itself. The source stays pinned until the reload lands,
6426
- * so there is never an instant where `takeSourceUrl` points at the draft.
6427
- * Nothing is reloaded when no draft ever reached the viewport — cancelling
6428
- * an in-flight preview (Esc, undo with an empty stack, a joint switch) must
6429
- * not cost a re-fetch and re-decode of a take that is already on screen. */
6430
- async function revertLinePreview() {
6431
- const source = linePreviewSourceRef.current;
6432
- const shown = linePreviewShownRef.current;
6433
- linePreviewShownRef.current = null;
6434
- setLinePreviewUrl(null);
6435
- if (!source || !shown) {
6436
- setPreviewSource(null);
6437
- return;
6438
- }
6439
- try {
6440
- await loadMotion(source.url, source.prompt, source.rotationDeg, null, source.charId, null, { preview: true });
6441
- } catch {
6442
- /* loadMotion already surfaced the decode failure in the panel */
6443
- }
6444
- if (linePreviewSourceRef.current === source) setPreviewSource(null);
6445
- }
6446
-
6447
- /** Stop the loop. Bumping the token is what makes an in-flight answer
6448
- * harmless: it arrives, finds itself stale, and is dropped without ever
6449
- * reaching the viewport. `revert:false` is for the callers that are ALREADY
6450
- * loading a different take (a version chip) and must not race a reload of
6451
- * the take they are leaving. */
6452
- function cancelLinePreview({ revert = true } = {}) {
6453
- if (linePreviewTimerRef.current) {
6454
- window.clearTimeout(linePreviewTimerRef.current);
6455
- linePreviewTimerRef.current = 0;
6456
- }
6457
- linePreviewPendingRef.current = null;
6458
- linePreviewTokenRef.current += 1;
6459
- const controller = linePreviewAbortRef.current;
6460
- linePreviewAbortRef.current = null;
6461
- if (controller) controller.abort();
6462
- setLinePreviewBusy(false);
6463
- setLinePreviewError("");
6464
- setLinePreviewMs(0);
6465
- if (revert) {
6466
- revertLinePreview();
6467
- } else {
6468
- linePreviewShownRef.current = null;
6469
- setLinePreviewUrl(null);
6470
- setPreviewSource(null);
6471
- }
6472
- }
6473
-
6474
- /** A released drag asks for a draft — after a beat, so drag-drag-drag costs
6475
- * one request rather than three. */
6476
- function scheduleLinePreview(curve) {
6477
- if (linePreviewTimerRef.current) window.clearTimeout(linePreviewTimerRef.current);
6478
- linePreviewTimerRef.current = window.setTimeout(() => {
6479
- linePreviewTimerRef.current = 0;
6480
- runLinePreview(curve);
6481
- }, LINE_PREVIEW_DEBOUNCE_MS);
6482
- }
6483
-
6484
- /** One preview round trip. Never more than one at a time (rule 3): a curve
6485
- * that arrives while a request is out becomes THE pending curve, replacing
6486
- * any earlier pending one, and is fired the moment the outstanding answer
6487
- * lands — whose result is then thrown away, because it describes a pull the
6488
- * artist has already moved past. */
6489
- async function runLinePreview(curve) {
6490
- if (!lineEditMode || !curve) return;
6491
- // A preview is a courtesy, never a blocker: no bridge, no route, or the
6492
- // box already busy with the real thing means the panel simply keeps the
6493
- // curve and waits for the artist to press 생성.
6494
- if (!bridge?.ok || !lineEditBackend || ardyRunning) return;
6495
- if (linePreviewAbortRef.current) {
6496
- linePreviewPendingRef.current = curve;
6497
- return;
6498
- }
6499
- const source = linePreviewSourceRef.current ?? (motion?.url
6500
- ? {
6501
- url: motion.url,
6502
- prompt: motion.prompt ?? "",
6503
- rotationDeg: motion.rotationDeg ?? activeChar.rot,
6504
- charId: activeChar.id,
6505
- }
6506
- : null);
6507
- if (!source) return;
6508
- // Full quality on release, by request: the warm resident makes the 100-step
6509
- // run ~2 s, and since the draft shares the session seed the confirm below
6510
- // reproduces it bit for bit — so what the artist sees IS the final take,
6511
- // and Generate is a commit, not a second opinion.
6512
- const request = buildLineEditRequest(curve);
6513
- if (!request.ok) {
6514
- if (request.message) setLinePreviewError(request.message);
6515
- return;
6516
- }
6517
- const token = ++linePreviewTokenRef.current;
6518
- const controller = new AbortController();
6519
- linePreviewAbortRef.current = controller;
6520
- linePreviewPendingRef.current = null;
6521
- setPreviewSource(source);
6522
- setLinePreviewBusy(true);
6523
- setLinePreviewError("");
6524
- const startedAt = Date.now();
6525
- let result = null;
6526
- let failure = "";
6527
- try {
6528
- // No onEvent work: a preview's status lines belong to nobody. The
6529
- // console stays the full-quality run's log.
6530
- result = await ardyGenerate(request.body, () => {}, { signal: controller.signal });
6531
- } catch (err) {
6532
- // An abort is this loop's own doing and says nothing to the artist.
6533
- failure = err?.name === "AbortError" ? "" : (err?.message || String(err));
6534
- }
6535
- // Stale: superseded by a newer drag or cancelled outright. Whoever bumped
6536
- // the token owns the state now.
6537
- if (linePreviewTokenRef.current !== token) return;
6538
- linePreviewAbortRef.current = null;
6539
- const pending = linePreviewPendingRef.current;
6540
- if (pending) {
6541
- linePreviewPendingRef.current = null;
6542
- runLinePreview(pending);
6543
- return;
6544
- }
6545
- setLinePreviewBusy(false);
6546
- if (failure) {
6547
- // Non-fatal by design: the curve survives, and 생성 still works.
6548
- setLinePreviewError(failure);
6549
- return;
6550
- }
6551
- if (!result?.motionUrl) return;
6552
- setLinePreviewMs(Date.now() - startedAt);
6553
- await showLinePreview(result.motionUrl);
6554
- }
6555
-
6556
- function exitLineEditMode() {
6557
- clearLineEdit();
6558
- lineHoverRef.current = null;
6559
- lineLiveRef.current = null;
6560
- const stage = stageRef.current;
6561
- if (stage) delete stage.dataset.lineGrab;
6562
- setLineEditMode(false);
6563
- }
6564
-
6565
- /** Entering is mutually exclusive with every other AUTHORING mode, the same
6566
- * way waypoint/IK/pose already are with each other: they all claim the same
6567
- * viewport pointer and the same playhead. Camera navigation is deliberately
6568
- * NOT in that list — the pointer is shared with it, not taken from it. */
6569
- function toggleLineEditMode() {
6570
- if (lineEditMode) {
6571
- exitLineEditMode();
6572
- setToast(ko("Line editing off", "라인 편집 꺼짐"));
6573
- return;
6574
- }
6575
- if (!motion?.url) {
6576
- setToast(ko("The current take has no bridge source — generate it once before editing a path", "현재 테이크에 브리지 원본이 없어요 — 궤적을 편집하기 전에 한 번 생성하세요"));
6577
- return;
6578
- }
6579
- if (waypointMode) setWaypointMode(false);
6580
- if (ikMode) leaveIkMode();
6581
- if (posing) setPosing(null);
6582
- clearLineEdit();
6583
- setLineEditMode(true);
6584
- setToast(ko(
6585
- "Path editing on — draw along the path to reroute that section, or grab a dot and pull; the view still orbits normally",
6586
- "궤적 편집 켜짐 — 궤적을 따라 그리면 그 구간만 새로 지나가고, 점을 잡아 끌 수도 있어요. 시점은 평소처럼 돌릴 수 있어요",
6587
- ));
6588
- }
6589
-
6590
- /* Switching joint, range, take or character drops any pull in hand: it was
6591
- * authored against a trajectory that is no longer the one on screen. The
6592
- * curve itself needs no rebuilding — with no edit it is re-projected every
6593
- * repaint from the live camera.
6594
- *
6595
- * The one thing worth SAYING here is framing. Part of the range may be
6596
- * behind the lens or out of shot; the visible part stays draggable and the
6597
- * hit test ignores the rest, but a path that stops halfway looks like a bug,
6598
- * and hidden frames cannot be constrained. The retry exists because mode
6599
- * entry and a measurable pane are not the same instant — the overlay mounts
6600
- * in the commit that turns the mode on and DualRender may not have settled
6601
- * the pane's aspect yet, so the projection legitimately refuses for a frame
6602
- * or two. Failing silently after that is deliberate: the plan and IK views
6603
- * have no pinhole camera at all and are not worth nagging about. */
6604
- useEffect(() => {
6605
- if (!lineEditMode || !motion) return undefined;
6606
- // ...unless the RANGE CHANGED BECAUSE A STROKE SAID SO. A drawn stroke
6607
- // picks its own window (drawStrokeEdit) and commits the curve for that
6608
- // window in the same gesture, so this run is reacting to the draw's own
6609
- // bookkeeping rather than to the artist moving the goalposts. Consumed
6610
- // once, so a later hand-typed range still drops the edit as before.
6611
- const auto = lineAutoRangeRef.current;
6612
- lineAutoRangeRef.current = null;
6613
- if (auto && auto.startFrame === lineEditRange?.startFrame && auto.endFrame === lineEditRange?.endFrame) return undefined;
6614
- // Whatever draft is on screen was drafted for the joint/range/take that
6615
- // just changed, so it goes back to the source take with the pull. Pins go
6616
- // with it for the same reason and one more: a pin names a JOINT, and the
6617
- // joint just changed under it.
6618
- cancelLinePreview();
6619
- setLineCurve(null);
6620
- setLinePins([]);
6621
- let attempts = 0;
6622
- let timer = 0;
6623
- const check = () => {
6624
- const live = projectLineCurve(lineEditPane());
6625
- if (!live) {
6626
- if (attempts >= 12) return;
6627
- attempts += 1;
6628
- timer = window.setTimeout(check, 120);
6629
- return;
6630
- }
6631
- const hidden = live.curve.reduce((count, point) => count + (isCurvePointOnScreen(point) ? 0 : 1), 0);
6632
- if (hidden > 0) {
6633
- setToast(isKo
6634
- ? `이 구간의 ${hidden}프레임이 화면 밖이에요 — 구간 전체가 보이도록 시점을 잡아 주세요`
6635
- : `${hidden} frame(s) of this range are outside the frame — orbit until the whole range is in view`);
6636
- }
6637
- };
6638
- check();
6639
- return () => window.clearTimeout(timer);
6640
- // The range is depended on by VALUE: lineEditRange is a fresh object on
6641
- // every render that touches lineRange, and depending on its identity
6642
- // would fire this constantly. activeChar's ground offset and scale are in
6643
- // here because jointTrailPoints places the trail with them — move or
6644
- // resize the character and the path really is somewhere else.
6645
- //
6646
- // THE TAKE is depended on as takeSourceUrl, not as `motion`: a preview
6647
- // swaps `motion` for a draft of the same take several times a minute,
6648
- // and depending on the object would make every landing draft wipe the
6649
- // very curve it is a picture of. takeSourceUrl only moves when the take
6650
- // really does.
6651
- // eslint-disable-next-line react-hooks/exhaustive-deps
6652
- }, [lineEditMode, lineTrack, takeSourceUrl, lineEditRange?.startFrame, lineEditRange?.endFrame, activeChar.y, activeChar.scale]);
6653
-
6654
- // Repaint on anything that changes what the overlay should show. The window
6655
- // resize listener is separate from React state because the pane rect can
6656
- // change without any of it changing.
6657
- useEffect(() => {
6658
- if (!lineEditMode) return undefined;
6659
- paintLineOverlay();
6660
- const repaint = () => paintLineOverlay();
6661
- window.addEventListener("resize", repaint);
6662
- // A gentle rAF loop while the mode is on, and it earns its keep three
6663
- // times over: the unedited curve is re-projected here so it follows the
6664
- // camera, a pointermove writes only lineDragRef so this is what shows the
6665
- // live pull without re-rendering App, and the render moves underneath
6666
- // regardless (playback, a follow camera). A few hundred 2d-canvas points
6667
- // per frame is noise next to the WebGL scene already animating behind it.
6668
- let raf = requestAnimationFrame(function tick() {
6669
- paintLineOverlay();
6670
- raf = requestAnimationFrame(tick);
6671
- });
6672
- return () => {
6673
- window.removeEventListener("resize", repaint);
6674
- cancelAnimationFrame(raf);
6675
- };
6676
- });
6677
-
6678
- // Pointer plumbing. The overlay canvas is `pointer-events: none` — it only
6679
- // PAINTS — and the listeners live on the stage container instead, so a
6680
- // pointer that misses the curve reaches the WebGL canvas untouched. See
6681
- // onLineStagePointerDown for why the down listener is in the capture phase
6682
- // and what "untouched" buys. No dependency array on purpose: the handlers
6683
- // close over lineCurve, lineRadius and the live refs, and must always be
6684
- // this render's.
6685
- useEffect(() => {
6686
- if (!lineEditMode) return undefined;
6687
- const stage = stageRef.current;
6688
- if (!stage) return undefined;
6689
- stage.addEventListener("pointerdown", onLineStagePointerDown, true);
6690
- stage.addEventListener("pointermove", onLineStageHover);
6691
- stage.addEventListener("pointerleave", onLineStageHoverEnd);
6692
- return () => {
6693
- stage.removeEventListener("pointerdown", onLineStagePointerDown, true);
6694
- stage.removeEventListener("pointermove", onLineStageHover);
6695
- stage.removeEventListener("pointerleave", onLineStageHoverEnd);
6696
- };
6697
- });
6698
-
6699
- // ESC leaves the mode, matching the shot look-through and the scene-object
6700
- // selection above.
6701
- useEffect(() => {
6702
- if (!lineEditMode) return undefined;
6703
- const onKey = (event) => {
6704
- if (event.key === "Escape") {
6705
- exitLineEditMode();
6706
- return;
6707
- }
6708
- // Ctrl/Cmd+Z inside the mode undoes the last PULL, not the scene.
6709
- // Capture phase, because the app's scene undo listens on the window
6710
- // bubble: consuming here keeps one keystroke from doing both. An
6711
- // empty stack still swallows the key — falling through to a scene
6712
- // undo the user cannot see happening behind the mode would be worse
6713
- // than a no-op. Text fields keep the browser's own undo. Shift+Z
6714
- // (redo) is left alone: there is no line-redo yet, and silently
6715
- // eating it would just feel broken in a different way.
6716
- if (event.code === "KeyZ" && (event.ctrlKey || event.metaKey) && !event.shiftKey) {
6717
- if (/INPUT|TEXTAREA/.test(document.activeElement?.tagName ?? "")) return;
6718
- event.preventDefault();
6719
- event.stopPropagation();
6720
- undoLineCurve();
6721
- }
6722
- };
6723
- window.addEventListener("keydown", onKey, true);
6724
- return () => window.removeEventListener("keydown", onKey, true);
6725
- }, [lineEditMode, lineCurve]);
6726
-
6727
- // Camera-drift watcher — and note what it does NOT do, which is now the
6728
- // interesting half: it never blocks navigation, and IT NEVER DESTROYS
6729
- // ANYTHING. It has no job at all while the curve is unedited, because an
6730
- // unedited curve is re-projected every repaint and simply follows the view.
6731
- //
6732
- // Once something has been pulled, that pull is a 2D offset authored through
6733
- // one specific lens — and the committed curve KEEPS that lens beside the
6734
- // points, which is what buildLineEditRequest puts on the wire. So moving the
6735
- // view invalidates the drawing of the line and nothing else: the edit, its
6736
- // preview and the confirming run are all still exactly what the artist
6737
- // authored. This watcher therefore only reports, in both directions — come
6738
- // back toward the snapshot and the line re-attaches, with no state to restore
6739
- // because none was thrown away. (Before this, it called clearLineEdit(): a
6740
- // right-drag fly, the app's own navigation gesture, silently wiped a finished
6741
- // edit. That was the bug.)
6742
- //
6743
- // Polling beats hooking every camera mutation: the cameras are moved from a
6744
- // dozen places (fly controls, shot presets, live control, follow tracks) and
6745
- // none of them reports to React. The painter re-derives the same answer every
6746
- // frame for the ghost, and onLineStagePointerDown re-derives it synchronously
6747
- // to close the 250 ms window between polls; this poll exists to drive the
6748
- // PANEL, which only re-renders on state.
6749
- useEffect(() => {
6750
- if (!lineEditMode || !lineCurve) {
6751
- // No edit, no drift: whatever the last curve's lens was, the live one
6752
- // is now the baseline again and gestures are re-enabled. This is what
6753
- // makes undo/reset back to no-curve hand the mode straight back.
6754
- lineDriftRef.current = false;
6755
- setLineDrifted(false);
6756
- return undefined;
6757
- }
6758
- const poll = () => {
6759
- // Never re-classify under a finger that is holding the curve or drawing
6760
- // a new one: a live gesture owns its own snapshot, and mid-gesture
6761
- // drift is the one case that is still a corruption rather than a
6762
- // misalignment (see the gesture handlers — that path is unchanged).
6763
- if (lineDragRef.current || lineDrawRef.current || linePinDragRef.current) return;
6764
- const drifted = lineCurveDrifted(lineEditPane(), lineCurve);
6765
- if (drifted === lineDriftRef.current) return;
6766
- lineDriftRef.current = drifted;
6767
- setLineDrifted(drifted);
6768
- };
6769
- // Immediately, then at 4 Hz: a view switch or a fly that finishes just as
6770
- // this effect re-runs should not leave the panel a quarter second behind
6771
- // the line it is describing.
6772
- poll();
6773
- const id = window.setInterval(poll, 250);
6774
- return () => window.clearInterval(id);
6775
- // lookThroughShot / preview / ikMode are dependencies even though the
6776
- // body never reads them directly: they are what lineEditPane branches on,
6777
- // so a stale closure would keep measuring the camera the pane used to
6778
- // hold and a view SWITCH — the most obvious way to invalidate a curve —
6779
- // would go undetected. Lens and aspect changes need no dependency: they
6780
- // move fx/fy, which the comparison sees on its own.
6781
- }, [lineEditMode, lineCurve, lookThroughShot, preview, ikMode]);
6782
-
6783
- // Use the same bounded, coalesced probe as generation. Capabilities can
6784
- // disappear under a live bridge too; every health result refreshes them.
6785
- useEffect(() => {
6786
- if (lineEditMode) bridgeRefreshRef.current();
6787
- }, [lineEditMode]);
6788
-
6789
- const genRunningRef = useRef(false);
6790
- const genJobSeq = useRef(0);
6791
-
6792
- useEffect(() => {
6793
- if (genRunningRef.current) return;
6794
- const next = genQueue.find((job) => job.status === "queued");
6795
- if (!next) return;
6796
- genRunningRef.current = true;
6797
- setGenQueue((queue) => queue.map((job) => (job.id === next.id ? { ...job, status: "running" } : job)));
6798
- (async () => {
6799
- try {
6800
- await executeMotionJob(next);
6801
- next.commandCompletion?.resolve();
6802
- setGenQueue((queue) => queue.map((job) => (job.id === next.id ? { ...job, status: "done" } : job)));
6803
- } catch (err) {
6804
- next.commandCompletion?.reject(err);
6805
- const message = err?.name === "AbortError" ? ko("Cancelled", "취소됨") : err?.message || String(err);
6806
- setGenQueue((queue) => queue.map((job) => (job.id === next.id ? { ...job, status: "error", error: message } : job)));
6807
- } finally {
6808
- genRunningRef.current = false;
6809
- generationPendingRef.current = false;
6810
- }
6811
- })();
6812
- }, [genQueue]);
6813
-
6814
- useEffect(() => {
6815
- // Never for a draft: a preview is not a take and must not mint a chip.
6816
- if (!motion?.url || linePreviewUrl) return;
6817
- // Never for a generated take either — its own job is about to commit a
6818
- // real recipe with a real seed, and this placeholder would beat it to the
6819
- // strip and label it "불러옴".
6820
- if (takeRecipeRef.current || ardyRunning || genQueue.length > 0) return;
6821
- if (takeVersions.some((entry) => entry.motionUrl === motion.url)) return;
6822
- seedLoadedTake(motion.url, motion.prompt, motion.frames);
6823
- // eslint-disable-next-line react-hooks/exhaustive-deps
6824
- }, [motion?.url, motion?.frames, linePreviewUrl, ardyRunning, genQueue.length, takeVersions]);
6825
-
6826
- const readinessState = selectedMotionReadiness();
6827
- const lineReadinessState = motionReadiness(bridge, { body: { lineEdit: true }, lineEditSupported: lineEditBackend });
6828
- const trailReadinessState = motionReadiness(bridge, { body: { motionEdit: true } });
6829
-
6830
4419
  // Studio native ports. Kept together so the binding test executes these exact
6831
4420
  // publication/history functions, not a substitute editor or parallel journal.
6832
4421
  function readStudioCamera() {
@@ -6841,11 +4430,11 @@ export default function App() {
6841
4430
  function readStudioState() {
6842
4431
  const live = appContext.live.state;
6843
4432
  const list = appContext.live.characters.map(c => c.id === loadedLayerCharRef.current ? {
6844
- ...c, layer: { ...c.layer, waypoints: bufferRef.current.waypoints, promptClips: bufferRef.current.promptClips }, sessionMotion: bufferRef.current.motion,
4433
+ ...c, layer: { ...c.layer, waypoints: bufferRef.current.waypoints }, sessionMotion: bufferRef.current.motion,
6845
4434
  } : c);
6846
4435
  const targets = new Map(list.map(c => [c.id, { rig: live.rigs[c.id], motion: c.sessionMotion ?? null,
6847
4436
  ikState: c.id === loadedLayerCharRef.current ? ikStateRef.current : ikStatesRef.current.get(c.id),
6848
- calibration: c.sessionMotion?.sceneCalibration, protectedFrames: c.id === loadedLayerCharRef.current ? physicsOptions.protectedFrames : [],
4437
+ calibration: c.sessionMotion?.sceneCalibration, protectedFrames: [],
6849
4438
  preserveAuthoredMotion: Boolean(c.layer?.waypoints?.length) }]));
6850
4439
  return { host: { workspaceId: liveWorkspaceIdRef.current, documentEpoch: studioDocumentEpochRef.current,
6851
4440
  sceneId: activeSceneIdRef.current, sceneEpoch: studioSceneEpochRef.current }, workspaceHandle: liveWorkspaceHandleRef.current,
@@ -6853,8 +4442,8 @@ export default function App() {
6853
4442
  objects: storeRef.current.objects, characters: list, targets, shots: live.shots, frameCount: live.timeline.frameCount,
6854
4443
  selection: live.studioSelection, activeCharacterId: live.activeCharacterId, selectedShotId: live.studioShotId,
6855
4444
  view: live.studioView, camera: live.studioCamera ?? readStudioCamera(), filmback: live.filmback, manual: manualCameraOverrideRef.current,
6856
- bridgeReady: Boolean(bridge?.ok), busy: storeRef.current.present() !== storeRef.current.objects || Boolean(studioGestureRef.current ||
6857
- ikBodyDragRef.current || lineDragRef.current || lineDrawRef.current || linePinDragRef.current || live.studioPhysicsRunning || recRef.current) };
4445
+ bridgeReady: false, busy: storeRef.current.present() !== storeRef.current.objects || Boolean(studioGestureRef.current ||
4446
+ ikBodyDragRef.current || recRef.current) };
6858
4447
  }
6859
4448
  function sampleStudioStateAt(frame, current = readStudioState()) {
6860
4449
  const shot = shotAtFrame(current.shots ?? current.shotDocument?.shots ?? [], frame);
@@ -6960,7 +4549,7 @@ export default function App() {
6960
4549
  }));
6961
4550
  parent.matrixAutoUpdate = false; parent.matrix.copy(target.rig.parent?.matrixWorld ?? new THREE.Matrix4()); parent.add(rig);
6962
4551
  try {
6963
- if (target.motion) applyMotionFrame(rig, target.motion, sampleAt({ frameCount: target.motion.frames, motion: target.motion }, null, frame).motionFrame);
4552
+ if (target.motion) applyTakeFrame(rig, target.motion, sampleAt({ frameCount: target.motion.frames, motion: target.motion }, null, frame).motionFrame);
6964
4553
  // The clone shares the source rig's bind pose, so its contact radii
6965
4554
  // and heights are the same numbers: measure the source once and
6966
4555
  // hand them over instead of re-scanning every vertex per query (#413).
@@ -7004,7 +4593,6 @@ export default function App() {
7004
4593
  studioSelection: selectedSceneObjectId ? { kind: "object", id: selectedSceneObjectId, hierarchyId: selectedHierarchyId } :
7005
4594
  selectedStudioChar ? { kind: "character", id: selectedStudioChar, hierarchyId: selectedHierarchyId } : selectedHierarchyId === "camera" ? { kind: "camera", id: "camera" } : { kind: "scene", id: activeSceneId },
7006
4595
  studioShotId: activeShot?.id ?? null,
7007
- studioPhysicsRunning: autoPhysicsRunning,
7008
4596
  // The agent's mode vocabulary is four (studio-agent-protocol.js): the
7009
4597
  // sculpt turntable is the Stage's scene seen in isolation.
7010
4598
  studioView: { mode: sculptMode ? "scene" : workflowMode, frame: tlFrame, playing: tlPlaying, lookThrough: lookThroughShot, grid: gridView, autoColor },
@@ -7017,20 +4605,17 @@ export default function App() {
7017
4605
  stepHistory: stepStudioHistory, capture: () => liveQueries.capture_framing_png({}), capturePlan: () => liveQueries.capture_plan_png({}),
7018
4606
  // One shot frame as raw read-back pixels (rows bottom-up), from the export
7019
4607
  // path captureShotFramePng uses; an export in flight renders at its output.
7020
- renderFrameBuffer: frame => {
7021
- const output = recRef.current?.capture ? recRef.current.request.context.output : shotOutput, data = withExportFrame(frame);
4608
+ // With a view (verify_result's own cameras) the posed frame is seen by that
4609
+ // camera instead, at view.output; the shot camera is left as it is.
4610
+ renderFrameBuffer: (frame, view = null) => {
4611
+ const output = view?.output ?? (recRef.current?.capture ? recRef.current.request.context.output : shotOutput), data = withExportFrame(frame, view && (() => captureRef.current.renderView(view)));
7022
4612
  if (!data) throw new Error("The shot renderer is not ready");
7023
4613
  return { data, width: output.width, height: output.height };
7024
4614
  },
7025
4615
  encodePng: (buffer, output) => bufferToPng(buffer, output),
7026
4616
  // The pose library a character.pose patch resolves its id against.
7027
4617
  poses: () => [DEFAULT_POSE, ...customPoses],
7028
- loadArtifact: (artifact, options) => {
7029
- // Keep the server-pinned URL. Stripping the origin would silently fetch
7030
- // from a different bridge after a reconnect. The bridge owner must allow
7031
- // CORS or supply a pinned same-origin artifact proxy; never use load_motion.
7032
- return loadMotionFromUrl(artifact.url, options);
7033
- },
4618
+
7034
4619
  ikRevision: (id, stamp) => {
7035
4620
  const prior = studioIkStampsRef.current.get(id);
7036
4621
  if (!prior || prior.stamp !== stamp) studioIkStampsRef.current.set(id, { stamp, revision: (prior?.revision ?? 0) + 1 });
@@ -7049,9 +4634,9 @@ export default function App() {
7049
4634
  state: () => ({
7050
4635
  shots: appContext.live.state.shots, objects: storeRef.current.objects, characters: appContext.live.characters, frame: tlFrame, frameCount: tlFrameCount,
7051
4636
  selectedObjectId: selectedSceneObjectId, activeCharacterId: activeChar?.id ?? null,
7052
- promptBlockCount: promptClips.filter((clip) => clip.text.trim()).length,
7053
- generating: Boolean(generationPendingRef.current || genRunningRef.current || generationBusy),
7054
- motionReady: bridge !== null && !bridgeChecking,
4637
+
4638
+ generating: false,
4639
+ motionReady: false,
7055
4640
  // The Export menu offers Video (mp4) on exactly these conditions.
7056
4641
  exporting: Boolean(recRef.current), canExportVideo: shots.length > 0 || hasCameraKeys || Boolean(motion),
7057
4642
  // The scene refs move synchronously with every scene handler.
@@ -7064,10 +4649,8 @@ export default function App() {
7064
4649
  aiShot: { mode, imageModel },
7065
4650
  // AI-video motion: the account's Fal access, the card's job state and
7066
4651
  // the daily generations left (null until the server says).
7067
- i2vMotion: { enabled: i2vMotionEnabled, status: i2vMotion.status, dailyRemaining: i2vMotion.dailyRemaining ?? null },
7068
4652
  }),
7069
4653
  addTimelineShot, splitTimelineShot, duplicateTimelineShot, removeTimelineShot, setTimelineShotRange, moveTimelineShot,
7070
- runAllPromptBlocks,
7071
4654
  duplicateSelectedSceneObject,
7072
4655
  addCharacterWaypoint, moveCharacterWaypoint, removeCharacterWaypoint, clearCharacterWaypoints, setWaypointMode,
7073
4656
  setCharacterIkKey, removeCharacterIkKey, clearCharacterIkKeys, attachSceneObject, setShotCameraRail, clearShotCameraRail,
@@ -7077,7 +4660,7 @@ export default function App() {
7077
4660
  afterRender: () => new Promise(resolve => renderWaitersRef.current.push(resolve)),
7078
4661
  saveProject, projectFileGranted: async () => (await queryHandlePermission(projectHandleRef.current)) === "granted",
7079
4662
  importAsset: (args, context) => objectsDomain.importAsset(args, context), fetchImportSource,
7080
- setAiShotMode: setMode, setAiImageModel: setImageModel, generate, generateI2vMotion,
4663
+ setAiShotMode: setMode, setAiImageModel: setImageModel, generate,
7081
4664
  });
7082
4665
  if (!studioActionsRef.current) studioActionsRef.current = createStudioAppActions(appContext.actionPorts);
7083
4666
  /** UI door into the shared registry. Refusal messages are written for the
@@ -7105,7 +4688,7 @@ export default function App() {
7105
4688
  studioBindingRef.current.publishSemantic = (domain, after) => {
7106
4689
  if (domain === "characters" && Array.isArray(after)) { appContext.publishCharacters(after); appContext.patchLive({ characters: after }); }
7107
4690
  if (domain === "shots") appContext.patchLive({ shots: after });
7108
- if (domain === "promptClips") bufferRef.current.promptClips = after;
4691
+
7109
4692
  };
7110
4693
  }
7111
4694
  useEffect(() => () => studioBindingRef.current?.dispose(), []);
@@ -7180,42 +4763,25 @@ export default function App() {
7180
4763
  deleteSceneDocumentFromUi, addSceneObject, renameSceneObject, deleteSceneObject, frameSelection, groupObjectUnderNewEmpty, renameRequest,
7181
4764
  toggleHierarchyHidden, propsDrop, hierarchyReparent, agentTouchedRows, workflowMode,
7182
4765
  selectWorkflowMode, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled, sculptView, requestSculptView,
7183
- preset, applyPreset, cameraPresetId, i2vMotionCameraLocked, shotAspectKey,
4766
+ preset, applyPreset, cameraPresetId, shotAspectKey,
7184
4767
  fovDeg, shotsDomain, shot, setNonce, workspaceLayout,
7185
4768
  viewMenuTriggerRef, viewMenuOpen, setViewMenuAnchor, setViewMenuOpen, viewLooksActive,
7186
4769
  viewMenuAnchor, gridView, setGridView, autoColor, setAutoColor,
7187
4770
  guideMode,
7188
4771
  isCharacterSelection, partColoursChoice, embedMode, agentCollapsed, sceneSaveError,
7189
4772
  studioAgentError, studioAgentMode, setStudioAgentMode, AgentPanel, setAgentCollapsed,
7190
- buildStudioAgentContext, highlightAgentTargets, generateI2vMotionFromUi, selectedSceneObject, rigSelection,
4773
+ buildStudioAgentContext, highlightAgentTargets, selectedSceneObject, rigSelection,
7191
4774
  inspectorActionsOpen, setInspectorActionsOpen, deleteSelectedSceneObject, inspectorHasContent, keyLightSelected,
7192
- keyLight, changeKeyLight, resetKeyLight, isCameraSelection, moveSequence,
4775
+ keyLight, lighting, changeKeyLight, resetKeyLight, isCameraSelection, moveSequence,
7193
4776
  cameraKeys, activeShot, changeShotTargetModel, updateCharacterAt, openStudio,
7194
4777
  posing, removeCharacter, setShowB, activeChar, changeInspectorCharacter,
7195
- i2vMotionModel, i2vMotionActions, setI2vMotionStudioOpen, selectablePoses, ikApplyPoseAsKey,
7196
- setStudioPick, appContext, removePose, setPhotoPoseError, photoPoseFileRef,
7197
- photoPoseState, photoPoseError, activeRig, saveCurrentPose, multiModelStatus,
7198
- multiModelStage, multiModelFileRef, chooseMultiModelFile, multiModelSource, multiModelUrl,
7199
- setMultiModelUrl, useMultiModelUrl, pasteMultiModelUrl, multiModelProgress, multiModelError,
7200
- multiModelFootage, extractMultiModelMotion, multiModelExtract, multiModelTake, multiModelExtractProgress,
7201
- multiModelExtractError, bridge, promptBlocksReveal, promptClips, selectedPromptId,
7202
- setSelectedPromptId, setArdyPrompt, setTlFrame, tlFrameCount, changePromptClip,
7203
- ardySeed, changeArdySeed, lineEditMode, toggleLineEditMode, linePreviewUrl,
7204
- lineCurve, lineDrifted, lineTrack, setLineTrack, linePinMode,
7205
- setLinePinMode, linePins, lineClipFrames, lineEditRange, setLineRange,
7206
- lineRadius, changeLineRadius, lineCurveDirty, lineEditFrom, lineEditTo,
7207
- lineCurvePointCount, lineDriftHint, lineCurveHidden, linePreviewBusy, linePreviewMs,
7208
- linePreviewError, generationBusy, bridgeChecking, lineReadinessState, runLineEdit,
7209
- openMotionSetup, recheckMotionHealth, resetLineCurve, exitLineEditMode, readinessState,
7210
- ardyRunning, cancelArdy, ardyStatus, ardyOutcome, addPromptClip,
4778
+ selectablePoses, ikApplyPoseAsKey,
4779
+ setStudioPick, appContext, removePose, activeRig, saveCurrentPose,
4780
+ setTlFrame, tlFrameCount,
7211
4781
  tlFrame, isRigSelection, ikChains, ikFocus, footSnap, poseObjectOpacity, setPoseObjectOpacity, capturePoseImage,
7212
- collisionCleanupSupported, runFixCollisions, runFixCollisionsRange, autoPhysicsRunning, physicsProgress,
7213
- physicsPreview, physicsShow, physicsOptions, platformFitRunning, platformFitProgress,
7214
- platformFitLast, platformFitApplied, changePhysicsOptions, runAutoPhysics, showPhysicsPreview,
7215
- applyPhysicsPreview, cancelPhysicsPreview, ikEditTool, setIkEditTool, showTrails,
7216
- setShowTrails, trailFalloffS, setTrailFalloffS, trailEdit, trailTrackFocus, selectTrailTrack, clearTrailTrackFocus, runTrailRegeneration,
4782
+ ikEditTool, setIkEditTool,
7217
4783
  pendingIkEdit, applyPendingIkEdit, cancelPendingIkEdit,
7218
- trailReadinessState, rangePins, rangePinResiduals, rangePinSelection, rangePinPartPick,
4784
+ rangePins, rangePinResiduals, rangePinSelection, rangePinPartPick,
7219
4785
  rangePinPreview, setRangePinSelection, setRangePinPartPick, previewRangePinDraft, applyRangePinDraft,
7220
4786
  deleteRangePin, hasEnvSheet, environment, style, environmentImage,
7221
4787
  inspectorDrop, cutoutInputRef, meshInputRef, importCutout, importMesh,
@@ -7223,31 +4789,27 @@ export default function App() {
7223
4789
  matteCanvasRef, matteStats, matteMode, setMatteMode, matteEditorRef,
7224
4790
  matteTolerance, setMatteTolerance, matteBrush, setMatteBrush, matteShrink,
7225
4791
  setMatteShrink, matteFeather, setMatteFeather, matteBusy, applyMatte,
7226
- recentObjectColors, rememberSceneObjectColor, objectColorDraft, setObjectColorDraft, posePhotoFile,
4792
+ recentObjectColors, rememberSceneObjectColor, objectColorDraft, setObjectColorDraft,
7227
4793
  posingIndex, posingChar, charA, allPoses, studioPick,
7228
4794
  posingClosing, closeStudio, castDomain, setToast, savePose,
7229
4795
  bottomTab, setBottomTab, beginAssetDrag, shelfImageIds, shelfMeshIds,
7230
4796
  manageAssetStorage, setManageAssetStorage, unusedAssetIds, usedAssetIds, usageCounts,
7231
4797
  projectAssetGraphSignature, assetTrash, deleteUnusedAsset, undoDeletedAsset, deletingAssetId,
7232
- takeSourceUrl, sceneDisabledReason, sceneMenuOpen, setSceneMenuOpen, refineDisabledReason,
7233
- enterRefineMode, sceneGenerateDisabledReason, runArdy, sceneAgainDisabledReason, runSceneAgain,
7234
- addSceneBlock, preserveStrength, setPreserveStrength, waypointMode, preserveTracksLine,
7235
- takeRecipe, takeVersions, loadTakeVersion, replayNotices, craneSelectedIndex,
4798
+ waypointMode,
4799
+ craneSelectedIndex,
7236
4800
  addActiveCranePoint, deleteSelectedCranePoint, setCraneSelectedIndex, tlFps, ghostLayers,
7237
- pathSpeed, tlPlaying, pendingWaypointFrame, stateBadge, applyMotionTrim,
7238
- resetMotionTrim, cutMotionAtPlayhead, changeMotionSegmentSpeed, removeMotionSegmentById, bodyContact,
4801
+ pathSpeed, tlPlaying, pendingWaypointFrame, stateBadge, bodyContact,
7239
4802
  activeShotIdx, railDraw, pathDraw, pathTarget, setPathDraw, setRailDraw,
7240
4803
  setWorkspaceLayout, timingTokenRef, railCurve, ikAddKeyframe, ikDeleteKeyframe, pathPointIndex, setPathPointIndex,
7241
4804
  setBodyContact, setFootSnap, advanceFrame, stepFrame, cameraPreviewEndRef,
7242
4805
  manualCameraOverrideRef, setTlPlaying, setWaypointMode, selectActiveCharacterInHierarchy,
7243
4806
  toggleWaypointMode: storyboardProject ? undefined : toggleWaypointMode,
7244
- setActiveWaypointId, setPendingWaypointFrame, removeWaypoint, queueRootWaypointFrame, revealPromptBlocks,
7245
- resizePromptClip, movePromptClip, removePromptClip, setSelectedHierarchyId, addCameraKeyframe,
4807
+ setActiveWaypointId, setPendingWaypointFrame, removeWaypoint, queueRootWaypointFrame, setSelectedHierarchyId, addCameraKeyframe,
7246
4808
  moveCameraKeyframe, removeCameraKeyframe, syncActiveCameraFraming, activeCamera, activeShotDuration,
7247
4809
  changeActiveCamera, cameraRail, previewCameraShot, toggleCameraRailDraw, deleteCameraRail,
7248
4810
  selectTimelineShot, clearMotion,
7249
4811
  spawnCharacter, exportShotVideo, stopShotRecording, retryExport, poseRefusal,
7250
- beginWorkspaceResize, motionSetupReveal, motionSetupKind,
4812
+ beginWorkspaceResize,
7251
4813
  preferencesOpen, setPreferencesOpen, statusText, setStatusText, previewRanges, setPreviewRanges,
7252
4814
  flySpeed, liveHubStatus: liveWorkspaceHandle ? "connected" : "disconnected",
7253
4815
  agentOpen: !agentCollapsed,
@@ -7343,16 +4905,9 @@ export default function App() {
7343
4905
  timelineHeight={workspaceLayout.timelineHeight}
7344
4906
  planZoom={workspaceLayout.planZoom}
7345
4907
  />
7346
- <color attach="background" args={[gridView ? GRID_BACKGROUND : stageBackground(uiTheme)]} />
7347
- {/* The open stage runs 500 m; without a falloff the whole deck
7348
- reads at once and the horizon sits a kilometre away. Blender's
7349
- viewport answer is a clip distance that lets the neutral void
7350
- show through; the fog below is the seamless version of the
7351
- same idea — it fades the floor INTO the background colour, so
7352
- past ~120 m the deck simply ceases to exist with no horizon
7353
- line, no clip edge and no tone break. */}
7354
- <fog attach="fog" args={gridView ? [GRID_FOG.color, GRID_FOG.near, GRID_FOG.far] : [stageBackground(uiTheme), 18, 54]} />
7355
- <StageLights keyLight={keyLight} neutral={gridView} light={uiTheme === "light"} />
4908
+ {/* Sky, fog and exposure of the stage look (stage-sky.jsx), for every pane and export. */}
4909
+ <StageAtmosphere lighting={lighting} keyLight={keyLight} theme={uiTheme} grid={gridView} />
4910
+ <StageLights keyLight={keyLight} lighting={lighting} neutral={gridView} light={uiTheme === "light"} />
7356
4911
  <KeyLightPuck
7357
4912
  keyLight={keyLight}
7358
4913
  selected={keyLightSelected}
@@ -7365,13 +4920,13 @@ export default function App() {
7365
4920
  onChange={(patch) => changeKeyLight("puck", patch)}
7366
4921
  onDragEnd={endGestureUndo}
7367
4922
  />
7368
- {gridView ? <GridFloor layer={GIZMO_LAYER} /> : <Room light={uiTheme === "light"} />}
4923
+ {gridView ? <GridFloor layer={GIZMO_LAYER} /> : <Room light={uiTheme === "light" && !lighting.sky && !(lighting.hdr && lighting.hdrBackground)} />}
7369
4924
  <SetProps
7370
4925
  objects={stageSceneObjects}
7371
4926
  authoredObjects={sceneObjects}
7372
4927
  selectedId={selectedSceneObjectId}
7373
4928
  frameRef={propFrameRef}
7374
- take={{ frameCount: tlFrameCount, fps: tlFps }}
4929
+ take={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }}
7375
4930
  attachFrameRef={attachFrameRef}
7376
4931
  syncRef={propSyncRef}
7377
4932
  worldRef={propWorldRef}
@@ -7440,7 +4995,7 @@ export default function App() {
7440
4995
  objectId={view.parent}
7441
4996
  objectsRef={sceneObjectLookupRef}
7442
4997
  frameRef={propFrameRef}
7443
- take={{ frameCount: tlFrameCount, fps: tlFps }}
4998
+ take={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }}
7444
4999
  registryRef={carrierRegistryRef}
7445
5000
  >
7446
5001
  <Character
@@ -7453,18 +5008,17 @@ export default function App() {
7453
5008
  partColoursMode={view.partColoursMode}
7454
5009
  pose={view.pose}
7455
5010
  scale={view.scale}
7456
- onRig={view.onRig}
7457
- pickId={view.pickId}
5011
+ onRig={view.onRig} pickId={view.pickId}
7458
5012
  />
7459
5013
  </ObjectCarrier>
7460
5014
  ))}
5015
+ <ViewGhosts rig={rigs[activeChar.id]} take={motion} frame={tlFrame} objects={sceneObjects} propTake={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }} /><DraftGhost rigs={rigs} objects={sceneObjects} />
7461
5016
 
7462
5017
  {/* Selection marker: XYZ tripod + ring on the picked cast
7463
5018
  member; the X/Z arrows drag it across the deck. */}
7464
5019
  {gizmoView && !playMode && !posing && !ikMode && (
7465
5020
  <ObjectGizmo
7466
5021
  pickOnly
7467
- claimPointer={lineEditMode ? lineGrabProbe : undefined}
7468
5022
  object={characterGizmoObject}
7469
5023
  objects={characterGizmoObject ? [characterGizmoObject] : []}
7470
5024
  mode={gizmoMode}
@@ -7554,7 +5108,6 @@ export default function App() {
7554
5108
  and the wheel dollies without wrecking the framing. */}
7555
5109
  <FlyControls
7556
5110
  enabled={!posing && !playMode && !sculptMode}
7557
- cameraLocked={lookThroughShot && i2vMotionCameraLocked}
7558
5111
  camRef={ikMode ? poserCamRef : lookThroughShot ? shotCamRef : editorCamRef}
7559
5112
  look={ikMode ? poserLook : lookThroughShot ? look : editorLook}
7560
5113
  getPivot={() => {
@@ -7696,6 +5249,7 @@ export default function App() {
7696
5249
  <ObjectGizmo
7697
5250
  object={cameraGizmoObject ?? lightGizmoObject ?? (selectedSceneObject && !isEffectivelyHidden(selectedSceneObject, sceneObjects, characters) ? gizmoSceneObject : null)}
7698
5251
  objects={sceneObjects}
5252
+ held={cameraGizmoObject || lightGizmoObject ? null : heldEdit.gizmo}
7699
5253
  mode={lightGizmoObject ? "move" : cameraGizmoObject ? (gizmoMode === "scale" ? "move" : gizmoMode) : gizmoMode}
7700
5254
  snap={snapEnabled}
7701
5255
  enabled={!planIsMain && !posing && !ikMode && !playMode && !sculptMode}
@@ -7717,7 +5271,6 @@ export default function App() {
7717
5271
  )
7718
5272
  }
7719
5273
  onGroundClick={waypointMode && !planIsMain ? addFloorWaypoint : undefined}
7720
- claimPointer={lineEditMode ? lineGrabProbe : undefined}
7721
5274
  />
7722
5275
  {!preview && railCurve && (
7723
5276
  <CameraRailScenePreview
@@ -7782,26 +5335,7 @@ export default function App() {
7782
5335
  height={shotOutput.height}
7783
5336
  />
7784
5337
  <CaptureRig apiRef={mcpCaptureRef} camRef={shotCamRef} width={MCP_CAPTURE_W} height={MCP_CAPTURE_H} />
7785
- {/* IK-mode motion trails: the root path plus the focused effector's
7786
- trajectory, grabbable to deform the take with falloff. */}
7787
- {ikMode && motion && (
7788
- <MotionTrails
7789
- motion={motion}
7790
- rig={activeRig}
7791
- baseY={activeChar.y ?? 0}
7792
- charScale={activeChar.scale ?? 1}
7793
- ikFocus={ikFocus}
7794
- activeTrackId={trailTrackFocus}
7795
- falloffFrames={trailFalloffFrames}
7796
- playheadFrame={tlFrame}
7797
- pendingEdit={trailEdit}
7798
- enabled={ikMode && ikEditTool === "trail" && showTrails && !posing && !playMode}
7799
- visible={showTrails}
7800
- onDragStart={onTrailDragStart}
7801
- onDragPreview={onTrailDragPreview}
7802
- onDragEnd={onTrailDragEnd}
7803
- />
7804
- )}
5338
+
7805
5339
  <DualRender
7806
5340
  stageRef={stageRef}
7807
5341
  mainRef={mainPaneRef}
@@ -7839,13 +5373,6 @@ export default function App() {
7839
5373
  pointer only when it actually grabs the curve. Everything else
7840
5374
  — orbit, pan, click-to-select — reaches the WebGL canvas
7841
5375
  untouched while the mode is on. */}
7842
- {lineEditMode && (
7843
- <canvas
7844
- ref={lineOverlayRef}
7845
- className="line-edit-overlay"
7846
- aria-hidden="true"
7847
- />
7848
- )}
7849
5376
 
7850
5377
  {!(globalThis.__cozyclayWebGLAvailable ??= (() => {
7851
5378
  if (typeof document === "undefined") return true;
@@ -7982,7 +5509,6 @@ export default function App() {
7982
5509
  }
7983
5510
  >
7984
5511
 
7985
- {i2vMotionStudioOpen && <I2vMotionModal model={i2vMotionModel} actions={i2vMotionActions} onClose={() => setI2vMotionStudioOpen(false)} />}
7986
5512
  {result && resultOpen && (
7987
5513
  <ResultModal
7988
5514
  result={result}