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
@@ -0,0 +1,210 @@
1
+ // One Studio action registry for the editor UI and the agent. The
2
+ // declarations below are data shared with the sidecar (ids, descriptions,
3
+ // input schemas, kinds); the editor registers ONE implementation per id, and
4
+ // both its UI controls and the agent's `run_action` family call
5
+ // `registry.run(id, args)`. No React, renderer or Node imports.
6
+ import { StudioProtocolError, StudioSchemas, freezeStudioData, validateStudioSchema } from "./studio-agent-protocol.js";
7
+
8
+ /** mutation: authored, undoable, answered with a journal receipt.
9
+ * transient: view state only. job: long-running work (a generation, an
10
+ * export); it answers "started", or "completed" with its output when it runs
11
+ * to its end. A job that needs longer than the hub's 30 s default declares
12
+ * `timeoutMs` (at most the hub's 300 s ceiling); one that starts a motion
13
+ * generation declares `generation: "motion"`, the agent's one per message.
14
+ * document: project-level work outside the undo history (scenes, the project
15
+ * file); it answers "completed", with the new host when the open scene moved. */
16
+ export const STUDIO_ACTION_KINDS = freezeStudioData(["mutation", "transient", "job", "document"]);
17
+
18
+ const idSchema = StudioSchemas.TargetGuard.properties.targetId;
19
+ const frame = { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER };
20
+ const input = (required = {}, optional = {}) => ({ type: "object", properties: { ...required, ...optional }, required: Object.keys(required), additionalProperties: false });
21
+ const shotId = { shotId: idSchema };
22
+ const sceneId = { sceneId: idSchema };
23
+ const NOT_UNDOABLE = "Scenes are outside the undo history: undo_edit and Ctrl+Z cannot revert this.";
24
+ const SCENE_MOVES = "The open scene changes, so later commands in this message are admitted in it; the receipt's host names it.";
25
+ const characterId = { characterId: idSchema };
26
+ /** A world floor point in metres; y is the floor. */
27
+ const floorPoint = input({ x: { type: "number" }, z: { type: "number" } });
28
+ const waypointFrame = { ...frame, minimum: 1 };
29
+ /** The tracks an IK key stores (src/ardy/ik.js IK_TRACKS and FK_TRACKS). A
30
+ * chain track keys its three bones (upper, lower, end); a joint track one. */
31
+ export const STUDIO_IK_CHAIN_TRACKS = freezeStudioData(["leftHand", "rightHand", "leftFoot", "rightFoot"]);
32
+ export const STUDIO_IK_JOINT_TRACKS = freezeStudioData(["hips", "spine", "chest", "neck", "head", "leftShoulder", "rightShoulder"]);
33
+ const num = { type: "number" };
34
+ const perBone = items => ({ type: "array", items, minItems: 1, maxItems: 3 });
35
+ const quaternion = input({ x: num, y: num, z: num, w: num });
36
+ /** One track's key: the IK state's { q, p, baseQ, basePos, chainP,
37
+ * keepTranslations, blend, pin } with quaternions as {x,y,z,w} and positions
38
+ * as {x,y,z}; blend is the key's own correction range in frames (src/ardy/ik.js
39
+ * correctionWeight), absent for the editor's default range; pin is the id of
40
+ * the range pin that baked the key (src/ardy/range-pin.js). */
41
+ const ikTrackKey = input({}, { q: perBone(quaternion), p: StudioSchemas.Vec3, baseQ: perBone(quaternion), basePos: StudioSchemas.Vec3,
42
+ chainP: perBone(StudioSchemas.Vec3), keepTranslations: { type: "boolean" }, blend: { type: "integer", minimum: 1, maximum: 240 },
43
+ pin: { type: "string", minLength: 1, maxLength: 64 } });
44
+ const ikTracks = input({}, Object.fromEntries([...STUDIO_IK_CHAIN_TRACKS, ...STUDIO_IK_JOINT_TRACKS].map(track => [track, ikTrackKey])));
45
+ /** The bones an object can ride (src/scene-objects.js SCENE_ATTACH_BONES). */
46
+ export const STUDIO_ATTACH_BONES = freezeStudioData(["hips", "spine", "chest", "neck", "head", "leftShoulder", "leftElbow", "leftHand",
47
+ "rightShoulder", "rightElbow", "rightHand", "leftKnee", "leftFoot", "rightKnee", "rightFoot"]);
48
+ /** The shot frame's composition guides (src/shot-guides.js GUIDE_MODES). */
49
+ const GUIDE_MODES = freezeStudioData(["off", "thirds", "golden", "center", "safe"]);
50
+ /** The image models the Studio writes Send-to-AI prompts for (src/shot.js IMAGE_MODELS). */
51
+ const AI_IMAGE_MODELS = freezeStudioData(["nano_banana_pro", "nano_banana_2", "gpt_image_2", "seedream_5", "flux_2"]);
52
+ const WAYPOINT_RULES = "Pins sit at least 8 frames apart, the walk between two pins must stay within 0.5-3 m/s, and x/z are clamped to +/-11 m; a pin that breaks a rule is refused with the frame or distance that would work.";
53
+
54
+ export const STUDIO_ACTIONS = freezeStudioData([
55
+ { id: "shot.create", label: "Add shot", kind: "mutation", undoDomain: "shot", input: input(),
56
+ description: "Add a new shot at the playhead, keyed with the current camera framing (the timeline's + Add shot). Move the playhead first with operate_studio { frame }. When the playhead is inside a shot, the new one goes in the next free gap." },
57
+ { id: "shot.split", label: "Split shot", kind: "mutation", undoDomain: "shot", input: input(shotId),
58
+ description: "Cut a shot in two at the playhead. The playhead must be inside that shot, after its first frame; the second half starts at the playhead." },
59
+ { id: "shot.duplicate", label: "Duplicate shot", kind: "mutation", undoDomain: "shot", input: input(shotId),
60
+ description: "Copy a shot, its camera and its keys into the next free gap on the timeline." },
61
+ { id: "shot.remove", label: "Delete shot", kind: "mutation", undoDomain: "shot", input: input(shotId),
62
+ description: "Delete a shot and leave its frames as free-camera time." },
63
+ { id: "shot.setRange", label: "Set shot range", kind: "mutation", undoDomain: "shot", input: input({ ...shotId, range: StudioSchemas.FrameRange }),
64
+ description: "Move a shot's start and end to a half-open frame range. Edges are clamped to the timeline and refused where they would overlap another shot; the receipt's delta shows the range that landed." },
65
+ { id: "shot.reorder", label: "Move shot", kind: "mutation", undoDomain: "shot", input: input({ ...shotId, startFrame: frame }),
66
+ description: "Move a shot in time to start at startFrame, keeping its length and camera keys. Refused (a noop) where it would overlap another shot." },
67
+ { id: "motion.generateAllBlocks", label: "Generate all blocks", kind: "job", generation: "motion", domain: "motion", background: true, input: input(),
68
+ description: "Generate the active character's motion from all of its prompt blocks, like the top-bar Generate Motion button when prompt blocks have text. It starts a job and returns status \"started\"; the take lands in the editor when the job finishes. Counts as the one motion generation of this message." },
69
+ { id: "character.addWaypoint", label: "Add root waypoint", kind: "mutation", undoDomain: "cast", input: input({ ...characterId, position: floorPoint }, { frame: waypointFrame }),
70
+ description: `Pin a character's root path: at frame, the character's root stands at position (world x/z metres). Frame 0 is the character's own spot, so pins start at frame 1 and each frame holds one pin. Omit frame to pace the pin at a walk (1.4 m/s) from the previous one. ${WAYPOINT_RULES} Read paths with inspect_studio { scope: "motion" }.` },
71
+ { id: "character.moveWaypoint", label: "Move root waypoint", kind: "mutation", undoDomain: "cast", input: input({ ...characterId, frame: waypointFrame, position: floorPoint }),
72
+ description: `Move the character's root waypoint at frame to a new floor position (world x/z metres), keeping its frame. ${WAYPOINT_RULES}` },
73
+ { id: "character.removeWaypoint", label: "Remove root waypoint", kind: "mutation", undoDomain: "cast", input: input({ ...characterId, frame: waypointFrame }),
74
+ description: "Remove the character's root waypoint at frame." },
75
+ { id: "character.clearWaypoints", label: "Clear root path", kind: "mutation", undoDomain: "cast", input: input(characterId),
76
+ description: "Remove every root waypoint of the character, leaving its motion unconstrained by a path." },
77
+ { id: "character.setIkKey", label: "Set IK key", kind: "mutation", undoDomain: "motion", input: input({ ...characterId, frame, tracks: ikTracks }),
78
+ description: "Key a character's IK correction layer at frame, the same key a pose drag bakes. tracks maps a track id to its key: q is the bones' LOCAL rotations as unit quaternions {x,y,z,w}, three for a chain track (leftHand, rightHand, leftFoot, rightFoot: upper, lower and end bone) and one for a joint track (hips, spine, chest, neck, head, leftShoulder, rightShoulder); p is a joint's local position {x,y,z} (the hips' height and lean). Optional baseQ/basePos give the take's own pose the key was made over, so it applies as a delta on the take; chainP gives a chain's three local bone positions; keepTranslations keeps them over the take's; blend (frames, 1-240) is how far this key's correction eases into the take around it, 6 when omitted; pin (1-64 characters) tags the key as baked by that range pin. A key needs q or p. Each named track replaces its key at frame and starts evaluating; other tracks at that frame stay. Keyed frames are in inspect_studio { scope: \"motion\" } ikKeyFrames." },
79
+ { id: "character.removeIkKey", label: "Delete IK key", kind: "mutation", undoDomain: "motion", input: input({ ...characterId, frame }),
80
+ description: "Delete the character's whole IK key at frame (every track keyed there), like the Full-Body lane's delete." },
81
+ { id: "character.clearIkKeys", label: "Clear IK keys", kind: "mutation", undoDomain: "motion", input: input(characterId),
82
+ description: "Delete every IK key of the character, returning it to its take or pose without corrections." },
83
+ { id: "shot.setCameraRail", label: "Set camera rail", kind: "mutation", undoDomain: "shot",
84
+ input: input({ ...shotId, points: { type: "array", items: floorPoint, minItems: 2, maxItems: 512 } }),
85
+ description: "Lay a shot's dolly rail through points (world floor x/z metres, in travel order) and put the shot's camera on it, like drawing the rail in the Top-View. It replaces any rail the shot had; an authored rail-follow range is kept, otherwise the camera travels the rail over the whole shot. Read rails with inspect_studio { scope: \"shot\" }." },
86
+ { id: "shot.clearCameraRail", label: "Delete camera rail", kind: "mutation", undoDomain: "shot", input: input(shotId),
87
+ description: "Delete a shot's camera rail; the shot's camera goes back to following its subject at the current distance." },
88
+ { id: "object.attach", label: "Attach to character", kind: "mutation", undoDomain: "objects",
89
+ input: input({ objectId: idSchema, ...characterId }, { bone: { type: "string", enum: STUDIO_ATTACH_BONES } }),
90
+ description: "Make a scene object ride a character, like dropping it on the character's Hierarchy row: on one bone (a cup in the right hand: bone \"rightHand\") or, with bone omitted, on the character's animated root so it travels with the body. The object keeps its place on screen: its position, rotation and scale are rewritten into the new frame (local to that bone while attached), and it leaves any group. Needs the character's rig on stage." },
91
+ { id: "object.detach", label: "Detach object", kind: "mutation", undoDomain: "objects", input: input({ objectId: idSchema }),
92
+ description: "Put an attached or grouped object back in the world where it is now, like the Inspector's Detach or dropping it on the Props row: it stops following the character, keeps its current world placement and leaves any group." },
93
+ { id: "view.setPartColours", label: "Part colours", kind: "transient", input: input({ mode: { type: "string", enum: ["off", "flat", "shaded"] } }),
94
+ description: "Paint the characters in stable per-body-part colours (the View menu's Part colours): flat colours, shaded colours that keep the surface lighting (the look motion reading from a frame needs), or off. A viewer setting: never saved in the scene or undone." },
95
+ { id: "view.setGuideMode", label: "Composition guides", kind: "transient", input: input({ mode: { type: "string", enum: GUIDE_MODES } }),
96
+ description: "Show a composition guide over the shot frame: rule of thirds, golden ratio, center cross, safe areas, or off. Overlay only, never in exported pixels; a viewer setting that is never undone." },
97
+ { id: "view.setInset", label: "Top-View inset", kind: "transient", input: input({ collapsed: { type: "boolean" } }),
98
+ description: "Fold (collapsed: true) or unfold the Top-View inset pane over the viewport. A viewer setting that is never undone." },
99
+ { id: "export.shotVideo", exposure: "confirm", confirmationReason: "Exporting writes a video file outside the Studio.", label: "Export video (mp4)", kind: "job", timeoutMs: 300_000, input: input({}, shotId),
100
+ description: "Record a shot to an MP4 (camera move and character motion, no editor chrome), like the Export menu's Video (mp4), and ask the browser to download it. With shotId it records that shot's own range; without it, what the menu records: the whole take when there is motion, else the shot under the playhead (else the first shot), keyed from the current camera if it has no keys. Waits until the file is encoded (up to 5 minutes) and answers status \"completed\" with output.fileName and output.frameCount. One export at a time; not a motion generation." },
101
+ { id: "scene.create", label: "New scene", kind: "document", input: input(),
102
+ description: `Add an empty scene (named SCENE 01, SCENE 02, ...) after the others and open it, like the scene menu's New scene. ${SCENE_MOVES} ${NOT_UNDOABLE}` },
103
+ { id: "scene.duplicate", label: "Duplicate scene", kind: "document", input: input(sceneId),
104
+ description: `Copy a scene (objects, shots, cast and stage) right after it and open the copy, like the Hierarchy scene menu's Duplicate. ${SCENE_MOVES} ${NOT_UNDOABLE}` },
105
+ { id: "scene.rename", label: "Rename scene", kind: "document", input: input({ ...sceneId, name: { type: "string", minLength: 1, maxLength: 240 } }),
106
+ description: `Rename a scene; a name another scene already has gets a number. ${NOT_UNDOABLE}` },
107
+ { id: "scene.delete", exposure: "confirm", confirmationReason: "Deleting a scene cannot be undone.", label: "Delete scene", kind: "document", input: input(sceneId),
108
+ description: `Delete a scene and everything in it, like the Hierarchy scene menu's Delete; delete only a scene the user asked to delete. The last scene cannot be deleted. Deleting the open scene opens its neighbour. ${SCENE_MOVES} ${NOT_UNDOABLE}` },
109
+ { id: "scene.switch", label: "Open scene", kind: "document", input: input(sceneId),
110
+ description: `Open another scene of the project, like the scene pill's menu; the scene being left keeps its state. ${SCENE_MOVES}` },
111
+ { id: "project.save", exposure: "confirm", confirmationReason: "Saving may overwrite a file or open a file picker.", label: "Save project", kind: "document", input: input(),
112
+ description: "Save the whole project to its current file, like the Project menu's Save Project, and answer status \"completed\" with output.fileName. A browser file picker opens only from the user's own click, so it is refused, with the reason, when the project has no file yet this session or the browser must re-grant access to it; ask the user to press Save Project once. An unnamed project opens the Save dialog for the user to name it. Browsers without file access download the project file instead." },
113
+ { id: "asset.import", label: "Import asset", kind: "mutation", undoDomain: "objects",
114
+ input: input({ source: { type: "string", minLength: 1, maxLength: 8_000_000, pattern: "^(data:|https?://)" }, name: { type: "string", minLength: 1, maxLength: 120 },
115
+ placeAs: { type: "string", enum: ["cutout", "backdrop", "mesh"] } }),
116
+ description: "Import a picture or a 3D model into the scene through the editor's own import path and place it: \"cutout\" stands a picture up as a 1.8 m card in front of the camera, \"backdrop\" as a 5 m plate 12 m down the shot camera's view, facing it, and \"mesh\" stands a GLB, OBJ or FBX model on the floor in front of the camera. source is a data: URL (up to 8 MB of text) or an http(s) URL the editor fetches (its server must allow cross-origin reads); name is the file name with its extension (poster.png, chair.glb). One undo entry; the new object is in affectedIds and its asset id in the summary. A file on the user's disk needs their own click: ask them to drop it on the Assets shelf." },
117
+ { id: "object.duplicate", label: "Duplicate object", kind: "mutation", undoDomain: "objects", input: input({}, { objectId: idSchema }),
118
+ description: "Copy a scene object (the selected one when objectId is omitted) and place the copy half a metre beside it." },
119
+ { id: "ai.prepareShot", label: "Send to AI", kind: "job", input: input({}, { mode: { type: "string", enum: ["image", "video"] }, model: { type: "string", enum: AI_IMAGE_MODELS } }),
120
+ description: "Prepare the Studio's Send-to-AI package for the shot under the playhead (else the current camera): the prompt for an external image or video model, written from the shot's camera, cast, poses, environment and style, plus reference frames rendered from the shot camera (its first and last camera key when it has keys). No AI service is called. mode \"image\" is a still, \"video\" a shot with its camera move; model is the image model the prompt is written for (image mode only). Each omitted choice keeps the Studio's current one; a given one becomes it. The Studio opens its result panel, where the user copies the prompt and downloads the frames; the frame images stay there and are not in the answer. Answers status \"completed\" with output.prompt, mode, modelLabel, shot (id, name, range), aspectRatio, cameraMode and referenceFrames (how many frames the panel shows). Changes nothing in the scene; not a motion generation." },
121
+ // The instruction leads the H3 prompt, which the motion API caps at 4000
122
+ // characters with its camera-lock text (about 225) appended.
123
+ { id: "motion.generateFromVideo", exposure: "confirm", confirmationReason: "AI video motion uses a paid external generation.", label: "Generate motion (AI video)", kind: "job", generation: "motion", timeoutMs: 300_000,
124
+ input: input({ instruction: { type: "string", minLength: 1, maxLength: 3700 } }),
125
+ description: "Make the active character perform instruction through a hosted AI video model (Fal H3 Max Turbo, 480P), like the agent panel's Generate motion button. It sends the character's pose frame (pose A from the Fal card, else a fresh capture from the shot camera) with the instruction to the hosted model, and each run spends one of the account's daily AI video generations. Run it only when the user explicitly asked for AI-video motion; for ordinary motion use generate_motion or motion.generateAllBlocks. The capture needs the character's full body inside the shot frame and shaded part colours (view.setPartColours { mode: \"shaded\" }); a prompt the user hand-edited in the Fal card is sent instead of instruction. Waits for the clip (up to 5 minutes) and answers status \"completed\" with output.videoUrl, resolution, durationSeconds, ingested, frames, fps and dailyRemaining. The clip is ingested as Video capture footage and the timeline takes its length and frame rate; no take is installed until GVHMR extraction runs on it in the Video capture panel. Counts as the one motion generation of this message." },
126
+ ]);
127
+ export const STUDIO_ACTION_IDS = freezeStudioData(STUDIO_ACTIONS.map(action => action.id));
128
+
129
+ const fail = (code, message) => { throw new StudioProtocolError(code, message); };
130
+
131
+ /** A refusal an editor control can also meet. `message` is written for the
132
+ * model; `uiMessage` is the localized text the editor's UI door shows a
133
+ * person. A refusal without one stays silent in the UI. */
134
+ export function studioActionRefusal(code, message, uiMessage) {
135
+ return Object.assign(new StudioProtocolError(code, message), { uiMessage });
136
+ }
137
+ // A localized toast producer is evaluated twice, without showing it twice:
138
+ // once for the existing UI and once in English for command evidence.
139
+ export function resolveStudioToast(value, isKo, ko) {
140
+ return typeof value === "function" ? { uiMessage: value(isKo, ko), message: value(false, en => en) } : { uiMessage: value, message: value };
141
+ }
142
+ const unknown = (id, known) => fail("INVALID_ARGUMENT", `Unknown Studio action "${id}". Known actions: ${known.join(", ")}. List them with inspect_studio { scope: "actions" }.`);
143
+
144
+ export function studioActionDeclaration(id) {
145
+ return STUDIO_ACTIONS.find(action => action.id === id) ?? unknown(id, STUDIO_ACTION_IDS);
146
+ }
147
+
148
+ /** `readState` is the editor's current action state; when it is supplied,
149
+ * run() refuses an unavailable action with its reason before anything runs. */
150
+ export function createStudioActionRegistry({ readState } = {}) {
151
+ const entries = new Map();
152
+ const availability = (entry, state) => {
153
+ const verdict = entry.available(state);
154
+ if (verdict !== true && (typeof verdict !== "string" || !verdict.trim())) throw new Error(`Studio action ${entry.id} must answer availability with true or a reason.`);
155
+ return verdict;
156
+ };
157
+ const settled = (id, result) => {
158
+ if (!Array.isArray(result?.affectedIds) || result.affectedIds.some(value => typeof value !== "string") || typeof result.summary !== "string") {
159
+ throw new Error(`Studio action ${id} must return { affectedIds, summary }.`);
160
+ }
161
+ return { affectedIds: [...result.affectedIds], summary: result.summary, ...(result.output === undefined ? {} : { output: structuredClone(result.output) }) };
162
+ };
163
+ const registry = {
164
+ register(entry) {
165
+ if (typeof entry?.id !== "string" || !new RegExp(idSchema.pattern).test(entry.id)) throw new Error("A Studio action needs an id.");
166
+ if (entries.has(entry.id)) throw new Error(`Studio action ${entry.id} is already registered.`);
167
+ if (!STUDIO_ACTION_KINDS.includes(entry.kind)) throw new Error(`Studio action ${entry.id} has an unknown kind.`);
168
+ if (entry.input?.type !== "object" || !entry.input.properties || !Array.isArray(entry.input.required)) throw new Error(`Studio action ${entry.id} needs an object input schema.`);
169
+ if (typeof entry.available !== "function") throw new Error(`Studio action ${entry.id} needs available(state).`);
170
+ if (typeof entry.run !== "function") throw new Error(`Studio action ${entry.id} needs run(args).`);
171
+ if (entry.timeoutMs !== undefined && (!Number.isSafeInteger(entry.timeoutMs) || entry.timeoutMs < 1 || entry.timeoutMs > 300_000)) throw new Error(`Studio action ${entry.id} timeoutMs must be 1..300000.`);
172
+ if (entry.exposure !== undefined && !["open", "confirm", "ui-only"].includes(entry.exposure)) throw new Error(`Studio action ${entry.id} has an invalid exposure.`);
173
+ entries.set(entry.id, Object.freeze({ exposure: "open", ...entry }));
174
+ return registry;
175
+ },
176
+ get(id) { return entries.get(id) ?? unknown(id, [...entries.keys()]); },
177
+ ids() { return [...entries.keys()]; },
178
+ state() { return readState?.(); },
179
+ /** Available actions carry their description and input schema;
180
+ * unavailable ones carry the reason instead of arguments. The declared
181
+ * hub timeout and generation travel with each, so no caller keeps its
182
+ * own copy of them. */
183
+ list(state = readState?.()) {
184
+ return [...entries.values()].map(entry => {
185
+ const verdict = availability(entry, state);
186
+ const row = { id: entry.id, label: entry.label, kind: entry.kind, description: entry.description, exposure: entry.exposure,
187
+ ...(entry.timeoutMs === undefined ? {} : { timeoutMs: entry.timeoutMs }), ...(entry.generation ? { generation: entry.generation } : {}) };
188
+ return verdict === true ? { ...row, available: true, input: entry.input } : { ...row, available: false, reason: verdict };
189
+ });
190
+ },
191
+ prepare(id, args = {}) {
192
+ const entry = registry.get(id);
193
+ const validated = validateStudioSchema(entry.input, args ?? {}, "INVALID_ARGUMENT", "$.args");
194
+ if (readState) {
195
+ const verdict = availability(entry, readState());
196
+ if (verdict !== true) fail("TARGET_NOT_READY", verdict);
197
+ }
198
+ return { entry, args: validated };
199
+ },
200
+ invoke(entry, args, context) {
201
+ const result = entry.run(args, context);
202
+ return typeof result?.then === "function" ? result.then(value => settled(entry.id, value)) : settled(entry.id, result);
203
+ },
204
+ run(id, args = {}, context) {
205
+ const prepared = registry.prepare(id, args);
206
+ return registry.invoke(prepared.entry, prepared.args, context);
207
+ },
208
+ };
209
+ return registry;
210
+ }
@@ -1,15 +1,13 @@
1
1
  // Studio composites prepare private domain drafts; the App owns publication,
