cozyclay 2.0.0 → 2.1.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 (260) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +219 -38
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +6 -5
  19. package/bin/cozyclay.mjs +12 -3
  20. package/bin/live/cli.mjs +2 -3
  21. package/bin/live/client.mjs +1 -1
  22. package/bin/live/studio.mjs +5 -2
  23. package/bin/mcp-runtime.mjs +12 -2
  24. package/bin/telemetry-state.mjs +3 -3
  25. package/dist/app/index.html +6 -6
  26. package/dist/assets/analytics-B41FmPYI.js +1 -0
  27. package/dist/assets/app-Do-Og5Pu.css +1 -0
  28. package/dist/assets/app-DwP-tl2x.js +4920 -0
  29. package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
  30. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
  31. package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
  32. package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
  33. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  34. package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
  35. package/dist/assets/workflow-Br-K7Srs.js +179 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/index.html +328 -222
  38. package/dist/sitemap.xml +5 -5
  39. package/dist/workflow/index.html +4 -4
  40. package/mcp/LIVE-PROTOCOL.md +23 -2
  41. package/mcp/README.md +37 -2
  42. package/mcp/ardy-prompts.mjs +1 -1
  43. package/mcp/image-file.mjs +132 -0
  44. package/mcp/live-hub.mjs +2 -2
  45. package/mcp/package.json +1 -0
  46. package/mcp/runtime/package-lock.json +7 -0
  47. package/mcp/runtime/package.json +1 -0
  48. package/mcp/server.mjs +0 -6
  49. package/mcp/tool-handlers.mjs +289 -87
  50. package/package.json +11 -2
  51. package/src/App.jsx +801 -259
  52. package/src/agent-image-client.js +94 -0
  53. package/src/analytics.js +47 -3
  54. package/src/app-stage.jsx +129 -2745
  55. package/src/ardy/auto-physics.js +6 -16
  56. package/src/ardy/collision-blockers.js +3 -7
  57. package/src/ardy/fix-collisions.js +5 -35
  58. package/src/ardy/ik-drag.js +86 -5
  59. package/src/ardy/ik-key-json.js +3 -1
  60. package/src/ardy/ik.js +15 -13
  61. package/src/ardy/motion-calibration.js +1 -1
  62. package/src/ardy/npz.js +0 -8
  63. package/src/ardy/physics-panel.jsx +3 -2
  64. package/src/ardy/physics-review.js +3 -2
  65. package/src/ardy/physics-support.js +1 -1
  66. package/src/ardy/physics-surface.js +1 -1
  67. package/src/ardy/platform-fit-panel.jsx +2 -1
  68. package/src/ardy/playback.js +1 -5
  69. package/src/ardy/range-pin.js +2 -2
  70. package/src/ardy/root-drop.js +0 -1
  71. package/src/ardy/timeline.css +196 -3
  72. package/src/ardy/timeline.jsx +117 -29
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +1 -4
  75. package/src/ardy-bridge-contract.js +281 -0
  76. package/src/asset-pane.jsx +19 -9
  77. package/src/auto-color.js +1 -1
  78. package/src/burn-in.js +1 -1
  79. package/src/camera-block.js +1 -1
  80. package/src/camera-follow.js +75 -32
  81. package/src/camera-move.js +0 -6
  82. package/src/camera-tutorial.jsx +43 -24
  83. package/src/character-kind.js +79 -0
  84. package/src/command-bus.js +76 -17
  85. package/src/commands/cast.js +31 -7
  86. package/src/commands/elements/object.js +1 -1
  87. package/src/commands/elements/shot.js +1 -1
  88. package/src/commands/elements.js +1 -1
  89. package/src/commands/export.js +3 -3
  90. package/src/commands/motion.js +2 -2
  91. package/src/commands/objects.js +105 -10
  92. package/src/commands/project.js +16 -3
  93. package/src/commands/sculpt-images.js +53 -0
  94. package/src/commands/shot.js +88 -10
  95. package/src/commands/view.js +10 -4
  96. package/src/coplanar-depth.js +216 -0
  97. package/src/cuts.js +7 -6
  98. package/src/domains/cast.js +60 -43
  99. package/src/domains/motion/motion-intent-codec.js +50 -0
  100. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  101. package/src/domains/motion/motion-video-capture.js +664 -0
  102. package/src/domains/motion.js +253 -742
  103. package/src/domains/objects.js +106 -5
  104. package/src/domains/scenes.js +89 -32
  105. package/src/domains/shots.js +33 -7
  106. package/src/error-boundary.jsx +1 -1
  107. package/src/execution-telemetry.js +5 -5
  108. package/src/facing-marks.js +14 -1
  109. package/src/first-success-guide.jsx +35 -4
  110. package/src/gizmo-claim.js +3 -1
  111. package/src/grid-floor.jsx +1 -4
  112. package/src/hierarchy-model.js +48 -35
  113. package/src/hierarchy-panel.css +1 -0
  114. package/src/hierarchy-panel.jsx +54 -102
  115. package/src/history.js +0 -2
  116. package/src/i2v-motion-client.js +1 -4
  117. package/src/ime.js +6 -0
  118. package/src/keyframe-pack.js +15 -6
  119. package/src/line-edit.js +4 -4
  120. package/src/live-control.js +6 -6
  121. package/src/matte-editor.js +2 -8
  122. package/src/matte.js +0 -19
  123. package/src/mesh-graph-clone.js +16 -2
  124. package/src/mesh-sniff.js +0 -1
  125. package/src/monotone-cubic.js +40 -0
  126. package/src/motion/generation.js +1 -1
  127. package/src/motion-store.js +4 -4
  128. package/src/motion-trail.js +5 -11
  129. package/src/multimodel-ingest.js +1 -3
  130. package/src/object-catalog.jsx +7 -5
  131. package/src/object-gizmo.jsx +45 -31
  132. package/src/object-path.js +310 -67
  133. package/src/object-travel.js +224 -0
  134. package/src/otio.js +8 -3
  135. package/src/panels/CharacterTransformPanel.jsx +24 -6
  136. package/src/panels/ObjectTransformPanel.jsx +4 -3
  137. package/src/panels/PoseImagePanel.jsx +205 -0
  138. package/src/panels/PromptBlocksPanel.jsx +3 -1
  139. package/src/panels/ProxyFigurePanel.jsx +56 -0
  140. package/src/panels/ReferenceImageField.jsx +1 -1
  141. package/src/panels/RigControlPanel.jsx +24 -3
  142. package/src/panels/RigPanel.jsx +2 -1
  143. package/src/panels/SculptPanel.jsx +405 -0
  144. package/src/panels/SubjectBox.jsx +1 -1
  145. package/src/panels/SubjectsPanel.jsx +0 -2
  146. package/src/panels/VideoCapturePanel.jsx +2 -1
  147. package/src/panels/pose-image.css +42 -0
  148. package/src/panels/pose.css +3 -0
  149. package/src/panels/sculpt.css +162 -0
  150. package/src/plan-diagram.js +184 -0
  151. package/src/planview.jsx +127 -80
  152. package/src/playground.js +3 -5
  153. package/src/pose-fade.js +77 -0
  154. package/src/pose-gizmo.css +23 -0
  155. package/src/pose-gizmo.js +96 -0
  156. package/src/posestudio.jsx +134 -83
  157. package/src/project-browser.css +53 -0
  158. package/src/project-browser.jsx +52 -3
  159. package/src/project.js +55 -24
  160. package/src/props.jsx +177 -36
  161. package/src/proxy-figure.jsx +69 -0
  162. package/src/range-pin-panel.jsx +0 -1
  163. package/src/receipt-findings.js +20 -0
  164. package/src/render-passes.js +0 -1
  165. package/src/resource-status.jsx +1 -1
  166. package/src/root-path.js +48 -0
  167. package/src/route-owner.js +52 -0
  168. package/src/scene-asset-cache.js +1 -2
  169. package/src/scene-assets.js +12 -4
  170. package/src/scene-fbx.js +1 -1
  171. package/src/scene-mesh-cache.js +0 -1
  172. package/src/scene-objects.js +319 -19
  173. package/src/scenes.js +62 -17
  174. package/src/sculpt-blueprint.js +65 -0
  175. package/src/sculpt-compare.js +384 -0
  176. package/src/sculpt-geometry.js +141 -0
  177. package/src/sculpt-mode.js +168 -0
  178. package/src/sculpt-recipe.js +385 -0
  179. package/src/sculpt-run.js +351 -0
  180. package/src/sculpt-stage.jsx +221 -0
  181. package/src/settings-menu.jsx +1 -1
  182. package/src/shell/BoardSlot.jsx +341 -0
  183. package/src/shell/BottomDock.jsx +58 -14
  184. package/src/shell/DetailsSlot.jsx +26 -6
  185. package/src/shell/MenuBar.jsx +78 -6
  186. package/src/shell/OutlinerSlot.jsx +3 -1
  187. package/src/shell/TopBar.jsx +17 -3
  188. package/src/shell/ViewportToolbar.jsx +18 -3
  189. package/src/shell/dock.css +239 -0
  190. package/src/shell/glass-regions.css +3 -2
  191. package/src/shell/log-store.js +4 -4
  192. package/src/shell/mode.css +20 -0
  193. package/src/shell/studio-shell-context.js +4 -1
  194. package/src/shot-authoring.js +69 -6
  195. package/src/stable-items.js +1 -1
  196. package/src/stage-attach-frames.js +169 -0
  197. package/src/stage-canvas-rigs.jsx +402 -0
  198. package/src/stage-capture.jsx +216 -0
  199. package/src/stage-characters.jsx +302 -0
  200. package/src/stage-editor-gizmos.jsx +1229 -0
  201. package/src/stage-labels-ko.js +204 -0
  202. package/src/stage-startup-defaults.js +182 -0
  203. package/src/starter-scenes.js +1 -1
  204. package/src/storyboard.js +24 -5
  205. package/src/studio-actions.js +42 -9
  206. package/src/studio-agent-commands.js +186 -23
  207. package/src/studio-agent-context.js +11 -9
  208. package/src/studio-agent-motion.js +2 -1
  209. package/src/studio-agent-protocol.js +56 -23
  210. package/src/studio-app-binding.js +52 -23
  211. package/src/studio-contact-sheet.js +3 -3
  212. package/src/studio-elements.js +11 -2
  213. package/src/studio-geometry-facts.js +114 -0
  214. package/src/styles.css +111 -10
  215. package/src/theme.js +1 -1
  216. package/src/timeline-fps.js +13 -0
  217. package/src/ui-scrub.js +4 -4
  218. package/src/ui.jsx +6 -4
  219. package/src/use-asset-shelf.js +60 -0
  220. package/src/use-camera-tutorial.js +50 -0
  221. package/src/use-embed-bridge.js +12 -0
  222. package/src/use-first-success-prompt.js +7 -0
  223. package/src/use-i2v-capture-modal.js +15 -0
  224. package/src/use-sculpt-mode.js +95 -0
  225. package/src/workflow/AgentPanel.jsx +110 -20
  226. package/src/workflow/WorkflowBuilder.jsx +1 -1
  227. package/src/workflow/agent-client.js +113 -45
  228. package/src/workflow/agent-panel.css +35 -0
  229. package/src/workflow/attachment-image.js +1 -1
  230. package/src/workflow/canvas-commands.js +3 -3
  231. package/src/workflow/cozy-scene-node.js +3 -3
  232. package/src/workflow/local-workflow.js +2 -2
  233. package/src/workflow/motion-input.js +3 -3
  234. package/src/workflow/node-schema.js +1 -1
  235. package/src/workflow/scene-asset-sync.js +2 -9
  236. package/src/workflow/shot-prompt-node.js +1 -1
  237. package/src/workflow/video-contract.js +1 -1
  238. package/src/workflow/workflow-resources.js +0 -8
  239. package/src/workflow-send.js +87 -0
  240. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  241. package/tools/check-eslint.mjs +57 -0
  242. package/tools/check-knip.mjs +60 -0
  243. package/tools/check-loc.mjs +54 -0
  244. package/tools/demo-worker/api-client.mjs +7 -13
  245. package/tools/demo-worker/generate.mjs +2 -4
  246. package/tools/eslint-baseline.json +23 -0
  247. package/tools/kimodo/effector-constraints.mjs +4 -4
  248. package/tools/kimodo/generate.mjs +1 -1
  249. package/tools/knip-baseline.json +331 -0
  250. package/tools/loc-baseline.json +126 -0
  251. package/tools/process-supervisor.mjs +1 -1
  252. package/tools/projflow/replay.mjs +4 -4
  253. package/tools/projflow/runner.mjs +3 -3
  254. package/tools/qa/studio-control-count.mjs +2 -0
  255. package/tools/run-tests.mjs +96 -7
  256. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  257. package/dist/assets/app-C1sdVbg4.css +0 -1
  258. package/dist/assets/app-CA55tcI3.js +0 -4876
  259. package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
  260. package/dist/assets/workflow-dNPGY9xz.js +0 -179
