cozyclay 2.0.2 → 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 (256) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +211 -36
  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 +4 -4
  19. package/bin/live/cli.mjs +2 -3
  20. package/bin/live/client.mjs +1 -1
  21. package/bin/live/studio.mjs +5 -2
  22. package/bin/mcp-runtime.mjs +2 -2
  23. package/bin/telemetry-state.mjs +3 -3
  24. package/dist/app/index.html +6 -6
  25. package/dist/assets/analytics-B41FmPYI.js +1 -0
  26. package/dist/assets/app-Do-Og5Pu.css +1 -0
  27. package/dist/assets/app-DwP-tl2x.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
  32. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  33. package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
  34. package/dist/assets/workflow-Br-K7Srs.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/sitemap.xml +1 -1
  38. package/dist/workflow/index.html +4 -4
  39. package/mcp/LIVE-PROTOCOL.md +23 -2
  40. package/mcp/README.md +37 -2
  41. package/mcp/ardy-prompts.mjs +1 -1
  42. package/mcp/image-file.mjs +132 -0
  43. package/mcp/live-hub.mjs +2 -2
  44. package/mcp/package.json +1 -0
  45. package/mcp/runtime/package-lock.json +7 -0
  46. package/mcp/runtime/package.json +1 -0
  47. package/mcp/server.mjs +0 -6
  48. package/mcp/tool-handlers.mjs +289 -87
  49. package/package.json +11 -2
  50. package/src/App.jsx +789 -263
  51. package/src/agent-image-client.js +94 -0
  52. package/src/analytics.js +47 -3
  53. package/src/app-stage.jsx +129 -2745
  54. package/src/ardy/auto-physics.js +6 -16
  55. package/src/ardy/collision-blockers.js +3 -7
  56. package/src/ardy/fix-collisions.js +5 -35
  57. package/src/ardy/ik-drag.js +86 -5
  58. package/src/ardy/ik-key-json.js +3 -1
  59. package/src/ardy/ik.js +15 -13
  60. package/src/ardy/motion-calibration.js +1 -1
  61. package/src/ardy/npz.js +0 -8
  62. package/src/ardy/physics-panel.jsx +3 -2
  63. package/src/ardy/physics-review.js +3 -2
  64. package/src/ardy/physics-support.js +1 -1
  65. package/src/ardy/physics-surface.js +1 -1
  66. package/src/ardy/platform-fit-panel.jsx +2 -1
  67. package/src/ardy/playback.js +1 -5
  68. package/src/ardy/range-pin.js +2 -2
  69. package/src/ardy/root-drop.js +0 -1
  70. package/src/ardy/timeline.css +184 -6
  71. package/src/ardy/timeline.jsx +113 -28
  72. package/src/ardy/to-cskel27.js +0 -2
  73. package/src/ardy/waypoints.js +0 -3
  74. package/src/ardy-bridge-contract.js +281 -0
  75. package/src/asset-pane.jsx +19 -9
  76. package/src/auto-color.js +1 -1
  77. package/src/burn-in.js +1 -1
  78. package/src/camera-block.js +1 -1
  79. package/src/camera-follow.js +75 -32
  80. package/src/camera-move.js +0 -6
  81. package/src/camera-tutorial.jsx +6 -7
  82. package/src/character-kind.js +79 -0
  83. package/src/command-bus.js +76 -17
  84. package/src/commands/cast.js +31 -7
  85. package/src/commands/elements/object.js +1 -1
  86. package/src/commands/elements/shot.js +1 -1
  87. package/src/commands/elements.js +1 -1
  88. package/src/commands/export.js +3 -3
  89. package/src/commands/motion.js +2 -2
  90. package/src/commands/objects.js +105 -10
  91. package/src/commands/project.js +15 -2
  92. package/src/commands/sculpt-images.js +53 -0
  93. package/src/commands/shot.js +88 -10
  94. package/src/commands/view.js +10 -4
  95. package/src/coplanar-depth.js +216 -0
  96. package/src/cuts.js +7 -6
  97. package/src/domains/cast.js +60 -43
  98. package/src/domains/motion/motion-intent-codec.js +50 -0
  99. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  100. package/src/domains/motion/motion-video-capture.js +664 -0
  101. package/src/domains/motion.js +253 -742
  102. package/src/domains/objects.js +106 -5
  103. package/src/domains/scenes.js +79 -28
  104. package/src/domains/shots.js +33 -7
  105. package/src/error-boundary.jsx +1 -1
  106. package/src/execution-telemetry.js +5 -5
  107. package/src/facing-marks.js +14 -1
  108. package/src/first-success-guide.jsx +35 -4
  109. package/src/gizmo-claim.js +3 -1
  110. package/src/grid-floor.jsx +1 -4
  111. package/src/hierarchy-model.js +48 -35
  112. package/src/hierarchy-panel.css +1 -0
  113. package/src/hierarchy-panel.jsx +54 -102
  114. package/src/history.js +0 -2
  115. package/src/i2v-motion-client.js +1 -4
  116. package/src/ime.js +6 -0
  117. package/src/keyframe-pack.js +15 -6
  118. package/src/line-edit.js +4 -4
  119. package/src/live-control.js +6 -6
  120. package/src/matte-editor.js +2 -8
  121. package/src/matte.js +0 -19
  122. package/src/mesh-graph-clone.js +16 -2
  123. package/src/mesh-sniff.js +0 -1
  124. package/src/monotone-cubic.js +40 -0
  125. package/src/motion-store.js +4 -4
  126. package/src/motion-trail.js +5 -11
  127. package/src/multimodel-ingest.js +1 -3
  128. package/src/object-catalog.jsx +7 -5
  129. package/src/object-gizmo.jsx +45 -31
  130. package/src/object-path.js +310 -67
  131. package/src/object-travel.js +224 -0
  132. package/src/otio.js +8 -3
  133. package/src/panels/CharacterTransformPanel.jsx +24 -6
  134. package/src/panels/ObjectTransformPanel.jsx +4 -3
  135. package/src/panels/PoseImagePanel.jsx +205 -0
  136. package/src/panels/PromptBlocksPanel.jsx +3 -1
  137. package/src/panels/ProxyFigurePanel.jsx +56 -0
  138. package/src/panels/ReferenceImageField.jsx +1 -1
  139. package/src/panels/RigControlPanel.jsx +24 -3
  140. package/src/panels/RigPanel.jsx +2 -1
  141. package/src/panels/SculptPanel.jsx +405 -0
  142. package/src/panels/SubjectBox.jsx +1 -1
  143. package/src/panels/SubjectsPanel.jsx +0 -2
  144. package/src/panels/VideoCapturePanel.jsx +2 -1
  145. package/src/panels/pose-image.css +42 -0
  146. package/src/panels/pose.css +3 -0
  147. package/src/panels/sculpt.css +162 -0
  148. package/src/plan-diagram.js +184 -0
  149. package/src/planview.jsx +127 -80
  150. package/src/playground.js +3 -5
  151. package/src/pose-fade.js +77 -0
  152. package/src/pose-gizmo.css +23 -0
  153. package/src/pose-gizmo.js +96 -0
  154. package/src/posestudio.jsx +134 -83
  155. package/src/project-browser.css +53 -0
  156. package/src/project-browser.jsx +49 -4
  157. package/src/project.js +55 -24
  158. package/src/props.jsx +177 -36
  159. package/src/proxy-figure.jsx +69 -0
  160. package/src/range-pin-panel.jsx +0 -1
  161. package/src/receipt-findings.js +20 -0
  162. package/src/render-passes.js +0 -1
  163. package/src/resource-status.jsx +1 -1
  164. package/src/root-path.js +48 -0
  165. package/src/route-owner.js +52 -0
  166. package/src/scene-asset-cache.js +1 -2
  167. package/src/scene-assets.js +12 -4
  168. package/src/scene-fbx.js +1 -1
  169. package/src/scene-mesh-cache.js +0 -1
  170. package/src/scene-objects.js +319 -19
  171. package/src/scenes.js +58 -9
  172. package/src/sculpt-blueprint.js +65 -0
  173. package/src/sculpt-compare.js +384 -0
  174. package/src/sculpt-geometry.js +141 -0
  175. package/src/sculpt-mode.js +168 -0
  176. package/src/sculpt-recipe.js +385 -0
  177. package/src/sculpt-run.js +351 -0
  178. package/src/sculpt-stage.jsx +221 -0
  179. package/src/settings-menu.jsx +1 -1
  180. package/src/shell/BoardSlot.jsx +341 -0
  181. package/src/shell/BottomDock.jsx +58 -14
  182. package/src/shell/DetailsSlot.jsx +26 -6
  183. package/src/shell/MenuBar.jsx +78 -6
  184. package/src/shell/OutlinerSlot.jsx +3 -1
  185. package/src/shell/TopBar.jsx +17 -3
  186. package/src/shell/ViewportToolbar.jsx +18 -3
  187. package/src/shell/dock.css +239 -0
  188. package/src/shell/glass-regions.css +3 -2
  189. package/src/shell/log-store.js +4 -4
  190. package/src/shell/mode.css +20 -0
  191. package/src/shell/studio-shell-context.js +4 -1
  192. package/src/shot-authoring.js +69 -6
  193. package/src/stable-items.js +1 -1
  194. package/src/stage-attach-frames.js +169 -0
  195. package/src/stage-canvas-rigs.jsx +402 -0
  196. package/src/stage-capture.jsx +216 -0
  197. package/src/stage-characters.jsx +302 -0
  198. package/src/stage-editor-gizmos.jsx +1229 -0
  199. package/src/stage-labels-ko.js +204 -0
  200. package/src/stage-startup-defaults.js +182 -0
  201. package/src/starter-scenes.js +1 -1
  202. package/src/storyboard.js +24 -5
  203. package/src/studio-actions.js +42 -9
  204. package/src/studio-agent-commands.js +186 -23
  205. package/src/studio-agent-context.js +11 -9
  206. package/src/studio-agent-motion.js +2 -1
  207. package/src/studio-agent-protocol.js +56 -23
  208. package/src/studio-app-binding.js +52 -23
  209. package/src/studio-contact-sheet.js +3 -3
  210. package/src/studio-elements.js +11 -2
  211. package/src/studio-geometry-facts.js +114 -0
  212. package/src/styles.css +92 -9
  213. package/src/ui-scrub.js +4 -4
  214. package/src/ui.jsx +6 -4
  215. package/src/use-asset-shelf.js +60 -0
  216. package/src/use-camera-tutorial.js +50 -0
  217. package/src/use-embed-bridge.js +12 -0
  218. package/src/use-first-success-prompt.js +7 -0
  219. package/src/use-i2v-capture-modal.js +15 -0
  220. package/src/use-sculpt-mode.js +95 -0
  221. package/src/workflow/AgentPanel.jsx +106 -18
  222. package/src/workflow/WorkflowBuilder.jsx +1 -1
  223. package/src/workflow/agent-client.js +113 -45
  224. package/src/workflow/agent-panel.css +35 -0
  225. package/src/workflow/attachment-image.js +1 -1
  226. package/src/workflow/canvas-commands.js +3 -3
  227. package/src/workflow/cozy-scene-node.js +3 -3
  228. package/src/workflow/local-workflow.js +2 -2
  229. package/src/workflow/motion-input.js +3 -3
  230. package/src/workflow/node-schema.js +1 -1
  231. package/src/workflow/scene-asset-sync.js +2 -9
  232. package/src/workflow/shot-prompt-node.js +1 -1
  233. package/src/workflow/video-contract.js +1 -1
  234. package/src/workflow/workflow-resources.js +0 -8
  235. package/src/workflow-send.js +87 -0
  236. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  237. package/tools/check-eslint.mjs +57 -0
  238. package/tools/check-knip.mjs +60 -0
  239. package/tools/check-loc.mjs +54 -0
  240. package/tools/demo-worker/api-client.mjs +7 -13
  241. package/tools/demo-worker/generate.mjs +2 -4
  242. package/tools/eslint-baseline.json +23 -0
  243. package/tools/kimodo/effector-constraints.mjs +4 -4
  244. package/tools/kimodo/generate.mjs +1 -1
  245. package/tools/knip-baseline.json +331 -0
  246. package/tools/loc-baseline.json +126 -0
  247. package/tools/process-supervisor.mjs +1 -1
  248. package/tools/projflow/replay.mjs +4 -4
  249. package/tools/projflow/runner.mjs +3 -3
  250. package/tools/qa/studio-control-count.mjs +2 -0
  251. package/tools/run-tests.mjs +94 -7
  252. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  253. package/dist/assets/app-BxBLC6GT.js +0 -4876
  254. package/dist/assets/app-CJdxvnrl.css +0 -1
  255. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  256. package/dist/assets/workflow-B5XZwVfe.js +0 -179
