cozyclay 1.10.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/bin/agent/agent-routes.mjs +82 -72
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/codex-auth.mjs +2 -1
  10. package/bin/cozyclay.mjs +38 -11
  11. package/bin/live/cli.mjs +22 -3
  12. package/bin/mcp-runtime.mjs +45 -20
  13. package/bin/telemetry-state.mjs +5 -2
  14. package/bin/update-check.mjs +15 -0
  15. package/dist/app/index.html +6 -6
  16. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  17. package/dist/assets/app-BxBLC6GT.js +4876 -0
  18. package/dist/assets/app-CJdxvnrl.css +1 -0
  19. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  20. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  21. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  22. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  23. package/dist/assets/shot-prompt-hN0j_O-p.js +17 -0
  24. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  25. package/dist/assets/workflow-B5XZwVfe.js +179 -0
  26. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  27. package/dist/cozyclay-package.json +1 -1
  28. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  29. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  30. package/dist/fonts/README.md +13 -9
  31. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  33. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  35. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  36. package/dist/index.html +7 -7
  37. package/dist/privacy/index.html +13 -8
  38. package/dist/sitemap.xml +2 -2
  39. package/dist/workflow/index.html +5 -5
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +219 -376
  44. package/package.json +2 -1
  45. package/src/App.jsx +1275 -9086
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/fix-collisions.js +10 -66
  51. package/src/ardy/ik-drag.js +101 -0
  52. package/src/ardy/ik-key-json.js +43 -0
  53. package/src/ardy/ik.js +97 -67
  54. package/src/ardy/physics-panel.css +2 -0
  55. package/src/ardy/physics-panel.jsx +17 -5
  56. package/src/ardy/platform-fit-panel.css +64 -0
  57. package/src/ardy/platform-fit-panel.jsx +40 -0
  58. package/src/ardy/platform-fit.js +326 -0
  59. package/src/ardy/playback.js +166 -20
  60. package/src/ardy/range-pin.js +299 -0
  61. package/src/ardy/timeline-coordinates.js +26 -0
  62. package/src/ardy/timeline.css +1214 -0
  63. package/src/ardy/timeline.jsx +152 -41
  64. package/src/ardy/waypoints.js +3 -3
  65. package/src/asset-pane.css +462 -0
  66. package/src/asset-pane.jsx +461 -232
  67. package/src/camera-tutorial.jsx +38 -18
  68. package/src/command-bus.js +349 -0
  69. package/src/commands/ai.js +31 -0
  70. package/src/commands/cast.js +140 -0
  71. package/src/commands/elements/character.js +13 -0
  72. package/src/commands/elements/motion.js +14 -0
  73. package/src/commands/elements/object.js +22 -0
  74. package/src/commands/elements/scene.js +16 -0
  75. package/src/commands/elements/shot.js +9 -0
  76. package/src/commands/elements/stage.js +11 -0
  77. package/src/commands/elements.js +155 -0
  78. package/src/commands/export.js +20 -0
  79. package/src/commands/index.js +40 -0
  80. package/src/commands/motion.js +231 -0
  81. package/src/commands/objects.js +174 -0
  82. package/src/commands/project.js +53 -0
  83. package/src/commands/scene.js +81 -0
  84. package/src/commands/shared.js +19 -0
  85. package/src/commands/shot.js +103 -0
  86. package/src/commands/stage.js +17 -0
  87. package/src/commands/view.js +42 -0
  88. package/src/document-store.js +144 -0
  89. package/src/domains/cast.js +1007 -0
  90. package/src/domains/motion.js +3219 -0
  91. package/src/domains/objects.js +932 -0
  92. package/src/domains/scenes.js +823 -0
  93. package/src/domains/shots.js +404 -0
  94. package/src/domains/stage.js +93 -0
  95. package/src/dualview.jsx +113 -57
  96. package/src/facing-marks.js +3 -0
  97. package/src/first-success-guide.jsx +14 -14
  98. package/src/grid-view.js +18 -5
  99. package/src/hierarchy-panel.css +386 -0
  100. package/src/hierarchy-panel.jsx +88 -19
  101. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  102. package/src/i2v-motion-studio.jsx +180 -0
  103. package/src/ik-camera.js +3 -0
  104. package/src/main.jsx +6 -0
  105. package/src/motion/generation.js +105 -0
  106. package/src/motion-readiness-ui.jsx +4 -4
  107. package/src/motion-readiness.js +11 -0
  108. package/src/motion-trail.js +366 -9
  109. package/src/object-gizmo.jsx +7 -1
  110. package/src/panels/CameraPanel.jsx +60 -0
  111. package/src/panels/CharacterTransformPanel.jsx +48 -0
  112. package/src/panels/EnvironmentPanel.jsx +40 -0
  113. package/src/panels/Foldout.jsx +32 -0
  114. package/src/panels/LightPanel.jsx +24 -0
  115. package/src/panels/ObjectTransformPanel.jsx +554 -0
  116. package/src/panels/PosePanel.jsx +97 -0
  117. package/src/panels/ProjectPanel.jsx +33 -0
  118. package/src/panels/PromptBlocksPanel.jsx +442 -0
  119. package/src/panels/PropsPanel.jsx +70 -0
  120. package/src/panels/ReferenceImageField.jsx +91 -0
  121. package/src/panels/RigControlPanel.jsx +78 -0
  122. package/src/panels/RigPanel.jsx +37 -0
  123. package/src/panels/SubjectBox.jsx +47 -0
  124. package/src/panels/SubjectsPanel.jsx +39 -0
  125. package/src/panels/VideoCapturePanel.jsx +180 -0
  126. package/src/panels/details.css +1171 -0
  127. package/src/panels/motion.css +197 -0
  128. package/src/panels/pose.css +39 -0
  129. package/src/planview.jsx +60 -31
  130. package/src/posestudio.jsx +59 -4
  131. package/src/project-browser.css +1073 -0
  132. package/src/project-browser.jsx +288 -123
  133. package/src/range-pin-object-transform.js +19 -0
  134. package/src/range-pin-panel.css +518 -0
  135. package/src/range-pin-panel.jsx +344 -0
  136. package/src/result-modal.jsx +5 -5
  137. package/src/room.jsx +79 -51
  138. package/src/scenes.js +9 -8
  139. package/src/semantic-edit.js +2 -0
  140. package/src/settings-menu.jsx +39 -105
  141. package/src/shell/BottomDock.jsx +450 -0
  142. package/src/shell/DetailsSlot.jsx +513 -0
  143. package/src/shell/LibrarySlot.jsx +86 -0
  144. package/src/shell/MenuBar.jsx +601 -0
  145. package/src/shell/OutlinerSlot.jsx +50 -0
  146. package/src/shell/PreferencesDialog.jsx +538 -0
  147. package/src/shell/PreferencesSlot.jsx +40 -0
  148. package/src/shell/StatusBar.jsx +125 -0
  149. package/src/shell/StudioShell.jsx +92 -0
  150. package/src/shell/TopBar.jsx +129 -0
  151. package/src/shell/ViewportToolbar.jsx +656 -0
  152. package/src/shell/agent-glass.css +296 -0
  153. package/src/shell/dock.css +93 -0
  154. package/src/shell/glass-regions.css +985 -0
  155. package/src/shell/glass.css +687 -0
  156. package/src/shell/log-store.js +52 -0
  157. package/src/shell/mode.css +9 -0
  158. package/src/shell/preferences.css +651 -0
  159. package/src/shell/shell.css +282 -0
  160. package/src/shell/studio-shell-context.js +19 -0
  161. package/src/shell/topbar.css +435 -0
  162. package/src/shell/viewport.css +451 -0
  163. package/src/store/authored-intent.js +22 -0
  164. package/src/store/runtime-adapters.js +92 -0
  165. package/src/store/scene-stage.js +21 -0
  166. package/src/store/use-document-store.js +14 -0
  167. package/src/studio-actions.js +210 -0
  168. package/src/studio-agent-commands.js +38 -268
  169. package/src/studio-agent-context.js +38 -9
  170. package/src/studio-agent-motion.js +122 -378
  171. package/src/studio-agent-protocol.js +66 -30
  172. package/src/studio-app-binding.js +379 -0
  173. package/src/studio-contact-sheet.js +73 -0
  174. package/src/studio-elements.js +58 -51
  175. package/src/styles/themes.css +200 -0
  176. package/src/styles/tokens.css +69 -0
  177. package/src/styles.css +456 -1748
  178. package/src/theme.js +44 -0
  179. package/src/timeline-extent.js +16 -0
  180. package/src/timeline-fps.js +13 -0
  181. package/src/trail-key-conflicts.js +39 -0
  182. package/src/trail-pick.js +59 -0
  183. package/src/ui.jsx +8 -8
  184. package/src/use-case-question.jsx +66 -0
  185. package/src/workflow/AgentPanel.jsx +86 -71
  186. package/src/workflow/agent-client.js +12 -2
  187. package/src/workflow/agent-panel.css +34 -32
  188. package/src/workflow/cozy-scene-node.css +2 -0
  189. package/src/workflow/workflow.css +2 -0
  190. package/tools/ardy/bridge.mjs +164 -146
  191. package/tools/ardy/visual-qa.mjs +5 -5
  192. package/tools/bench/EXP3.md +61 -0
  193. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  194. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  195. package/tools/bench/cclay_bench_runner.py +42 -0
  196. package/tools/bench/cube-contact.mjs +162 -0
  197. package/tools/bench/exp3.mjs +152 -0
  198. package/tools/bench/extract-bench-lib.mjs +216 -0
  199. package/tools/bench/extract-bench.mjs +302 -0
  200. package/tools/bench/fal-generate.mjs +88 -0
  201. package/tools/bench/fit/README.md +189 -0
  202. package/tools/bench/fit/camera.mjs +59 -0
  203. package/tools/bench/fit/contact.mjs +419 -0
  204. package/tools/bench/fit/footlock.mjs +234 -0
  205. package/tools/bench/fit/motion.mjs +85 -0
  206. package/tools/bench/fit/pin.mjs +27 -0
  207. package/tools/bench/fit/remote.mjs +105 -0
  208. package/tools/bench/fit-bench.mjs +126 -0
  209. package/tools/bench/fit-sanity.mjs +45 -0
  210. package/tools/bench/metrics.mjs +313 -0
  211. package/tools/bench/obs/depth.mjs +258 -0
  212. package/tools/bench/obs/extrinsics.mjs +175 -0
  213. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  214. package/tools/bench/obs/ground.mjs +294 -0
  215. package/tools/bench/obs/heading.mjs +151 -0
  216. package/tools/bench/obs/ladder.mjs +555 -0
  217. package/tools/bench/obs/mannequin-betas.json +124 -0
  218. package/tools/bench/obs/remote.mjs +83 -0
  219. package/tools/bench/obs/rest_joints.py +52 -0
  220. package/tools/bench/obs/ybot-targets.mjs +49 -0
  221. package/tools/bench/obs-bench.mjs +520 -0
  222. package/tools/bench/score.mjs +343 -0
  223. package/tools/bench/summarize.mjs +86 -0
  224. package/tools/dev/pages/privacy.html +13 -8
  225. package/tools/gt-render/browser.mjs +159 -0
  226. package/tools/gt-render/camera-math.mjs +276 -0
  227. package/tools/gt-render/page.mjs +267 -0
  228. package/tools/gt-render/render.mjs +514 -0
  229. package/tools/gt-render/scene-box.mjs +31 -0
  230. package/tools/gt-render/take-transform.mjs +87 -0
  231. package/tools/qa/css-rule-usage.mjs +283 -0
  232. package/tools/qa/studio-control-count.mjs +70 -32
  233. package/tools/run-tests.mjs +60 -3
  234. package/tools/track/DESIGN.md +513 -0
  235. package/tools/track/backfill-provenance.mjs +116 -0
  236. package/tools/track/budget.mjs +8 -0
  237. package/tools/track/check-rig.mjs +209 -0
  238. package/tools/track/diagnostics.schema.json +60 -0
  239. package/tools/track/export-rig.mjs +256 -0
  240. package/tools/track/fallback.mjs +7 -0
  241. package/tools/track/fk-parity-fixture.mjs +161 -0
  242. package/tools/track/gate.mjs +349 -0
  243. package/tools/track/masks.mjs +318 -0
  244. package/tools/track/metrics.mjs +201 -0
  245. package/tools/track/publish-obs.mjs +108 -0
  246. package/tools/track/py/check_env.py +61 -0
  247. package/tools/track/py/eval_lr.py +229 -0
  248. package/tools/track/py/lr_viterbi.py +294 -0
  249. package/tools/track/py/masks.py +405 -0
  250. package/tools/track/py/objective.py +382 -0
  251. package/tools/track/py/rig.py +508 -0
  252. package/tools/track/py/scene.py +326 -0
  253. package/tools/track/py/test_joint_indices.py +123 -0
  254. package/tools/track/py/test_lr_viterbi.py +195 -0
  255. package/tools/track/py/test_masks.py +229 -0
  256. package/tools/track/py/test_rig.py +288 -0
  257. package/tools/track/py/test_scene.py +354 -0
  258. package/tools/track/py/test_track.py +328 -0
  259. package/tools/track/py/track.py +411 -0
  260. package/tools/track/remote.mjs +140 -0
  261. package/tools/track/rig-dump.mjs +227 -0
  262. package/tools/track/run-box-tests.mjs +14 -0
  263. package/tools/track/run-box.mjs +168 -0
  264. package/tools/track/setup-box.sh +59 -0
  265. package/tools/track/study-2d.mjs +728 -0
  266. package/dist/assets/analytics-B1hnH66c.js +0 -1
  267. package/dist/assets/app-Cu1cexZR.css +0 -1
  268. package/dist/assets/app-Dxdh8kds.js +0 -4861
  269. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  270. package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
  271. package/dist/assets/workflow-CJd-PDtV.js +0 -179
  272. package/dist/assets/workflow-DsudxKHF.css +0 -1
  273. package/src/fal-motion-studio.jsx +0 -180
  274. package/src/scene-history.js +0 -129
  275. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