@@ -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
  ];
@@ -98,8 +98,6 @@ export const CAMERA_TUTORIAL_STEPS = [
98
98
  const NAV_KINDS = new Set(["fly", "walk", "dolly", "orbit"]);
99
99
  const SIGNAL_KINDS = new Set(["shot", "rail"]);
100
100
 
101
- /* ------------------------------------------------------------- beacons --- */
102
-
103
101
  // Where each of the last three steps actually happens (#211). The card names
104
102
  // the gesture; these pin a numbered circle and a caption onto the control
105
103
  // itself, so "click + Add shot" stops being a treasure hunt across two panes.
@@ -119,10 +117,13 @@ export const TUTORIAL_BEACONS = {
119
117
  label: () => ko("1. Select the shot", "1. 샷을 선택"),
120
118
  },
121
119
  { 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", "여기에 선을 그으세요") },
120
+ // The Top-View becomes an active drawing surface after the tool is armed.
121
+ { role: "top-view", selector: ".vp-inset", requires: '[data-rail-draw="1"]', label: () => ko("Drag a line here", "여기에 선을 그으세요") },
123
122
  ],
124
123
  play: [
125
- { role: "look-through", selector: ".vp-look-through", label: () => ko("Click to fly the shot camera", "클릭해 샷 카메라를 조종") },
124
+ // The glass shell keeps the same action in the bottom dock while the
125
+ // compact viewport card owns it in the classic shell.
126
+ { role: "look-through", selector: ".dock-shot-look, .vp-look-through", label: () => ko("Click to fly the shot camera", "클릭해 샷 카메라를 조종") },
126
127
  ],
127
128
  };