2
2
  // history, gesture fences and semantic revision/telemetry. No UI callbacks here.
3
3
  import { Euler, Vector3, PerspectiveCamera } from 'three';
4
- import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, supportHeightForObject, OBJECT_LIBRARY } from './scene-objects.js';
5
- import { createCharacterEntry, createSceneStage } from './scenes.js';
6
- import { createShotAuthoringDocument } from './shot-authoring.js';
7
- import { elementByPath } from './studio-elements.js';
4
+ import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, isEffectivelyHidden, supportHeightForObject, OBJECT_LIBRARY } from './scene-objects.js';
5
+ import { createCharacterEntry } from './scenes.js';
8
6
  import { createShot, shotAtFrame } from './cuts.js';
9
7
  import { captureFraming } from './camera-move.js';
10
8
  import { createStableItemId } from './stable-items.js';
11
9
  import { focalMmToFov, SENSOR_FORMATS } from './shot.js';
12
- import { StudioProtocolError, StudioSchemas, STUDIO_PATCH_DOMAINS, STUDIO_PATCH_KINDS, STUDIO_PATCH_DESCRIPTORS, validateStudioSchema, validateStudioCommand, validateStudioIdentity, validateReceipt, freezeStudioData, utf8ByteLength } from './studio-agent-protocol.js';
10
+ import { StudioProtocolError, StudioSchemas, STUDIO_PATCH_KINDS, STUDIO_PATCH_DESCRIPTORS, validateStudioSchema, validateStudioIdentity, validateReceipt, freezeStudioData } from './studio-agent-protocol.js';
13
11
 
