cozyclay 1.10.0 → 2.0.2

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 (275) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/bin/agent/agent-routes.mjs +82 -72
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/codex-auth.mjs +2 -1
  10. package/bin/cozyclay.mjs +38 -11
  11. package/bin/live/cli.mjs +22 -3
  12. package/bin/mcp-runtime.mjs +45 -20
  13. package/bin/telemetry-state.mjs +5 -2
  14. package/bin/update-check.mjs +15 -0
  15. package/dist/app/index.html +6 -6
  16. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  17. package/dist/assets/app-BxBLC6GT.js +4876 -0
  18. package/dist/assets/app-CJdxvnrl.css +1 -0
  19. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  20. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  21. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  22. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  23. package/dist/assets/shot-prompt-hN0j_O-p.js +17 -0
  24. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  25. package/dist/assets/workflow-B5XZwVfe.js +179 -0
  26. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  27. package/dist/cozyclay-package.json +1 -1
  28. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  29. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  30. package/dist/fonts/README.md +13 -9
  31. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  33. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  35. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  36. package/dist/index.html +7 -7
  37. package/dist/privacy/index.html +13 -8
  38. package/dist/sitemap.xml +2 -2
  39. package/dist/workflow/index.html +5 -5
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +219 -376
  44. package/package.json +2 -1
  45. package/src/App.jsx +1275 -9086
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/fix-collisions.js +10 -66
  51. package/src/ardy/ik-drag.js +101 -0
  52. package/src/ardy/ik-key-json.js +43 -0
  53. package/src/ardy/ik.js +97 -67
  54. package/src/ardy/physics-panel.css +2 -0
  55. package/src/ardy/physics-panel.jsx +17 -5
  56. package/src/ardy/platform-fit-panel.css +64 -0
  57. package/src/ardy/platform-fit-panel.jsx +40 -0
  58. package/src/ardy/platform-fit.js +326 -0
  59. package/src/ardy/playback.js +166 -20
  60. package/src/ardy/range-pin.js +299 -0
  61. package/src/ardy/timeline-coordinates.js +26 -0
  62. package/src/ardy/timeline.css +1214 -0
  63. package/src/ardy/timeline.jsx +152 -41
  64. package/src/ardy/waypoints.js +3 -3
  65. package/src/asset-pane.css +462 -0
  66. package/src/asset-pane.jsx +461 -232
  67. package/src/camera-tutorial.jsx +38 -18
  68. package/src/command-bus.js +349 -0
  69. package/src/commands/ai.js +31 -0
  70. package/src/commands/cast.js +140 -0
  71. package/src/commands/elements/character.js +13 -0
  72. package/src/commands/elements/motion.js +14 -0
  73. package/src/commands/elements/object.js +22 -0
  74. package/src/commands/elements/scene.js +16 -0
  75. package/src/commands/elements/shot.js +9 -0
  76. package/src/commands/elements/stage.js +11 -0
  77. package/src/commands/elements.js +155 -0
  78. package/src/commands/export.js +20 -0
  79. package/src/commands/index.js +40 -0
  80. package/src/commands/motion.js +231 -0
  81. package/src/commands/objects.js +174 -0
  82. package/src/commands/project.js +53 -0
  83. package/src/commands/scene.js +81 -0
  84. package/src/commands/shared.js +19 -0
  85. package/src/commands/shot.js +103 -0
  86. package/src/commands/stage.js +17 -0
  87. package/src/commands/view.js +42 -0
  88. package/src/document-store.js +144 -0
  89. package/src/domains/cast.js +1007 -0
  90. package/src/domains/motion.js +3219 -0
  91. package/src/domains/objects.js +932 -0
  92. package/src/domains/scenes.js +823 -0
  93. package/src/domains/shots.js +404 -0
  94. package/src/domains/stage.js +93 -0
  95. package/src/dualview.jsx +113 -57
  96. package/src/facing-marks.js +3 -0
  97. package/src/first-success-guide.jsx +14 -14
  98. package/src/grid-view.js +18 -5
  99. package/src/hierarchy-panel.css +386 -0
  100. package/src/hierarchy-panel.jsx +88 -19
  101. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  102. package/src/i2v-motion-studio.jsx +180 -0
  103. package/src/ik-camera.js +3 -0
  104. package/src/main.jsx +6 -0
  105. package/src/motion/generation.js +105 -0
  106. package/src/motion-readiness-ui.jsx +4 -4
  107. package/src/motion-readiness.js +11 -0
  108. package/src/motion-trail.js +366 -9
  109. package/src/object-gizmo.jsx +7 -1
  110. package/src/panels/CameraPanel.jsx +60 -0
  111. package/src/panels/CharacterTransformPanel.jsx +48 -0
  112. package/src/panels/EnvironmentPanel.jsx +40 -0
  113. package/src/panels/Foldout.jsx +32 -0
  114. package/src/panels/LightPanel.jsx +24 -0
  115. package/src/panels/ObjectTransformPanel.jsx +554 -0
  116. package/src/panels/PosePanel.jsx +97 -0
  117. package/src/panels/ProjectPanel.jsx +33 -0
  118. package/src/panels/PromptBlocksPanel.jsx +442 -0
  119. package/src/panels/PropsPanel.jsx +70 -0
  120. package/src/panels/ReferenceImageField.jsx +91 -0
  121. package/src/panels/RigControlPanel.jsx +78 -0
  122. package/src/panels/RigPanel.jsx +37 -0
  123. package/src/panels/SubjectBox.jsx +47 -0
  124. package/src/panels/SubjectsPanel.jsx +39 -0
  125. package/src/panels/VideoCapturePanel.jsx +180 -0
  126. package/src/panels/details.css +1171 -0
  127. package/src/panels/motion.css +197 -0
  128. package/src/panels/pose.css +39 -0
  129. package/src/planview.jsx +60 -31
  130. package/src/posestudio.jsx +59 -4
  131. package/src/project-browser.css +1073 -0
  132. package/src/project-browser.jsx +288 -123
  133. package/src/range-pin-object-transform.js +19 -0
  134. package/src/range-pin-panel.css +518 -0
  135. package/src/range-pin-panel.jsx +344 -0
  136. package/src/result-modal.jsx +5 -5
  137. package/src/room.jsx +79 -51
  138. package/src/scenes.js +9 -8
  139. package/src/semantic-edit.js +2 -0
  140. package/src/settings-menu.jsx +39 -105
  141. package/src/shell/BottomDock.jsx +450 -0
  142. package/src/shell/DetailsSlot.jsx +513 -0
  143. package/src/shell/LibrarySlot.jsx +86 -0
  144. package/src/shell/MenuBar.jsx +601 -0
  145. package/src/shell/OutlinerSlot.jsx +50 -0
  146. package/src/shell/PreferencesDialog.jsx +538 -0
  147. package/src/shell/PreferencesSlot.jsx +40 -0
  148. package/src/shell/StatusBar.jsx +125 -0
  149. package/src/shell/StudioShell.jsx +92 -0
  150. package/src/shell/TopBar.jsx +129 -0
  151. package/src/shell/ViewportToolbar.jsx +656 -0
  152. package/src/shell/agent-glass.css +296 -0
  153. package/src/shell/dock.css +93 -0
  154. package/src/shell/glass-regions.css +985 -0
  155. package/src/shell/glass.css +687 -0
  156. package/src/shell/log-store.js +52 -0
  157. package/src/shell/mode.css +9 -0
  158. package/src/shell/preferences.css +651 -0
  159. package/src/shell/shell.css +282 -0
  160. package/src/shell/studio-shell-context.js +19 -0
  161. package/src/shell/topbar.css +435 -0
  162. package/src/shell/viewport.css +451 -0
  163. package/src/store/authored-intent.js +22 -0
  164. package/src/store/runtime-adapters.js +92 -0
  165. package/src/store/scene-stage.js +21 -0
  166. package/src/store/use-document-store.js +14 -0
  167. package/src/studio-actions.js +210 -0
  168. package/src/studio-agent-commands.js +38 -268
  169. package/src/studio-agent-context.js +38 -9
  170. package/src/studio-agent-motion.js +122 -378
  171. package/src/studio-agent-protocol.js +66 -30
  172. package/src/studio-app-binding.js +379 -0
  173. package/src/studio-contact-sheet.js +73 -0
  174. package/src/studio-elements.js +58 -51
  175. package/src/styles/themes.css +200 -0
  176. package/src/styles/tokens.css +69 -0
  177. package/src/styles.css +456 -1748
  178. package/src/theme.js +44 -0
  179. package/src/timeline-extent.js +16 -0
  180. package/src/timeline-fps.js +13 -0
  181. package/src/trail-key-conflicts.js +39 -0
  182. package/src/trail-pick.js +59 -0
  183. package/src/ui.jsx +8 -8
  184. package/src/use-case-question.jsx +66 -0
  185. package/src/workflow/AgentPanel.jsx +86 -71
  186. package/src/workflow/agent-client.js +12 -2
  187. package/src/workflow/agent-panel.css +34 -32
  188. package/src/workflow/cozy-scene-node.css +2 -0
  189. package/src/workflow/workflow.css +2 -0
  190. package/tools/ardy/bridge.mjs +164 -146
  191. package/tools/ardy/visual-qa.mjs +5 -5
  192. package/tools/bench/EXP3.md +61 -0
  193. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  194. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  195. package/tools/bench/cclay_bench_runner.py +42 -0
  196. package/tools/bench/cube-contact.mjs +162 -0
  197. package/tools/bench/exp3.mjs +152 -0
  198. package/tools/bench/extract-bench-lib.mjs +216 -0
  199. package/tools/bench/extract-bench.mjs +302 -0
  200. package/tools/bench/fal-generate.mjs +88 -0
  201. package/tools/bench/fit/README.md +189 -0
  202. package/tools/bench/fit/camera.mjs +59 -0
  203. package/tools/bench/fit/contact.mjs +419 -0
  204. package/tools/bench/fit/footlock.mjs +234 -0
  205. package/tools/bench/fit/motion.mjs +85 -0
  206. package/tools/bench/fit/pin.mjs +27 -0
  207. package/tools/bench/fit/remote.mjs +105 -0
  208. package/tools/bench/fit-bench.mjs +126 -0
  209. package/tools/bench/fit-sanity.mjs +45 -0
  210. package/tools/bench/metrics.mjs +313 -0
  211. package/tools/bench/obs/depth.mjs +258 -0
  212. package/tools/bench/obs/extrinsics.mjs +175 -0
  213. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  214. package/tools/bench/obs/ground.mjs +294 -0
  215. package/tools/bench/obs/heading.mjs +151 -0
  216. package/tools/bench/obs/ladder.mjs +555 -0
  217. package/tools/bench/obs/mannequin-betas.json +124 -0
  218. package/tools/bench/obs/remote.mjs +83 -0
  219. package/tools/bench/obs/rest_joints.py +52 -0
  220. package/tools/bench/obs/ybot-targets.mjs +49 -0
  221. package/tools/bench/obs-bench.mjs +520 -0
  222. package/tools/bench/score.mjs +343 -0
  223. package/tools/bench/summarize.mjs +86 -0
  224. package/tools/dev/pages/privacy.html +13 -8
  225. package/tools/gt-render/browser.mjs +159 -0
  226. package/tools/gt-render/camera-math.mjs +276 -0
  227. package/tools/gt-render/page.mjs +267 -0
  228. package/tools/gt-render/render.mjs +514 -0
  229. package/tools/gt-render/scene-box.mjs +31 -0
  230. package/tools/gt-render/take-transform.mjs +87 -0
  231. package/tools/qa/css-rule-usage.mjs +283 -0
  232. package/tools/qa/studio-control-count.mjs +70 -32
  233. package/tools/run-tests.mjs +60 -3
  234. package/tools/track/DESIGN.md +513 -0
  235. package/tools/track/backfill-provenance.mjs +116 -0
  236. package/tools/track/budget.mjs +8 -0
  237. package/tools/track/check-rig.mjs +209 -0
  238. package/tools/track/diagnostics.schema.json +60 -0
  239. package/tools/track/export-rig.mjs +256 -0
  240. package/tools/track/fallback.mjs +7 -0
  241. package/tools/track/fk-parity-fixture.mjs +161 -0
  242. package/tools/track/gate.mjs +349 -0
  243. package/tools/track/masks.mjs +318 -0
  244. package/tools/track/metrics.mjs +201 -0
  245. package/tools/track/publish-obs.mjs +108 -0
  246. package/tools/track/py/check_env.py +61 -0
  247. package/tools/track/py/eval_lr.py +229 -0
  248. package/tools/track/py/lr_viterbi.py +294 -0
  249. package/tools/track/py/masks.py +405 -0
  250. package/tools/track/py/objective.py +382 -0
  251. package/tools/track/py/rig.py +508 -0
  252. package/tools/track/py/scene.py +326 -0
  253. package/tools/track/py/test_joint_indices.py +123 -0
  254. package/tools/track/py/test_lr_viterbi.py +195 -0
  255. package/tools/track/py/test_masks.py +229 -0
  256. package/tools/track/py/test_rig.py +288 -0
  257. package/tools/track/py/test_scene.py +354 -0
  258. package/tools/track/py/test_track.py +328 -0
  259. package/tools/track/py/track.py +411 -0
  260. package/tools/track/remote.mjs +140 -0
  261. package/tools/track/rig-dump.mjs +227 -0
  262. package/tools/track/run-box-tests.mjs +14 -0
  263. package/tools/track/run-box.mjs +168 -0
  264. package/tools/track/setup-box.sh +59 -0
  265. package/tools/track/study-2d.mjs +728 -0
  266. package/dist/assets/analytics-B1hnH66c.js +0 -1
  267. package/dist/assets/app-Cu1cexZR.css +0 -1
  268. package/dist/assets/app-Dxdh8kds.js +0 -4861
  269. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  270. package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
  271. package/dist/assets/workflow-CJd-PDtV.js +0 -179
  272. package/dist/assets/workflow-DsudxKHF.css +0 -1
  273. package/src/fal-motion-studio.jsx +0 -180
  274. package/src/scene-history.js +0 -129
  275. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