@@ -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."
@@ -3,7 +3,11 @@ import { studioActionDeclaration } from "../studio-actions.js";
3
3
  import { characterOf, fail } from "./shared.js";
4
4
  import { elementSetSchema, registerElementSet } from './elements.js';
5
5
  import './elements/object.js';
6
- import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, normalizeSceneObject } from '../scene-objects.js';
6
+ import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, normalizeSceneObject, groupUnderNewEmpty, objectPatchFields, createSculptObject, SCULPT_KIND } from '../scene-objects.js';
7
+ import { expandSculptParts, normalizeSculptRecipe, patchSculptRecipe } from '../sculpt-recipe.js';
8
+ import { rankingRow, scoreSculptAgainstReference } from '../sculpt-compare.js';
9
+ import { browserSculptImages, shrinkRgba } from './sculpt-images.js';
10
+ import { clampWarning } from '../receipt-findings.js';
7
11
  import { STUDIO_TOOL_SCHEMAS, StudioSchemas } from '../studio-agent-protocol.js';
8
12
 
9
13
  const id = StudioSchemas.TargetGuard.properties.targetId;
@@ -14,15 +18,24 @@ const placement = input({ x: number, y: number, z: number, rot: number }, []);
14
18
  const setInput = elementSetSchema('object');
15
19
  const scale = setInput.properties.set.properties.scale;