128
129
 
@@ -217,7 +218,7 @@ export function TutorialBeacon({ kind, step, role, selector, label, requires = n
217
218
  if (pane) resize.observe(pane);
218
219
  }
219
220
  const mutations = new MutationObserver(schedule);
220
- mutations.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
221
+ mutations.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "data-rail-draw"] });
221
222
  return () => {
222
223
  if (frame) cancelAnimationFrame(frame);
223
224
  window.removeEventListener("resize", schedule);
@@ -246,8 +247,6 @@ export function TutorialBeacon({ kind, step, role, selector, label, requires = n
246
247
  );
247
248
  }
248
249
 
249
- /* -------------------------------------------------------- gesture cues --- */
250
-
251
250
  // Steps 1–4 have no control to point at: the gesture IS the interface. The cue
252
251
  // draws the mouse and the keys the step wants, animated the way the hand is
253
252
  // supposed to move, and sits centre-bottom of the viewport where the eye
@@ -327,11 +326,18 @@ export function GestureCue({ kind, walked, leaving = false, onLeft }) {
327
326
  * Top-View inset, and never takes the pointer except on its own close button
328
327
  * — every step is completed by working the studio underneath it.
329
328
  */
330
- export function CameraTutorial({ previewing = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
329
+ export function CameraTutorial({ previsMode, previewing = false, shotCount = 0, railReady = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
331
330
  const [done, setDone] = useState(() => new Set());
332
331
  const [walked, setWalked] = useState(() => new Set());
333
332
  const [cue, setCue] = useState(null);
334
333
  const completionReported = useRef(false);
334
+ const effectiveDone = useMemo(() => {
335
+ const next = new Set(done);
336
+ if (shotCount > 0) next.add("shot");
337
+ if (railReady) next.add("rail");
338
+ if (previewing && next.has("rail")) next.add("play");
339
+ return next;
340
+ }, [done, previewing, railReady, shotCount]);
335
341
 
336
342
  useEffect(() => {
337
343
  const complete = (kind) => setDone((current) => (current.has(kind) ? current : new Set(current).add(kind)));
@@ -362,6 +368,16 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
362
368
  };
363
369
  }, []);
364
370
 
371
+ // Studio-native completion comes from the authored state. The old playground
372
+ // signal was never emitted by the Studio shell, so a real Shot or rail could
373
+ // leave the tutorial stuck on its previous step.
374
+ useEffect(() => {
375
+ if (shotCount > 0) setDone((current) => (current.has("shot") ? current : new Set(current).add("shot")));
376
+ }, [shotCount]);
377
+ useEffect(() => {
378
+ if (railReady) setDone((current) => (current.has("rail") ? current : new Set(current).add("rail")));
379
+ }, [railReady]);
380
+
365
381
  // The player is the last step, and only once there is a rail to ride:
366
382
  // look-through before the dolly exists shows a still frame, which teaches
367
383
  // nothing about the move. Analytics v1 observes this existing look-through
@@ -369,12 +385,12 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
369
385
  useEffect(() => {
370
386
  if (!previewing) return;
371
387
  setDone((current) => (!current.has("rail") || current.has("play") ? current : new Set(current).add("play")));
372
- }, [previewing]);
388
+ }, [previewing, railReady]);
373
389
 
374
- const current = useMemo(() => CAMERA_TUTORIAL_STEPS.find((step) => !done.has(step.kind)) ?? null, [done]);
390
+ const current = useMemo(() => CAMERA_TUTORIAL_STEPS.find((step) => !effectiveDone.has(step.kind)) ?? null, [effectiveDone]);
375
391
  const complete = current === null;
376
392
  const currentKind = current?.kind ?? null;
377
- const showHandoff = !!shotId && (handoff?.canShow(done) ?? false);
393
+ const showHandoff = !!shotId && (handoff?.canShow(effectiveDone) ?? false);
378
394
  useEffect(() => {
379
395
  if (!complete || completionReported.current) return;
380
396
  completionReported.current = true;
@@ -384,8 +400,8 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
384
400
  // Observe committed progression, outside state updaters (which StrictMode
385
401
  // may replay). The attempt belongs to App and survives effect cleanup/resume.
386
402
  useEffect(() => {
387
- analytics?.observe(done, currentKind);
388
- }, [analytics, done, currentKind]);
403
+ analytics?.observe(effectiveDone, currentKind);
404
+ }, [analytics, effectiveDone, currentKind]);
389
405
 
390
406
  // The step the operator is on is the studio's business too: App puts it on
391
407
  // the .app root as data-tutorial-step, and styles.css spotlights whichever
@@ -409,6 +425,9 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
409
425
  });
410
426
  }, [currentKind]);
411
427
 
428
+ // Storyboard projects never show the camera tutorial (its steps are shot/rail/play).
429
+ if (previsMode === "storyboard") return null;
430
+
412
431
  return (
413
432
  <>
414
433
  <aside
@@ -425,10 +444,10 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
425
444
  key={step.kind}
426
445
  data-testid="camera-tutorial-step"
427
446
  data-kind={step.kind}
428
- data-done={done.has(step.kind) ? 1 : 0}
447
+ data-done={effectiveDone.has(step.kind) ? 1 : 0}
429
448
  data-current={step === current ? 1 : 0}
430
449
  >
431
- <i aria-hidden="true">{done.has(step.kind) ? "✓" : index + 1}</i>
450
+ <i aria-hidden="true">{effectiveDone.has(step.kind) ? "✓" : index + 1}</i>
432
451
  {step.label}
433
452
  </li>
434
453
  ))}