14
12
  const DEG = Math.PI / 180, EPS = 1e-8, CHARACTER_SUPPORT_TOLERANCE = 5e-3;
15
13
  const fail = (code, message) => { throw new StudioProtocolError(code, message); };
@@ -57,7 +55,7 @@ function support(spec, state) {
57
55
  const id = spec.onObject ?? (typeof spec.support === 'object' ? spec.support.objectId : null);
58
56
  if (!id) return { y: state.floorY, label: 'floor' };
59
57
  const object = entityById(state, id);
60
- if (!object.renderer || object.renderer === 'cutout' || ['sphere', 'capsule', 'cone', 'car', 'small-plane'].includes(object.renderer) || object.path || object.attach || Math.abs(object.rotX) > EPS || Math.abs(object.rotZ) > EPS) fail('TARGET_NOT_READY', 'Support must be a stationary solid upright surface.');
58
+ if (!object.renderer || object.renderer === 'cutout' || ['sphere', 'capsule', 'cone', 'car', 'small-plane'].includes(object.renderer) || object.path || object.attach || Math.abs(object.rotX) > EPS || Math.abs(object.rotZ) > EPS || isEffectivelyHidden(object, state.objects, state.characters)) fail('TARGET_NOT_READY', 'Support must be a stationary solid upright surface.');
61
59
  return { y: object.y + supportHeightForObject(object) * object.scaleY, label: `object:${id}`, object };
62
60
  }