16
20
  setInput.properties.set.properties.scale = { oneOf: [number, scale] };
21
+ const PATCH_HINTS = { scale: 'use scaleX, scaleY, scaleZ', rotY: 'use rot for yaw', yaw: 'use rot for yaw', rotation: 'use rot, rotX, rotZ', position: 'use x, y, z', parent: 'use object.group or object.set parent', parentId: 'use object.group or object.set parent' };
17
22
  const mutation = (id, label, input) => ({ id, label, description: label, kind: 'mutation', undoDomain: 'objects', input });
18
23
  const semantic = [
19
24
  mutation('object.set', 'Set object fields', setInput),
20
- mutation('object.add', 'Add object', input({ kind: { type: 'string' }, placement, name: { type: 'string' }, parent: id }, ['kind'])),
25
+ mutation('object.add', 'Add object', input({ kind: { type: 'string' }, placement, name: { type: 'string' }, parent: id, first: { type: 'boolean' } }, ['kind'])),
26
+ // A clay prop from a recipe of soft parts (docs/sculpt-from-image.md). With `id` it re-sculpts that
27
+ // sculpt in place (one undo); without, it creates one. The recipe is checked by sculpt-recipe.js.
28
+ { ...mutation('object.sculpt', 'Sculpt a clay prop from a recipe', input({ recipe: { type: 'object', properties: {}, additionalProperties: true }, edits: { type: 'array', minItems: 1, maxItems: 48, items: { type: 'object', properties: {}, additionalProperties: true } }, id, name: { type: 'string', maxLength: 240 }, placement, parent: id }, [])),
29
+ description: 'Create a sculpt (or re-sculpt one by id) from a recipe, or patch a sculpt by id with edits [{ set: partId, ...fields } | { add: part } | { remove: partId }]. Recipe { version: 1, parts: [{ id, shape: blob|box|cylinder|torus|frame, size: [x,y,z] m, position, rotation (deg), color, parent, mirror, roundness, taper, border }] }. Floor is y=0, front +z, the subject\'s left +x; mirror reflects across x=0.' },
21
30
  mutation('object.remove', 'Remove objects', input({ ids })),
22
31
  mutation('object.rename', 'Rename object', input({ id, name: { type: 'string', maxLength: 240 } })),
23
32
  mutation('object.group', 'Group objects', input({ parent: id, children: ids })),
24
33
  mutation('object.ungroup', 'Ungroup objects', input({ children: ids })),
25
- mutation('object.update', 'Update object', input({ id, patch: { type: 'object', properties: {}, additionalProperties: true } }, [])),
34
+ // The Outliner's "Group under new Empty": the Empty and the reparent are one write, so one undo.
35
+ { ...mutation('object.groupUnderEmpty', 'Group object under a new Empty', input({ id })), exposure: 'ui-only' },
36
+ { ...mutation('object.update', 'Update object', input({ id, patch: { type: 'object', properties: {}, additionalProperties: true } }, [])), agentHidden: true },
37
+ // agentHidden: object.update silently ignores unknown keys, so the agent's action index and inspect_studio leave it out
38
+ // and the prompt points at object.set / arrange_objects; MCP, the CLI and the UI keep it.
26
39
  mutation('objects.arrange', 'Arrange objects', STUDIO_TOOL_SCHEMAS.arrange_objects),
27
40
  ];