@@ -490,8 +509,8 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
490
509
  role={beacon.role}
491
510
  selector={beacon.selector}
492
511
  label={beacon.label}
493
- requires={beacon.requires ?? null}
494
- absent={beacon.absent ?? null}
512
+ requires={beacon.requires}
513
+ absent={beacon.absent}
495
514
  />
496
515
  ))}
497
516
  </>
@@ -0,0 +1,79 @@
1
+ // What a cast member can do depends on its kind. A rigged character has a
2
+ // skeleton every motion, IK and physics tool writes to; a capsule figure
3
+ // (PROXY_FIGURE_MODEL) is a placement-only stand-in with no bones at all, so
4
+ // those tools refuse it by name instead of failing on a missing rig.
5
+ import { isProxyFigure } from "./scenes.js";
6
+ import { isKo } from "./locale.js";
7
+ import { StudioProtocolError } from "./studio-agent-protocol.js";
8
+
9
+ export const CHARACTER_CAPABILITIES = Object.freeze(["rig", "ik", "pose", "motion", "mocap", "physics", "trails", "lineEdit"]);
10
+ const PROXY_BODY_RADIUS = 0.22;
11
+ const PROXY_BODY_HEIGHT = 1.45;
12
+ const PROXY_HEAD_RADIUS = 0.12;
13
+
14
+ /** Return a capsule figure's rendered world-space bounds without a rig. */
15
+ export function proxyFigureBounds(character, placement = character) {
16
+ const posture = character?.posture === "sit" || character?.posture === "lie" ? character.posture : "stand";
17
+ const bodyHeight = posture === "sit" ? PROXY_BODY_HEIGHT * 0.6 : PROXY_BODY_HEIGHT;
18
+ const stature = Number.isFinite(placement?.scale) ? placement.scale : Number.isFinite(character?.scale) ? character.scale : 1;
19
+ const x = Number.isFinite(placement?.x) ? placement.x : character?.x ?? 0;
20
+ const y = Number.isFinite(placement?.y) ? placement.y : character?.y ?? 0;
21
+ const z = Number.isFinite(placement?.z) ? placement.z : character?.z ?? 0;
22
+ const yaw = ((Number.isFinite(placement?.rot) ? placement.rot : character?.rot ?? 0) + (posture === "lie" ? 180 : 0)) * Math.PI / 180;
23
+ const [minCorner, maxCorner] = posture === "lie"
24
+ ? [[-PROXY_BODY_RADIUS, PROXY_BODY_RADIUS - PROXY_HEAD_RADIUS, -(bodyHeight + 0.25)],
25
+ [PROXY_BODY_RADIUS, PROXY_BODY_RADIUS + PROXY_HEAD_RADIUS, 0]]
26
+ : [[-PROXY_BODY_RADIUS, 0, -PROXY_BODY_RADIUS], [PROXY_BODY_RADIUS, bodyHeight + 0.25, PROXY_BODY_RADIUS]];
27
+ const min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
28
+ for (const lx of [minCorner[0], maxCorner[0]]) for (const ly of [minCorner[1], maxCorner[1]]) for (const lz of [minCorner[2], maxCorner[2]]) {
29
+ const sx = lx * stature, sy = ly * stature, sz = lz * stature;
30
+ const wx = x + sx * Math.cos(yaw) + sz * Math.sin(yaw);
31
+ const wz = z - sx * Math.sin(yaw) + sz * Math.cos(yaw);
32
+ min.x = Math.min(min.x, wx); max.x = Math.max(max.x, wx);
33
+ min.y = Math.min(min.y, y + sy); max.y = Math.max(max.y, y + sy);
34
+ min.z = Math.min(min.z, wz); max.z = Math.max(max.z, wz);
35
+ }
36
+ return { min, max };
37
+ }
38
+
39
+ /** `{ rig, ik, pose, motion, mocap, physics, trails, lineEdit }` - all false
40
+ * for a capsule figure, all true for a rigged character. */
41
+ export function characterCapabilities(character) {
42
+ const rigged = !isProxyFigure(character);
43
+ return Object.freeze(Object.fromEntries(CHARACTER_CAPABILITIES.map((name) => [name, rigged])));
44
+ }
45
+
46
+ // The noun each refusal names. A feature id not listed here is used verbatim.
47
+ const FEATURE_LABELS = {
48
+ rig: ["the rig", "리그"],
49
+ ik: ["IK", "IK"],
50
+ pose: ["Pose mode", "포즈 모드"],
51
+ motion: ["Motion generation", "모션 생성"],
52
+ mocap: ["Motion capture", "모션 캡처"],
53
+ photoPose: ["Photo pose", "사진 포즈"],
54
+ physics: ["Physics", "물리 보정"],
55
+ collision: ["Collision cleanup", "충돌 정리"],
56
+ trails: ["Motion trails", "궤적선"],
57
+ lineEdit: ["Line edit", "라인 편집"],
58
+ take: ["Loading a take", "테이크 불러오기"],
59
+ };
60
+
61
+ // A character's kind (rig or capsule figure) is fixed at creation; character.update refuses a kind change.
62
+ export const KIND_FIXED_REFUSAL = Object.freeze({
63
+ en: "A character's kind is chosen when it is created; add a new character instead.",
64
+ ko: "캐릭터 종류는 만들 때 정합니다. 새 캐릭터를 추가하세요.",
65
+ });
66
+
67
+ export function kindRefusal(feature, ko = false) {
68
+ const label = FEATURE_LABELS[feature]?.[ko ? 1 : 0] ?? feature;
69
+ return ko
70
+ ? `캡슐 인물은 리그가 없어요 - ${label}는 리그 캐릭터에서만 됩니다.`
71
+ : `Capsule figures have no rig - ${label} works on rigged characters only.`;
72
+ }
73
+
74
+ /** Throw TARGET_NOT_READY when `character` is a capsule figure. The protocol
75
+ * message is English (the agent reads it); `uiMessage` is the locale's. */
76
+ export function refuseRigOnly(character, feature) {
77
+ if (!isProxyFigure(character)) return;
78
+ throw Object.assign(new StudioProtocolError("TARGET_NOT_READY", kindRefusal(feature, false)), { uiMessage: kindRefusal(feature, isKo) });
79
+ }
@@ -45,9 +45,10 @@ export const isBusRunActive = () => activeRunDepth > 0;
45
45
  export function createCommandBus({ registry, ports }) {
46
46
  const pending = new Map(), transactions = new Map(), jobs = new Map(), listeners = new Set();
47
47
  const confirmations = new Map(), historyReceipts = new Map();
48
+ let agentTurn = null;
48
49
  function exposure(entry, args, request) {
49
50
  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.exposure === 'ui-only') fail('CAPABILITY_MISSING', entry.uiOnlyHint ?? 'This command is available only from the Studio UI.');
51
52
  if (entry.id === 'load_scenes') {
52
53
  const ids = scenes => [...new Set(scenes.map(scene => scene.id))].sort();
53
54
  if (same(ids(args.document.scenes), ids(registry.state().scenes))) return;
@@ -61,6 +62,9 @@ export function createCommandBus({ registry, ports }) {
61
62
  const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
62
63
  const argsSchema = { type: 'object', properties: {}, required: [], additionalProperties: true };
63
64
  const controls = {
65
+ 'agent.turn.begin': object({ turnId: identifier }),
66
+ 'agent.turn.finish': object({ turnId: identifier }),
67
+ 'agent.turn.cancel': object({ turnId: identifier }),
64
68
  'run.begin': object({ id: identifier, args: argsSchema }),
65
69
  'run.update': object({ txId: identifier, args: argsSchema }),
66
70
  'run.commit': object({ txId: identifier }),
@@ -81,7 +85,29 @@ export function createCommandBus({ registry, ports }) {
81
85
  tx.timer = (ports.setTimeout ?? setTimeout)(() => cancelTransaction(tx, true), ports.transactionIdleMs ?? 30_000);
82
86
  }
83
87
  function control(id, args, request, before) {
84
- if (id === 'edit.undo' || id === 'edit.redo') {
88
+ if (id.startsWith('agent.turn.')) {
89
+ if (request.origin !== 'agent' || request.turnId !== args.turnId) fail('CAPABILITY_MISSING', 'Only the owning agent turn can control this transaction.');
90
+ if (id === 'agent.turn.begin') {
91
+ if (agentTurn || transactions.size) fail('TARGET_BUSY', 'Finish the open transaction first.');
92
+ agentTurn = { txId: args.turnId, before, affectedIds: new Set(), toasts: [], session: null };
93
+ return transactionReceipt(id, agentTurn, request, before);
94
+ }
95
+ if (agentTurn?.txId !== args.turnId) fail('STALE_TARGET', 'The agent turn is no longer open.');
96
+ const turn = agentTurn;
97
+ // A rolled-back turn restores every owner it touched and leaves no entry.
98
+ if (id === 'agent.turn.cancel') { agentTurn = null; turn.session?.cancel(); return transactionReceipt(id, turn, request, before); }
99
+ try { turn.historyEntryId = turn.session?.commit().historyEntryId; }
100
+ catch (error) { turn.session?.cancel(); throw error; }
101
+ finally { agentTurn = null; }
102
+ return transactionReceipt(id, turn, request, turn.before);
103
+ }
104
+ // Every Studio turn opens a turn transaction (#715), so "undo the last thing" arrives
105
+ // inside one. Until the turn has authored anything (no session yet) history traversal
106
+ // is safe: the undo lands as its own entry and a later edit still groups into the turn.
107
+ // After the first authored edit the turn owns the frontier and traversal waits (#735).
108
+ const traversal = id === 'edit.undo' || id === 'edit.redo';
109
+ if (agentTurn && !id.startsWith('job.') && !(traversal && !agentTurn.session)) fail('TARGET_BUSY', 'Finish the agent turn before traversing history or opening another transaction.');
110
+ if (traversal) {
85
111
  const redo = id === 'edit.redo', historyEntryId = ports.history?.(redo);
86
112
  const previous = args.receiptId ? ports.receipt(args.receiptId) : historyReceipts.get(historyEntryId)
87
113
  ?? (historyEntryId ? { receiptId: historyEntryId, host: before.host, affectedIds: [before.host.sceneId],
@@ -180,23 +206,51 @@ export function createCommandBus({ registry, ports }) {
180
206
  mutated: changed, preserved: { authoredState: changed ? 'changed' : 'unchanged' }, recovery: { action: 'inspect', retryAllowed: false },
181
207
  message: [...String(error.message || error)].slice(0, 500).join('') });
182
208
  }
183
- function receipt(entry, request, before, result, historyEntryId, toasts = []) {
209
+ /** Findings first, toasts after; past the schema's twelve, say how many were left out. */
210
+ const receiptWarnings = (findings = [], toasts = [], omitted = 0, limit = 12) => {
211
+ const all = [...(omitted ? [{ code: 'READBACK_TRUNCATED', count: omitted, message: `Readback lists 8 of ${8 + omitted} affected ids; inspect_studio scope entities with ids for the rest.` }] : []), ...findings, ...toastWarnings(toasts)];
212
+ return all.length <= limit ? all : [...all.slice(0, limit - 1), { code: 'WARNINGS_TRUNCATED', count: all.length - limit + 1, message: `${all.length - limit + 1} more warnings omitted.` }];
213
+ };
214
+ function receipt(entry, request, before, result, historyEntryId, toasts = [], turn = null) {
184
215
  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);
216
+ // A turn's transaction is the undo entry only for a command that changed the scene: a read-only
217
+ // job inside an agent turn (sculpt.compare) has nothing to undo.
218
+ const retainedHistoryEntryId = historyEntryId ?? (changed ? turn?.txId : null) ?? null;
219
+ if (entry.kind === 'mutation' && changed && !historyEntryId && !turn) fail('UNCERTAIN_APPLY', `${entry.id} changed the scene without one undoable entry.`);
220
+ const completed = entry.kind === 'job' || entry.kind === 'document' || (entry.kind === 'mutation' && changed && (turn || after.revision > before.revision + 1));
187
221
  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,
222
+ if (turn && changed) for (const id of ids) turn.affectedIds.add(id);
223
+ const build = level => validateReceipt({ ok: true, commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
189
224
  action: entry.id, summary: result.summary, status: completed ? 'completed' : entry.kind === 'transient' ? 'transient' : changed ? 'applied' : 'noop',
190
225
  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
226
  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 } : {}) });
227
+ delta: ids.slice(0, 8).map(id => ({ id, after: ports.readback?.(id, after, level > 0) ?? { removed: true } })),
228
+ // The command's own measurements (an arrangement's overlap evidence) ride beside the coverage tag.
229
+ checks: { coverage: `studio-action:${entry.id}`, ...result.checks }, warnings: receiptWarnings(result.warnings, toasts, Math.max(0, ids.length - 8), level > 1 ? 4 : 12),
230
+ undo: retainedHistoryEntryId ? { historyEntryId: retainedHistoryEntryId, entries: 1, canUndoDirect: !turn } : null, ...(ids.length > 8 ? { detailCursor: request.commandId } : {}) });
231
+ // Full readback and warnings are richer than the 8 KiB receipt always allows; shed the extras
232
+ // (box and children, then all but the first warnings) rather than fail an edit that was applied.
233
+ for (let level = 0; ; level++) {
234
+ try { return build(level); }
235
+ catch (error) { if (level >= 2 || error?.code !== 'INVALID_RECEIPT' || !/8 KiB/.test(error.message)) throw error; }
236
+ }
195
237
  }
196
238
  function executeRun(id, args = {}, options = {}) {
197
239
  const request = { origin: 'ui', commandId: crypto.randomUUID(), ...options };
198
240
  if (request.origin === 'ui' && (id === 'edit.undo' || id === 'edit.redo')) ports.finishHistoryGesture?.();
199
241
  const before = ports.read(), journal = ports.journal();
242
+ if (agentTurn && !same(agentTurn.before.host, before.host)) {
243
+ agentTurn.session?.cancel({ restore: false }); agentTurn = null;
244
+ }
245
+ const turn = request.origin === 'agent' && request.turnId === agentTurn?.txId ? agentTurn : null;
246
+ const recordAction = (domain, invoke, targetId, nested = false) => {
247
+ if (!turn) return ports.recordAction(domain, invoke, targetId, nested);
248
+ // Enrol each owner in the existing composed session. Do not commit (or
249
+ // cancel the parent on a tool refusal) at an individual command boundary.
250
+ turn.session ??= ports.beginAction(domain, targetId);
251
+ const result = turn.session.run(() => ports.beginAction(domain, targetId).run(invoke));
252
+ return { result, historyEntryId: null };
253
+ };
200
254
  let begun = false, releaseToasts, timer, foregroundTimer, job, applied = false, committedHistoryId;
201
255
  const controller = new AbortController();
202
256
  const clearTimer = () => {
@@ -228,6 +282,7 @@ export function createCommandBus({ registry, ports }) {
228
282
  };
229
283
  try {
230
284
  if (request.origin !== 'ui' && !same(validateStudioIdentity(request.host), before.host)) fail('STALE_SCENE', 'The live document changed.');
285
+ if (request.turnId && !turn && id !== 'agent.turn.begin') fail('STALE_TARGET', 'The agent turn is no longer open.');
231
286
  const signature = JSON.stringify({ id, args, ...request });
232
287
  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
288
  begun = true;
@@ -237,11 +292,12 @@ export function createCommandBus({ registry, ports }) {
237
292
  // Job controls observe/cancel an admitted identity; completion itself
238
293
  // can advance the revision while their wire request is in transit.
239
294
  // 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.');
295
+ if (!['job.await', 'job.cancel', 'agent.turn.finish', 'agent.turn.cancel'].includes(id) && request.expectedRevision !== before.revision) fail('STALE_SCENE', 'Authored state changed; obtain fresh intent.');
296
+ if (before.busy && !turn && !transactions.has(validated.txId) && !id.startsWith('job.')) fail('TARGET_BUSY', 'Finish the current editor gesture first.');
242
297
  }
243
298
  if (controls[id]) return mapResult(control(id, validated, request, before), remember, rejected);
244
299
  if (transactions.size) fail('TARGET_BUSY', 'Finish or cancel the open command transaction first.');
300
+ if (turn && entry.kind === 'document') fail('TARGET_BUSY', 'Finish the agent turn before changing documents.');
245
301
  exposure(entry, validated, request);
246
302
  releaseToasts = ports.captureToasts?.(toast => toasts.push(typeof toast === 'string' ? { message: toast } : toast));
247
303
  const domain = entry.domain ?? entry.undoDomain;
@@ -258,12 +314,15 @@ export function createCommandBus({ registry, ports }) {
258
314
  check() {
259
315
  controller.signal.throwIfAborted();
260
316
  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.');
317
+ // Name the fence that tripped: the three share one code, and a bare "something changed" leaves the user and the agent guessing.
318
+ if (!same(current.host, before.host)) fail('STALE_TARGET', 'The open document or scene changed while the job was running.');
319
+ if (domain && current.domainRevisions?.[domain] !== domainRevision) fail('STALE_TARGET', `The ${domain} data changed while the job was running (revision ${domainRevision} to ${current.domainRevisions?.[domain]}); an edit landed between the request and its result.`);
320
+ if (targetId && ports.readTarget?.(targetId) !== token) fail('STALE_TARGET', `${targetId} changed while the job was running.`);
262
321
  },
263
322
  commit(apply) {
264
323
  context.check();
265
324
  if (!domain) fail('INVALID_ARGUMENT', 'A committing job must declare its domain.');
266
- const recorded = ports.recordAction(domain, apply, targetId ?? null);
325
+ const recorded = recordAction(domain, apply, targetId ?? null);
267
326
  if (recorded?.then) fail('INVALID_ARGUMENT', 'Job publication must be synchronous; prepare before commit.');
268
327
  committedHistoryId = recorded.historyEntryId; applied ||= Boolean(committedHistoryId);
269
328
  if (job) rebase();
@@ -273,7 +332,7 @@ export function createCommandBus({ registry, ports }) {
273
332
  if (job) context.check(); else controller.signal.throwIfAborted();
274
333
  const nested = registry.prepare(nestedId, nestedArgs); exposure(nested.entry, nested.args, request);
275
334
  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() };
335
+ const value = nested.entry.kind === 'mutation' ? recordAction(nested.entry.undoDomain, invoke, nested.args.characterId ?? null, true) : { result: invoke() };
277
336
  // Only the synchronous owned step may advance the fence. Never absorb
278
337
  // an external edit while an asynchronous nested step is suspended;
279
338
  // its later publication must itself use context.run/commit.
@@ -297,13 +356,13 @@ export function createCommandBus({ registry, ports }) {
297
356
  const deadline = new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true }));
298
357
  return Promise.race([value, deadline]);
299
358
  };
300
- const value = entry.kind === 'mutation' ? ports.recordAction(entry.undoDomain, invoke, validated.characterId ?? null) : { result: invoke(), historyEntryId: null };
359
+ const value = entry.kind === 'mutation' ? recordAction(entry.undoDomain, invoke, validated.characterId ?? null) : { result: invoke(), historyEntryId: null };
301
360
  const finish = ({ result, historyEntryId }) => mapResult(result, output => {
302
361
  clearTimer();
303
362
  releaseToasts?.(); releaseToasts = null;
304
363
  const refused = toastRefusal();
305
364
  if (refused) throw refused;
306
- return remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts));
365
+ return remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts, turn));
307
366
  });
308
367
  const finished = value?.then ? value.then(finish) : finish(value);
309
368
  const answer = finished?.then ? finished.catch(rejected) : finished;
@@ -345,5 +404,5 @@ export function createCommandBus({ registry, ports }) {
345
404
  // Trusted UI adapter only: wire callers can consume, never mint a token.
346
405
  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
406
  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(); } };