@@ -11,7 +11,7 @@ import { ko } from "./locale.js";
11
11
  // to them directly.
12
12
  //
13
13
  // cozyclay:nav fly / walk / dolly / orbit (src/controls.jsx)
14
- // cozyclay:playground-signal shot / rail (src/App.jsx)
14
+ // shotCount / railReady shot / rail (authored state)
15
15
  // previewing prop play (lookThroughShot)
16
16
  //
17
17
  // Nothing here drives the studio. A step is done when the operator has done
@@ -69,16 +69,16 @@ export const CAMERA_TUTORIAL_STEPS = [
69
69
  label: ko("Shot", "샷"),
70
70
  // The control lives in another region than the card, so the card says
71
71
  // which way to look before it says what to do.
72
- where: ko("↓ Timeline, Shots lane", "↓ 타임라인 샷 레인"),
72
+ where: ko("↓ Timeline, Camera Cuts", "↓ 타임라인 Camera Cuts"),
73
73
  how: () => ko(
74
- <>In the timeline's Shots lane click <b>+ Add shot</b>. That is your cut.</>,
75
- <>타임라인의 샷 레인에서 <b>+ 샷 추가</b>를 누르세요. 그게 컷입니다.</>,
74
+ <>In the timeline's Camera Cuts lane click <b>+ Add shot</b>. That is your cut.</>,
75
+ <>타임라인의 Camera Cuts 레인에서 <b>+ 샷 추가</b>를 누르세요. 그게 컷입니다.</>,
76
76
  ),
77
77
  },