28
41
  const batch = { id: 'objects.batch', label: 'Batch objects', description: 'Apply legacy object operations in one retained entry.', kind: 'job', domain: 'objects',
@@ -37,8 +50,18 @@ const assetImport = { ...studioActionDeclaration('asset.import'), kind: 'job', d
37
50
  input({ assetId: id, placement, placeAs: assetProperties.placeAs }, ['assetId', 'placeAs']),
38
51
  input({ fileToken: id, placeAs: { type: 'string', enum: ['mesh', 'cutout'] } }, ['fileToken', 'placeAs']),
39
52
  ] } };
53
+ // Read-only: a job that never commits, so its receipt is `completed` with the score as output,
54
+ // authored false and no undo. The scoring is MCP compare_sculpt's (sculpt-compare.js).
55
+ const openObject = { type: 'object', properties: {}, additionalProperties: true };
56
+ const sculptCompare = { id: 'sculpt.compare', label: 'Compare a sculpt with its reference', kind: 'job', timeoutMs: 60000,
57
+ description: 'Score a sculpt\'s silhouette against a reference image without changing anything: IoU, width-to-height delta, missing area by height band (top, middle, bottom), parts that stick out, and a reference | overlay sheet (grey both, red missing, blue extra) as sheetDataUrl. reference is { assetId } of a project image or { dataUrl }. yaw is the reference camera azimuth in degrees (0 in front, positive toward the subject\'s own left); omitted tries -45..45 and keeps the best. candidates (up to 12 { label, edits }) are scored from the same view and ranked against the current sculpt; none is applied. Apply the winner with object.sculpt { id, edits }.',
58
+ input: input({ id, reference: { oneOf: [input({ assetId: id }), input({ dataUrl: { type: 'string', pattern: '^data:image/(png|jpeg|webp|gif);base64,', maxLength: 6000000 } })] },
59
+ yaw: { type: 'number', minimum: -180, maximum: 180 }, elevation: { type: 'number', minimum: -60, maximum: 60 },
60
+ candidates: { type: 'array', minItems: 1, maxItems: 12, items: input({ label: { type: 'string', maxLength: 60 }, edits: { type: 'array', minItems: 1, maxItems: 48, items: openObject } }) } }, ['id', 'reference']) };
61
+ // A receipt is at most 8 KiB, so the sheet is shrunk until the whole output fits this.
62
+ const SCULPT_COMPARE_OUTPUT_BYTES = 6500;
40
63
  const matte = { id: 'object.matte', label: 'Apply object matte', description: 'Prepare derived assets, then publish one fenced object edit.', kind: 'job', domain: 'objects', exposure: 'ui-only', input: input({ objectId: id }) };