407
+ dispose() { agentTurn?.session?.cancel(); agentTurn = null; 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
408
  }
@@ -5,7 +5,9 @@ import { studioActionDeclaration } from "../studio-actions.js";
5
5
  import { characterOf, fail, changedIds } from "./shared.js";
6
6
  import { elementSetSchema, registerElementSet } from './elements.js';
7
7
  import './elements/character.js';
8
- import { createCharacterEntry } from '../scenes.js';
8
+ import { createCharacterEntry, CHARACTER_KIND_IDS, POSTURES, PROXY_FIGURE_MODEL, isProxyFigure } from '../scenes.js';
9
+ import { KIND_FIXED_REFUSAL } from '../character-kind.js';
10
+ import { track } from '../analytics.js';
9
11
  import { DEFAULT_POSE } from '../poses.js';
10
12
  import { createStableItemId, updateStableItem, removeStableItem } from '../stable-items.js';
11
13
  import { movePromptClipFrames } from '../ardy/prompt-clips.js';
@@ -14,13 +16,18 @@ import { STUDIO_TOOL_SCHEMAS, StudioSchemas } from '../studio-agent-protocol.js'
14
16
  const id = StudioSchemas.TargetGuard.properties.targetId;
15
17
  const input = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
16
18
  const record = { type: 'object', properties: {}, additionalProperties: true };
