cozyclay 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  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/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/LIVE-PROTOCOL.md +1 -1
  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 +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  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/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. package/src/scene-history.js +0 -129
@@ -28,10 +28,10 @@ import { createHash, randomUUID } from "node:crypto";
28
28
 
29
29
  import { z } from "zod";
30
30
 
31
- import { LiveMutationUncertainError } from "./live-hub.mjs";
31
+ import { MAX_COMMAND_TIMEOUT_MS } from "./live-hub.mjs";
32
32
  import { readMeshFromPath } from "./mesh-file.mjs";
33
- import { readMotionStream } from "../bin/agent/motion-runtime.mjs";
34
- import { motionPreflightReason, startMotionRequest } from "../src/analytics.js";
33
+ import { DEFAULT_POSE } from "../src/poses.js";
34
+ import { generationArgs } from '../src/motion/generation.js';
35
35
  import { BLOCK_MAX_SECONDS, PROMPT_GUIDE, normalizePhases, splitLongBeat, tileClipFrames } from "./ardy-prompts.mjs";
36
36
 
37
37
  import {
@@ -99,6 +99,7 @@ const liveWorkspaceTools = new Set([
99
99
  "set_camera", "frame_shot", "add_character", "place_character", "remove_character",
100
100
  "focus_character", "place_object", "import_mesh", "group_objects", "set_prompt_blocks", "generate_motion", "update_object",
101
101
  "remove_object", "apply_batch", "add_scene", "switch_scene", "open_project", "capture_frame", "load_motion",
102
+ "studio_commands", "studio_run",
102
103
  ]);
103
104
  const MAX_CAPTURE_BYTES = 1_000_000;
104
105
  const CAPTURE_ARTIFACT_TTL_MS = 10 * 60_000;
@@ -166,8 +167,41 @@ const TOOL_ANNOTATIONS = Object.freeze({
166
167
  switch_scene: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
167
168
  open_project: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
168
169
  save_project: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
170
+ studio_commands: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
171
+ // Whatever the editor registers: some commands delete, export or spend a
172
+ // paid generation, and those answer CONFIRMATION_REQUIRED instead of running.
173
+ studio_run: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
169
174
  });
170
175
 
176
+ /** The document identity a Studio command is admitted in. */
177
+ const STUDIO_IDENTITY_KEYS = ["workspaceId", "documentEpoch", "sceneId", "sceneEpoch"];
178
+
179
+ /** One registered editor command through the editor's own bus, admitted at
180
+ * the open document and its current revision like the agent's run_action. Its
181
+ * declaration (read from the editor, never from this server) sets the hub
182
+ * deadline unless the caller gives one. */
183
+ const executeStudioCommand = async ({ action, args, expectedRevision, commandId, timeoutMs, confirmationToken, inspected }) => {
184
+ const workspaceHandle = liveWorkspace.getStore();
185
+ inspected ??= await liveHub.command("inspect_studio", { scope: "actions", ids: [action] }, workspaceHandle);
186
+ const context = inspected?.context;
187
+ if (!context?.host || !Number.isSafeInteger(context.revision?.scene)) throw new Error("The editor did not return a Studio context to admit this command against.");
188
+ const declared = inspected.actions?.find((row) => row.id === action);
189
+ const receipt = await liveHub.command("run_action", {
190
+ name: "run_action",
191
+ args: { action, args: typeof args === "function" ? await args(context) : args ?? {}, ...(confirmationToken ? { confirmationToken } : {}) },
192
+ commandId: commandId ?? randomUUID(),
193
+ host: Object.fromEntries(STUDIO_IDENTITY_KEYS.map((key) => [key, context.host[key]])),
194
+ expectedRevision: expectedRevision ?? context.revision.scene,
195
+ }, workspaceHandle, { timeoutMs: timeoutMs ?? (declared?.timeoutMs === undefined ? undefined : Math.min(MAX_COMMAND_TIMEOUT_MS, declared.timeoutMs + (declared.generation ? 5000 : 0))) });
196
+ return receipt;
197
+ };
198
+ // A refusal is the editor's receipt: its code and recovery are the answer.
199
+ const studioResult = (...receipts) => ({
200
+ content: receipts.map(receipt => ({ type: "text", text: JSON.stringify(receipt) })),
201
+ ...(receipts.some(receipt => receipt.ok === false) ? { isError: true } : {}),
202
+ });
203
+ const runStudioCommand = async options => studioResult(await executeStudioCommand(options));
204
+
171
205
  const scene = () => activeScene(state.doc.scenes, state.doc.activeSceneId);
172
206
  const stage = () => scene().stage;
173
207
 
@@ -189,6 +223,13 @@ const findCharacter = (ref) => {
189
223
  return null;
190
224
  };
191
225
 
226
+ // Resolve legacy letters and slots from the complete live cast inside the
227
+ // inspected admission. A missing id is refused by the command, not hidden here.
228
+ const liveCharacterId = async ref => {
229
+ await refreshLiveDescription();
230
+ return findCharacter(ref)?.id ?? ref;
231
+ };
232
+
192
233
  /** The studio labels the cast A, B, C… by position. */
193
234
  const letterFor = (character) => String.fromCharCode(65 + cast().indexOf(character));
194
235
 
@@ -245,31 +286,6 @@ const framing = () => {
245
286
 
246
287
  const currentShot = () => deriveShot(state.camera, subject(), fov(), undefined, filmback());
247
288
 
248
- const appliedLiveMutation = async (name, args) => {
249
- const workspaceHandle = liveWorkspace.getStore();
250
- const value = await liveHub.command(name, args, workspaceHandle);
251
- try {
252
- if (!await refreshLiveDescription(workspaceHandle)) throw new Error("Live editor disconnected before verification.");
253
- } catch (error) {
254
- throw new LiveMutationUncertainError(`Live editor accepted ${name}, but its state could not be verified: ${error.message} The mutation may have been applied. Do not retry it; describe the scene before choosing a recovery action.`);
255
- }
256
- return value;
257
- };
258
-
259
- const requireLiveSceneParity = (name, document, result) => {
260
- const expected = document.scenes.map(({ id, name: sceneName }) => ({ id, name: sceneName }));
261
- const received = Array.isArray(result?.scenes) ? result.scenes : [];
262
- const sameScenes = received.length === expected.length && received.every((scene, index) =>
263
- scene?.id === expected[index].id && scene?.name === expected[index].name,
264
- );
265
- if (sameScenes && result?.activeSceneId === document.activeSceneId) return;
266
- throw new LiveMutationUncertainError(
267
- `Live editor accepted ${name}, but did not confirm the complete scene list and active scene ` +
268
- `(expected ${JSON.stringify(expected)} active ${document.activeSceneId}; received ${JSON.stringify(received)} active ${result?.activeSceneId ?? "none"}). ` +
269
- "The mutation may have been applied. Do not retry it; describe the scene before choosing a recovery action.",
270
- );
271
- };
272
-
273
289
  const modelById = (id) =>
274
290
  [...VIDEO_MODELS, ...IMAGE_MODELS].find((m) => m.id === id) ?? null;
275
291
 
@@ -409,7 +425,8 @@ function sceneReport({ characterCursor = 0, objectCursor = 0, limit = 50 } = {})
409
425
  const size = objectSize(object);
410
426
  lines.push(
411
427
  ` ${object.id} ${object.name} at x ${round(object.x)}, y ${round(object.y)}, z ${round(object.z)}` +
412
- ` yaw ${round(object.rot, 1)}deg size ${round(size.width)}x${round(size.height)}x${round(size.depth)}m`,
428
+ ` yaw ${round(object.rot, 1)}deg size ${round(size.width)}x${round(size.height)}x${round(size.depth)}m` +
429
+ `${object.hidden ? " hidden" : ""}`,
413
430
  ` rotX: ${round(object.rotX ?? 0, 1)} rotZ: ${round(object.rotZ ?? 0, 1)} color: ${object.color ?? null} parent: ${object.parent ?? null}`,
414
431
  );
415
432
  if (object.path?.points?.length >= 2) {
@@ -469,13 +486,24 @@ function shotReport() {
469
486
 
470
487
  /* --------------------------------- tools --------------------------------- */
471
488
 
489
+ const studioAliasTools = new Set([
490
+ "set_camera", "frame_shot", "place_object", "update_object", "remove_object",
491
+ "import_mesh", "group_objects", "add_scene", "switch_scene", "apply_batch", "open_project",
492
+ "add_character", "place_character", "remove_character", "set_prompt_blocks", "load_motion", "generate_motion",
493
+ ]);
494
+ const studioAdmissionSchema = {
495
+ expectedRevision: z.number().int().min(0).optional().describe("scene revision to admit against; defaults to the inspected revision"),
496
+ commandId: z.string().min(1).max(120).optional().describe("idempotency key; reuse with the original expectedRevision to replay its receipt"),
497
+ timeoutMs: z.number().int().min(1).max(MAX_COMMAND_TIMEOUT_MS).optional().describe("hub deadline; defaults to the editor command declaration"),
498
+ };
499
+
472
500
  /** One tool, as the registry hands it out: everything server.mjs needs to
473
501
  * register it, and everything a direct caller needs to run it. */
474
502
  const tool = (name, config, handler) => ({
475
503
  name,
476
504
  title: config.title,
477
505
  description: config.description,
478
- inputSchema: config.inputSchema,
506
+ inputSchema: studioAliasTools.has(name) ? { ...config.inputSchema, ...studioAdmissionSchema } : config.inputSchema,
479
507
  annotations: TOOL_ANNOTATIONS[name],
480
508
  live: liveWorkspaceTools.has(name),
481
509
  handler,
@@ -488,14 +516,9 @@ const tool = (name, config, handler) => ({
488
516
  * @param {Promise<string>} [deps.projectRootPromise] the resolved directory
489
517
  * `open_project`/`save_project` confine themselves to; the owner resolves it
490
518
  * because doing so chdirs the process.
491
- * @param {object} [deps.motionJobs] the MotionJobRegistry `generate_motion`
492
- * records work in.
493
- * @param {(job: object) => Promise<void>} [deps.publishMotionJob] delivers a
494
- * finished job to its workspace; the owner holds it because the live hub's
495
- * reconnect and cancel paths publish jobs too.
496
519
  * @returns {Array<{name: string, title: string, description: string, inputSchema: object, annotations: object, live: boolean, handler: Function}>}
497
520
  */
498
- export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMotionJob } = {}) => {
521
+ export const createToolHandlers = ({ projectRootPromise } = {}) => {
499
522
  const resolveProjectPath = async (path, { existing }) => {
500
523
  if (!path.endsWith(".cclayproject")) throw new Error("Project path must end in .cclayproject.");
501
524
  const root = await projectRootPromise;
@@ -674,17 +697,19 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
674
697
  look_at_z: z.number().optional().describe("world z of an explicit aim point; give all three to aim the lens"),
675
698
  },
676
699
  },
677
- async ({ x, y, z: zPos, focal_mm, look_at_x, look_at_y, look_at_z }) => {
700
+ async ({ x, y, z: zPos, focal_mm, look_at_x, look_at_y, look_at_z, ...admission }) => {
678
701
  if (liveHub?.connected) {
679
702
  try {
680
- // The aim is only forwarded when the caller gave a whole point: a
681
- // partial target has no direction, and an omitted one deliberately
682
- // leaves the live editor's orientation exactly where it was.
683
- const aim = [look_at_x, look_at_y, look_at_z].every((value) => value !== undefined)
684
- ? { lookAtX: look_at_x, lookAtY: look_at_y, lookAtZ: look_at_z }
685
- : {};
686
- await appliedLiveMutation("set_camera", { x, y, z: zPos, focalMm: focal_mm, ...aim });
687
- return text(`Camera set.\n\n${shotReport()}`);
703
+ return await runStudioCommand({ ...admission, action: "shot.frame", args: context => {
704
+ const camera = context.camera;
705
+ const position = { x: x ?? camera.position.x, y: y ?? camera.position.y, z: zPos ?? camera.position.z };
706
+ // Preserve direction when no complete explicit aim point was given.
707
+ const lookAt = [look_at_x, look_at_y, look_at_z].every(value => value !== undefined)
708
+ ? { x: look_at_x, y: look_at_y, z: look_at_z }
709
+ : Object.fromEntries(["x", "y", "z"].map(axis => [axis, position[axis] + camera.lookAt[axis] - camera.position[axis]]));
710
+ return { subjectIds: [state.focusLocked ? state.focus : context.activeCharacterId],
711
+ framing: { exact: { position, lookAt, focalMm: focal_mm ?? camera.focalMm } } };
712
+ } });
688
713
  } catch (error) {
689
714
  return liveError(error);
690
715
  }
@@ -729,9 +754,11 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
729
754
  focal_mm: z.number().min(8).max(300).default(35).describe("lens to frame with"),
730
755
  },
731
756
  },
732
- async ({ size, view, level, side, focal_mm }) => {
757
+ async ({ size, view, level, side, focal_mm, ...admission }) => {
758
+ let inspected;
733
759
  if (liveHub?.connected) {
734
760
  try {
761
+ inspected = await liveHub.command("inspect_studio", { scope: "actions", ids: ["shot.frame"] }, liveWorkspace.getStore());
735
762
  await refreshLiveDescription();
736
763
  } catch (error) {
737
764
  return liveError(error);
@@ -804,19 +831,13 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
804
831
  };
805
832
  if (liveHub?.connected) {
806
833
  try {
807
- // Placing the lens is only half the shot. deriveShot and captureFraming
808
- // both measure as if the lens points at the framing pivot (see
809
- // aimAtSubject), which the in-memory path gets for free because it has
810
- // no orientation at all. A live editor has one and keeps it, so a
811
- // position-only move orbited every view except `front` off the subject
812
- // while the slate still read "98% of frame height". Send the same pivot
813
- // the vocabulary is measured against, with the position.
814
- await appliedLiveMutation("set_camera", {
815
- ...nextCamera,
816
- lookAtX: s.x,
817
- lookAtY: FRAMING_PIVOT_Y,
818
- lookAtZ: s.z,
819
- });
834
+ return await runStudioCommand({ ...admission, action: "shot.frame", inspected, args: {
835
+ subjectIds: [(findCharacter(state.focus) ?? cast()[0]).id],
836
+ framing: { exact: {
837
+ position: { x: nextCamera.x, y: nextCamera.y, z: nextCamera.z },
838
+ lookAt: { x: s.x, y: FRAMING_PIVOT_Y, z: s.z }, focalMm: lensMm,
839
+ } },
840
+ } });
820
841
  } catch (error) {
821
842
  return liveError(error);
822
843
  }
@@ -851,13 +872,12 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
851
872
  .describe("which mannequin to use"),
852
873
  },
853
874
  },
854
- async ({ subject: desc, x, z: zPos, facing, model }) => {
875
+ async ({ subject: desc, x, z: zPos, facing, model, ...admission }) => {
855
876
  if (liveHub?.connected) {
856
877
  try {
857
- const result = await appliedLiveMutation("add_character", { subject: desc, x, z: zPos, rot: facing, model });
858
- const added = stage().characters.find((character) => character.id === result?.id);
859
- if (added && model !== undefined) added.model = model;
860
- return text(`Added ${result?.id ?? "character"}.\n\n${sceneReport()}`);
878
+ return await runStudioCommand({ ...admission, action: "character.add", args: {
879
+ character: { subject: desc, x, z: zPos, rot: facing, model, pose: DEFAULT_POSE },
880
+ } });
861
881
  } catch (error) {
862
882
  return liveError(error);
863
883
  }
@@ -900,11 +920,12 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
900
920
  hidden: z.boolean().optional().describe("hide without removing from the cast"),
901
921
  },
902
922
  },
903
- async ({ character, x, z: zPos, y, facing, subject: desc, hidden }) => {
923
+ async ({ character, x, z: zPos, y, facing, subject: desc, hidden, ...admission }) => {
904
924
  if (liveHub?.connected) {
905
925
  try {
906
- await appliedLiveMutation("update_character", { ref: character, x, y, z: zPos, rot: facing, subject: desc, hidden });
907
- return text(`Character updated.\n\n${sceneReport()}`);
926
+ return await runStudioCommand({ ...admission, action: "character.update", args: async () => ({
927
+ characterId: await liveCharacterId(character), patch: { x, y, z: zPos, rot: facing, subject: desc, hidden },
928
+ }) });
908
929
  } catch (error) {
909
930
  return liveError(error);
910
931
  }
@@ -930,11 +951,12 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
930
951
  character: z.string().describe('which character — letter, slot number or id'),
931
952
  },
932
953
  },
933
- async ({ character }) => {
954
+ async ({ character, ...admission }) => {
934
955
  if (liveHub?.connected) {
935
956
  try {
936
- await appliedLiveMutation("remove_character", { ref: character });
937
- return text(`Character removed.\n\n${sceneReport()}`);
957
+ return await runStudioCommand({ ...admission, action: "character.remove", args: async () => ({
958
+ characterId: await liveCharacterId(character),
959
+ }) });
938
960
  } catch (error) {
939
961
  return liveError(error);
940
962
  }
@@ -1000,11 +1022,10 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1000
1022
  .describe("object id to attach to — the parent then carries this object when it moves"),
1001
1023
  },
1002
1024
  },
1003
- async ({ kind, x, z: zPos, y, facing, name, parent }) => {
1025
+ async ({ kind, x, z: zPos, y, facing, name, parent, ...admission }) => {
1004
1026
  if (liveHub?.connected) {
1005
1027
  try {
1006
- const result = await appliedLiveMutation("place_object", { kind, x, z: zPos, y, rot: facing, name, parent });
1007
- return text(`Placed object as ${result?.id ?? "unknown"}.\n\n${sceneReport()}`);
1028
+ return await runStudioCommand({ ...admission, action: "object.add", args: { kind, placement: { x, z: zPos, y, rot: facing }, name, parent } });
1008
1029
  } catch (error) {
1009
1030
  return liveError(error);
1010
1031
  }
@@ -1052,7 +1073,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1052
1073
  height: z.number().positive().optional().describe("standing height in metres; omitted uses the fitted size"),
1053
1074
  },
1054
1075
  },
1055
- async ({ path, clay, name, x, z: zPos, y, facing, height }) => {
1076
+ async ({ path, clay, name, x, z: zPos, y, facing, height, ...admission }) => {
1056
1077
  if (!liveHub?.connected) {
1057
1078
  return liveError(new Error(noLiveEditor("import_mesh requires a connected CozyClay editor.")));
1058
1079
  }
@@ -1065,7 +1086,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1065
1086
  const liveArgs = {
1066
1087
  name: typeof name === "string" && name.trim() ? name.trim() : mesh.name,
1067
1088
  mimeType: mesh.mimeType,
1068
- dataUrl: `data:${mesh.mimeType};base64,${Buffer.from(mesh.bytes).toString("base64")}`,
1089
+ source: `data:${mesh.mimeType};base64,${Buffer.from(mesh.bytes).toString("base64")}`,
1069
1090
  placeAs: "mesh",
1070
1091
  };
1071
1092
  if (clay === true) liveArgs.clay = true;
@@ -1077,10 +1098,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1077
1098
  if (facing !== undefined) liveArgs.rot = facing;
1078
1099
  if (height !== undefined) liveArgs.height = height;
1079
1100
  try {
1080
- const result = await appliedLiveMutation("import_asset", liveArgs);
1081
- return text(
1082
- `Placed ${liveArgs.name} as ${result?.objectId ?? "unknown"} (${result?.assetId ?? "unknown"}).\n\n${sceneReport()}`,
1083
- );
1101
+ return await runStudioCommand({ ...admission, action: "asset.import", args: liveArgs });
1084
1102
  } catch (error) {
1085
1103
  return liveError(error);
1086
1104
  }
@@ -1101,16 +1119,10 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1101
1119
  children: z.array(z.string()).min(1).describe("object ids to attach or detach"),
1102
1120
  },
1103
1121
  },
1104
- async ({ parent, children }) => {
1122
+ async ({ parent, children, ...admission }) => {
1105
1123
  if (liveHub?.connected) {
1106
1124
  try {
1107
- await appliedLiveMutation(parent === null ? "ungroup_objects" : "group_objects", { parent, children });
1108
- return text(
1109
- (parent === null
1110
- ? `Detached ${children.length} object(s).`
1111
- : `Grouped ${children.length} object(s) under ${parent} — move ${parent} and they follow.`) +
1112
- `\n\n${sceneReport()}`,
1113
- );
1125
+ return await runStudioCommand({ ...admission, action: parent === null ? "object.ungroup" : "object.group", args: parent === null ? { children } : { parent, children } });
1114
1126
  } catch (error) {
1115
1127
  return liveError(error);
1116
1128
  }
@@ -1135,7 +1147,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1135
1147
  description:
1136
1148
  "Write Prompt Blocks onto the timeline WITHOUT generating — the beats and their frame " +
1137
1149
  "ranges, so a schedule can be read and revised before any GPU time is spent. Hit " +
1138
- "'Generate all N blocks' in the studio, or call generate_motion, when it reads right.\n\n" +
1150
+ "the studio's top-bar Generate Motion button, or call generate_motion, when it reads right.\n\n" +
1139
1151
  PROMPT_GUIDE,
1140
1152
  inputSchema: {
1141
1153
  beats: z
@@ -1155,7 +1167,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1155
1167
  .describe("beats in order; each one becomes a contiguous block"),
1156
1168
  },
1157
1169
  },
1158
- async ({ beats }) => {
1170
+ async ({ beats, ...admission }) => {
1159
1171
  if (!liveHub?.connected) {
1160
1172
  return text("Prompt Blocks live on the studio timeline — open the editor and try again.");
1161
1173
  }
@@ -1163,12 +1175,10 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1163
1175
  // The timeline runs on a 24 fps production clock.
1164
1176
  const TIMELINE_FPS = 24;
1165
1177
  let cursor = 0;
1166
- let chained = 0;
1167
1178
  const blocks = [];
1168
1179
  for (const [i, textValue] of normalized.texts.entries()) {
1169
1180
  const whole = beats[Math.min(normalized.sources[i], beats.length - 1)].seconds ?? 3;
1170
1181
  const spans = splitLongBeat(whole);
1171
- if (spans.length > 1) chained += spans.length - 1;
1172
1182
  for (const span of spans) {
1173
1183
  const frames = Math.max(1, Math.round(span * TIMELINE_FPS));
1174
1184
  blocks.push({ startFrame: cursor, endFrame: cursor + frames, text: textValue });
@@ -1176,24 +1186,12 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1176
1186
  }
1177
1187
  }
1178
1188
  try {
1179
- await appliedLiveMutation("set_prompt_blocks", { blocks });
1189
+ return await runStudioCommand({ ...admission, action: "character.setPromptBlocks", args: context => ({
1190
+ characterId: context.activeCharacterId, blocks,
1191
+ }) });
1180
1192
  } catch (error) {
1181
1193
  return liveError(error);
1182
1194
  }
1183
- // Report the normalised text itself, not blocks[i]: a long beat becomes
1184
- // several blocks, so block indices run ahead of phase indices and quoting
1185
- // blocks[i] would attribute one beat's edits to another beat's wording.
1186
- const rewrites = normalized.notes
1187
- .map((notes, i) => (notes.length ? ` ${i + 1}. ${normalized.texts[i]} ← ${notes.join("; ")}` : null))
1188
- .filter(Boolean);
1189
- return text(
1190
- `${blocks.length} block(s) on the timeline (${(cursor / TIMELINE_FPS).toFixed(1)}s total):\n` +
1191
- blocks.map((b) => ` ${b.startFrame}-${b.endFrame}f ${b.text}`).join("\n") +
1192
- (chained > 0 ? `\n (${chained} block(s) chained to keep every block within ${BLOCK_MAX_SECONDS}s)` : "") +
1193
- (rewrites.length ? `\n\nRewritten for ARDY:\n${rewrites.join("\n")}` : "") +
1194
- (normalized.dropped > 0 ? `\n (${normalized.dropped} beat(s) past the 8-phase limit were dropped)` : "") +
1195
- "\n\nGenerate them from the studio's Prompt Blocks panel, or with generate_motion.",
1196
- );
1197
1195
  },
1198
1196
  ),
1199
1197
 
@@ -1215,23 +1213,10 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1215
1213
  if (!motionUrlPattern.test(args.url)) {
1216
1214
  throw new Error(`Unsupported motion url "${args.url}". Use /ardy/motions/<id> or /ardy/assembled/<name>.npz.`);
1217
1215
  }
1218
- const workspaceHandle = liveWorkspace.getStore() ?? liveHub.resolveWorkspace("load_motion", args.workspace_handle);
1219
- // Without an explicit target the editor installs onto ITS active character,
1220
- // which silently stacks multi-cast loads onto one rig — the generate path
1221
- // always names its target, so the load path must be able to as well.
1222
- let characterId;
1223
- if (args.character !== undefined) {
1224
- try {
1225
- await refreshLiveDescription();
1226
- } catch (error) {
1227
- return liveError(error);
1228
- }
1229
- const target = findCharacter(args.character);
1230
- if (!target) return text(`No character "${args.character}". ${castHint()}`);
1231
- characterId = target.id;
1232
- }
1233
- await liveHub.command("load_motion", { url: args.url, prompt: args.prompt ?? "", ...(characterId ? { characterId } : {}) }, workspaceHandle);
1234
- return text(`Motion installed from ${args.url}${characterId ? ` onto ${characterId}` : ""}.`);
1216
+ return runStudioCommand({ ...args, action: 'motion.replace', args: async context => ({
1217
+ characterId: args.character === undefined ? context.activeCharacterId : await liveCharacterId(args.character),
1218
+ url: args.url, prompt: args.prompt ?? '',
1219
+ }) });
1235
1220
  },
1236
1221
  ),
1237
1222
 
@@ -1240,9 +1225,8 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1240
1225
  {
1241
1226
  title: "Generate character motion (Kimodo)",
1242
1227
  description:
1243
- "Start a multi-phase Kimodo motion job for the connected live editor and return immediately with its " +
1244
- "task-shaped identity. Completion, failure, or cancellation arrives as the live socket's motion_job event; " +
1245
- "the MCP model does not poll task state. A completed event loads the active character's timeline.\n\n" +
1228
+ "Alias for motion.generate in the editor. Returns its bus receipt; started jobs carry jobId for studio_run job.await or job.cancel. " +
1229
+ "The installed take is undoable with studio_run edit.undo.\n\n" +
1246
1230
  PROMPT_GUIDE,
1247
1231
  inputSchema: {
1248
1232
  phases: z
@@ -1289,203 +1273,16 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1289
1273
  ),
1290
1274
  },
1291
1275
  },
1292
- async ({ phases: rawPhases, seconds, seed, motion_url, drop }) => {
1293
- const phases = rawPhases.map((p) => (typeof p === "string" ? p : p.text));
1294
- const phaseSeconds = rawPhases.map((p) => (typeof p === "string" ? null : p.seconds));
1295
- const timed = phaseSeconds.some((s) => s !== null);
1296
- const bridge = (process.env.COZYCLAY_BRIDGE_ORIGIN ?? process.env.COZYCLAY_BRIDGE ?? "http://127.0.0.1:5181").replace(/\/$/, "");
1297
-
1298
- // Every phase is rewritten into ARDY's own sentence shape before it is ever
1299
- // sent. One input beat stays one phase: the caller's phase list is the
1300
- // sequence, so a composite beat is theirs to split, not ours to re-cut.
1301
- const normalized = normalizePhases(phases);
1302
- // Keep notes beside the prompt they belong to: a blank beat normalises to
1303
- // "" and is dropped, which would otherwise slide every later note onto the
1304
- // wrong prompt in the rewrite report.
1305
- const kept = normalized.texts
1306
- .map((t, i) => ({ text: t, notes: normalized.notes[i], source: normalized.sources[i] }))
1307
- .filter((p) => p.text);
1308
- const prompts = kept.map((p) => p.text);
1309
- // One complete beat is a legitimate clip — ARDY's own examples are single
1310
- // prompts ("A person walks in a circle."), so a valid one-phase request must
1311
- // not fail merely for being one phase. Only an empty request is refused.
1312
- if (prompts.length === 0) return text("Give at least one motion beat.");
1313
-
1314
- // ARDY Core is 20 fps; segments must tile 0..clipFrames exactly, and each
1315
- // one needs at least 3 frames.
1316
- const ARDY_FPS =
1317
- (process.env.CCLAY_MOTION_BACKEND || "kimodo").trim().toLowerCase() === "kimodo" ? 24 : 20;
1318
- let segments;
1319
- let clipFrames;
1320
- let chained = 0;
1321
- if (timed) {
1322
- // A beat that became several blocks shares its time between them, so an
1323
- // explicit "6 seconds of walking" stays 6 seconds however it was phrased.
1324
- const pieceCount = kept.reduce((acc, piece) => {
1325
- acc[piece.source] = (acc[piece.source] ?? 0) + 1;
1326
- return acc;
1327
- }, {});
1328
- segments = [];
1329
- let cursor = 0;
1330
- for (const { text: prompt, source } of kept) {
1331
- const whole = phaseSeconds[source] ?? seconds / phases.length;
1332
- const share = whole / pieceCount[source];
1333
- // The studio caps a block at BLOCK_MAX_SECONDS and refuses to generate
1334
- // a longer one, so a long beat becomes consecutive blocks here rather
1335
- // than something the UI would reject.
1336
- const spans = splitLongBeat(share);
1337
- if (spans.length > 1) chained += spans.length - 1;
1338
- for (const span of spans) {
1339
- const startFrame = cursor;
1340
- cursor += Math.max(3, Math.round(span * ARDY_FPS));
1341
- segments.push({ startFrame, endFrame: cursor, prompt });
1342
- }
1343
- }
1344
- clipFrames = cursor;
1345
- } else {
1346
- clipFrames = Math.floor(seconds * ARDY_FPS);
1347
- // Even division must also respect the cap: enough blocks that no single
1348
- // one exceeds it, distributed evenly across the clip.
1349
- const perPhase = seconds / prompts.length;
1350
- const piecesPer = Math.ceil(perPhase / BLOCK_MAX_SECONDS);
1351
- if (piecesPer > 1) chained = prompts.length * (piecesPer - 1);
1352
- const total = prompts.length * piecesPer;
1353
- const per = Math.floor(clipFrames / total);
1354
- segments = [];
1355
- for (const [i, prompt] of prompts.entries()) {
1356
- for (let k = 0; k < piecesPer; k += 1) {
1357
- const index = i * piecesPer + k;
1358
- segments.push({
1359
- startFrame: index * per,
1360
- endFrame: index === total - 1 ? clipFrames : (index + 1) * per,
1361
- prompt,
1362
- });
1363
- }
1364
- }
1365
- }
1366
- const clipSeconds = clipFrames / ARDY_FPS;
1367
- // A single beat has no sequence to chain: send it as a plain prompt;
1368
- // composite physical wording remains intact in the normalized text.
1369
- // Long single prompts are the exception: split the actual clip length
1370
- // into equal Kimodo blocks so every wire segment stays within the cap.
1371
- const singlePromptNeedsChain = prompts.length === 1 && clipSeconds > BLOCK_MAX_SECONDS;
1372
- if (prompts.length === 1) {
1373
- if (singlePromptNeedsChain) {
1374
- segments = tileClipFrames(clipFrames, clipSeconds).map((b) => ({ ...b, prompt: prompts[0] }));
1375
- chained = segments.length - 1;
1376
- } else {
1377
- chained = 0;
1378
- }
1379
- }
1380
- const rewrites = kept
1381
- .map(({ text: prompt, notes }, i) => (notes.length ? ` ${i + 1}. ${prompt} ← ${notes.join("; ")}` : null))
1382
- .filter(Boolean);
1383
- const chainNote = chained > 0 ? `\n (${chained} block(s) chained to keep every block within ${BLOCK_MAX_SECONDS}s)` : "";
1384
- // A drop is reported on its own account: a caller can send nine already-perfect
1385
- // phases, which produces no rewrite and no chaining yet still loses the ninth
1386
- // to the schema's 8-phase limit, and silence there would hide a lost beat.
1387
- const dropNote =
1388
- normalized.dropped > 0 ? `\n (${normalized.dropped} beat(s) past the 8-phase limit were dropped)` : "";
1389
- const promptNote =
1390
- rewrites.length || chainNote || dropNote
1391
- ? (rewrites.length ? `\n\nRewritten for ARDY:\n${rewrites.join("\n")}` : "\n") + dropNote + chainNote
1392
- : "";
1393
-
1394
- if (!liveHub?.connected) return text(noLiveEditor("generate_motion requires a connected CozyClay editor so completion can be delivered over its live socket."));
1395
- const workspaceHandle = liveWorkspace.getStore() ?? liveHub.resolveWorkspace("generate_motion");
1396
- const workspaceId = liveHub.workspaceId(workspaceHandle);
1397
- // Reusing an existing take is installation, not generation demand. The
1398
- // shared lifecycle sanitizes before the targeted live transport sees it;
1399
- // disconnected events are omitted, never reconstructed on reconnect.
1400
- const motionRequest = motion_url ? null : startMotionRequest({ surface: "mcp", input_mode: "prompt" }, {
1401
- capture: (event, props) => liveHub?.sendEvent(workspaceId, "motion_telemetry", { event, props }),
1402
- });
1403
- try {
1404
- await refreshLiveDescription();
1405
- } catch (error) {
1406
- return liveError(error);
1407
- }
1408
- const targetCharacterId = stage().characters.find((character) => character.id === state.focus)?.id ?? stage().characters[0]?.id ?? null;
1409
- let job;
1410
- try {
1411
- job = motionJobs.create(workspaceId);
1412
- job.motionRequest = motionRequest;
1413
- } catch (error) {
1414
- return liveError(error);
1415
- }
1416
- const body =
1417
- prompts.length === 1 && !singlePromptNeedsChain
1418
- ? { prompt: prompts[0], duration: clipSeconds, posePin: false }
1419
- : { prompt: prompts.join(" "), duration: clipSeconds, segments, posePin: false };
1420
- if (seed !== undefined) body.seed = seed;
1421
-
1422
- // Abort tears down the HTTP stream. The bridge owns the child process group
1423
- // and kills it on disconnect, so cancellation stops generator work before a
1424
- // terminal cancelled event is sent; it never reaches editor installation.
1425
- const run = async () => {
1426
- if (job.status === "cancelled") return;
1427
- const controller = new AbortController();
1428
- const deadline = setTimeout(() => controller.abort(new Error("Motion generation exceeded the 5 minute deadline.")), 5 * 60_000);
1429
- deadline.unref?.();
1430
- job.cancel = () => {
1431
- controller.abort();
1432
- motionRequest?.fail(null, "aborted");
1433
- };
1434
- try {
1435
- let motionUrl = motion_url;
1436
- if (!motionUrl) {
1437
- let health;
1438
- try {
1439
- const response = await fetch(`${bridge}/ardy/health`, {
1440
- signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5_000)]),
1441
- });
1442
- const payload = await response.json();
1443
- health = { ...payload, ok: response.ok && payload?.ok !== false };
1444
- } catch (error) {
1445
- if (controller.signal.aborted) throw error;
1446
- health = { ok: false };
1447
- }
1448
- if (job.status === "cancelled") return;
1449
- // Readiness controls execution independently of best-effort telemetry.
1450
- const reason = motionPreflightReason(health, { body });
1451
- motionRequest?.preflight(health, { body });
1452
- if (reason) {
1453
- motionJobs.transition(job, "failed", { message: `Motion generation preflight blocked: ${reason}.` });
1454
- await publishMotionJob(job);
1455
- return;
1456
- }
1457
- motionJobs.transition(job, "running");
1458
- motionRequest?.start();
1459
- const res = await fetch(`${bridge}/ardy/generate`, {
1460
- method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: controller.signal,
1461
- });
1462
- motionUrl = await readMotionStream(res);
1463
- }
1464
- if (job.status === "cancelled") return;
1465
- if (typeof motionUrl !== "string") throw new Error("Generation ended without a motion.");
1466
- if (!motionUrlPattern.test(motionUrl)) throw new Error("Generator returned an invalid motion URL.");
1467
- motionRequest?.succeed();
1468
- motionJobs.transition(job, "completed", {
1469
- motionUrl, prompt: prompts.join(" "), blocks: segments, drop, targetCharacterId,
1470
- summary: `${clipSeconds.toFixed(1)}s / ${clipFrames} frames${promptNote}`,
1471
- });
1472
- await publishMotionJob(job);
1473
- } catch (error) {
1474
- if (job.status === "cancelled" || controller.signal.aborted || error?.name === "AbortError") {
1475
- motionRequest?.fail(error, "aborted");
1476
- if (job.status !== "cancelled") motionJobs.transition(job, "cancelled", { message: "Generation cancelled before editor delivery." });
1477
- } else {
1478
- motionRequest?.fail(error);
1479
- motionJobs.transition(job, "failed", { message: error instanceof Error ? error.message : "Motion generation failed." });
1480
- }
1481
- await publishMotionJob(job);
1482
- } finally {
1483
- clearTimeout(deadline);
1484
- job.cancel = null;
1485
- }
1486
- };
1487
- queueMicrotask(run);
1488
- return text(JSON.stringify(motionJobs.task(job)));
1276
+ async ({ phases, seconds = 9, seed, motion_url, drop, ...admission }) => {
1277
+ if (!liveHub?.connected) return text(noLiveEditor("Motion requires a connected CozyClay editor."));
1278
+ const normalized = normalizePhases(phases.map(phase => typeof phase === 'string' ? phase : phase.text));
1279
+ const beats = normalized.texts.map((text, index) => ({ text, seconds: phases[normalized.sources[index]]?.seconds ?? seconds / phases.length })).filter(beat => beat.text);
1280
+ return runStudioCommand({ ...admission, action: motion_url ? 'motion.replace' : 'motion.generate', args: context => {
1281
+ const generated = generationArgs({ characterId: context.activeCharacterId, source: { kind: 'generate', beats, ...(seed === undefined ? {} : { seed }) } });
1282
+ return motion_url ? { characterId: generated.characterId, url: motion_url, prompt: beats.map(beat => beat.text).join(' '),
1283
+ blocks: generated.blocks.map(({ startFrame, endFrame, text: prompt }) => ({ startFrame, endFrame, prompt })), ...(drop ? { drop } : {}) }
1284
+ : { ...generated, ...(drop ? { drop } : {}) };
1285
+ } });
1489
1286
  },
1490
1287
  ),
1491
1288
 
@@ -1495,7 +1292,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1495
1292
  title: "Move, rotate or scale an object",
1496
1293
  description:
1497
1294
  "Unlike place_object, update_object changes an existing prop instead of adding one. " +
1498
- "Every field is optional; omitted fields are left alone. Transforms go through the same clamp/snap path the studio's gizmo uses.",
1295
+ "Every field is optional; omitted fields are left alone. Transforms go through the same clamp/snap path the studio's gizmo uses. hidden shows or hides the prop without deleting it.",
1499
1296
  inputSchema: {
1500
1297
  id: z.string().describe("object id from place_object or describe_scene"),
1501
1298
  x: z.number().optional(),
@@ -1530,9 +1327,10 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1530
1327
  .describe("travel path; null clears it and the object stands still again"),
1531
1328
  height: z.number().positive().optional().describe("cutout or mesh height in metres"),
1532
1329
  clay: z.boolean().optional().describe("mesh only: replace file materials with matte clay"),
1330
+ hidden: z.boolean().optional().describe("true hides the prop without deleting it"),
1533
1331
  },
1534
1332
  },
1535
- async ({ id, x, y, z: zPos, facing, tilt, roll, scale, scale_x, scale_y, scale_z, color, name, path, height, clay }) => {
1333
+ async ({ id, x, y, z: zPos, facing, tilt, roll, scale, scale_x, scale_y, scale_z, color, name, path, height, clay, hidden, ...admission }) => {
1536
1334
  const travelPath = path === null
1537
1335
  ? null
1538
1336
  : path
@@ -1540,14 +1338,11 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1540
1338
  : undefined;
1541
1339
  if (liveHub?.connected) {
1542
1340
  try {
1543
- await appliedLiveMutation("update_object", {
1544
- id, x, y, z: zPos, rot: facing, rotX: tilt, rotZ: roll,
1545
- scale, scaleX: scale_x, scaleY: scale_y, scaleZ: scale_z, color, name,
1546
- ...(travelPath !== undefined ? { path: travelPath } : {}),
1547
- ...(height !== undefined ? { height } : {}),
1548
- ...(clay !== undefined ? { clay } : {}),
1549
- });
1550
- return text(`Updated ${id}.\n\n${sceneReport()}`);
1341
+ return await runStudioCommand({ ...admission, action: "object.update", args: { id, patch: {
1342
+ x, y, z: zPos, rot: facing, rotX: tilt, rotZ: roll,
1343
+ scaleX: scale_x ?? scale, scaleY: scale_y ?? scale, scaleZ: scale_z ?? scale,
1344
+ color, name, path: travelPath, height, clay, hidden,
1345
+ } } });
1551
1346
  } catch (error) {
1552
1347
  return liveError(error);
1553
1348
  }
@@ -1575,6 +1370,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1575
1370
  if (name !== undefined) patch.name = name;
1576
1371
  if (height !== undefined) patch.height = height;
1577
1372
  if (clay !== undefined) patch.clay = clay;
1373
+ if (hidden !== undefined) patch.hidden = hidden;
1578
1374
  sc.objects = updateSceneObject(sc.objects, id, patch);
1579
1375
  return text(`Updated ${id}.\n\n${sceneReport()}`);
1580
1376
  },
@@ -1587,11 +1383,10 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1587
1383
  description: "Take a prop out of the set.",
1588
1384
  inputSchema: { id: z.string().describe("object id") },
1589
1385
  },
1590
- async ({ id }) => {
1386
+ async ({ id, ...admission }) => {
1591
1387
  if (liveHub?.connected) {
1592
1388
  try {
1593
- await appliedLiveMutation("remove_object", { id });
1594
- return text(`Removed ${id}.\n\n${sceneReport()}`);
1389
+ return await runStudioCommand({ ...admission, action: "object.remove", args: { ids: [id] } });
1595
1390
  } catch (error) {
1596
1391
  return liveError(error);
1597
1392
  }
@@ -1635,18 +1430,12 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1635
1430
  label: z.string().min(1).default("MCP batch").describe("the single editor undo entry name"),
1636
1431
  },
1637
1432
  },
1638
- async ({ ops, atomic, stopOnError, label }) => {
1433
+ async ({ ops, atomic, stopOnError, label, ...admission }) => {
1639
1434
  if (!liveHub?.connected) return text(noLiveEditor("apply_batch requires a connected CozyClay editor."));
1640
1435
  try {
1641
- const result = await appliedLiveMutation("apply_batch", { ops, atomic, stopOnError, label });
1642
- const applied = Array.isArray(result?.applied) ? result.applied : [];
1643
- const failed = Array.isArray(result?.failed) ? result.failed : [];
1644
- const failure = failed[0];
1645
- const summary = result?.rolledBack
1646
- ? `Batch rolled back after failure at operation ${failure?.index ?? "unknown"}.`
1647
- : `Applied ${applied.length} operation(s).`;
1648
- const detail = failure ? ` Failure at operation ${failure.index}: ${failure.error}.` : "";
1649
- return text(`${summary}${detail}\n\n${sceneReport()}`);
1436
+ // #446: the v1 alias is objects-only, so objects.batch (not a
1437
+ // generic run.batch) preserves its options and single undo entry.
1438
+ return await runStudioCommand({ ...admission, action: "objects.batch", args: { ops, atomic, stopOnError, label } });
1650
1439
  } catch (error) {
1651
1440
  return liveError(error);
1652
1441
  }
@@ -1776,30 +1565,30 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1776
1565
  {
1777
1566
  title: "Add a scene",
1778
1567
  description:
1779
- "Add another scene to the project and make it active. With a connected editor, the complete " +
1780
- "scene document is forwarded through that workspace's load_scenes command before success is reported; " +
1781
- "without one, this changes MCP memory only.",
1568
+ "Add another scene to the project and make it active. A connected editor runs scene.create then scene.rename " +
1569
+ "and returns both bus receipts; without one, this changes MCP memory only.",
1782
1570
  inputSchema: { name: z.string().default("SCENE 02").describe("scene name") },
1783
1571
  },
1784
- async ({ name }) => {
1572
+ async ({ name, ...admission }) => {
1785
1573
  if (liveHub?.connected) {
1574
+ let created;
1786
1575
  try {
1787
- await refreshLiveDescription();
1576
+ created = await executeStudioCommand({ ...admission, action: "scene.create" });
1577
+ if (!created.ok) return studioResult(created);
1578
+ // Creation is a document boundary; naming is a separate retained edit.
1579
+ const renamed = await executeStudioCommand({ ...admission, action: "scene.rename",
1580
+ commandId: admission.commandId ? createHash("sha256").update(`${admission.commandId}:rename`).digest("hex") : undefined,
1581
+ args: { sceneId: created.host.sceneId, name }, expectedRevision: created.revision.after });
1582
+ return studioResult(created, renamed);
1788
1583
  } catch (error) {
1789
- return liveError(error);
1584
+ const failure = liveError(error);
1585
+ if (created) failure.content.unshift(...studioResult(created).content);
1586
+ return failure;
1790
1587
  }
1791
1588
  }
1792
1589
  const document = JSON.parse(JSON.stringify(state.doc));
1793
1590
  document.scenes = addScene(document.scenes, name);
1794
1591
  document.activeSceneId = document.scenes[document.scenes.length - 1].id;
1795
- if (liveHub?.connected) {
1796
- try {
1797
- const live = await appliedLiveMutation("load_scenes", { document });
1798
- requireLiveSceneParity("add_scene", document, live);
1799
- } catch (error) {
1800
- return liveError(error);
1801
- }
1802
- }
1803
1592
  state.doc = document;
1804
1593
  return text(`Added "${scene().name}".\n\n${sceneReport()}`);
1805
1594
  },
@@ -1810,14 +1599,18 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1810
1599
  {
1811
1600
  title: "Switch the active scene",
1812
1601
  description:
1813
- "Make a different scene active. With a connected editor, the complete scene document is forwarded " +
1814
- "through that workspace's load_scenes command before success is reported; without one, this changes MCP memory only.",
1602
+ "Make a different scene active through scene.switch and return its bus receipt. Without a connected editor, this changes MCP memory only.",
1815
1603
  inputSchema: { name: z.string().describe("scene name to switch to") },
1816
1604
  },
1817
- async ({ name }) => {
1605
+ async ({ name, ...admission }) => {
1818
1606
  if (liveHub?.connected) {
1819
1607
  try {
1820
- await refreshLiveDescription();
1608
+ return await runStudioCommand({ ...admission, action: "scene.switch", args: async () => {
1609
+ await refreshLiveDescription();
1610
+ const target = state.doc.scenes.find(row => row.name.toLowerCase() === name.toLowerCase());
1611
+ if (!target) throw new Error(`No scene "${name}".`);
1612
+ return { sceneId: target.id };
1613
+ } });
1821
1614
  } catch (error) {
1822
1615
  return liveError(error);
1823
1616
  }
@@ -1828,14 +1621,6 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1828
1621
  }
1829
1622
  const document = JSON.parse(JSON.stringify(state.doc));
1830
1623
  document.activeSceneId = target.id;
1831
- if (liveHub?.connected) {
1832
- try {
1833
- const live = await appliedLiveMutation("load_scenes", { document });
1834
- requireLiveSceneParity("switch_scene", document, live);
1835
- } catch (error) {
1836
- return liveError(error);
1837
- }
1838
- }
1839
1624
  state.doc = document;
1840
1625
  return text(`Switched to "${scene().name}".\n\n${sceneReport()}`);
1841
1626
  },
@@ -1846,10 +1631,13 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1846
1631
  {
1847
1632
  title: "Open a .cclayproject file",
1848
1633
  description:
1849
- "Load a project authored in the CozyClay studio (or saved here). Replaces the current state.",
1850
- inputSchema: { path: z.string().describe("path to a .cclayproject file") },
1634
+ "Load a project authored in the CozyClay studio (or saved here). Replacing scene ids in a connected editor requires a Studio-issued confirmationToken; returns the load_scenes bus receipt.",
1635
+ inputSchema: {
1636
+ path: z.string().describe("path to a .cclayproject file"),
1637
+ confirmationToken: z.string().min(1).max(120).optional().describe("token issued by the Studio UI for this exact scene document"),
1638
+ },
1851
1639
  },
1852
- async ({ path }) => {
1640
+ async ({ path, ...admission }) => {
1853
1641
  let full;
1854
1642
  let raw;
1855
1643
  try {
@@ -1875,8 +1663,14 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1875
1663
  const nextDocument = scenes.document;
1876
1664
  if (liveHub?.connected) {
1877
1665
  try {
1878
- const live = await appliedLiveMutation("load_scenes", { document: nextDocument });
1879
- requireLiveSceneParity("open_project", nextDocument, live);
1666
+ const loaded = await executeStudioCommand({ ...admission, action: "load_scenes", args: { document: nextDocument } });
1667
+ if (loaded.ok) {
1668
+ state.name = result.project.name;
1669
+ state.focus = null;
1670
+ state.focusLocked = false;
1671
+ state.markedFraming = null;
1672
+ }
1673
+ return studioResult(loaded);
1880
1674
  } catch (error) {
1881
1675
  return liveError(error);
1882
1676
  }
@@ -1968,5 +1762,58 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1968
1762
  return text(`Saved "${state.name}" to ${full} (${state.doc.scenes.length} scene(s)).`);
1969
1763
  },
1970
1764
  ),
1765
+
1766
+ tool(
1767
+ "studio_commands",
1768
+ {
1769
+ title: "List Studio commands",
1770
+ description:
1771
+ "List every command the connected editor registers, for studio_run: id, label, kind (mutation, transient, job, document), " +
1772
+ "exposure, whether it is available now (the reason when not), and timeoutMs or generation where declared. The list carries " +
1773
+ "no schemas; pass ids to read those commands' full declarations, description and input schema included.",
1774
+ inputSchema: {
1775
+ ids: z.array(z.string().min(1).max(120)).min(1).max(32).optional().describe("command ids whose full declarations to read"),
1776
+ },
1777
+ },
1778
+ async ({ ids }) => {
1779
+ if (!liveHub?.connected) return liveError(new Error(noLiveEditor("studio_commands requires a connected CozyClay editor.")));
1780
+ try {
1781
+ const listed = await liveHub.command("inspect_studio", { scope: "actions", ...(ids ? { ids } : {}) }, liveWorkspace.getStore());
1782
+ return text(JSON.stringify({ actions: listed?.actions ?? [] }));
1783
+ } catch (error) {
1784
+ return liveError(error);
1785
+ }
1786
+ },
1787
+ ),
1788
+
1789
+ tool(
1790
+ "studio_run",
1791
+ {
1792
+ title: "Run a Studio command",
1793
+ description:
1794
+ "Run one command the connected editor registers, by id, through the editor's command bus: the same door its UI and agent use. " +
1795
+ "args must match the command's input schema (read it with studio_commands and ids). The command is admitted at the open " +
1796
+ "document and its current revision unless expectedRevision is given, and answers the editor's JSON receipt: status, summary, " +
1797
+ "affectedIds, undo, and output for a job. A refusal is a receipt with ok false and its code: STALE_SCENE means read again and " +
1798
+ "re-issue; CONFIRMATION_REQUIRED means obtain the user's approval in Studio and pass its confirmationToken. Reusing a commandId returns the receipt of " +
1799
+ "that earlier call instead of running again.",
1800
+ inputSchema: {
1801
+ action: z.string().min(1).max(120).describe("command id, as studio_commands lists it"),
1802
+ args: z.record(z.string(), z.unknown()).default({}).describe("the command's arguments, matching its input schema"),
1803
+ expectedRevision: z.number().int().min(0).optional().describe("the scene revision the command is admitted at; defaults to the current one"),
1804
+ commandId: z.string().min(1).max(120).optional().describe("idempotency key; a repeated id answers the first call's receipt"),
1805
+ timeoutMs: z.number().int().min(1).max(MAX_COMMAND_TIMEOUT_MS).optional().describe("how long the hub waits; defaults to the command's declared timeout"),
1806
+ confirmationToken: z.string().min(1).max(120).optional().describe("Studio UI approval token for this exact command; MCP cannot mint one"),
1807
+ },
1808
+ },
1809
+ async (args) => {
1810
+ if (!liveHub?.connected) return liveError(new Error(noLiveEditor("studio_run requires a connected CozyClay editor.")));
1811
+ try {
1812
+ return await runStudioCommand(args);
1813
+ } catch (error) {
1814
+ return liveError(error);
1815
+ }
1816
+ },
1817
+ ),
1971
1818
  ];
1972
1819
  };