63
61
  function facingYaw(spec, entity, state) {
@@ -120,8 +118,16 @@ function place(entity, op, state, ports) {
120
118
  }
121
119
  function overlapsFor(entity, state, ports) {
122
120
  const own = aabb(geometry(entity, state, ports));
123
- return [...state.objects, ...state.characters].filter(e => e.id !== entity.id && !e.hidden).map(other => ({ id: other.id, bounds: aabb(geometry(other, state, ports)) })).map(other => ({ ...other, depth: overlap(own, other.bounds) })).filter(o => o.depth > EPS);
121
+ return [...state.objects, ...state.characters].filter(e => e.id !== entity.id && !isEffectivelyHidden(e, state.objects, state.characters)).map(other => ({ id: other.id, bounds: aabb(geometry(other, state, ports)) })).map(other => ({ ...other, depth: overlap(own, other.bounds) })).filter(o => o.depth > EPS);
124
122
  }
123
+ function overlapsOf(ids, state, ports) {
124
+ const entities = [...state.objects, ...state.characters];
125
+ return ids.flatMap(id => { const e = entities.find(row => row.id === id); return e ? overlapsFor(e, state, ports) : []; });
126
+ }
127
+ const overlapEvidence = overlaps => ({ coverage: 'same-frame-world-AABB-proxies', overlapIds: [...new Set(overlaps.map(o => o.id))].slice(0, 100), maximumFootprintOverlapM: Math.max(0, ...overlaps.map(o => o.depth)) });
128
+ /** Placement evidence for entity ids in the given scene: the same-frame AABB
129
+ * overlap an arrangement receipt reports for the entities it moved. */
130
+ export const placementChecks = (ids, state, ports) => overlapEvidence(overlapsOf(ids, state, ports));
125
131
  function avoid(entity, relation, state, ports) {
126
132
  const blocked = overlapsFor(entity, state, ports);
127
133
  if (!blocked.length) return entity;
@@ -143,7 +149,7 @@ function transformPatch(op, isObject) {
143
149
  if (op.rotationDeg) Object.assign(patch, { rotX: op.rotationDeg.x, rot: op.rotationDeg.y, rotZ: op.rotationDeg.z });
144
150
  return patch;
145
151
  }
146
- function arrangement(command, before, ports) {
152
+ export function arrangement(command, before, ports) {
147
153
  const isObject = command.name === 'arrange_objects', key = isObject ? 'objects' : 'characters';
148
154
  let rows = before[key];
149
155
  const relations = [], warnings = [];
@@ -188,7 +194,7 @@ function arrangement(command, before, ports) {
188
194
  }
189
195
  const after = { ...before, [key]: rows };
190
196
  const affectedIds = [...new Set([...before[key], ...rows].map(e => e.id))].filter(id => !equal(before[key].find(e => e.id === id), rows.find(e => e.id === id)));
191
- const overlaps = affectedIds.flatMap(id => { const e = rows.find(e => e.id === id); return e ? overlapsFor(e, after, ports) : []; });
197
+ const overlaps = overlapsOf(affectedIds, after, ports);
192
198
  for (const relation of relations) {
193
199
  if (!relation.spec.relativeTo) continue;
194
200
  const subject = rows.find(e => e.id === relation.id), reference = entityById(after, relation.spec.relativeTo);
@@ -203,7 +209,7 @@ function arrangement(command, before, ports) {
203
209
  if (relations.some(r => r.adjustmentM)) warnings.push({ code: 'OUTWARD_ADJUSTMENT', count: relations.filter(r => r.adjustmentM).length });
204
210
  const relation = relations.length === 1 ? relations[0] : null;
205
211
  return { domain: isObject ? 'objects' : 'cast', draft: equal(rows, before[key]) ? before[key] : rows, affectedIds,
206
- checks: { coverage: 'same-frame-world-AABB-proxies', overlapIds: [...new Set(overlaps.map(o => o.id))].slice(0, 100), maximumFootprintOverlapM: Math.max(0, ...overlaps.map(o => o.depth)),
212
+ checks: { ...overlapEvidence(overlaps),
207
213
  ...(relation ? { support: relation.support, baseY: relation.baseY, relationSatisfied: true, ...(relation.axis ? { basis: relation.basis, requestedGapM: relation.requestedGapM, actualGapM: relation.actualGapM } : {}) } : {}) },
208
214
  warnings, details: relations.map(({ axis, ...rest }) => rest) };
209
215
  }
@@ -211,7 +217,29 @@ function arrangement(command, before, ports) {
211
217
  const FRACTIONS = { 'extreme close-up': 3.4, 'close-up': 2.2, 'medium close-up': 1.375, 'medium shot': 0.975, 'medium-wide shot': 0.66, 'wide shot': 0.41, 'extreme wide shot': 0.2 };
212
218
  const LEVELS = { ground: 0.3 / 1.8, low: 0.7 / 1.8, hip: 1.1 / 1.8, eye: 1.65 / 1.8, high: 2.1 / 1.8, overhead: 2.8 / 1.8 };
213
219
  const ANGLES = { front: 0, 'front three-quarter': 40, profile: 90, 'rear three-quarter': 140, back: 180 };
214
- function frameDraft(command, state, ports) {
220
+ /** Where subject bound points land through one camera: the evidence a
221
+ * frame_shot receipt reports, plus the screen box its details keep. */
222
+ function projectSubject(points, camera, fov, aspectRatio) {
223
+ const direction = vector(camera.lookAt).sub(vector(camera.position));
224
+ const projection = new PerspectiveCamera(fov / DEG, aspectRatio, 0.01, 10000);
225
+ projection.position.copy(vector(camera.position)); projection.lookAt(vector(camera.lookAt)); projection.updateMatrixWorld(true);
226
+ const screen = points.map(p => p.clone().project(projection));
227
+ const screenBounds = aabb(screen);
228
+ const behindCamera = points.some(p => p.clone().sub(projection.position).dot(direction) <= 0);
229
+ // Measured projection, not distance to an arbitrary aim point. Moving lookAt
230
+ // along the same ray cannot change the subject's observed screen coverage.
231
+ const screenFraction = (screenBounds.y.max - screenBounds.y.min) / 2;
232
+ const derivedSize = [['extreme close-up', 2.8], ['close-up', 1.6], ['medium close-up', 1.15], ['medium shot', 0.8], ['medium-wide shot', 0.52], ['wide shot', 0.3], ['extreme wide shot', 0]].find(([, threshold]) => screenFraction >= threshold)[0];
233
+ return { screenBounds, checks: { coverage: 'same-frame-subject-bounds-projection', screenFraction, derivedSize, behindCamera, clipped: behindCamera || screen.some(p => Math.abs(p.x) > 1 || Math.abs(p.y) > 1 || p.z < -1 || p.z > 1) } };
234
+ }
235
+ /** Framing evidence for entity ids through the current shot camera at the
236
+ * current frame: the same subject-bounds projection frame_shot reports. */
237
+ export function framingChecks(ids, state, ports) {
238
+ if (!state.camera || !SENSOR_FORMATS[state.filmback?.sensorId] || !(state.filmback.aspectRatio > 0)) fail('TARGET_NOT_READY', 'The shot camera and filmback must be available.');
239
+ const points = ids.flatMap(id => geometry(entityById(state, id), state, ports));
240
+ return projectSubject(points, state.camera, focalMmToFov(state.camera.focalMm, state.filmback.sensorId, state.filmback.aspectRatio), state.filmback.aspectRatio).checks;
241
+ }
242
+ export function frameDraft(command, state, ports) {
215
243
  if (state.frameCount <= 0) fail('TARGET_NOT_READY', 'A nonempty timeline is required.');
216
244
  const subject = state.characters.find(e => e.id === command.args.subjectIds[0]);
217
245
  if (!subject || subject.hidden) fail('TARGET_NOT_READY', 'Framing requires one visible character.');
@@ -248,188 +276,14 @@ function frameDraft(command, state, ports) {
248
276
  }
249
277
  const nextShot = { ...shot, cameraKeys: keys, camera: { ...shot.camera, mode: 'keys' } };
250
278
  const shots = created ? [nextShot] : state.shotDocument.shots.map(s => s.id === shot.id ? nextShot : s);
251
- const projection = new PerspectiveCamera(fov / DEG, state.filmback.aspectRatio, 0.01, 10000);
252
- projection.position.copy(vector(camera.position)); projection.lookAt(vector(camera.lookAt)); projection.updateMatrixWorld(true);
253
- const screen = points.map(p => p.clone().project(projection));
254
- const screenBounds = aabb(screen);
255
- const behindCamera = points.some(p => p.clone().sub(projection.position).dot(direction) <= 0);
256
- // Measured projection, not distance to an arbitrary aim point. Moving lookAt
257
- // along the same ray cannot change the subject's observed screen coverage.
258
- const screenFraction = (screenBounds.y.max - screenBounds.y.min) / 2;
259
- const derivedSize = [['extreme close-up', 2.8], ['close-up', 1.6], ['medium close-up', 1.15], ['medium shot', 0.8], ['medium-wide shot', 0.52], ['wide shot', 0.3], ['extreme wide shot', 0]].find(([, threshold]) => screenFraction >= threshold)[0];
279
+ const { screenBounds, checks } = projectSubject(points, camera, fov, state.filmback.aspectRatio), derivedSize = checks.derivedSize;
260
280
  const resolvedCamera = { ...camera, sensorId: state.filmback.sensorId, slate: derivedSize };
261
281
  const draft = { shotDocument: { ...state.shotDocument, shots }, camera: resolvedCamera, manual: true };
262
282
  return { domain: 'shot', draft, affectedIds: [shot.id, ...(keyId ? [keyId] : [])],
263
- checks: { coverage: 'same-frame-subject-bounds-projection', screenFraction, derivedSize, behindCamera, clipped: behindCamera || screen.some(p => Math.abs(p.x) > 1 || Math.abs(p.y) > 1 || p.z < -1 || p.z > 1) },
283
+ checks,
264
284
  warnings: [{ code: 'OCCLUSION_UNMEASURED' }], details: { created, shotId: shot.id, keyId, frame, framing, screenBounds, subjectIds: [subject.id] } };
265
285
  }
266
286
 
267
- /* ------------------------------------------------ element patches ----
268
- * One thin family over the declared element table. Every value goes through
269
- * the domain's own persistence normalizer (createCharacterEntry,
270
- * updateSceneObject, createShotAuthoringDocument's shot repair,
271
- * createSceneStage) and is then READ BACK: a field the normalizer refused to
272
- * keep is reported as a dropped path, never as a silent success. One patch is
273
- * one domain, so it is one draft, one commit and one history entry. */
274
- const DOMAIN_KEYS = { objects: 'objects', cast: 'characters', stage: 'stage' };
275
- const domainState = (state, domain) => domain === 'shot'
276
- ? { shotDocument: state.shotDocument, camera: state.camera, manual: state.manual }
277
- : state[DOMAIN_KEYS[domain]];
278
- const withDomain = (state, domain, draft) => domain === 'shot' ? { ...state, ...draft } : { ...state, [DOMAIN_KEYS[domain]]: draft };
279
- /** Did the requested value survive? Normalizer-added members (stable ids,
280
- * default route flags) are allowed; a changed, clamped or refused value is not. */
281
- function survives(requested, actual) {
282
- if (requested === null || typeof requested !== 'object') return requested === actual;
283
- if (Array.isArray(requested)) return Array.isArray(actual) && requested.length === actual.length && requested.every((item, index) => survives(item, actual[index]));
284
- if (!actual || typeof actual !== 'object' || Array.isArray(actual)) return false;
285
- return Object.entries(requested).every(([key, value]) => survives(value, actual[key]));
286
- }
287
- const PATH_READERS = {
288
- 'character.position': row => ({ x: row.x, y: row.y ?? 0, z: row.z }),
289
- 'character.rot': row => row.rot, 'character.scale': row => row.scale, 'character.subject': row => row.subject,
290
- 'character.hidden': row => row.hidden === true, 'character.model': row => row.model, 'character.tint': row => row.tint ?? null,
291
- 'character.identityImage': row => row.identityImage ?? null, 'character.pose': row => row.pose?.id ?? null,
292
- 'character.promptBlocks': row => row.layer?.promptClips ?? [],
293
- 'character.motionRef.url': row => row.motionRef?.url ?? null, 'character.motionRef.motionId': row => row.motionRef?.motionId ?? null,
294
- 'object.renderer': row => row?.renderer ?? null, 'object.position': row => row && ({ x: row.x, y: row.y ?? 0, z: row.z }),
295
- 'object.rotation': row => row && ({ x: row.rotX, y: row.rot, z: row.rotZ }), 'object.scale': row => row && ({ x: row.scaleX, y: row.scaleY, z: row.scaleZ }),
296
- 'object.name': row => row?.name, 'object.color': row => row?.color ?? null, 'object.parent': row => row?.parent ?? null,
297
- 'object.path': row => row?.path ?? null, 'object.remove': row => row === undefined,
298
- 'shot.cameraKeys': shot => shot.cameraKeys, 'shot.targetModel': shot => shot.targetModel ?? null,
299
- 'stage.environmentImage': stage => stage.environmentImage, 'stage.camera': stage => stage.shotAspect,
300
- 'stage.environment': stage => stage.environment, 'stage.style': stage => stage.style, 'stage.hasEnvSheet': stage => stage.hasEnvSheet === true,
301
- ...Object.fromEntries(['x', 'y', 'z', 'intensity', 'warmth'].map(axis => [`stage.keyLight.${axis}`, stage => stage.keyLight[axis]])),
302
- };
303
- /** Measured readback for one path: one typed member, never the payload. A
304
- * picture is reported by its size and a schedule by its length. */
305
- function patchedValue(path, value) {
306
- // A cleared, removed or refused field has no measurement but its own absence.
307
- if (value === null || value === undefined) return { path, text: null };
308
- const type = elementByPath(path)?.type;
309
- if (type === 'image') return { path, bytes: utf8ByteLength(value) };
310
- if (type === 'array') return { path, count: Array.isArray(value) ? value.length : value.points?.length ?? 0 };
311
- if (type === 'vec3') return { path, vec: { x: value.x, y: value.y, z: value.z } };
312
- if (typeof value === 'number') return { path, number: value };
313
- if (typeof value === 'boolean') return { path, flag: value };
314
- return { path, text: [...String(value)].slice(0, 512).join('') };
315
- }
316
- function patchCharacters(command, before, ports) {
317
- let rows = before.characters;
318
- const poses = ports.poses?.() ?? [];
319
- const targets = [];
320
- for (const op of command.args.ops) {
321
- const entity = rows.find(row => row.id === op.target.id);
322
- if (!entity) fail('AMBIGUOUS_TARGET', 'Patched character is not present in the admitted scene.');
323
- const source = { ...entity }, fields = new Set();
324
- for (const [key, value] of Object.entries(op.set)) {
325
- if (key === 'position') { Object.assign(source, value); for (const axis of ['x', 'y', 'z']) fields.add(axis); }
326
- else if (key === 'pose') { source.pose = value === null ? null : poses.find(pose => pose.id === value) ?? null; fields.add('pose'); }
327
- else if (key === 'promptBlocks') { source.layer = { ...(source.layer ?? {}), promptClips: value }; fields.add('layer'); }
328
- else if (key.startsWith('motionRef.')) { source.motionRef = { ...(source.motionRef ?? {}), [key.slice('motionRef.'.length)]: value }; fields.add('motionRef'); }
329
- else { source[key] = value; fields.add(key); }
330
- }
331
- // The domain normalizer owns every clamp and whitelist; only the patched
332
- // fields are taken from it, so runtime-only state stays where it lives.
333
- const normalized = createCharacterEntry(source);
334
- const next = { ...entity };
335
- for (const field of fields) next[field] = normalized[field];
336
- rows = rows.map(row => row.id === entity.id ? next : row);
337
- targets.push({ id: entity.id, read: path => PATH_READERS[path](rows.find(row => row.id === entity.id)) });
338
- }
339
- return { draft: rows, targets };
340
- }
341
- function patchObjects(command, before) {
342
- let rows = before.objects;
343
- const targets = [];
344
- for (const op of command.args.ops) {
345
- const entity = rows.find(row => row.id === op.target.id);
346
- if (!entity) fail('AMBIGUOUS_TARGET', 'Patched object is not present in the admitted scene.');
347
- const patch = {};
348
- for (const [key, value] of Object.entries(op.set)) {
349
- if (key === 'position') Object.assign(patch, { x: value.x, y: value.y, z: value.z });
350
- else if (key === 'rotation') Object.assign(patch, { rotX: value.x, rot: value.y, rotZ: value.z });
351
- else if (key === 'scale') Object.assign(patch, { scaleX: value.x, scaleY: value.y, scaleZ: value.z });
352
- else if (!['parent', 'remove'].includes(key)) patch[key] = value;
353
- }
354
- if (Object.keys(patch).length) rows = updateSceneObject(rows, entity.id, patch);
355
- if (Object.hasOwn(op.set, 'parent')) {
356
- if (op.set.parent !== null && descendantsOf(rows, entity.id).some(row => row.id === op.set.parent)) fail('INVALID_ARGUMENT', 'Grouping would create a cycle.');
357
- rows = setSceneObjectParent(rows, entity.id, op.set.parent);
358
- }
359
- if (op.set.remove === true) rows = removeSceneObject(rows, entity.id);
360
- targets.push({ id: entity.id, read: path => PATH_READERS[path](rows.find(row => row.id === entity.id)) });
361
- }
362
- return { draft: rows, targets };
363
- }
364
- function patchShot(command, before) {
365
- let shots = before.shotDocument.shots;
366
- const targets = [];
367
- for (const op of command.args.ops) {
368
- const shot = op.target.id ? shots.find(row => row.id === op.target.id)
369
- : shotAtFrame(shots, before.frame) ?? shots.find(row => row.id === before.selectedShotId);
370
- if (!shot) fail('AMBIGUOUS_TARGET', 'No shot owns this frame; name the shot explicitly.');
371
- const patched = { ...shot };
372
- for (const [key, value] of Object.entries(op.set)) patched[key] = value === null ? undefined : value;
373
- shots = shots.map(row => row.id === shot.id ? patched : row);
374
- targets.push({ id: shot.id, read: path => PATH_READERS[path](shots.find(row => row.id === shot.id)) });
375
- }
376
- // The shot document's own repair owns key ordering, ranges and the video
377
- // model whitelist; rebuilding it is how a patched shot stays a legal shot.
378
- shots = createShotAuthoringDocument({ shots, waypoints: [], frameCount: before.frameCount }).shots;
379
- return { draft: { shotDocument: { ...before.shotDocument, shots }, camera: before.camera, manual: before.manual }, targets };
380
- }
381
- const STAGE_FIELDS = ['hasCharSheet', 'environmentImage', 'environment', 'style', 'hasEnvSheet', 'shotAspect', 'cameraPresetId', 'sensorId', 'keyLight'];
382
- function patchStage(command, before) {
383
- if (!before.stage) fail('CAPABILITY_MISSING', 'This editor does not publish an authored stage.');
384
- let stage = before.stage;
385
- const targets = [];
386
- for (const op of command.args.ops) {
387
- const merged = { ...stage, keyLight: { ...stage.keyLight } };
388
- for (const [key, value] of Object.entries(op.set)) {
389
- if (key.startsWith('keyLight.')) merged.keyLight[key.slice('keyLight.'.length)] = value;
390
- else if (key === 'camera') merged.shotAspect = value;
391
- else merged[key] = value;
392
- }
393
- const normalized = createSceneStage(merged);
394
- // Spread over the live envelope: the stage keeps its own field order, and
395
- // the cast stays owned by the cast domain rather than the stage draft.
396
- stage = { ...stage, ...Object.fromEntries(STAGE_FIELDS.map(field => [field, normalized[field]])) };
397
- targets.push({ id: before.host.sceneId, read: path => PATH_READERS[path](stage) });
398
- }
399
- return { draft: stage, targets };
400
- }
401
- function patchPlan(command, before, ports) {
402
- const kind = command.args.ops[0].target.kind, domain = STUDIO_PATCH_DOMAINS[kind];
403
- const { draft, targets } = kind === 'character' ? patchCharacters(command, before, ports)
404
- : kind === 'object' ? patchObjects(command, before)
405
- : kind === 'shot' ? patchShot(command, before) : patchStage(command, before);
406
- const ops = command.args.ops.map((op, index) => {
407
- const droppedPaths = Object.entries(op.set)
408
- .filter(([key, value]) => !survives(value, targets[index].read(`${kind}.${key}`)))
409
- .map(([key]) => `${kind}.${key}`);
410
- return { index, status: droppedPaths.length ? 'partial' : 'applied', ...(droppedPaths.length ? { droppedPaths } : {}) };
411
- });
412
- const unchanged = equal(draft, domainState(before, domain));
413
- const readback = state => command.args.ops.map((op, index) => ({ id: targets[index].id,
414
- after: { patched: Object.keys(op.set).map(key => patchedValue(`${kind}.${key}`, patchTargetRead(kind, state, targets[index].id, `${kind}.${key}`))) } }));
415
- return { domain, draft, unchanged,
416
- status: unchanged ? 'noop' : ops.some(op => op.status === 'partial') ? 'partial' : 'applied',
417
- ops: unchanged ? ops.map(op => ({ ...op, status: op.status === 'partial' ? 'partial' : 'noop' })) : ops,
418
- affectedIds: [...new Set(targets.map(target => target.id))], patchReadback: readback,
419
- checks: { coverage: 'declared-element-readback' }, warnings: [], details: { kind, paths: command.args.ops.flatMap(op => Object.keys(op.set).map(key => `${kind}.${key}`)) } };
420
- }
421
- /** Read one path back out of the COMMITTED state, so the receipt quotes the
422
- * editor and not the draft the planner built. */
423
- function patchTargetRead(kind, state, id, path) {
424
- if (kind === 'stage') return PATH_READERS[path](state.stage);
425
- if (kind === 'shot') {
426
- const shot = state.shotDocument.shots.find(row => row.id === id);
427
- if (!shot) fail('UNCERTAIN_APPLY', 'Committed shot is unavailable at readback.');
428
- return PATH_READERS[path](shot);
429
- }
430
- return PATH_READERS[path]((kind === 'character' ? state.characters : state.objects).find(row => row.id === id));
431
- }
432
-
433
287
  /** Local journal. Unsettled/protected records are never evicted. Call prune on
434
288
  * history/job release; completed unprotected outcomes expire after ten minutes. */
435
289
  export function createStudioCommandJournal({ host, now = Date.now, isRetained = () => false, maxCompleted = 256, retentionMs = 600000 } = {}) {
@@ -469,90 +323,6 @@ export function createStudioCommandJournal({ host, now = Date.now, isRetained =
469
323
  details(commandId) { return records.get(commandId)?.details ?? null; },
470
324
  };
471
325
  }
472
- function readback(plan, state) {
473
- if (plan.patchReadback) return plan.patchReadback(state);
474
- return plan.affectedIds.map(id => {
475
- if (plan.domain === 'shot') {
476
- const shot = state.shotDocument.shots.find(s => s.id === plan.details.shotId);
477
- if (!shot) fail('UNCERTAIN_APPLY', 'Committed shot is unavailable at readback.');
478
- return { id, after: { shotId: shot.id, range: { startFrame: shot.startFrame, endFrameExclusive: shot.endFrame + 1 }, camera: state.camera, subjectIds: plan.details.subjectIds, ...(id === plan.details.keyId ? { keyId: id, frame: shot.cameraKeys.find(k => k.id === id).frame } : {}) } };
479
- }
480
- const entity = state[plan.domain === 'objects' ? 'objects' : 'characters'].find(e => e.id === id);
481
- return { id, after: !entity ? { removed: true } : { position: pos(entity), yawDeg: entity.rot,
482
- ...(entity.renderer ? { name: entity.name, renderer: entity.renderer, rotationDeg: { x: entity.rotX, y: entity.rot, z: entity.rotZ }, scale: { x: entity.scaleX, y: entity.scaleY, z: entity.scaleZ }, parentId: entity.parent, color: entity.color } : { name: entity.subject, scale: entity.scale, modelId: entity.model, hidden: entity.hidden, activeCharacterId: state.activeCharacterId }) } };
483
- });
484
- }
485
- /** ports: synchronous read/bounds/commit. commit must atomically publish
486
- * exactly the draft and one history entry, or throw before changing anything.
487
- * A thrown/invalid commit is conservatively unknown, never automatically retried. */
488
- export function createStudioCommands(ports) {
489
- const journal = ports.journal ?? createStudioCommandJournal({ host: ports.read().host, isRetained: ports.isRetained });
490
- const documentIsCurrent = () => equal(validateStudioIdentity(ports.read().host), journal.host);
491
- function execute(request) {
492
- const host = validateStudioIdentity(request.host);
493
- validateStudioSchema(StudioSchemas.TargetGuard.properties.targetId, request.commandId);
494
- const signature = JSON.stringify(request);
495
- let phase = 'admission', committing = false;
496
- function failure(code, failurePhase, mutated, message) {
497
- return validateReceipt({ ok: false, commandId: request.commandId, host, code, phase: failurePhase, affectedIds: [], expectedTargets: [], currentTargets: [], mutated,
498
- preserved: { authoredState: mutated === false ? 'unchanged' : 'unknown' }, recovery: { action: mutated === false ? 'inspect' : 'reconcile', ...(mutated === false ? { retryAllowed: false } : {}) }, ...(message ? { message: [...message].slice(0, 120).join('') } : {}) });
499
- }
500
- if (!equal(host, journal.host) || !documentIsCurrent()) return failure('STALE_SCENE', phase, false, 'Request belongs to a different live document.');
501
- try {
502
- if (!journal.begin(request.commandId, signature)) return journal.get(request.commandId) ?? failure('UNCERTAIN_APPLY', 'reconcile', 'unknown');
503
- } catch (error) { return failure(error.code, phase, false, error.message); }
504
- try {
505
- const command = validateStudioCommand({ name: request.name, args: request.args });
506
- if (!['arrange_objects', 'arrange_characters', 'frame_shot', 'patch_elements'].includes(command.name)) fail('CAPABILITY_MISSING', 'This module exposes arrangement, framing and element patches only.');
507
- const before = structuredClone(ports.read());
508
- const fence = () => {
509
- const current = ports.read();
510
- if (!equal(host, validateStudioIdentity(current.host)) || !equal(host, journal.host)) fail('STALE_SCENE', 'Live document identity changed.');
511
- if (request.expectedRevision !== current.revision) fail('STALE_SCENE', 'Authored scene revision changed.');
512
- if (current.busy) fail('TARGET_BUSY', 'A domain gesture is in progress.');
513
- // The exact scene revision is the whole authored fence here: it bumps on
514
- // every authored change, so per-dependency incarnation tokens added
515
- // nothing but a false refusal for a turn's second edit to one entity.
516
- // A dependency that vanished is refused by the planner, which resolves
517
- // every referenced ID against the admitted draft.
518
- if (current.frame !== before.frame || !equal(current.camera, before.camera)) fail('STALE_SCENE', 'Reference view changed during draft evaluation.');
519
- };
520
- fence(); phase = 'prepare';
521
- const plan = command.name === 'frame_shot' ? frameDraft(command, before, ports)
522
- : command.name === 'patch_elements' ? patchPlan(command, before, ports) : arrangement(command, before, ports);
523
- const unchanged = plan.unchanged ?? (plan.domain === 'shot' ? equal(plan.draft, before.shotState ?? { shotDocument: before.shotDocument, camera: before.camera, manual: before.manual }) : !plan.affectedIds.length);
524
- const ops = plan.ops ? { ops: plan.ops } : {};
525
- const base = { ok: true, commandId: request.commandId, receiptId: createStableItemId('receipt'), host, revision: { before: before.revision, after: before.revision }, affectedIds: plan.affectedIds, delta: [], checks: plan.checks, warnings: plan.warnings, undo: null };
526
- if (unchanged) return journal.record({ ...base, ...ops, status: 'noop', authored: false, mutated: false });
527
- if (plan.affectedIds.length > 100) fail('INVALID_ARGUMENT', 'Affected domain exceeds receipt capacity.');
528
- const preview = withDomain(before, plan.domain, plan.draft);
529
- const allDelta = readback(plan, preview);
530
- const makeReceipt = (delta, historyEntryId) => {
531
- const r = { ...base, ...ops, status: plan.status ?? 'applied', authored: true, revision: { before: before.revision, after: before.revision + 1 }, delta: delta.slice(0, 8), undo: { historyEntryId, entries: 1, canUndoDirect: true }, detailCursor: request.commandId };
532
- while (r.delta.length > 1 && utf8ByteLength(JSON.stringify(r)) > 8000) r.delta.pop();
533
- return validateReceipt(r);
534
- };
535
- makeReceipt(allDelta, 'preflight-history'); // Reject receipt overflow BEFORE mutation.
536
- fence(); phase = 'commit'; committing = true;
537
- const committed = ports.commit({ domain: plan.domain, before, draft: plan.draft, commandId: request.commandId, host, expectedRevision: before.revision });
538
- if (committed?.then) fail('UNCERTAIN_APPLY', 'Commit must publish synchronously.');
539
- const current = ports.read();
540
- const actual = domainState(current, plan.domain);
541
- if (current.revision !== before.revision + 1 || !equal(current.host, host) || !equal(actual, plan.draft)) fail('UNCERTAIN_APPLY', 'Commit did not synchronously publish the admitted poststate.');
542
- const delta = readback(plan, current);
543
- return journal.record(makeReceipt(delta, committed.historyEntryId), { delta, geometry: plan.details });
544
- } catch (error) {
545
- const receipt = failure(committing ? 'UNCERTAIN_APPLY' : error instanceof StudioProtocolError ? error.code : 'INVALID_ARGUMENT', phase, committing ? 'unknown' : false, error.message);
546
- // An owner may already have journaled the commit before a lost ack.
547
- const recorded = journal.reconcile({ commandId: request.commandId });
548
- if (recorded.status === 'applied') return recorded.receipt;
549
- return journal.record(receipt);
550
- }
551
- }
552
- return { execute, journal,
553
- reconcile_studio_command: args => documentIsCurrent() ? journal.reconcile(args) : { status: 'unknown' },
554
- readDetails: commandId => documentIsCurrent() ? journal.details(commandId) : null };
555
- }
556
326
  export function studioObjectCatalogue() {
557
327
  return freezeStudioData({ objects: OBJECT_LIBRARY.map(({ kind, footprint, height, supportY }) => ({ kind, footprint: { ...footprint }, height, supportY: supportY ?? height })), imageRefs: [],
558
328
  // The patchable vocabulary, so a caller reads the paths and their declared