19
+ // A capsule figure is cast by model id; its posture is the one body choice it
20
+ // has. A patched posture outside POSTURES normalizes to "stand" (createCharacterEntry).
21
+ const newCharacter = { ...record, properties: { model: { type: 'string', enum: [...CHARACTER_KIND_IDS] }, posture: { type: 'string', enum: [...POSTURES] } }, required: [] };
22
+ const characterPatch = { ...record, properties: { posture: { type: 'string', description: `One of ${POSTURES.join(', ')}; anything else stands.` } }, required: [] };
17
23
  const mutation = (id, label, schema, exposure = 'open') => ({ id, label, description: label, kind: 'mutation', undoDomain: 'cast', input: schema, exposure });
18
24
  const blocks = elementSetSchema('character').properties.set.properties.layer.properties.promptClips;
19
25
  const semantic = [
20
26
  mutation('character.set', 'Set character fields', elementSetSchema('character')),
21
- mutation('character.add', 'Add character', input({ character: record })),
27
+ mutation('character.add', 'Add character', input({ character: newCharacter })),
22
28
  mutation('character.remove', 'Remove character', input({ characterId: id })),
23
- mutation('character.update', 'Update character', input({ characterId: id, patch: record })),
29
+ mutation('character.update', 'Update character', input({ characterId: id, patch: characterPatch })),
30
+ studioActionDeclaration('character.move'),
24
31
  mutation('character.setPose', 'Set character pose', input({ characterId: id, pose: { oneOf: [id, record, { type: 'null' }] }, clearMotion: { type: 'boolean' } }, ['characterId', 'pose'])),
25
32
  mutation('character.setPromptBlocks', 'Set prompt blocks', input({ characterId: id, blocks })),
26
33
  mutation('characters.arrange', 'Arrange characters', STUDIO_TOOL_SCHEMAS.arrange_characters),
@@ -41,7 +48,14 @@ export function register(registry, ports) {
41
48
  const mounted = () => Boolean(ports.storeDomain?.('cast')) || 'The cast document owner is not mounted.';
42
49
  const character = characterId => owner().read().find(row => row.id === characterId) ?? fail('STALE_TARGET', `Character ${characterId} is not in this scene.`);
43
50
  const pose = value => typeof value === 'string' ? owner().poses().find(row => row.id === value) ?? fail('STALE_TARGET', `Pose ${value} is not in the library.`) : value;
44
- const patch = (characterId, update) => { character(characterId); owner().write(rows => rows.map(row => row.id === characterId ? { ...row, ...update } : row)); };
51
+ const patch = (characterId, update) => { character(characterId); owner().update(characterId, update); };
52
+ // A character groups under a scene object that stands in the world. An
53
+ // object a character carries is refused: it rides a bone, not a route.
54
+ const groupParent = value => {
55
+ if (value === null || value === undefined) return;
56
+ const object = ports.state().objects.find(row => row.id === value) ?? fail('STALE_TARGET', `Object ${value} is not in this scene.`);
57
+ if (object.attach) fail('INVALID_ARGUMENT', 'A character cannot be grouped under an object that a character carries.');
58
+ };
45
59
  function promptEdit(command, args) {
46
60
  const before = character(args.characterId), clips = before.layer.promptClips;
47
61
  let next;
@@ -72,7 +86,13 @@ export function register(registry, ports) {
72
86
  owner().write(rows => [...rows, row]);
73
87
  },
74
88
  'character.remove': ({ characterId }) => { character(characterId); if (owner().read().length <= 1) fail('INVALID_ARGUMENT', 'Cannot remove the final character.'); owner().write(rows => rows.filter(row => row.id !== characterId)); },
75
- 'character.update': ({ characterId, patch: value }) => patch(characterId, value),
89
+ 'character.update': ({ characterId, patch: value }) => {
90
+ // Rig <-> capsule figure is a different kind of cast member; rig-to-rig model swaps stay.
91
+ if (Object.hasOwn(value, 'model') && (value.model === PROXY_FIGURE_MODEL) !== isProxyFigure(character(characterId))) fail('INVALID_ARGUMENT', KIND_FIXED_REFUSAL.en);
92
+ if (Object.hasOwn(value, 'parent')) groupParent(value.parent);
93
+ patch(characterId, value);
94
+ },
95
+ 'character.move': ({ characterId, ...value }) => patch(characterId, value),
76
96
  'character.setPose': ({ characterId, pose: value, clearMotion }) => { character(characterId); owner().applyPose(characterId, pose(value), clearMotion); },
77
97
  'character.setPromptBlocks': ({ characterId, blocks }) => {
78
98
  const entry = character(characterId); patch(characterId, { layer: { ...entry.layer, promptClips: blocks } });
@@ -96,6 +116,7 @@ export function register(registry, ports) {
96
116
  registry.register({ ...entry, available: mounted, run(args) {
97
117
  // A pose id selects a complete library entry, not a deep bone patch.
98
118
  const ops = args.ops ?? [args];
119
+ for (const op of ops) if (Object.hasOwn(op.set, 'parent')) groupParent(op.set.parent);
99
120
  const poses = ops.filter(op => Object.hasOwn(op.set, 'pose')).map(op => ({ id: op.id, value: pose(op.set.pose) }));
100
121
  const fields = ops.map(({ id, set: { pose: _pose, ...set } }) => ({ id, set }));
101
122
  const result = entry.run(args.ops ? { ops: fields } : fields[0]);
@@ -103,10 +124,13 @@ export function register(registry, ports) {
103
124
  return result;
104
125
  } });
105
126
  } }, ports, semantic[0]);
106
- for (const declaration of semantic.slice(1)) registry.register({ ...declaration, available: mounted, run(args) {
107
- if (declaration.id === 'characters.arrange') { const plan = owner().arrange(args); return { affectedIds: plan.affectedIds, summary: 'Arranged characters.' }; }
127
+ for (const declaration of semantic.slice(1)) registry.register({ ...declaration, available: mounted, run(args, context) {
128
+ if (declaration.id === 'characters.arrange') { const plan = owner().arrange(args); return { affectedIds: plan.affectedIds, summary: 'Arranged characters.', checks: plan.checks, warnings: plan.warnings }; }
108
129
  const before = owner().read();
109
130
  if (declaration.id.endsWith('PromptBlock')) promptEdit(declaration.id, args); else methods[declaration.id](args);
131
+ // The Assets tile and the MCP live path report themselves; this is the agent and
132
+ // any other non-UI bus caller adding a capsule figure.
133
+ if (declaration.id === 'character.add' && context?.origin && context.origin !== 'ui' && isProxyFigure(owner().read().find(row => !before.some(prior => prior.id === row.id)))) track('cast:proxy_added', { surface: 'agent' });
110
134
  const changed = changedIds(before, owner().read());
111
135
  return { affectedIds: changed.length ? changed : [args.characterId ?? ports.state().activeSceneId], summary: declaration.label };
112
136
  } });
@@ -14,7 +14,7 @@ const elements = STUDIO_ELEMENTS.filter(row => row.path.startsWith('object.')).m
14
14
  return { ...element, ...(components[field] ? { documentPath: components[field] } : {}),
15
15
  ...(field === 'remove' ? { readback: item => ({ flag: item === undefined }) } : {}) };
16
16
  });
17
- export function normalizeObjectSet(record) {
17
+ function normalizeObjectSet(record) {
18
18
  const item = normalizeSceneObject(record);
19
19
  // The owning collection consumes this lifecycle intent before persistence.
20
20
  return record.remove === true ? { ...item, remove: true } : item;
@@ -2,7 +2,7 @@ import { createShotAuthoringDocument } from '../../shot-authoring.js';
2
2
  import { STUDIO_ELEMENTS } from '../../studio-elements.js';
3
3
  import { registerElementKind } from '../elements.js';
4
4
 
5
- export function normalizeShot(record) {
5
+ function normalizeShot(record) {
6
6
  return createShotAuthoringDocument({ shots: [record], frameCount: Math.max(24, record.endFrame + 1) }).shots[0];
7
7
  }
8
8
  registerElementKind('shot', { collection: true, documentKey: 'shots',
@@ -58,7 +58,7 @@ export function readElement(document, path) {
58
58
  ? Object.fromEntries(Object.entries(element.documentPath).map(([component, stored]) => [component, atPath(document, stored)]))
59
59
  : atPath(document, setPath(element));
60
60
  }
61
- export function mergeElementSet(document = {}, patch) {
61
+ function mergeElementSet(document = {}, patch) {
62
62
  document = document ?? {};
63
63
  const next = { ...document };
64
64
  for (const [key, value] of Object.entries(patch)) next[key] = value && typeof value === 'object' && !Array.isArray(value)
@@ -10,11 +10,11 @@ export function register(registry, ports) {
10
10
  registry.register({ ...studioActionDeclaration("export.shotVideo"),
11
11
  available: state => state.exporting ? "An export is already running; wait for it to finish."
12
12
  : state.canExportVideo || "There is nothing to record yet: add a shot (shot.create), camera keys or a motion take first.",
13
- run: async ({ shotId }, context) => {
13
+ run: async ({ shotId, scope }, context) => {
14
14
  const shot = shotId ? shotOf(ports, shotId) : null;
15
- const result = await ports.exportShotVideo({ shotId: shotId ?? null }, context);
15
+ const result = await ports.exportShotVideo({ shotId: shotId ?? null, ...(scope ? { scope } : {}) }, context);
16
16
  if (!result?.fileName) fail("TARGET_NOT_READY", "The video export did not finish; the editor's export panel shows why and offers Retry.");
17
17
  return { affectedIds: shot ? [shot.id] : [], output: { fileName: result.fileName, frameCount: result.frameCount },
18
- summary: `Recorded ${shot ? shotLabel(shot) : "the shot"} to ${result.fileName} (${result.frameCount} frames); the browser was asked to download it.` };
18
+ summary: `Recorded ${scope === "timeline" ? "the whole timeline" : shot ? shotLabel(shot) : "the shot"} to ${result.fileName} (${result.frameCount} frames); the browser was asked to download it.` };
19
19
  } });
20
20
  }
@@ -70,7 +70,7 @@ const queued = ['motion.commitLineEdit', 'motion.regenerateTrail'].map(id => ({
70
70
  const legacyIk = ['character.setIkKey', 'character.removeIkKey', 'character.clearIkKeys'].map(studioActionDeclaration);
71
71
  const ik = legacyIk.map((entry, index) => ({ ...entry, id: ['ik.setKey', 'ik.removeKey', 'ik.clearKeys'][index] }));
72
72
 
73
- const clear = { id: 'motion.clear', label: 'Clear motion', description: 'Clear the active take, its corrections and take-owned cast fields.',
73
+ const clear = { id: 'motion.clear', label: 'Clear motion', description: 'Clear the active take, its corrections, prompt blocks and take-owned cast fields.',
74
74
  kind: 'mutation', undoDomain: 'motion', input: { type: 'object', properties: { characterId: { type: 'string' } }, required: ['characterId'], additionalProperties: false } };
75
75
  const videoDraft = { id: 'motion.setVideoDraft', label: 'Set video motion draft', description: 'Set the uncommitted AI-video form without changing the project or its history.', kind: 'transient',
76
76
  input: input({ instruction: { oneOf: [{ const: '' }, { type: 'string', maxLength: 3700 }] }, promptOverride: { oneOf: [{ const: '' }, { type: 'string', maxLength: 3700 }] }, duration: { oneOf: I2V_MOTION_DURATIONS.map(value => ({ const: value })) } }, []) };
@@ -194,7 +194,7 @@ export function register(registry, ports) {
194
194
  if (ports.state().activeCharacterId !== characterId) fail('TARGET_NOT_READY', 'Select this character before clearing its take.');
195
195
  ports.clearMotionNative();
196
196
  }
197
- return { affectedIds: [characterId], summary: 'Cleared motion.' };
197
+ return { affectedIds: [characterId], summary: 'Cleared motion and prompt blocks.' };
198
198
  } });
199
199
  registry.register({ ...studioActionDeclaration("motion.generateAllBlocks"), target: () => ports.state().activeCharacterId,
200
200
  available: state => state.generating ? "A motion generation is already running."