@@ -0,0 +1,231 @@
1
+ // Motion and IK commands share one per-character authored-intent owner.
2
+ import { STUDIO_IK_CHAIN_TRACKS, studioActionDeclaration } from "../studio-actions.js";
3
+ import { fail, characterOf } from "./shared.js";
4
+ import { elementSetSchema, registerElementSet } from './elements.js';
5
+ import './elements/motion.js';
6
+ import './elements/character.js';
7
+ import { generationArgs } from '../motion/generation.js';
8
+ import { applyRootDrop, normalizeRootDrop } from '../ardy/root-drop.js';
9
+ import { characterScaleFor } from '../ardy/npz.js';
10
+ import { I2V_MOTION_DURATIONS } from '../i2v-motion-client.js';
11
+ import { createMotionEdit, trimMotionEdit, splitMotionEdit, setMotionSegmentSpeed, removeMotionSegment } from '../ardy/motion-edit.js';
12
+ const id = { type: 'string', minLength: 1 }, frame = { type: 'integer', minimum: 0 };
13
+ const input = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
14
+ const drop = { oneOf: [{ type: 'null' }, input({ from_s: { type: 'number', minimum: 0 }, to_s: { type: 'number', exclusiveMinimum: 0 }, meters: { type: 'number', exclusiveMinimum: 0, maximum: 30 } })] };
15
+ const mutation = (id, label, properties, required) => ({ id, label, description: label, kind: 'mutation', undoDomain: 'motion', input: input(properties, required) });
16
+ const setInput = elementSetSchema('motion');
17
+ // A collection transaction has no single native character target. Normalize
18
+ // that scope explicitly so begin/update carry the same target sentinel.
19
+ for (const variant of setInput.oneOf) variant.properties.characterId = { type: 'null', default: null };
20
+ const vector3 = { type: 'array', items: { type: 'number' }, minItems: 3, maxItems: 3 };
21
+ const rangePin = input({ id: { ...id, maxLength: 64 }, track: { type: 'string', enum: ['leftHand', 'rightHand', 'leftFoot', 'rightFoot'] },
22
+ startFrame: frame, endFrame: frame, blend: { type: 'integer', minimum: 1, maximum: 240 }, reach: { type: 'string', enum: ['limb', 'body'] },
23
+ target: { oneOf: [input({ space: { const: 'world' }, position: vector3 }), input({ space: { const: 'object' }, objectId: id, local: vector3 })] },
24
+ }, ['id', 'track', 'startFrame', 'endFrame', 'blend', 'target']);
25
+ const rangePins = [
26
+ mutation('motion.rangePin.apply', 'Apply range pin', { characterId: id, pin: rangePin, replaceExisting: { type: 'boolean', default: false } }, ['characterId', 'pin']),
27
+ mutation('motion.rangePin.remove', 'Remove range pin', { characterId: id, pinId: id }),
28
+ ];
29
+ const platformFit = [
30
+ { id: 'motion.platformFit.run', label: 'Fit motion to platforms', description: 'Analyse scene platforms and prepare or apply an IK correction.', kind: 'job', domain: 'motion', timeoutMs: 300000, input: input({ characterId: id, preview: { type: 'boolean', default: false } }, ['characterId']) },
31
+ { id: 'motion.platformFit.remove', label: 'Remove platform fit', description: 'Apply or remove the reviewed platform-fit correction.', kind: 'job', domain: 'motion', timeoutMs: 300000, input: input({ characterId: id, apply: { type: 'boolean', default: false } }, ['characterId']) },
32
+ ];
33
+ const edits = [
34
+ { ...mutation('motion.set', 'Set take fields', {}), input: setInput },
35
+ mutation('motion.trim', 'Trim motion', { characterId: id, start: frame, end: frame }),
36
+ mutation('motion.resetTrim', 'Restore full take', { characterId: id }),
37
+ mutation('motion.cut', 'Cut motion segment', { characterId: id, frame }),
38
+ mutation('motion.setSegmentSpeed', 'Set segment speed', { characterId: id, id, speed: { type: 'number', minimum: 0.05, maximum: 8 } }),
39
+ mutation('motion.removeSegment', 'Remove motion segment', { characterId: id, id }),
40
+ mutation('motion.fixCollisions', 'Fix body collisions', { characterId: id, scope: { type: 'string', enum: ['frame', 'clip'], default: 'frame' } }, ['characterId']),
41
+ ];
42
+ const generate = { id: 'motion.generate', label: 'Generate motion', description: 'Generate the named character through the editor pipeline, following its prompt blocks, root path, pose pins and take lineage.',
43
+ kind: 'job', domain: 'motion', generation: 'motion', background: true, timeoutMs: 1000,
44
+ input: input({ characterId: id, drop, blocks: elementSetSchema('character').properties.set.properties.layer.properties.promptClips,
45
+ durationSeconds: { type: 'number', minimum: 1, maximum: 1200 }, seed: { type: 'integer', minimum: 0, maximum: 2147483647 } }, ['characterId']) };
46
+ const prepared = { ...mutation('motion.applyPrepared', 'Apply prepared motion edit', { characterId: id, token: id }), exposure: 'ui-only' };
47
+ const loads = [
48
+ { id: 'motion.replace', label: 'Replace take', input: input({ characterId: id, url: id, prompt: { type: 'string', default: '' },
49
+ blocks: { type: 'array', maxItems: 120, items: input({ startFrame: frame, endFrame: { ...frame, minimum: 1 }, prompt: { type: 'string' } }) },
50
+ drop,
51
+ }, ['characterId', 'url']) },
52
+ { id: 'motion.loadVersion', label: 'Restore take version', input: input({ characterId: id, motionUrl: id }) },
53
+ ].map(entry => ({ ...entry, description: entry.label, kind: 'job', domain: 'motion' }));
54
+ const tools = [
55
+ mutation('motion.applyPhysics', 'Apply reviewed physics', { characterId: id }),
56
+ mutation('motion.editTrail', 'Edit motion trail', { characterId: id, track: { type: 'string', enum: ['hips', 'leftFoot', 'rightFoot', 'leftHand', 'rightHand', 'head'], default: 'hips' }, grabFrame: frame, radiusFrames: { ...frame, minimum: 1 }, delta: input({ x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } }) }, ['characterId', 'grabFrame', 'radiusFrames', 'delta']),
57
+ mutation('ik.applyPose', 'Key full-body pose', { characterId: id, frame, pose: { type: 'object', properties: { bones: { type: 'object', properties: {}, required: [], additionalProperties: true }, rootY: { type: 'number' } }, required: ['bones'], additionalProperties: true } }),
58
+ ];
59
+ const physics = { id: 'motion.autoPhysics', label: 'Review motion physics', description: 'Analyse real rig motion and optionally apply one retained correction.', kind: 'job', domain: 'motion',
60
+ input: input({ characterId: id, apply: { type: 'boolean', default: true }, strength: { type: 'number', minimum: 0, maximum: 1, default: 1 },
61
+ protectedFrames: { type: 'array', items: frame, default: [] }, overrides: { type: 'array', default: [], items: input({
62
+ site: { type: 'string', enum: ['leftFoot', 'rightFoot', 'leftHand', 'rightHand', 'leftKnee', 'rightKnee'] }, start: frame, end: frame,
63
+ mode: { type: 'string', enum: ['plant', 'free'] },
64
+ }) },
65
+ }, ['characterId']) };
66
+ // These only queue the existing editor producers. Their eventual take
67
+ // publication is owned above; generation-pipeline unification remains #444.
68
+ const queued = ['motion.commitLineEdit', 'motion.regenerateTrail'].map(id => ({ id, label: id === 'motion.commitLineEdit' ? 'Commit line edit' : 'Regenerate trail edit',
69
+ description: 'Queue the current editor draft through the existing generation producer.', kind: 'transient', exposure: 'ui-only', input: input({ characterId: { type: 'string' } }) }));
70
+ const legacyIk = ['character.setIkKey', 'character.removeIkKey', 'character.clearIkKeys'].map(studioActionDeclaration);
71
+ const ik = legacyIk.map((entry, index) => ({ ...entry, id: ['ik.setKey', 'ik.removeKey', 'ik.clearKeys'][index] }));
72
+
73
+ const clear = { id: 'motion.clear', label: 'Clear motion', description: 'Clear the active take, its corrections and take-owned cast fields.',
74
+ kind: 'mutation', undoDomain: 'motion', input: { type: 'object', properties: { characterId: { type: 'string' } }, required: ['characterId'], additionalProperties: false } };
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
+ 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 })) } }, []) };
77
+ export const declarations = Object.freeze([videoDraft, generate, ...queued, prepared, ...loads, physics, ...tools, ...rangePins, ...platformFit, ...edits, ...legacyIk, ...ik, clear, ...["motion.generateAllBlocks", "motion.generateFromVideo"].map(studioActionDeclaration)]);
78
+
79
+ export function register(registry, ports) {
80
+ const owner = () => ports.storeDomain('motion');
81
+ const mounted = () => Boolean(ports.storeDomain?.('motion')) || 'The motion owner is not mounted.';
82
+ const take = characterId => { characterOf(ports, characterId); return owner().motionFor(characterId) ?? fail('TARGET_NOT_READY', 'Load a take for this character first.'); };
83
+ registry.register({ ...videoDraft, available: mounted, run(args) {
84
+ owner().setVideoDraft(args);
85
+ return { affectedIds: [], summary: videoDraft.label };
86
+ } });
87
+ registry.registerToolAlias('generate_motion', generate.id, generationArgs);
88
+ registry.register({ ...generate, available: mounted, target: args => args.characterId, async run(args, context) {
89
+ characterOf(ports, args.characterId);
90
+ if (owner().isGenerating()) fail('TARGET_BUSY', 'A motion generation is already running.');
91
+ if (args.drop && !normalizeRootDrop(args.drop)) fail('INVALID_ARGUMENT', 'Invalid drop.');
92
+ if (args.blocks) context.run('character.setPromptBlocks', { characterId: args.characterId, blocks: args.blocks });
93
+ const generationContext = !args.drop ? context : { ...context, commit: apply => context.commit(() => {
94
+ const result = apply(), take = owner().motionFor(args.characterId), layer = owner().layer(args.characterId);
95
+ owner().replace(args.characterId, applyRootDrop(take, args.drop, { worldScale: characterScaleFor(take) }), {
96
+ recipe: layer.takeRecipe, versions: layer.takeVersions, ikKeys: layer.ikKeys,
97
+ });
98
+ owner().writeLayer(args.characterId, { committedIkEdits: layer.committedIkEdits });
99
+ return result;
100
+ }) };
101
+ await owner().generate(args, generationContext);
102
+ return { affectedIds: [args.characterId], summary: 'Generated motion.' };
103
+ } });
104
+ for (const declaration of queued) registry.register({ ...declaration, available: mounted, run({ characterId }) {
105
+ characterOf(ports, characterId);
106
+ if (ports.state().activeCharacterId !== characterId) fail('TARGET_NOT_READY', 'Select the character that owns this editor draft.');
107
+ if (declaration.id === 'motion.commitLineEdit') owner().requestLineEdit(); else owner().requestTrailRegeneration();
108
+ return { affectedIds: [], summary: declaration.label };
109
+ } });
110
+ registry.register({ ...prepared, available: mounted, run({ characterId, token }) {
111
+ characterOf(ports, characterId); const result = owner().applyPrepared(token);
112
+ return { affectedIds: result?.affectedIds ?? [characterId], summary: prepared.label };
113
+ } });
114
+ for (const declaration of loads) registry.register({ ...declaration, available: mounted, target: args => args.characterId, async run(args, context) {
115
+ characterOf(ports, args.characterId); await owner().loadRemote(args, context);
116
+ return { affectedIds: [args.characterId], summary: declaration.label };
117
+ } });
118
+ registry.register({ ...physics, available: mounted, target: args => args.characterId, async run(args, context) {
119
+ characterOf(ports, args.characterId);
120
+ return { affectedIds: [args.characterId], summary: physics.label, output: await owner().autoPhysics(args.characterId, args, context) };
121
+ } });
122
+ for (const declaration of tools) registry.register({ ...declaration, available: mounted, run(args) {
123
+ characterOf(ports, args.characterId);
124
+ if (declaration.id === 'motion.applyPhysics') owner().applyPhysics(args.characterId);
125
+ else if (declaration.id === 'motion.editTrail') owner().editTrail(args.characterId, args);
126
+ else {
127
+ if (args.frame >= ports.state().frameCount) fail('INVALID_RANGE', 'IK frame is outside the timeline.');
128
+ for (const angles of Object.values(args.pose.bones)) if (!Array.isArray(angles) || angles.length !== 3 || !angles.every(Number.isFinite)) fail('INVALID_ARGUMENT', 'Pose bones require three finite rotation angles.');
129
+ owner().keyPose(args.characterId, args.frame, args.pose);
130
+ }
131
+ return { affectedIds: [args.characterId], summary: declaration.label };
132
+ } });
133
+ for (const declaration of rangePins) registry.register({ ...declaration, available: mounted, run(args) {
134
+ characterOf(ports, args.characterId);
135
+ const output = declaration.id === 'motion.rangePin.apply'
136
+ ? owner().bakeRangePin(args.characterId, args.pin, args.replaceExisting)
137
+ : owner().removePin(args.characterId, args.pinId);
138
+ return { affectedIds: [args.characterId], summary: declaration.label, ...(output ? { output } : {}) };
139
+ } });
140
+ registry.register({ ...platformFit[0], available: mounted, target: args => args.characterId, async run(args, context) {
141
+ characterOf(ports, args.characterId);
142
+ const output = await owner().platformFitRun(args.characterId, context, { preview: args.preview === true });
143
+ return { affectedIds: [args.characterId], summary: platformFit[0].label, output };
144
+ } });
145
+ registry.register({ ...platformFit[1], available: mounted, target: args => args.characterId, run(args, context) {
146
+ characterOf(ports, args.characterId);
147
+ const output = owner().platformFitRemove(args.characterId, context, { apply: args.apply === true });
148
+ return { affectedIds: [args.characterId], summary: platformFit[1].label, ...(output ? { output } : {}) };
149
+ } });
150
+ registerElementSet({ register(entry) { registry.register({ ...entry, available: mounted, run(args) {
151
+ for (const op of args.ops ?? [args]) take(op.id);
152
+ return entry.run(args);
153
+ } }); } }, ports, edits[0]);
154
+ for (const declaration of edits.slice(1)) registry.register({ ...declaration, available: mounted, run(args) {
155
+ const { characterId } = args; characterOf(ports, characterId);
156
+ if (declaration.id === 'motion.fixCollisions') owner().fix(characterId, args.scope);
157
+ else {
158
+ const current = take(characterId), full = owner().fullMotionFor(characterId);
159
+ let segments = current.editSegments;
160
+ if (declaration.id === 'motion.trim') {
161
+ if (args.start > args.end || args.end >= current.frames) fail('INVALID_RANGE', 'Trim range is outside the take.');
162
+ segments = trimMotionEdit(segments, args.start, args.end);
163
+ } else if (declaration.id === 'motion.resetTrim') segments = createMotionEdit(full.frames);
164
+ else if (declaration.id === 'motion.cut') segments = splitMotionEdit(segments, args.frame);
165
+ else if (declaration.id === 'motion.setSegmentSpeed') segments = setMotionSegmentSpeed(segments, args.id, args.speed);
166
+ else { if (segments.length <= 1) fail('INVALID_ARGUMENT', 'Use motion.clear to remove the final segment.'); segments = removeMotionSegment(segments, args.id); }
167
+ if (segments !== current.editSegments) owner().editSegments(characterId, segments);
168
+ }
169
+ return { affectedIds: [characterId], summary: declaration.label };
170
+ } });
171
+ for (const [index, declaration] of [...legacyIk, ...ik].entries()) registry.register({ ...declaration,
172
+ available: state => state.characters.length > 0 || 'There are no characters in this scene.', run(args) {
173
+ const character = characterOf(ports, args.characterId), kind = index % 3;
174
+ if (kind === 0) {
175
+ const { frame, tracks } = args, names = Object.keys(tracks);
176
+ if (frame >= ports.state().frameCount) fail('INVALID_RANGE', 'IK frame is outside the timeline.');
177
+ if (!names.length) fail('INVALID_ARGUMENT', 'Name at least one track.');
178
+ for (const track of names) {
179
+ const key = tracks[track], chain = STUDIO_IK_CHAIN_TRACKS.includes(track), bones = chain ? 3 : 1;
180
+ if (!key.q && !key.p) fail('INVALID_ARGUMENT', `tracks.${track} needs q or p.`);
181
+ if (key.chainP && !chain) fail('INVALID_ARGUMENT', `tracks.${track}.chainP is for chain tracks only.`);
182
+ for (const field of ['q', 'baseQ', 'chainP']) if (key[field] && key[field].length !== bones) fail('INVALID_ARGUMENT', `tracks.${track}.${field} needs ${bones} entries.`);
183
+ if ([...(key.q ?? []), ...(key.baseQ ?? [])].some(q => Math.hypot(q.x, q.y, q.z, q.w) < 1e-6)) fail('INVALID_ARGUMENT', `tracks.${track} has a zero-length quaternion.`);
184
+ }
185
+ ports.setCharacterIkKey(args.characterId, frame, tracks);
186
+ } else if (kind === 1) ports.removeCharacterIkKey(args.characterId, args.frame);
187
+ else ports.clearCharacterIkKeys(args.characterId);
188
+ return { affectedIds: [character.id], summary: declaration.label };
189
+ } });
190
+ registry.register({ ...clear, available: () => Boolean(ports.storeDomain?.('motion')) || typeof ports.clearMotionNative === 'function' || 'The motion owner is not mounted.',
191
+ run({ characterId }) {
192
+ if (ports.storeDomain?.('motion')) { characterOf(ports, characterId); owner().clear(characterId); }
193
+ else {
194
+ if (ports.state().activeCharacterId !== characterId) fail('TARGET_NOT_READY', 'Select this character before clearing its take.');
195
+ ports.clearMotionNative();
196
+ }
197
+ return { affectedIds: [characterId], summary: 'Cleared motion.' };
198
+ } });
199
+ registry.register({ ...studioActionDeclaration("motion.generateAllBlocks"), target: () => ports.state().activeCharacterId,
200
+ available: state => state.generating ? "A motion generation is already running."
201
+ : !state.motionReady ? "The motion backend is not ready."
202
+ : state.promptBlockCount === 0 ? "The active character has no prompt block with text; write them with patch_elements character.promptBlocks." : true,
203
+ run: (_args, context) => {
204
+ const { activeCharacterId, promptBlockCount } = ports.state();
205
+ const shown = ports.runAllPromptBlocks(context) ?? [];
206
+ if (shown?.then) return shown.then(() => ({ affectedIds: activeCharacterId ? [activeCharacterId] : [], summary: `Generated motion from ${promptBlockCount} prompt blocks.` }));
207
+ // The generation queues synchronously or not at all; when it does not,
208
+ // the editor's last toast names the refusal (rig not loaded, a root
209
+ // waypoint outside the clip, an over-long block, a line-edit draft).
210
+ if (!ports.state().generating) fail("TARGET_NOT_READY", shown.length ? `Generation not started: ${shown.at(-1)}` : "The editor did not start the generation; check the active character's rig and prompt blocks.");
211
+ return { affectedIds: activeCharacterId ? [activeCharacterId] : [], summary: `Started generating the active character's motion from ${promptBlockCount} prompt block${promptBlockCount === 1 ? "" : "s"}.` };
212
+ } });
213
+ // AI-video motion: the agent panel's Generate motion (generateI2vMotion
214
+ // "act"), awaited to its clip. The AI motion card shows every failure it meets, so
215
+ // a refusal is silent in the UI and tells the model the reason in English.
216
+ registry.register({ ...studioActionDeclaration("motion.generateFromVideo"), domain: "motion", target: () => ports.state().activeCharacterId,
217
+ available: ({ i2vMotion }) => !i2vMotion.enabled ? "AI video motion (Fal) is not enabled for this account."
218
+ : !["idle", "done", "error", "failed"].includes(i2vMotion.status) ? "An AI video motion generation is already running; wait for it to finish."
219
+ : i2vMotion.dailyRemaining === 0 ? "The account's daily AI video generations are used up." : true,
220
+ run: async ({ instruction }, context) => {
221
+ const outcome = await ports.generateI2vMotion("act", instruction, context);
222
+ if (outcome.failed) fail("TARGET_NOT_READY", outcome.failed);
223
+ const { job, footage, dailyRemaining } = outcome;
224
+ if (!job.video?.url) fail("TARGET_NOT_READY", "The AI video model finished without returning a video.");
225
+ return { affectedIds: [], output: { videoUrl: job.video.url, resolution: job.resolution ?? null, durationSeconds: job.resultDuration ?? job.duration ?? null,
226
+ ingested: Boolean(footage), frames: footage?.frames ?? null, fps: footage?.fps ?? null, dailyRemaining },
227
+ summary: footage
228
+ ? `The AI video (${job.resolution}, ${footage.frames} frames at ${footage.fps} fps) is ingested as Video capture footage and the timeline now spans it; its motion becomes a take once GVHMR extraction runs in the Video capture panel.`
229
+ : `The AI video is ready at ${job.video.url}, but ingesting it as footage failed; the Video capture panel shows why.` };
230
+ } });
231
+ }
@@ -0,0 +1,174 @@
1
+ // Scene object commands: attachment, duplication and asset import.
2
+ import { studioActionDeclaration } from "../studio-actions.js";
3
+ import { characterOf, fail } from "./shared.js";
4
+ import { elementSetSchema, registerElementSet } from './elements.js';
5
+ import './elements/object.js';
6
+ import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, normalizeSceneObject } from '../scene-objects.js';
7
+ import { STUDIO_TOOL_SCHEMAS, StudioSchemas } from '../studio-agent-protocol.js';
8
+
9
+ const id = StudioSchemas.TargetGuard.properties.targetId;
10
+ const input = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
11
+ const ids = { type: 'array', items: id, minItems: 1, maxItems: 100 };
12
+ const number = { type: 'number' };
13
+ const placement = input({ x: number, y: number, z: number, rot: number }, []);
14
+ const setInput = elementSetSchema('object');
15
+ const scale = setInput.properties.set.properties.scale;
16
+ setInput.properties.set.properties.scale = { oneOf: [number, scale] };
17
+ const mutation = (id, label, input) => ({ id, label, description: label, kind: 'mutation', undoDomain: 'objects', input });
18
+ const semantic = [
19
+ mutation('object.set', 'Set object fields', setInput),
20
+ mutation('object.add', 'Add object', input({ kind: { type: 'string' }, placement, name: { type: 'string' }, parent: id }, ['kind'])),
21
+ mutation('object.remove', 'Remove objects', input({ ids })),
22
+ mutation('object.rename', 'Rename object', input({ id, name: { type: 'string', maxLength: 240 } })),
23
+ mutation('object.group', 'Group objects', input({ parent: id, children: ids })),
24
+ mutation('object.ungroup', 'Ungroup objects', input({ children: ids })),
25
+ mutation('object.update', 'Update object', input({ id, patch: { type: 'object', properties: {}, additionalProperties: true } }, [])),
26
+ mutation('objects.arrange', 'Arrange objects', STUDIO_TOOL_SCHEMAS.arrange_objects),
27
+ ];
28
+ const batch = { id: 'objects.batch', label: 'Batch objects', description: 'Apply legacy object operations in one retained entry.', kind: 'job', domain: 'objects',
29
+ input: input({ ops: { type: 'array', maxItems: 100, items: input({ name: { type: 'string' }, args: { type: 'object', properties: {}, additionalProperties: true } }) }, atomic: { type: 'boolean' }, stopOnError: { type: 'boolean' }, label: { type: 'string' } }, ['ops']) };
30
+ const replace = { ...mutation('objects.replace', 'Replace authored objects', input({ objects: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } } })), exposure: 'ui-only' };
31
+ const changedIds = (before, after) => [...new Set([...before, ...after].map(row => row.id))].filter(id => JSON.stringify(before.find(row => row.id === id)) !== JSON.stringify(after.find(row => row.id === id)));
32
+ const assetProperties = { ...studioActionDeclaration('asset.import').input.properties, placement,
33
+ ...Object.fromEntries(['x', 'y', 'z', 'rot', 'height'].map(key => [key, number])), clay: { type: 'boolean' }, mimeType: { type: 'string' } };
34
+ const assetImport = { ...studioActionDeclaration('asset.import'), kind: 'job', domain: 'objects',
35
+ input: { ...input({ ...assetProperties, assetId: id, fileToken: id }, ['placeAs']), oneOf: [
36
+ input(assetProperties, ['source', 'name', 'placeAs']),
37
+ input({ assetId: id, placement, placeAs: assetProperties.placeAs }, ['assetId', 'placeAs']),
38
+ input({ fileToken: id, placeAs: { type: 'string', enum: ['mesh', 'cutout'] } }, ['fileToken', 'placeAs']),
39
+ ] } };
40
+ 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]);
42
+
43
+ export function register(registry, ports) {
44
+ const objectOf = objectId => ports.state().objects.find(object => object.id === objectId)
45
+ ?? fail("STALE_TARGET", `Object ${objectId} is not in this scene.`);
46
+ const owned = () => ports.storeDomain('objects');
47
+ const available = () => Boolean(ports.storeDomain?.('objects')) || 'The objects document owner is not mounted.';
48
+ const result = (before, summary) => ({ affectedIds: changedIds(before, owned().read()), summary });
49
+ const methods = {
50
+ 'object.add': args => {
51
+ const before = owned().read();
52
+ if (args.parent !== undefined) objectOf(args.parent);
53
+ const object = createSceneObject(args.kind, before, args.placement);
54
+ if (!object) fail('INVALID_ARGUMENT', `Unknown object kind: ${args.kind}`);
55
+ const placed = updateSceneObject([object], object.id, { ...(args.placement ?? {}), ...(args.name === undefined ? {} : { name: args.name }) })[0];
56
+ const next = [...before, placed];
57
+ owned().write(args.parent === undefined ? next : setSceneObjectParent(next, placed.id, args.parent));
58
+ return result(before, 'Added object.');
59
+ },
60
+ 'object.remove': ({ ids }) => {
61
+ const before = owned().read(); ids.forEach(objectOf);
62
+ owned().write(ids.reduce((rows, id) => removeSceneObject(rows, id), before));
63
+ return result(before, 'Removed objects.');
64
+ },
65
+ 'object.rename': ({ id, name }) => {
66
+ objectOf(id); const before = owned().read();
67
+ owned().write(updateSceneObject(before, id, { name }));
68
+ return result(before, 'Renamed object.');
69
+ },
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.');
74
+ },
75
+ 'object.group': ({ parent, children }) => {
76
+ objectOf(parent); children.forEach(objectOf);
77
+ const before = owned().read();
78
+ if (children.some(id => id === parent || descendantsOf(before, id).some(row => row.id === parent))) fail('INVALID_ARGUMENT', 'Grouping would create a cycle.');
79
+ owned().group(parent, children);
80
+ return result(before, 'Grouped objects.');
81
+ },
82
+ 'object.ungroup': ({ children }) => {
83
+ children.forEach(objectOf); const before = owned().read();
84
+ owned().write(children.reduce((rows, id) => setSceneObjectParent(rows, id, null), before));
85
+ return result(before, 'Ungrouped objects.');
86
+ },
87
+ 'objects.arrange': args => {
88
+ const plan = owned().arrange(args);
89
+ return { affectedIds: plan.affectedIds, summary: 'Arranged objects.' };
90
+ },
91
+ };
92
+ // Scalar scale is a convenience at the command boundary; the registry still
93
+ // owns all field mapping, normalization, publication and patch aliases.
94
+ registerElementSet({ register(entry) {
95
+ const expand = op => typeof op.set.scale === 'number' ? { ...op, set: { ...op.set, scale: { x: op.set.scale, y: op.set.scale, z: op.set.scale } } } : op;
96
+ registry.register({ ...entry, available, run: args => {
97
+ const ops = (args.ops ?? [args]).map(expand);
98
+ let draft = owned().read();
99
+ for (const { id, set } of ops) if (Object.hasOwn(set, 'parent')) {
100
+ const object = objectOf(id), parent = set.parent;
101
+ if (parent !== null) objectOf(parent);
102
+ if (parent === id || descendantsOf(draft, id).some(row => row.id === parent)) fail('INVALID_ARGUMENT', 'Grouping would create a cycle.');
103
+ if (object.attach) fail('CAPABILITY_MISSING', 'Use object.group to preserve an attached object world transform.');
104
+ draft = setSceneObjectParent(draft, id, parent);
105
+ }
106
+ return entry.run(args.ops ? { ops } : ops[0]);
107
+ } });
108
+ } }, ports, semantic[0]);
109
+ for (const declaration of semantic.slice(1)) registry.register({ ...declaration, available, run: methods[declaration.id] });
110
+ // Native adapters stay usable until their owner is mounted. In the editor,
111
+ // domains mount before registry construction, so this is always the bus alias.
112
+ if (ports.storeDomain?.('objects')) registry.registerToolAlias('arrange_objects', 'objects.arrange');
113
+ registry.register({ ...replace, available, run: ({ objects }) => {
114
+ const next = objects.map(normalizeSceneObject);
115
+ if (next.some(row => !row) || new Set(next.map(row => row.id)).size !== next.length) fail('INVALID_ARGUMENT', 'Objects require valid records and unique ids.');
116
+ const before = owned().read(); owned().write(next);
117
+ return result(before, 'Replaced authored objects.');
118
+ } });
119
+ registry.register({ ...batch, available, run: (args, context) => {
120
+ const before = owned().read();
121
+ const output = context.commit(() => owned().batch(args));
122
+ return { ...result(before, 'Applied object batch.'), output };
123
+ } });
124
+ registry.register({ ...studioActionDeclaration("object.attach"),
125
+ available: state => state.objects.length === 0 ? "There are no scene objects to attach."
126
+ : state.characters.length === 0 ? "There are no characters to attach an object to." : true,
127
+ run: ({ objectId, characterId, bone }) => {
128
+ const object = objectOf(objectId), character = characterOf(ports, characterId), before = ports.state().objects;
129
+ ports.attachSceneObject(objectId, { characterId, bone: bone ?? null });
130
+ const frameName = `${character.subject || character.id}'s ${bone ?? "root"}`;
131
+ return { affectedIds: [objectId], summary: ports.state().objects === before
132
+ ? `${object.name || objectId} already rides ${frameName}; nothing changed.`
133
+ : `Attached ${object.name || objectId} to ${frameName}, keeping its place on screen.` };
134
+ } });
135
+ registry.register({ ...studioActionDeclaration("object.detach"),
136
+ available: state => state.objects.some(object => object.attach || object.parent) || "No scene object is attached to a character or grouped.",
137
+ run: ({ objectId }) => {
138
+ const object = objectOf(objectId);
139
+ if (!object.attach && !object.parent) fail("TARGET_NOT_READY", `${object.name || objectId} is not attached to a character or in a group.`);
140
+ ports.attachSceneObject(objectId, null);
141
+ return { affectedIds: [objectId], summary: `Put ${object.name || objectId} back in the world where it is now.` };
142
+ } });
143
+ registry.register({ ...studioActionDeclaration("object.duplicate"),
144
+ available: state => state.objects.length > 0 || "There are no scene objects to duplicate.",
145
+ run: ({ objectId }) => {
146
+ const state = ports.state(), id = objectId ?? state.selectedObjectId;
147
+ if (!id) fail("TARGET_NOT_READY", "Name objectId or select an object first.");
148
+ const source = state.objects.find(object => object.id === id) ?? fail("STALE_TARGET", `Object ${id} is not in this scene.`);
149
+ ports.duplicateSelectedSceneObject(id);
150
+ const after = ports.state().objects, affectedIds = changedIds(state.objects, after);
151
+ const copy = after.find(object => affectedIds.includes(object.id));
152
+ return { affectedIds, summary: copy ? `Duplicated ${source.name || source.id} as ${copy.name || copy.id}.` : "Duplicate object: nothing changed." };
153
+ } });
154
+ // The live import_asset path (validate, store the bytes, ONE atomic store
155
+ // entry), fed a data URL; an http(s) source is fetched into one first.
156
+ registry.register({ ...matte, available, run: async ({ objectId }, context) => {
157
+ await owned().applyMatte(objectId, context);
158
+ return { affectedIds: [objectId], summary: 'Applied the object matte.' };
159
+ } });
160
+ registry.register({ ...assetImport, available: () => true,
161
+ run: async (args, context) => {
162
+ const { source, ...options } = args;
163
+ const { name, placeAs } = options;
164
+ let dataUrl = source;
165
+ if (source && !source.startsWith("data:")) {
166
+ try { dataUrl = await ports.fetchImportSource(source); }
167
+ catch (error) { fail("TARGET_NOT_READY", `Could not fetch the source (${error?.message || error}); its server must allow cross-origin reads.`); }
168
+ }
169
+ let imported;
170
+ try { imported = await (ports.storeDomain?.('objects')?.importAsset ?? ports.importAsset)({ ...options, dataUrl }, context); }
171
+ catch (error) { fail(error.code ?? "INVALID_ARGUMENT", `Not imported: ${error?.message || error}`); }
172
+ return { affectedIds: [imported.objectId], output: imported, summary: `Imported ${name ?? imported.objectId} as a ${placeAs} (object ${imported.objectId}, asset ${imported.assetId}).` };
173
+ } });
174
+ }
@@ -0,0 +1,53 @@
1
+ import { studioActionDeclaration } from "../studio-actions.js";
2
+ import { fail } from "./shared.js";
3
+
4
+ const input = (properties = {}, required = []) => ({ type: "object", properties, required, additionalProperties: false });
5
+ const name = { type: "string", minLength: 1, maxLength: 240 };
6
+ const token = { type: "string", minLength: 1, maxLength: 240 };
7
+ const document = (id, label, properties = {}) => ({ id, label, description: label, kind: "document", exposure: "confirm", input: input(properties) });
8
+ const rename = { id: "project.rename", label: "Rename project", description: "Rename the project in one retained undo entry.", kind: "mutation", undoDomain: "scenes", input: input({ name }, ["name"]) };
9
+ const save = { ...studioActionDeclaration("project.save"), input: input({ name }) };
10
+ const saveAs = { ...save, id: "project.saveAs", label: "Save project as" };
11
+ const opening = document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token });
12
+ const fresh = document("project.new", "Start a new project", { name });
13
+ const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "tutorial", "launch"] }, name });
14
+ const restore = { ...document("project.restore", "Restore the remembered project", { handleToken: token }), exposure: "ui-only" };
15
+ const browse = { id: "project.browse", label: "Browse projects", description: "Show the project browser.", kind: "transient", exposure: "ui-only", input: input() };
16
+ export const declarations = Object.freeze([rename, save, saveAs, opening, fresh, starter, restore, browse]);
17
+
18
+ export function register(registry, ports) {
19
+ const owner = () => ports.storeDomain?.("scenes");
20
+ const available = () => Boolean(owner()) || "The project document owner is not mounted.";
21
+ registry.register({ ...rename, available, run: ({ name }) => {
22
+ if (!name.trim()) fail("INVALID_ARGUMENT", "A project name cannot be blank.");
23
+ owner().renameProject(name);
24
+ return { affectedIds: [owner().metadata().activeSceneId], summary: "Renamed project." };
25
+ } });
26
+ for (const declaration of [save, saveAs]) registry.register({ ...declaration, available: () => true,
27
+ requiresConfirmation: state => {
28
+ const project = owner()?.fileState() ?? state.project;
29
+ return project.hasFile || project.fileAccess;
30
+ },
31
+ run: async (args, context) => {
32
+ const project = owner()?.fileState() ?? ports.state().project;
33
+ const as = declaration.id === "project.saveAs";
34
+ if (context.origin !== "ui" && !project.gesture && project.name !== null && project.fileAccess) {
35
+ if (as || !project.hasFile) fail("TARGET_NOT_READY", "Only the user's click can open the file picker. Ask them to press Save Project.");
36
+ if (!(await ports.projectFileGranted())) fail("TARGET_NOT_READY", "The browser needs the user's click to re-grant access to the project file.");
37
+ }
38
+ const saved = owner() ? await owner().save({ ...args, saveAs: as }, context) : await ports.saveProject(as);
39
+ if (saved?.naming) fail("TARGET_NOT_READY", "Not saved: the project has no name yet. The Save dialog is open for the user.");
40
+ if (saved?.cancelled) fail("TARGET_NOT_READY", "Not saved: the user closed the file picker.");
41
+ if (!saved?.saved) fail("TARGET_NOT_READY", saved?.failure === "missing-resources" ? "Not saved: some project assets or motions are missing."
42
+ : saved?.failure === "resources-too-large" ? "Not saved: the project's resources are too large." : "Not saved: writing the project file failed.");
43
+ return { affectedIds: [], output: { fileName: saved.fileName }, summary: saved.downloaded
44
+ ? `This browser has no file access, so the project ${saved.name} was downloaded as ${saved.fileName}.`
45
+ : `Saved the project ${saved.name} to ${saved.fileName}.` };
46
+ } });
47
+ for (const declaration of [opening, fresh, starter, restore, browse]) registry.register({ ...declaration,
48
+ available, run: async (args, context) => {
49
+ const result = await owner().projectAction(declaration.id, args, context);
50
+ if (result === false) fail("TARGET_NOT_READY", "The project was not opened; check the project dialog.");
51
+ return { affectedIds: [owner().metadata().activeSceneId], output: { opened: result === true }, summary: `${declaration.label}.` };
52
+ } });
53
+ }
@@ -0,0 +1,81 @@
1
+ // Scene commands: the scene pill's and the Hierarchy scene menu's own
2
+ // handlers. When the open scene moves, the action answers once React has
3
+ // rendered the new room, so the next command reads that scene's state.
4
+ import { studioActionDeclaration } from "../studio-actions.js";
5
+ import { fail } from "./shared.js";
6
+ import { elementSetSchema, registerElementSet } from "./elements.js";
7
+ import "./elements/scene.js";
8
+
9
+ const mutation = (id, label, input) => ({ id, label, description: label, kind: "mutation", undoDomain: "scenes", input });
10
+ const set = mutation("scene.set", "Set scene name or order", elementSetSchema("scene"));
11
+ const reorder = mutation("scene.reorder", "Reorder a scene", { type: "object", properties: {
12
+ sceneId: studioActionDeclaration("scene.rename").input.properties.sceneId, order: { type: "number" },
13
+ }, required: ["sceneId", "order"], additionalProperties: false });
14
+ const load = { id: "load_scenes", label: "Load scene document", description: "Load scenes through the non-authored boundary; changed scene ids require confirmation.", kind: "document", exposure: "confirm",
15
+ input: { type: "object", properties: { document: { type: "object", properties: {
16
+ version: { type: "integer" }, activeSceneId: { type: "string" }, scenes: { type: "array", minItems: 1, items: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: true } },
17
+ }, required: ["version", "activeSceneId", "scenes"], additionalProperties: false } }, required: ["document"], additionalProperties: false } };
18
+ export const declarations = Object.freeze([load, set, reorder,
19
+ ...["scene.create", "scene.duplicate", "scene.rename", "scene.delete", "scene.switch"].map(id => {
20
+ const declaration = studioActionDeclaration(id);
21
+ return id === "scene.rename" ? { ...declaration, kind: "mutation", undoDomain: "scenes", description: "Rename a scene in one retained undo entry." } : declaration;
22
+ }),
23
+ ]);
24
+
25
+ export function register(registry, ports) {
26
+ const available = () => Boolean(ports.storeDomain?.("scenes")) || "The project document owner is not mounted.";
27
+ registry.register({ ...load, available, run: args => {
28
+ const output = ports.storeDomain("scenes").loadScenes(args);
29
+ return { affectedIds: [output.activeSceneId], output, summary: "Loaded scene document." };
30
+ } });
31
+ registerElementSet(registry, ports, set);
32
+ registry.register({ ...reorder, available, run: ({ sceneId, order }) => {
33
+ const owner = ports.storeDomain("scenes");
34
+ if (!owner.read().some(row => row.id === sceneId)) fail("STALE_TARGET", `Scene ${sceneId} is not in this project.`);
35
+ owner.write(owner.read().map(row => row.id === sceneId ? { ...row, order } : row));
36
+ return { affectedIds: [sceneId], summary: "Reordered scene." };
37
+ } });
38
+ const sceneOf = sceneId => ports.state().scenes.find(scene => scene.id === sceneId) ?? fail("STALE_TARGET", `Scene ${sceneId} is not in this project.`);
39
+ const sceneName = scene => `${scene.name} (${scene.id})`;
40
+ const sceneAction = (id, available, run) => registry.register({ ...studioActionDeclaration(id), available,
41
+ run: args => {
42
+ const before = ports.state(), describe = run(args, before), after = ports.state();
43
+ const moved = after.activeSceneId !== before.activeSceneId, opened = after.scenes.find(scene => scene.id === after.activeSceneId);
44
+ const affectedIds = [...new Set([
45
+ ...after.scenes.filter(scene => !before.scenes.some(row => row.id === scene.id && row.name === scene.name)).map(scene => scene.id),
46
+ ...before.scenes.filter(scene => !after.scenes.some(row => row.id === scene.id)).map(scene => scene.id),
47
+ ...(moved ? [after.activeSceneId] : []),
48
+ ])];
49
+ const result = { affectedIds, summary: describe(after, opened, moved) };
50
+ return moved ? ports.afterRender().then(() => result) : result;
51
+ } });
52
+ const manyScenes = state => state.scenes.length > 1;
53
+ sceneAction("scene.create", () => true, () => {
54
+ ports.addSceneDocument();
55
+ return (after, opened) => `Created and opened scene ${sceneName(opened)}.`;
56
+ });
57
+ sceneAction("scene.duplicate", () => true, ({ sceneId }) => {
58
+ const source = sceneOf(sceneId);
59
+ ports.duplicateSceneDocument(sceneId);
60
+ return (after, opened) => `Duplicated ${sceneName(source)} as ${sceneName(opened)} and opened the copy.`;
61
+ });
62
+ sceneAction("scene.rename", () => true, ({ sceneId, name }) => {
63
+ const source = sceneOf(sceneId);
64
+ ports.renameSceneDocument(sceneId, name);
65
+ return after => {
66
+ const renamed = after.scenes.find(scene => scene.id === sceneId);
67
+ return renamed.name === source.name ? `${sceneName(source)} already has that name; nothing changed.` : `Renamed scene ${sceneId} from ${source.name} to ${renamed.name}.`;
68
+ };
69
+ });
70
+ sceneAction("scene.delete", state => manyScenes(state) || "The project's last scene cannot be deleted.", ({ sceneId }) => {
71
+ const source = sceneOf(sceneId);
72
+ ports.deleteSceneDocument(sceneId);
73
+ return (after, opened, moved) => `Deleted scene ${sceneName(source)}${moved ? `; opened ${sceneName(opened)}` : ""}.`;
74
+ });
75
+ sceneAction("scene.switch", state => manyScenes(state) || "This project has one scene; add one with scene.create.", ({ sceneId }, before) => {
76
+ const target = sceneOf(sceneId);
77
+ if (sceneId === before.activeSceneId) return () => `${sceneName(target)} is already open; nothing changed.`;
78
+ ports.switchSceneDocument(sceneId);
79
+ return () => `Opened scene ${sceneName(target)}.`;
80
+ });
81
+ }
@@ -0,0 +1,19 @@
1
+ // Helpers the command modules share. `ports` is the editor's one generic port
2
+ // object: its members are read at call time, so a run always reaches the
3
+ // latest handlers and `ports.state()` the synchronously published document.
4
+ import { StudioProtocolError } from "../studio-agent-protocol.js";
5
+
6
+ export const fail = (code, message) => { throw new StudioProtocolError(code, message); };
7
+
8
+ /** Ids of the rows added, changed or removed between two renders of a list. */
9
+ export const changedIds = (before, after) => [...new Set([
10
+ ...after.filter(row => !before.includes(row)).map(row => row.id),
11
+ ...before.filter(row => !after.some(next => next.id === row.id)).map(row => row.id),
12
+ ])];
13
+
14
+ export const shotLabel = shot => `${shot.name} [${shot.startFrame}, ${shot.endFrame + 1})`;
15
+
16
+ export const shotOf = (ports, shotId) => ports.state().shots.find(shot => shot.id === shotId) ?? fail("STALE_TARGET", `Shot ${shotId} is not in this scene.`);
17
+
18
+ export const characterOf = (ports, characterId) => ports.state().characters.find(entry => entry.id === characterId)
19
+ ?? fail("STALE_TARGET", `Character ${characterId} is not in this scene.`);