78
78
  {
79
79
  kind: "rail",
80
80
  label: ko("Rail", "레일"),
81
- where: ko("↓ Timeline, Shots lane", "↓ 타임라인 샷 레인"),
81
+ where: ko("↓ Timeline, Camera Cuts", "↓ 타임라인 Camera Cuts"),
82
82
  how: () => ko(
83
83
  <>Select the shot, click <b>Draw rail</b> in the camera bar, and drag a line across the top view. That line is the dolly move.</>,
84
84
  <>샷을 선택하고 카메라 바의 <b>레일 그리기</b>를 누른 뒤, 탑뷰에 선을 그으세요. 그 선이 돌리 이동입니다.</>,
@@ -87,10 +87,10 @@ export const CAMERA_TUTORIAL_STEPS = [
87
87
  {
88
88
  kind: "play",
89
89
  label: ko("Play", "재생"),
90
- where: ko("→ Viewport", "→ 뷰포트"),
90
+ where: ko("↘ Shot Camera monitor", "↘ Shot Camera 모니터"),
91
91
  how: () => ko(
92
92
  <>Click <b>Look through</b> in the Shot monitor to fly the shot camera; <b>▶</b> rides the rail, <kbd>Esc</kbd> returns to the free camera.</>,
93
- <>뷰포트의 <b>샷 시점</b>을 눌러 샷 카메라를 조종하세요. <b>▶</b>는 레일을 타고, <kbd>Esc</kbd>로 자유 카메라로 돌아옵니다.</>,
93
+ <>Shot Camera 모니터의 <b>샷 시점</b>을 눌러 샷 카메라를 조종하세요. <b>▶</b>는 레일을 타고, <kbd>Esc</kbd>로 자유 카메라로 돌아옵니다.</>,
94
94
  ),
95
95
  },
96
96
  ];
@@ -119,10 +119,13 @@ export const TUTORIAL_BEACONS = {
119
119
  label: () => ko("1. Select the shot", "1. 샷을 선택"),
120
120
  },
121
121
  { role: "draw-rail", selector: ".tl-rail-draw", label: () => ko("2. Draw rail, then drag across the top view", "2. 레일 그리기 후 탑뷰에 선 긋기") },
122
- { role: "top-view", selector: ".vp-inset", requires: ".tl-rail-draw", label: () => ko("Drag a line here", "여기에 선을 그으세요") },
122
+ // The Top-View becomes an active drawing surface after the tool is armed.
123
+ { role: "top-view", selector: ".vp-inset", requires: '[data-rail-draw="1"]', label: () => ko("Drag a line here", "여기에 선을 그으세요") },
123
124
  ],
124
125
  play: [
125
- { role: "look-through", selector: ".vp-look-through", label: () => ko("Click to fly the shot camera", "클릭해 샷 카메라를 조종") },
126
+ // The glass shell keeps the same action in the bottom dock while the
127
+ // compact viewport card owns it in the classic shell.
128
+ { role: "look-through", selector: ".dock-shot-look, .vp-look-through", label: () => ko("Click to fly the shot camera", "클릭해 샷 카메라를 조종") },
126
129
  ],
127
130
  };
128
131
 
