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,155 @@
1
+ // Shared set/read machinery. Domain modules supply only their kind, declared
2
+ // fields and persistence normalizer; no UI or runtime resources live here.
3
+ import { patchValueSchema, validateStudioSchema, StudioProtocolError, StudioSchemas, utf8ByteLength } from '../studio-agent-protocol.js';
4
+
5
+ import { isSettableElement } from '../studio-elements.js';
6
+
7
+ const kinds = new Map();
8
+ // Each kind registers its declared elements and persistence normalizer once.
9
+ // Its command port supplies read()/write(); all schemas and path aliases use
10
+ // this same spec so a migration adds a module, not another generic switch.
11
+ export function registerElementKind(kind, spec) {
12
+ if (kinds.has(kind)) throw new Error(`Element kind already registered: ${kind}`);
13
+ kinds.set(kind, { documentKey: kind, ...spec });
14
+ }
15
+ const projectionKind = key => [...kinds].find(([, spec]) => spec.documentKey === key)?.[0] ?? key;
16
+ const patchElements = kind => kinds.get(kind).elements.filter(isSettableElement);
17
+ const object = () => ({ type: 'object', properties: {}, required: [], additionalProperties: false });
18
+ // String mappings retain the single-document schema. Component mappings use
19
+ // the logical field in set args and fan out only at the storage boundary.
20
+ const setPath = element => typeof element.documentPath === 'string' ? element.documentPath : element.path.slice(element.path.indexOf('.') + 1);
21
+ const atPath = (value, path) => path.split('.').reduce((value, key) => value?.[key], value);
22
+ const nestedValue = (path, value) => path.split('.').reduceRight((value, key) => ({ [key]: value }), value);
23
+ export function elementSetSchema(kind) {
24
+ const schema = object();
25
+ for (const element of patchElements(kind)) {
26
+ const keys = setPath(element).split('.');
27
+ let parent = schema;
28
+ for (const key of keys.slice(0, -1)) parent = parent.properties[key] ??= object();
29
+ // Validate the wire type here; persistence owns numeric clamps. Empty
30
+ // strings are valid authored text, including intermediate typing states.
31
+ const value = element.type === 'number' ? { type: 'number' }
32
+ : element.type === 'string' ? { oneOf: [{ const: '' }, { type: 'string', maxLength: 240 }] } : patchValueSchema(element);
33
+ parent.properties[keys.at(-1)] = element.nullable ? { oneOf: [value, { type: 'null' }] } : value;
34
+ }
35
+ if (!kinds.get(kind).collection) return schema;
36
+ // A single item is { id, set }; a batch is { ops: [{ id, set }] }.
37
+ // Both forms publish once and therefore own exactly one history entry.
38
+ const item = { ...object(), properties: { id: StudioSchemas.TargetGuard.properties.targetId, set: schema }, required: ['id', 'set'] };
39
+ const batch = { ...object(), properties: { ops: { type: 'array', items: item, minItems: 1, maxItems: 32 } }, required: ['ops'] };
40
+ return { ...object(), properties: { ...item.properties, ...batch.properties }, oneOf: [item, batch] };
41
+ }
42
+ export function elementTarget(kind, value, id, sceneId) {
43
+ return kinds.get(projectionKind(kind)).collection ? value.find(row => row.id === id) : id === sceneId ? value : undefined;
44
+ }
45
+ export function readElementDocument(projection, { ids, select } = {}, sceneId) {
46
+ const entries = Object.entries(projection).flatMap(([key, value]) => {
47
+ const kind = projectionKind(key);
48
+ if (select && !select.includes(key) && !select.includes(kind)) return [];
49
+ if (!ids || ids.includes(key) || ids.includes(kind) || ids.includes(sceneId)) return [[key, value]];
50
+ const selected = kinds.get(kind).collection ? value.filter(row => ids.includes(row.id)) : [];
51
+ return selected.length ? [[key, selected]] : [];
52
+ });
53
+ return { document: structuredClone(Object.fromEntries(entries)), schema: Object.fromEntries(entries.map(([key]) => [key, elementSetSchema(projectionKind(key))])) };
54
+ }
55
+ export function readElement(document, path) {
56
+ const element = kinds.get(path.slice(0, path.indexOf('.'))).elements.find(row => row.path === path);
57
+ return element.documentPath && typeof element.documentPath === 'object'
58
+ ? Object.fromEntries(Object.entries(element.documentPath).map(([component, stored]) => [component, atPath(document, stored)]))
59
+ : atPath(document, setPath(element));
60
+ }
61
+ export function mergeElementSet(document = {}, patch) {
62
+ document = document ?? {};
63
+ const next = { ...document };
64
+ for (const [key, value] of Object.entries(patch)) next[key] = value && typeof value === 'object' && !Array.isArray(value)
65
+ ? mergeElementSet(document[key], value) : value;
66
+ return next;
67
+ }
68
+ function storedElementSet(kind, patch) {
69
+ let stored = mergeElementSet({}, patch);
70
+ for (const element of patchElements(kind)) {
71
+ if (!element.documentPath || typeof element.documentPath !== 'object') continue;
72
+ const value = atPath(patch, setPath(element));
73
+ if (value === undefined) continue;
74
+ const keys = setPath(element).split('.');
75
+ const parent = keys.slice(0, -1).reduce((value, key) => value[key], stored);
76
+ delete parent[keys.at(-1)];
77
+ for (const [component, path] of Object.entries(element.documentPath)) stored = mergeElementSet(stored, nestedValue(path, value[component]));
78
+ }
79
+ return stored;
80
+ }
81
+ export function elementPatchArgs(kind, args) {
82
+ const set = { ...object(), additionalProperties: true };
83
+ const collection = kinds.get(kind).collection;
84
+ const target = { ...object(), properties: { kind: { const: kind }, ...(collection ? { id: StudioSchemas.TargetGuard.properties.targetId } : {}) }, required: collection ? ['kind', 'id'] : ['kind'] };
85
+ const op = { ...object(), properties: { target, set }, required: ['target', 'set'] };
86
+ const schema = { ...object(), properties: { ops: { type: 'array', items: op, minItems: 1, maxItems: 32 } }, required: ['ops'] };
87
+ const validated = validateStudioSchema(schema, args);
88
+ const ops = validated.ops.map(({ target, set }) => {
89
+ let patch = {};
90
+ for (const [key, value] of Object.entries(set)) {
91
+ const element = patchElements(kind).find(row => row.path === `${kind}.${key}`);
92
+ if (!element) throw new StudioProtocolError('INVALID_ARGUMENT', `Unknown ${kind} path: ${key}`);
93
+ const nested = nestedValue(setPath(element), value);
94
+ patch = mergeElementSet(patch, nested);
95
+ }
96
+ return { id: target.id, set: patch };
97
+ });
98
+ return collection ? ops.length === 1 ? ops[0] : { ops } : ops.reduce((patch, op) => mergeElementSet(patch, op.set), {});
99
+ }
100
+ export function elementReadback(kind, item, paths, document) {
101
+ return patchElements(projectionKind(kind)).filter(element => !paths || paths.includes(element.path)).map(element => {
102
+ const path = element.path;
103
+ if (element.readback) return { path, ...element.readback(item, document) };
104
+ const value = readElement(item, path);
105
+ if (value === null || value === undefined || value === '') return { path, text: null };
106
+ if (element.type === 'image') return { path, bytes: utf8ByteLength(value) };
107
+ if (element.type === 'vec3') return { path, vec: value };
108
+ if (element.type === 'array') return { path, count: Array.isArray(value) ? value.length : value.points?.length ?? 0 };
109
+ return { path, [typeof value === 'number' ? 'number' : typeof value === 'boolean' ? 'flag' : 'text']: value };
110
+ });
111
+ }
112
+ // Normalizer-added defaults are allowed; changed/clamped requested members
113
+ // are reported as dropped, just like the native patch planners.
114
+ function survives(requested, actual) {
115
+ if (requested === null || typeof requested !== 'object') return requested === actual;
116
+ if (Array.isArray(requested)) return Array.isArray(actual) && requested.length === actual.length && requested.every((value, index) => survives(value, actual[index]));
117
+ return actual !== null && typeof actual === 'object' && !Array.isArray(actual) && Object.entries(requested).every(([key, value]) => survives(value, actual[key]));
118
+ }
119
+ export function elementPatchReceipt(receipt, request, projection) {
120
+ const kind = request?.args.ops[0].target.kind;
121
+ if (!receipt.ok || !kind || !kinds.get(kind).collection) return receipt;
122
+ const value = projection[kinds.get(kind).documentKey];
123
+ const ops = request.args.ops.map(({ target, set }, index) => {
124
+ const item = elementTarget(kind, value, target.id, receipt.host.sceneId);
125
+ const droppedPaths = Object.entries(set).filter(([key, requested]) => {
126
+ const element = patchElements(kind).find(element => element.path === `${kind}.${key}`);
127
+ return !element.readback && !survives(requested, readElement(item, element.path));
128
+ }).map(([key]) => `${kind}.${key}`);
129
+ return { index, status: droppedPaths.length ? 'partial' : receipt.authored ? 'applied' : 'noop', ...(droppedPaths.length ? { droppedPaths } : {}) };
130
+ });
131
+ const partial = receipt.authored && ops.some(op => op.status === 'partial');
132
+ // The protocol's partial variant is a patch receipt, not an action receipt.
133
+ const { action, summary, ...base } = receipt;
134
+ return { ...(partial ? base : receipt), ops, status: partial ? 'partial' : receipt.status,
135
+ delta: !receipt.authored ? [] : request.args.ops.slice(0, 8).map(({ target, set }) => ({ id: target.id, after: {
136
+ patched: elementReadback(kind, elementTarget(kind, value, target.id, receipt.host.sceneId), Object.keys(set).map(key => `${kind}.${key}`), value),
137
+ } })) };
138
+ }
139
+ export function registerElementSet(registry, ports, declaration) {
140
+ const kind = declaration.id.slice(0, declaration.id.indexOf('.'));
141
+ const { normalize, collection } = kinds.get(kind);
142
+ registry.register({ ...declaration, available: () => true, run(args) {
143
+ const domain = ports.storeDomain(declaration.undoDomain ?? kind);
144
+ let next = domain.read();
145
+ const ops = collection ? args.ops ?? [args] : [{ id: ports.state().activeSceneId, set: args }];
146
+ for (const { id, set } of ops) {
147
+ const before = collection ? next.find(row => row.id === id) : next;
148
+ if (!before) throw new StudioProtocolError('TARGET_NOT_READY', `Unknown ${kind} id: ${id}`);
149
+ const after = normalize(mergeElementSet(before, storedElementSet(kind, set)));
150
+ next = collection ? next.map(row => row.id === id ? { ...after, id } : row) : after;
151
+ }
152
+ if (JSON.stringify(next) !== JSON.stringify(domain.read())) domain.write(next);
153
+ return { affectedIds: [...new Set(ops.map(op => op.id))], summary: `Updated ${kind}.` };
154
+ } });
155
+ }
@@ -0,0 +1,20 @@
1
+ // Export commands: the Export menu's Video (mp4), the same export, awaited to
2
+ // its file. A failure was already shown in the export panel, so it refuses
3
+ // silently.
4
+ import { studioActionDeclaration } from "../studio-actions.js";
5
+ import { fail, shotLabel, shotOf } from "./shared.js";
6
+
7
+ export const declarations = Object.freeze(["export.shotVideo"].map(studioActionDeclaration));
8
+
9
+ export function register(registry, ports) {
10
+ registry.register({ ...studioActionDeclaration("export.shotVideo"),
11
+ available: state => state.exporting ? "An export is already running; wait for it to finish."
12
+ : state.canExportVideo || "There is nothing to record yet: add a shot (shot.create), camera keys or a motion take first.",
13
+ run: async ({ shotId }, context) => {
14
+ const shot = shotId ? shotOf(ports, shotId) : null;
15
+ const result = await ports.exportShotVideo({ shotId: shotId ?? null }, context);
16
+ if (!result?.fileName) fail("TARGET_NOT_READY", "The video export did not finish; the editor's export panel shows why and offers Retry.");
17
+ return { affectedIds: shot ? [shot.id] : [], output: { fileName: result.fileName, frameCount: result.frameCount },
18
+ summary: `Recorded ${shot ? shotLabel(shot) : "the shot"} to ${result.fileName} (${result.frameCount} frames); the browser was asked to download it.` };
19
+ } });
20
+ }
@@ -0,0 +1,40 @@
1
+ // The editor's Studio actions: ONE registry whose entries call the same
2
+ // handlers the UI controls call, so a timeline button and the agent's
3
+ // run_action share one code path. Each command module registers its own
4
+ // actions over `ports`, the one generic port object App refreshes on every
5
+ // render; `ports.state()` reads the synchronously published document, so an
6
+ // action sees its own edit at once.
7
+ import { createStudioActionRegistry, STUDIO_ACTIONS } from "../studio-actions.js";
8
+ import * as shot from "./shot.js";
9
+ import * as cast from "./cast.js";
10
+ import * as motion from "./motion.js";
11
+ import * as objects from "./objects.js";
12
+ import * as view from "./view.js";
13
+ import * as scene from "./scene.js";
14
+ import * as project from "./project.js";
15
+ import * as exporting from "./export.js";
16
+ import * as ai from "./ai.js";
17
+ import * as stage from "./stage.js";
18
+
19
+ export const COMMAND_MODULES = Object.freeze({ shot, cast, motion, objects, view, scene, project, export: exporting, ai, stage });
20
+
21
+ export function commandDeclarations(modules = COMMAND_MODULES) {
22
+ return Object.values(modules).flatMap(module => module.declarations.map(declaration => ({
23
+ ...STUDIO_ACTIONS.find(entry => entry.id === declaration.id), ...declaration,
24
+ })));
25
+ }
26
+
27
+ export function createStudioAppActions(ports, modules = COMMAND_MODULES) {
28
+ const registry = createStudioActionRegistry({ readState: () => ports.state() });
29
+ const declarations = new Map(commandDeclarations(modules).map(entry => [entry.id, entry]));
30
+ const register = registry.register;
31
+ registry.register = entry => register({ ...entry, ...declarations.get(entry.id) });
32
+ const aliases = new Map();
33
+ registry.registerToolAlias = (tool, action, args = value => value) => {
34
+ if (aliases.has(tool)) throw new Error(`Tool alias already registered: ${tool}`);
35
+ aliases.set(tool, { action, args });
36
+ };
37
+ registry.toolAlias = tool => aliases.get(tool);
38
+ for (const module of Object.values(modules)) module.register(registry, ports);
39
+ return registry;
40
+ }
@@ -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
+ }