cozyclay 2.1.0 → 3.0.0

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