41
- export const declarations = Object.freeze([...semantic, replace, batch, ...['object.attach', 'object.detach', 'object.duplicate'].map(studioActionDeclaration), matte, assetImport]);
64
+ export const declarations = Object.freeze([...semantic, replace, batch, ...['object.attach', 'object.detach', 'object.duplicate'].map(studioActionDeclaration), matte, sculptCompare, assetImport]);
42
65
 
43
66
  export function register(registry, ports) {
44
67
  const objectOf = objectId => ports.state().objects.find(object => object.id === objectId)
@@ -53,10 +76,43 @@ export function register(registry, ports) {
53
76
  const object = createSceneObject(args.kind, before, args.placement);
54
77
  if (!object) fail('INVALID_ARGUMENT', `Unknown object kind: ${args.kind}`);
55
78
  const placed = updateSceneObject([object], object.id, { ...(args.placement ?? {}), ...(args.name === undefined ? {} : { name: args.name }) })[0];
56
- const next = [...before, placed];
79
+ // `first`: the Outliner lists Props in record order, so a UI-made object
80
+ // goes to the top where it is seen, not below a long expanded group.
81
+ const next = args.first ? [placed, ...before] : [...before, placed];
57
82
  owned().write(args.parent === undefined ? next : setSceneObjectParent(next, placed.id, args.parent));
58
83
  return result(before, 'Added object.');
59
84
  },
85
+ 'object.sculpt': args => {
86
+ const before = owned().read();
87
+ if ((args.recipe === undefined) === (args.edits === undefined)) fail('INVALID_ARGUMENT', 'Give exactly one of recipe (whole) or edits (part by part).');
88
+ if (args.edits !== undefined && args.id === undefined) fail('INVALID_ARGUMENT', 'edits patch an existing sculpt: give its id.');
89
+ let refused, changedParts = null;
90
+ if (args.edits !== undefined) {
91
+ const target = objectOf(args.id);
92
+ if (target.renderer !== SCULPT_KIND) fail('INVALID_ARGUMENT', `${args.id} is a ${target.renderer}, not a sculpt.`);
93
+ refused = patchSculptRecipe(target.recipe, args.edits);
94
+ changedParts = refused.changed;
95
+ } else refused = normalizeSculptRecipe(args.recipe);
96
+ if (!refused.ok) fail('INVALID_ARGUMENT', `Recipe refused at ${refused.error}`);
97
+ const patch = { ...(args.placement ?? {}), ...(args.name === undefined ? {} : { name: args.name }) };
98
+ let objectId = args.id;
99
+ if (objectId !== undefined) {
100
+ const target = objectOf(objectId);
101
+ if (target.renderer !== SCULPT_KIND) fail('INVALID_ARGUMENT', `${objectId} is a ${target.renderer}, not a sculpt; omit id to create a new sculpt.`);
102
+ owned().write(updateSceneObject(before, objectId, { ...patch, recipe: refused.recipe }));
103
+ } else {
104
+ if (args.parent !== undefined) objectOf(args.parent);
105
+ const made = createSculptObject({ recipe: refused.recipe, name: args.name }, before, args.placement);
106
+ objectId = made.object.id;
107
+ const next = [...before, updateSceneObject([made.object], objectId, patch)[0]];
108
+ owned().write(args.parent === undefined ? next : setSceneObjectParent(next, objectId, args.parent));
109
+ }
110
+ const sculpted = owned().read().find(row => row.id === objectId);
111
+ const parts = expandSculptParts(sculpted.recipe).length;
112
+ const size = `${sculpted.footprint.width} × ${sculpted.footprint.depth} m, ${sculpted.height} m tall`;
113
+ return { affectedIds: [objectId, ...result(before, '').affectedIds.filter(row => row !== objectId)],
114
+ summary: `${args.id === undefined ? 'Sculpted' : changedParts ? `Patched ${changedParts.join(', ')} on` : 'Re-sculpted'} ${sculpted.name} (${objectId}): ${parts} parts, ${size}.` };
115
+ },
60
116
  'object.remove': ({ ids }) => {
61
117
  const before = owned().read(); ids.forEach(objectOf);
62
118
  owned().write(ids.reduce((rows, id) => removeSceneObject(rows, id), before));
@@ -67,10 +123,16 @@ export function register(registry, ports) {
67
123
  owned().write(updateSceneObject(before, id, { name }));
68
124
  return result(before, 'Renamed object.');
69
125
  },
70
- 'object.update': ({ id, patch }) => {
71
- objectOf(id); const before = owned().read();
72
- owned().write(updateSceneObject(before, id, patch));
73
- return result(before, 'Updated object.');
126
+ 'object.update': ({ id, patch }, context) => {
127
+ const target = objectOf(id), before = owned().read();
128
+ // Wire callers get an error for a key nothing reads; the UI's own patches stay lenient.
129
+ const unknown = Object.keys(patch ?? {}).filter(key => !objectPatchFields(target).includes(key));
130
+ if (unknown.length && context?.origin && context.origin !== 'ui') fail('INVALID_ARGUMENT', `object.update cannot set ${unknown.join(', ')}${unknown.some(key => PATCH_HINTS[key]) ? ` (${unknown.filter(key => PATCH_HINTS[key]).map(key => `${key}: ${PATCH_HINTS[key]}`).join('; ')})` : ''}. Supported fields: ${objectPatchFields(target).join(', ')}.`);
131
+ const next = updateSceneObject(before, id, patch);
132
+ owned().write(next);
133
+ const kept = next.find(row => row.id === id);
134
+ const warning = clampWarning(id, Object.fromEntries(['x', 'y', 'z', 'scaleX', 'scaleY', 'scaleZ'].filter(key => Object.hasOwn(patch ?? {}, key)).map(key => [key, Number(patch[key])])), kept);
135
+ return { ...result(before, 'Updated object.'), ...(warning ? { warnings: [warning] } : {}) };
74
136
  },
75
137
  'object.group': ({ parent, children }) => {
76
138
  objectOf(parent); children.forEach(objectOf);
@@ -84,9 +146,17 @@ export function register(registry, ports) {
84
146
  owned().write(children.reduce((rows, id) => setSceneObjectParent(rows, id, null), before));
85
147
  return result(before, 'Ungrouped objects.');
86
148
  },
149
+ 'object.groupUnderEmpty': ({ id }) => {
150
+ const object = objectOf(id); const before = owned().read();
151
+ if (object.attach) fail('CAPABILITY_MISSING', 'Detach the object from its character before grouping it.');
152
+ const grouped = groupUnderNewEmpty(before, id);
153
+ if (!grouped) fail('INVALID_ARGUMENT', 'Cannot group this object.');
154
+ owned().write(grouped.objects);
155
+ return { affectedIds: [grouped.emptyId, id], summary: 'Grouped the object under a new Empty.' };
156
+ },
87
157
  'objects.arrange': args => {
88
158
  const plan = owned().arrange(args);
89
- return { affectedIds: plan.affectedIds, summary: 'Arranged objects.' };
159
+ return { affectedIds: plan.affectedIds, summary: 'Arranged objects.', checks: plan.checks, warnings: plan.warnings };
90
160
  },
91
161
  };
92
162
  // Scalar scale is a convenience at the command boundary; the registry still
@@ -157,6 +227,31 @@ export function register(registry, ports) {
157
227
  await owned().applyMatte(objectId, context);
158
228
  return { affectedIds: [objectId], summary: 'Applied the object matte.' };
159
229
  } });
230
+ registry.register({ ...sculptCompare, available, run: async ({ id: objectId, reference, yaw, elevation = 0, candidates }, context) => {
231
+ const target = objectOf(objectId);
232
+ if (target.renderer !== SCULPT_KIND || !target.recipe) fail('INVALID_ARGUMENT', `${objectId} is a ${target.renderer}, not a sculpt.`);
233
+ const images = ports.sculptImages ?? browserSculptImages;
234
+ let image, scored;
235
+ try { image = await images.decode(reference); }
236
+ catch (error) { fail('INVALID_ARGUMENT', `Could not read the reference image: ${error?.message || error}`); }
237
+ context?.signal?.throwIfAborted();
238
+ try { scored = scoreSculptAgainstReference(target.recipe, image, { yaw, elevation, candidates }); }
239
+ catch (error) { fail('INVALID_ARGUMENT', error?.message || String(error)); }
240
+ const { result, ranked, refused } = scored;
241
+ const output = { id: objectId, ...result.report };
242
+ if (ranked) Object.assign(output, { ranking: [...ranked.map(row => rankingRow(row, result)), ...refused.map(({ label, error }) => ({ label, refused: error }))],
243
+ sheetLabel: ranked[0].label, note: `Nothing was applied. Apply the winner with object.sculpt { id: "${objectId}", edits }.` });
244
+ const sheet = (ranked ? ranked[0].result : result).sheet;
245
+ const room = SCULPT_COMPARE_OUTPUT_BYTES - JSON.stringify(output).length;
246
+ // The full-size sheet is always tried, however narrow; shrinking stops at 64 px.
247
+ for (let factor = 1; factor === 1 || sheet.width / factor >= 64; factor *= 2) {
248
+ const dataUrl = await images.encodePng(factor === 1 ? sheet : shrinkRgba(sheet, factor));
249
+ if (dataUrl.length + 40 <= room) { Object.assign(output, { sheetDataUrl: dataUrl, ...(factor > 1 ? { sheetScale: 1 / factor } : {}) }); break; }
250
+ }
251
+ if (!output.sheetDataUrl) output.sheetOmitted = 'The overlay sheet does not fit in a receipt at a readable size; read missingByBand and extraByPart.';
252
+ const lead = ranked ? `best ${ranked[0].label} at IoU ${ranked[0].result.report.iou} (current ${result.report.iou}), ${candidates.length} candidates scored, none applied` : `IoU ${result.report.iou}`;
253
+ return { affectedIds: [], output, summary: `Compared ${target.name || objectId} with the reference at yaw ${result.report.view.yaw}: ${lead}.` };
254
+ } });
160
255
  registry.register({ ...assetImport, available: () => true,
161
256
  run: async (args, context) => {
162
257
  const { source, ...options } = args;