@@ -217,7 +220,7 @@ export function TutorialBeacon({ kind, step, role, selector, label, requires = n
217
220
  if (pane) resize.observe(pane);
218
221
  }
219
222
  const mutations = new MutationObserver(schedule);
220
- mutations.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
223
+ mutations.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "data-rail-draw"] });
221
224
  return () => {
222
225
  if (frame) cancelAnimationFrame(frame);
223
226
  window.removeEventListener("resize", schedule);
@@ -327,11 +330,18 @@ export function GestureCue({ kind, walked, leaving = false, onLeft }) {
327
330
  * Top-View inset, and never takes the pointer except on its own close button
328
331
  * — every step is completed by working the studio underneath it.
329
332
  */
330
- export function CameraTutorial({ previewing = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
333
+ export function CameraTutorial({ previewing = false, shotCount = 0, railReady = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
331
334
  const [done, setDone] = useState(() => new Set());
332
335
  const [walked, setWalked] = useState(() => new Set());
333
336
  const [cue, setCue] = useState(null);
334
337
  const completionReported = useRef(false);
338
+ const effectiveDone = useMemo(() => {
339
+ const next = new Set(done);
340
+ if (shotCount > 0) next.add("shot");
341
+ if (railReady) next.add("rail");
342
+ if (previewing && next.has("rail")) next.add("play");
343
+ return next;
344
+ }, [done, previewing, railReady, shotCount]);
335
345
 
336
346
  useEffect(() => {
337
347
  const complete = (kind) => setDone((current) => (current.has(kind) ? current : new Set(current).add(kind)));
@@ -362,6 +372,16 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
362
372
  };
363
373
  }, []);
364
374
 
375
+ // Studio-native completion comes from the authored state. The old playground
376
+ // signal was never emitted by the Studio shell, so a real Shot or rail could
377
+ // leave the tutorial stuck on its previous step.
378
+ useEffect(() => {
379
+ if (shotCount > 0) setDone((current) => (current.has("shot") ? current : new Set(current).add("shot")));
380
+ }, [shotCount]);
381
+ useEffect(() => {
382
+ if (railReady) setDone((current) => (current.has("rail") ? current : new Set(current).add("rail")));
383
+ }, [railReady]);
384
+
365
385
  // The player is the last step, and only once there is a rail to ride:
366
386
  // look-through before the dolly exists shows a still frame, which teaches
367
387
  // nothing about the move. Analytics v1 observes this existing look-through
@@ -369,12 +389,12 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
369
389
  useEffect(() => {
370
390
  if (!previewing) return;
371
391
  setDone((current) => (!current.has("rail") || current.has("play") ? current : new Set(current).add("play")));
372
- }, [previewing]);
392
+ }, [previewing, railReady]);
373
393
 
374
- const current = useMemo(() => CAMERA_TUTORIAL_STEPS.find((step) => !done.has(step.kind)) ?? null, [done]);
394
+ const current = useMemo(() => CAMERA_TUTORIAL_STEPS.find((step) => !effectiveDone.has(step.kind)) ?? null, [effectiveDone]);
375
395
  const complete = current === null;
376
396
  const currentKind = current?.kind ?? null;
377
- const showHandoff = !!shotId && (handoff?.canShow(done) ?? false);
397
+ const showHandoff = !!shotId && (handoff?.canShow(effectiveDone) ?? false);
378
398
  useEffect(() => {
379
399
  if (!complete || completionReported.current) return;
380
400
  completionReported.current = true;
@@ -384,8 +404,8 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
384
404
  // Observe committed progression, outside state updaters (which StrictMode
385
405
  // may replay). The attempt belongs to App and survives effect cleanup/resume.
386
406
  useEffect(() => {
387
- analytics?.observe(done, currentKind);
388
- }, [analytics, done, currentKind]);
407
+ analytics?.observe(effectiveDone, currentKind);
408
+ }, [analytics, effectiveDone, currentKind]);
389
409
 
390
410
  // The step the operator is on is the studio's business too: App puts it on
391
411
  // the .app root as data-tutorial-step, and styles.css spotlights whichever
@@ -425,10 +445,10 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
425
445
  key={step.kind}
426
446
  data-testid="camera-tutorial-step"
427
447
  data-kind={step.kind}
428
- data-done={done.has(step.kind) ? 1 : 0}
448
+ data-done={effectiveDone.has(step.kind) ? 1 : 0}
429
449
  data-current={step === current ? 1 : 0}
430
450
  >
431
- <i aria-hidden="true">{done.has(step.kind) ? "✓" : index + 1}</i>
451
+ <i aria-hidden="true">{effectiveDone.has(step.kind) ? "✓" : index + 1}</i>
432
452
  {step.label}
433
453
  </li>
434
454
  ))}
@@ -0,0 +1,349 @@
1
+ // The Studio command runner. The registry owns declarations/implementations;
2
+ // the editor ports own native state/history, and the existing journal owns
3
+ // idempotency. Synchronous UI actions stay synchronous (including user gestures).
4
+ import { HISTORY_LIMIT } from './history.js';
5
+ import { StudioProtocolError, StudioSchemas, validateStudioSchema, validateReceipt, validateStudioIdentity } from './studio-agent-protocol.js';
6
+
7
+ // A batching/retention adapter over the existing object history owner. The
8
+ // native store still owns every snapshot and transition; this tracks only the
9
+ // retained pre-image identities needed by receipts.
10
+ export function withCommandHistory(store) {
11
+ let past = [], future = [], group = null;
12
+ const adapter = Object.create(store);
13
+ const pushed = before => { if (before !== store.present()) { past.push(before); past = past.slice(-HISTORY_LIMIT); future = []; } };
14
+ adapter.hasHistoryState = state => state === store.present() || past.includes(state) || future.includes(state);
15
+ adapter.settle = (...args) => { const before = store.present(); store.settle(...args); pushed(before); };
16
+ adapter.applyAtomic = fn => {
17
+ if (group) { if (!group.running) fail('TARGET_BUSY', 'A command transaction owns the object history.'); return store.applyIn(group.token, fn); }
18
+ adapter.settle(); const before = store.present(); store.applyAtomic(fn); pushed(before);
19
+ };
20
+ adapter.begin = (...args) => { adapter.settle(); return store.begin(...args); };
21
+ adapter.end = (token, options) => { const before = store.present(), ended = store.end(token, options); if (options.commit) pushed(before); return ended; };
22
+ adapter.undo = () => { adapter.settle(); const before = store.present(), restored = store.undo(); if (restored !== null) { past.pop(); future.unshift(before); } return restored; };
23
+ adapter.redo = () => { const before = store.present(), restored = store.redo(); if (restored !== null) { past.push(before); future.shift(); } return restored; };
24
+ adapter.beginCommand = () => {
25
+ if (group) fail('TARGET_BUSY', 'An object command is already in progress.');
26
+ adapter.settle(); const before = store.present();
27
+ const current = { running: 0, token: store.begin('command-bus', () => { current.closed = true; group = null; }) }; group = current;
28
+ const check = () => { if (current.closed) fail('STALE_TARGET', 'Native object transaction was retired.'); };
29
+ return {
30
+ run(fn) { check(); current.running++; try { const result = fn(); if (result?.then) return result.finally(() => { current.running--; }); current.running--; return result; } catch (error) { current.running--; throw error; } },
31
+ commit() { check(); current.closed = true; group = null; store.end(current.token, { commit: true }); pushed(before); return before !== store.present(); },
32
+ cancel() { if (current.closed) return false; current.closed = true; group = null; return store.end(current.token, { commit: false }); },
33
+ };
34
+ };
35
+ return adapter;
36
+ }
37
+
38
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
39
+ const fail = (code, message) => { throw new StudioProtocolError(code, message); };
40
+ const mapResult = (value, success, failure) => value?.then ? value.then(success, failure) : success(value);
41
+ const toastWarnings = toasts => toasts.slice(-12).map(toast => ({ code: 'STUDIO_TOAST', message: [...toast.message].slice(0, 120).join('') }));
42
+ let activeRunDepth = 0;
43
+ export const isBusRunActive = () => activeRunDepth > 0;
44
+
45
+ export function createCommandBus({ registry, ports }) {
46
+ const pending = new Map(), transactions = new Map(), jobs = new Map(), listeners = new Set();
47
+ const confirmations = new Map(), historyReceipts = new Map();
48
+ function exposure(entry, args, request) {
49
+ if (request.origin === 'ui') return;
50
+ if (entry.exposure === 'ui-only') fail('CAPABILITY_MISSING', 'This command is available only from the Studio UI.');
51
+ if (entry.id === 'load_scenes') {
52
+ const ids = scenes => [...new Set(scenes.map(scene => scene.id))].sort();
53
+ if (same(ids(args.document.scenes), ids(registry.state().scenes))) return;
54
+ } else if (entry.exposure !== 'confirm' || entry.requiresConfirmation?.(registry.state(), args) === false) return;
55
+ const token = confirmations.get(request.confirmationToken);
56
+ if (!token || token.id !== entry.id || !same(token.args, args) || !same(token.host, ports.read().host) || token.expires < (ports.now ?? Date.now)()) fail('CONFIRMATION_REQUIRED', entry.confirmationReason ?? 'This command needs user confirmation.');
57
+ confirmations.delete(request.confirmationToken);
58
+ }
59
+ const emit = event => { for (const listener of listeners) listener(event); ports.emit?.(event); };
60
+ const identifier = StudioSchemas.TargetGuard.properties.targetId;
61
+ const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
62
+ const argsSchema = { type: 'object', properties: {}, required: [], additionalProperties: true };
63
+ const controls = {
64
+ 'run.begin': object({ id: identifier, args: argsSchema }),
65
+ 'run.update': object({ txId: identifier, args: argsSchema }),
66
+ 'run.commit': object({ txId: identifier }),
67
+ 'run.cancel': object({ txId: identifier }),
68
+ 'job.await': { ...object({ jobId: identifier }), properties: { jobId: identifier, timeoutMs: { type: 'integer', minimum: 1, maximum: 300_000, default: 30_000 } } },
69
+ 'job.cancel': object({ jobId: identifier }),
70
+ 'edit.undo': { ...object({ receiptId: identifier }), required: [] },
71
+ 'edit.redo': { ...object({ receiptId: identifier }), required: [] },
72
+ };
73
+ const clear = timer => (ports.clearTimeout ?? clearTimeout)(timer);
74
+ function cancelTransaction(tx, expired = false, reason = new StudioProtocolError('CANCELLED', 'Transaction was cancelled.')) {
75
+ transactions.delete(tx.txId); clear(tx.timer); if (tx.workTimer !== undefined) clear(tx.workTimer); tx.controller.abort(reason);
76
+ tx.session.cancel({ restore: same(tx.before.host, ports.read().host) });
77
+ emit({ type: 'transaction.cancelled', txId: tx.txId, expired });
78
+ }
79
+ function renew(tx) {
80
+ if (tx.timer !== undefined) clear(tx.timer);
81
+ tx.timer = (ports.setTimeout ?? setTimeout)(() => cancelTransaction(tx, true), ports.transactionIdleMs ?? 30_000);
82
+ }
83
+ function control(id, args, request, before) {
84
+ if (id === 'edit.undo' || id === 'edit.redo') {
85
+ const redo = id === 'edit.redo', historyEntryId = ports.history?.(redo);
86
+ const previous = args.receiptId ? ports.receipt(args.receiptId) : historyReceipts.get(historyEntryId)
87
+ ?? (historyEntryId ? { receiptId: historyEntryId, host: before.host, affectedIds: [before.host.sceneId],
88
+ undo: { historyEntryId, entries: 1, canUndoDirect: true } } : null);
89
+ if (!args.receiptId && !previous) return validateReceipt({ ok: true, status: 'noop', action: id,
90
+ commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host, authored: false, mutated: false,
91
+ revision: { before: before.revision, after: before.revision }, affectedIds: [], delta: [],
92
+ checks: { coverage: 'native-history-restoration' }, undo: null, warnings: [] });
93
+ if (!previous?.undo || !ports.isRetained(previous)) fail('UNDO_EXPIRED', 'The receipt no longer has a retained history entry.');
94
+ if (!same(previous.host, before.host)) fail('STALE_SCENE', 'The receipt belongs to another document.');
95
+ if (redo ? historyEntryId !== previous.undo.historyEntryId : !ports.canUndo(previous)) fail('UNDO_CONFLICT', 'Another edit owns the history boundary.');
96
+ (redo ? ports.redo : ports.undo)();
97
+ const after = ports.read(), ids = previous.affectedIds;
98
+ const restoredTargets = ids.map(targetId => ({ ...before.host, targetId, token: ports.readTarget?.(targetId) ?? `removed-${crypto.randomUUID()}` }));
99
+ return validateReceipt({ ok: true, status: redo ? 'applied' : 'undone', commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
100
+ authored: true, revision: { before: before.revision, after: after.revision }, affectedIds: ids,
101
+ delta: ids.slice(0, 8).map(targetId => ({ id: targetId, after: { token: restoredTargets.find(t => t.targetId === targetId).token } })),
102
+ checks: { coverage: 'native-history-restoration' }, undo: { ...previous.undo, canUndoDirect: redo }, warnings: [],
103
+ ...(redo ? { action: id, mutated: true } : { undoneReceiptId: previous.receiptId, restoredTargets }),
104
+ ...(ids.length > 8 ? { detailCursor: request.commandId } : {}) });
105
+ }
106
+ if (id.startsWith('job.')) {
107
+ const job = jobs.get(args.jobId);
108
+ if (!job || !same(job.host, before.host)) fail('STALE_TARGET', 'Job is not in this document.');
109
+ if (id === 'job.cancel' && !job.outcome) job.controller.abort(new StudioProtocolError('CANCELLED', 'Job was cancelled.'));
110
+ let timer;
111
+ const wait = id === 'job.await' && !job.outcome ? Promise.race([job.completion, new Promise((_, reject) => {
112
+ timer = (ports.setTimeout ?? setTimeout)(() => reject(new StudioProtocolError('TIMEOUT', 'Job is still running.')), args.timeoutMs);
113
+ })]) : job.completion;
114
+ return wait.then(value => validateReceipt({ ...value, commandId: request.commandId, ...(value.ok ? { receiptId: crypto.randomUUID() } : {}), jobId: job.id })).finally(() => { if (timer !== undefined) clear(timer); });
115
+ }
116
+ let tx;
117
+ if (id === 'run.begin') {
118
+ if (transactions.size) fail('TARGET_BUSY', 'Finish or cancel the open command transaction first.');
119
+ const prepared = registry.prepare(args.id, args.args);
120
+ exposure(prepared.entry, prepared.args, request);
121
+ if (prepared.entry.kind !== 'mutation') fail('INVALID_ARGUMENT', 'Only mutations can open a transaction.');
122
+ tx = { txId: crypto.randomUUID(), entry: prepared.entry, before, origin: request.origin, targetId: prepared.args.characterId ?? null, controller: new AbortController(),
123
+ session: ports.beginAction(prepared.entry.undoDomain, prepared.args.characterId ?? null), affectedIds: new Set(), toasts: [] };
124
+ transactions.set(tx.txId, tx); renew(tx);
125
+ } else {
126
+ tx = transactions.get(args.txId);
127
+ if (!tx || !same(tx.before.host, before.host)) fail('STALE_TARGET', 'Transaction is no longer open in this document.');
128
+ if (request.origin !== tx.origin) fail('TARGET_BUSY', 'This transaction belongs to another origin.');
129
+ if (tx.updating && id !== 'run.cancel') fail('TARGET_BUSY', 'A transaction update is still running.');
130
+ if (id === 'run.update') {
131
+ const prepared = registry.prepare(tx.entry.id, args.args);
132
+ if (tx.entry.undoDomain === 'motion' && prepared.args.characterId !== tx.targetId) fail('INVALID_ARGUMENT', 'A motion transaction keeps its original target.');
133
+ const shown = [], release = ports.captureToasts?.(toast => shown.push(typeof toast === 'string' ? { message: toast } : toast));
134
+ const context = { origin: request.origin, signal: tx.controller.signal, check() {
135
+ tx.controller.signal.throwIfAborted();
136
+ if (!transactions.has(tx.txId) || !same(tx.before.host, ports.read().host)) fail('STALE_TARGET', 'Transaction is no longer current.');
137
+ } };
138
+ const finish = result => {
139
+ release?.(); clear(tx.workTimer); tx.updating = false; context.check();
140
+ if (shown.length && ports.read().revision === before.revision) fail('TARGET_NOT_READY', shown.at(-1).message);
141
+ for (const target of result.affectedIds) tx.affectedIds.add(target);
142
+ tx.toasts.push(...shown);
143
+ renew(tx); return transactionReceipt(id, tx, request, before);
144
+ };
145
+ const rejected = error => {
146
+ release?.(); clear(tx.workTimer); tx.updating = false;
147
+ if (!(error instanceof StudioProtocolError) && shown.length && ports.read().revision === before.revision) error = new StudioProtocolError('TARGET_NOT_READY', shown.at(-1).message);
148
+ if (transactions.has(tx.txId)) cancelTransaction(tx);
149
+ throw error;
150
+ };
151
+ try {
152
+ tx.updating = true; clear(tx.timer);
153
+ tx.workTimer = (ports.setTimeout ?? setTimeout)(() => cancelTransaction(tx, true, new StudioProtocolError('TIMEOUT', `${tx.entry.id} exceeded its deadline.`)), tx.entry.timeoutMs ?? 30_000);
154
+ const updated = tx.session.run(() => registry.invoke(tx.entry, prepared.args, context));
155
+ if (!updated?.then) return finish(updated);
156
+ const cancelled = new Promise((_, reject) => tx.controller.signal.addEventListener('abort', () => reject(tx.controller.signal.reason), { once: true }));
157
+ return Promise.race([updated, cancelled]).then(finish, rejected);
158
+ } catch (error) { return rejected(error); }
159
+ }
160
+ if (id === 'run.cancel') cancelTransaction(tx);
161
+ if (id === 'run.commit') {
162
+ transactions.delete(tx.txId); clear(tx.timer);
163
+ try { tx.historyEntryId = tx.session.commit().historyEntryId; }
164
+ catch (error) { tx.session.cancel({ restore: false }); throw error; }
165
+ }
166
+ }
167
+ return transactionReceipt(id, tx, request, id === 'run.commit' ? tx.before : before);
168
+ }
169
+ function transactionReceipt(id, tx, request, before) {
170
+ const after = ports.read();
171
+ return validateReceipt({ ok: true, status: 'completed', kind: 'transaction', commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
172
+ action: id, txId: tx.txId, authored: Boolean(tx.historyEntryId), revision: { before: before.revision, after: after.revision },
173
+ affectedIds: [...tx.affectedIds], delta: [], checks: { coverage: 'wire-transaction' }, warnings: toastWarnings(tx.toasts),
174
+ undo: tx.historyEntryId ? { historyEntryId: tx.historyEntryId, entries: 1, canUndoDirect: true } : null });
175
+ }
176
+ function refusal(request, before, error, mutated) {
177
+ const changed = mutated ?? (ports.read().revision !== before.revision);
178
+ return validateReceipt({ ok: false, commandId: request.commandId, host: before.host,
179
+ code: error.code ?? 'INVALID_ARGUMENT', phase: changed ? 'commit' : 'admission', affectedIds: [], expectedTargets: [], currentTargets: [],
180
+ mutated: changed, preserved: { authoredState: changed ? 'changed' : 'unchanged' }, recovery: { action: 'inspect', retryAllowed: false },
181
+ message: [...String(error.message || error)].slice(0, 500).join('') });
182
+ }
183
+ function receipt(entry, request, before, result, historyEntryId, toasts = []) {
184
+ const after = ports.read(), changed = after.revision !== before.revision;
185
+ if (entry.kind === 'mutation' && changed && !historyEntryId) fail('UNCERTAIN_APPLY', `${entry.id} changed the scene without one undoable entry.`);
186
+ const completed = entry.kind === 'job' || entry.kind === 'document' || (entry.kind === 'mutation' && changed && after.revision > before.revision + 1);
187
+ const ids = completed || changed ? result.affectedIds : entry.kind === 'transient' ? [before.host.sceneId] : [];
188
+ return validateReceipt({ ok: true, commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
189
+ action: entry.id, summary: result.summary, status: completed ? 'completed' : entry.kind === 'transient' ? 'transient' : changed ? 'applied' : 'noop',
190
+ authored: changed, ...(completed ? { kind: entry.kind, ...(result.output === undefined ? {} : { output: result.output }), ...(same(before.host, after.host) ? {} : { nextHost: after.host }) } : entry.kind === 'transient' ? { view: { before: before.viewRevision ?? 0, after: after.viewRevision ?? 0 } } : { mutated: changed }),
191
+ revision: { before: before.revision, after: after.revision }, affectedIds: ids,
192
+ delta: ids.slice(0, 8).map(id => ({ id, after: ports.readback?.(id, after) ?? { removed: true } })),
193
+ checks: { coverage: `studio-action:${entry.id}` }, warnings: toastWarnings(toasts),
194
+ undo: historyEntryId ? { historyEntryId, entries: 1, canUndoDirect: true } : null, ...(ids.length > 8 ? { detailCursor: request.commandId } : {}) });
195
+ }
196
+ function executeRun(id, args = {}, options = {}) {
197
+ const request = { origin: 'ui', commandId: crypto.randomUUID(), ...options };
198
+ if (request.origin === 'ui' && (id === 'edit.undo' || id === 'edit.redo')) ports.finishHistoryGesture?.();
199
+ const before = ports.read(), journal = ports.journal();
200
+ let begun = false, releaseToasts, timer, foregroundTimer, job, applied = false, committedHistoryId;
201
+ const controller = new AbortController();
202
+ const clearTimer = () => {
203
+ if (timer !== undefined) clear(timer);
204
+ if (foregroundTimer !== undefined) clear(foregroundTimer);
205
+ };
206
+ const toasts = [];
207
+ const toastRefusal = () => toasts.length && ports.read().revision === before.revision ? new StudioProtocolError('TARGET_NOT_READY', toasts.at(-1).message) : null;
208
+ const remember = value => {
209
+ if (job?.background) {
210
+ const commandId = job.completionCommandId ??= crypto.randomUUID();
211
+ journal.begin(commandId);
212
+ value = validateReceipt({ ...value, commandId, jobId: job.id });
213
+ }
214
+ const recorded = journal.record(value);
215
+ if (recorded.undo?.canUndoDirect && !id.startsWith('edit.')) historyReceipts.set(recorded.undo.historyEntryId, recorded);
216
+ for (const [key, receipt] of historyReceipts) if (ports.isRetained?.(receipt) === false) historyReceipts.delete(key);
217
+ ports.remember?.(recorded); return recorded;
218
+ };
219
+ const rejected = error => {
220
+ clearTimer();
221
+ releaseToasts?.(); releaseToasts = null;
222
+ if (!(error instanceof StudioProtocolError)) error = toastRefusal() ?? error;
223
+ if (request.origin === 'ui' && error.uiMessage) ports.showRefusal?.(error.uiMessage);
224
+ const value = refusal(request, before, error, job ? applied : undefined);
225
+ const recorded = begun ? remember(value) : value;
226
+ if (job && !job.completion) { job.outcome = recorded; job.completion = Promise.resolve(recorded); emit({ type: 'job.completed', jobId: job.id, receipt: recorded }); }
227
+ return recorded;
228
+ };
229
+ try {
230
+ if (request.origin !== 'ui' && !same(validateStudioIdentity(request.host), before.host)) fail('STALE_SCENE', 'The live document changed.');
231
+ const signature = JSON.stringify({ id, args, ...request });
232
+ if (!journal.begin(request.commandId, signature)) return journal.get(request.commandId) ?? pending.get(request.commandId) ?? refusal(request, before, new StudioProtocolError('UNCERTAIN_APPLY', 'Command is still executing.'));
233
+ begun = true;
234
+ const prepared = controls[id] ? { args: validateStudioSchema(controls[id], args) } : registry.prepare(id, args);
235
+ const { entry, args: validated } = prepared;
236
+ if (request.origin !== 'ui') {
237
+ // Job controls observe/cancel an admitted identity; completion itself
238
+ // can advance the revision while their wire request is in transit.
239
+ // Document identity and the job's own publication fence still apply.
240
+ if (!['job.await', 'job.cancel'].includes(id) && request.expectedRevision !== before.revision) fail('STALE_SCENE', 'Authored state changed; obtain fresh intent.');
241
+ if (before.busy && !transactions.has(validated.txId) && !id.startsWith('job.')) fail('TARGET_BUSY', 'Finish the current editor gesture first.');
242
+ }
243
+ if (controls[id]) return mapResult(control(id, validated, request, before), remember, rejected);
244
+ if (transactions.size) fail('TARGET_BUSY', 'Finish or cancel the open command transaction first.');
245
+ exposure(entry, validated, request);
246
+ releaseToasts = ports.captureToasts?.(toast => toasts.push(typeof toast === 'string' ? { message: toast } : toast));
247
+ const domain = entry.domain ?? entry.undoDomain;
248
+ const targetId = entry.target?.(validated, before) ?? validated.characterId ?? validated.shotId ?? validated.objectId;
249
+ let token = targetId ? ports.readTarget?.(targetId) : null;
250
+ let domainRevision = domain ? before.domainRevisions?.[domain] : null;
251
+ const rebase = () => {
252
+ const current = ports.read();
253
+ token = targetId ? ports.readTarget?.(targetId) : null;
254
+ domainRevision = domain ? current.domainRevisions?.[domain] : null;
255
+ };
256
+ const nestedIds = new Set();
257
+ const context = { origin: request.origin, signal: controller.signal,
258
+ check() {
259
+ controller.signal.throwIfAborted();
260
+ const current = ports.read();
261
+ if (!same(current.host, before.host) || (domain && current.domainRevisions?.[domain] !== domainRevision) || (targetId && ports.readTarget?.(targetId) !== token)) fail('STALE_TARGET', 'The target or its authored domain changed while the job was running.');
262
+ },
263
+ commit(apply) {
264
+ context.check();
265
+ if (!domain) fail('INVALID_ARGUMENT', 'A committing job must declare its domain.');
266
+ const recorded = ports.recordAction(domain, apply, targetId ?? null);
267
+ if (recorded?.then) fail('INVALID_ARGUMENT', 'Job publication must be synchronous; prepare before commit.');
268
+ committedHistoryId = recorded.historyEntryId; applied ||= Boolean(committedHistoryId);
269
+ if (job) rebase();
270
+ return recorded.result;
271
+ },
272
+ run(nestedId, nestedArgs = {}) {
273
+ if (job) context.check(); else controller.signal.throwIfAborted();
274
+ const nested = registry.prepare(nestedId, nestedArgs); exposure(nested.entry, nested.args, request);
275
+ const invoke = () => registry.invoke(nested.entry, nested.args, context);
276
+ const value = nested.entry.kind === 'mutation' ? ports.recordAction(nested.entry.undoDomain, invoke, nested.args.characterId ?? null, true) : { result: invoke() };
277
+ // Only the synchronous owned step may advance the fence. Never absorb
278
+ // an external edit while an asynchronous nested step is suspended;
279
+ // its later publication must itself use context.run/commit.
280
+ if (job) rebase();
281
+ return mapResult(value, recorded => mapResult(recorded.result, result => {
282
+ if (job) { context.check(); rebase(); applied ||= Boolean(recorded.historyEntryId); }
283
+ for (const target of result.affectedIds) nestedIds.add(target);
284
+ return result;
285
+ }));
286
+ },
287
+ };
288
+ if (entry.kind === 'job') { job = { id: crypto.randomUUID(), host: before.host, controller, background: false }; jobs.set(job.id, job); }
289
+ timer = (ports.setTimeout ?? setTimeout)(() => {
290
+ const error = new StudioProtocolError('TIMEOUT', `${entry.id} exceeded its deadline.`);
291
+ controller.abort(error);
292
+ }, job && entry.background === true ? 300_000 : entry.timeoutMs ?? 30_000);
293
+ // The race observes expiry even if a backend ignores cancellation.
294
+ const invoke = () => {
295
+ const value = registry.invoke(entry, validated, context);
296
+ if (!value?.then) return value;
297
+ const deadline = new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true }));
298
+ return Promise.race([value, deadline]);
299
+ };
300
+ const value = entry.kind === 'mutation' ? ports.recordAction(entry.undoDomain, invoke, validated.characterId ?? null) : { result: invoke(), historyEntryId: null };
301
+ const finish = ({ result, historyEntryId }) => mapResult(result, output => {
302
+ clearTimer();
303
+ releaseToasts?.(); releaseToasts = null;
304
+ const refused = toastRefusal();
305
+ if (refused) throw refused;
306
+ return remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts));
307
+ });
308
+ const finished = value?.then ? value.then(finish) : finish(value);
309
+ const answer = finished?.then ? finished.catch(rejected) : finished;
310
+ if (job) {
311
+ job.completion = Promise.resolve(answer).then(outcome => { job.outcome = outcome; emit({ type: 'job.completed', jobId: job.id, receipt: outcome }); return outcome; });
312
+ const detach = () => {
313
+ job.background = true;
314
+ const current = ports.read();
315
+ return journal.record(validateReceipt({ ok: true, commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
316
+ action: entry.id, status: 'started', kind: 'job', jobId: job.id, authored: false, revision: { before: current.revision, after: current.revision },
317
+ affectedIds: [], delta: [], checks: { coverage: `studio-action:${entry.id}` }, warnings: [], undo: null }));
318
+ };
319
+ if (answer?.then && request.wait === false) return detach();
320
+ if (answer?.then && entry.background === true) {
321
+ const foreground = Promise.race([job.completion, new Promise(resolve => {
322
+ foregroundTimer = (ports.setTimeout ?? setTimeout)(() => resolve(detach()), entry.timeoutMs ?? 30_000);
323
+ })]).finally(() => pending.delete(request.commandId));
324
+ pending.set(request.commandId, foreground);
325
+ return foreground;
326
+ }
327
+ }
328
+ if (answer?.then) { const settled = (job?.completion ?? answer).finally(() => pending.delete(request.commandId)); pending.set(request.commandId, settled); return settled; }
329
+ return answer;
330
+ } catch (error) { return rejected(error); }
331
+ }
332
+ function run(...args) {
333
+ activeRunDepth++;
334
+ try {
335
+ const value = executeRun(...args);
336
+ if (value?.then) return value.finally(() => { activeRunDepth--; });
337
+ activeRunDepth--;
338
+ return value;
339
+ } catch (error) {
340
+ activeRunDepth--;
341
+ throw error;
342
+ }
343
+ }
344
+ return { run,
345
+ // Trusted UI adapter only: wire callers can consume, never mint a token.
346
+ confirm(id, args = {}) { const prepared = registry.prepare(id, args), token = crypto.randomUUID(); confirmations.set(token, { id, args: prepared.args, host: ports.read().host, expires: (ports.now ?? Date.now)() + 300_000 }); return token; },
347
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
348
+ dispose() { for (const tx of transactions.values()) cancelTransaction(tx); for (const job of jobs.values()) if (!job.outcome) job.controller.abort(new StudioProtocolError('CANCELLED', 'Studio closed.')); listeners.clear(); } };
349
+ }
@@ -0,0 +1,31 @@
1
+ // AI commands: the Send-to-AI package, the editor's own generate(), which
2
+ // reads the mode and image model from the render. A changed choice is set
3
+ // first and generate() runs once React has rendered it; a commit already under
4
+ // way can predate it, so the wait repeats until the render shows the choice.
5
+ import { studioActionDeclaration } from "../studio-actions.js";
6
+ import { fail } from "./shared.js";
7
+
8
+ export const declarations = Object.freeze(["ai.prepareShot"].map(studioActionDeclaration));
9
+
10
+ export function register(registry, ports) {
11
+ registry.register({ ...studioActionDeclaration("ai.prepareShot"), available: () => true,
12
+ run: async ({ mode, model }, context) => {
13
+ const current = ports.state().aiShot, wanted = { mode: mode ?? current.mode, imageModel: model ?? current.imageModel };
14
+ if (model && wanted.mode !== "image") fail("INVALID_ARGUMENT", `model picks an image model, but this would be a ${wanted.mode} prompt; omit model or pass mode "image".`);
15
+ const rendered = () => { const { aiShot } = ports.state(); return aiShot.mode === wanted.mode && aiShot.imageModel === wanted.imageModel; };
16
+ if (!rendered()) {
17
+ ports.setAiShotMode(wanted.mode);
18
+ ports.setAiImageModel(wanted.imageModel);
19
+ for (let commits = 0; !rendered(); commits++) {
20
+ if (commits === 3) fail("TARGET_NOT_READY", "The Studio did not render the new mode and model; run the action again.");
21
+ await ports.afterRender();
22
+ }
23
+ }
24
+ context?.check();
25
+ const result = ports.generate();
26
+ const shot = result.shot && { id: result.shot.id, name: result.shot.name, range: { startFrame: result.shot.startFrame, endFrameExclusive: result.shot.endFrame + 1 } };
27
+ const referenceFrames = (result.frame ? 1 : 0) + (result.frameB ? 1 : 0);
28
+ return { affectedIds: [], output: { prompt: result.prompt, mode: result.mode, modelLabel: result.modelLabel ?? null, shot, aspectRatio: result.aspectRatio, cameraMode: result.camera?.mode ?? null, referenceFrames },
29
+ summary: `Prepared the ${result.mode} prompt${result.modelLabel ? ` for ${result.modelLabel}` : ""} for ${shot ? `${shot.name} [${shot.range.startFrame}, ${shot.range.endFrameExclusive})` : "the current camera"}; the Studio's result panel shows it with ${referenceFrames} reference frame${referenceFrames === 1 ? "" : "s"} for the user to copy and download.` };
30
+ } });
31
+ }