cozyclay 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +219 -38
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +6 -5
  19. package/bin/cozyclay.mjs +12 -3
  20. package/bin/live/cli.mjs +2 -3
  21. package/bin/live/client.mjs +1 -1
  22. package/bin/live/studio.mjs +5 -2
  23. package/bin/mcp-runtime.mjs +12 -2
  24. package/bin/telemetry-state.mjs +3 -3
  25. package/dist/app/index.html +6 -6
  26. package/dist/assets/analytics-B41FmPYI.js +1 -0
  27. package/dist/assets/app-Do-Og5Pu.css +1 -0
  28. package/dist/assets/app-DwP-tl2x.js +4920 -0
  29. package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
  30. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
  31. package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
  32. package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
  33. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  34. package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
  35. package/dist/assets/workflow-Br-K7Srs.js +179 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/index.html +328 -222
  38. package/dist/sitemap.xml +5 -5
  39. package/dist/workflow/index.html +4 -4
  40. package/mcp/LIVE-PROTOCOL.md +23 -2
  41. package/mcp/README.md +37 -2
  42. package/mcp/ardy-prompts.mjs +1 -1
  43. package/mcp/image-file.mjs +132 -0
  44. package/mcp/live-hub.mjs +2 -2
  45. package/mcp/package.json +1 -0
  46. package/mcp/runtime/package-lock.json +7 -0
  47. package/mcp/runtime/package.json +1 -0
  48. package/mcp/server.mjs +0 -6
  49. package/mcp/tool-handlers.mjs +289 -87
  50. package/package.json +11 -2
  51. package/src/App.jsx +801 -259
  52. package/src/agent-image-client.js +94 -0
  53. package/src/analytics.js +47 -3
  54. package/src/app-stage.jsx +129 -2745
  55. package/src/ardy/auto-physics.js +6 -16
  56. package/src/ardy/collision-blockers.js +3 -7
  57. package/src/ardy/fix-collisions.js +5 -35
  58. package/src/ardy/ik-drag.js +86 -5
  59. package/src/ardy/ik-key-json.js +3 -1
  60. package/src/ardy/ik.js +15 -13
  61. package/src/ardy/motion-calibration.js +1 -1
  62. package/src/ardy/npz.js +0 -8
  63. package/src/ardy/physics-panel.jsx +3 -2
  64. package/src/ardy/physics-review.js +3 -2
  65. package/src/ardy/physics-support.js +1 -1
  66. package/src/ardy/physics-surface.js +1 -1
  67. package/src/ardy/platform-fit-panel.jsx +2 -1
  68. package/src/ardy/playback.js +1 -5
  69. package/src/ardy/range-pin.js +2 -2
  70. package/src/ardy/root-drop.js +0 -1
  71. package/src/ardy/timeline.css +196 -3
  72. package/src/ardy/timeline.jsx +117 -29
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +1 -4
  75. package/src/ardy-bridge-contract.js +281 -0
  76. package/src/asset-pane.jsx +19 -9
  77. package/src/auto-color.js +1 -1
  78. package/src/burn-in.js +1 -1
  79. package/src/camera-block.js +1 -1
  80. package/src/camera-follow.js +75 -32
  81. package/src/camera-move.js +0 -6
  82. package/src/camera-tutorial.jsx +43 -24
  83. package/src/character-kind.js +79 -0
  84. package/src/command-bus.js +76 -17
  85. package/src/commands/cast.js +31 -7
  86. package/src/commands/elements/object.js +1 -1
  87. package/src/commands/elements/shot.js +1 -1
  88. package/src/commands/elements.js +1 -1
  89. package/src/commands/export.js +3 -3
  90. package/src/commands/motion.js +2 -2
  91. package/src/commands/objects.js +105 -10
  92. package/src/commands/project.js +16 -3
  93. package/src/commands/sculpt-images.js +53 -0
  94. package/src/commands/shot.js +88 -10
  95. package/src/commands/view.js +10 -4
  96. package/src/coplanar-depth.js +216 -0
  97. package/src/cuts.js +7 -6
  98. package/src/domains/cast.js +60 -43
  99. package/src/domains/motion/motion-intent-codec.js +50 -0
  100. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  101. package/src/domains/motion/motion-video-capture.js +664 -0
  102. package/src/domains/motion.js +253 -742
  103. package/src/domains/objects.js +106 -5
  104. package/src/domains/scenes.js +89 -32
  105. package/src/domains/shots.js +33 -7
  106. package/src/error-boundary.jsx +1 -1
  107. package/src/execution-telemetry.js +5 -5
  108. package/src/facing-marks.js +14 -1
  109. package/src/first-success-guide.jsx +35 -4
  110. package/src/gizmo-claim.js +3 -1
  111. package/src/grid-floor.jsx +1 -4
  112. package/src/hierarchy-model.js +48 -35
  113. package/src/hierarchy-panel.css +1 -0
  114. package/src/hierarchy-panel.jsx +54 -102
  115. package/src/history.js +0 -2
  116. package/src/i2v-motion-client.js +1 -4
  117. package/src/ime.js +6 -0
  118. package/src/keyframe-pack.js +15 -6
  119. package/src/line-edit.js +4 -4
  120. package/src/live-control.js +6 -6
  121. package/src/matte-editor.js +2 -8
  122. package/src/matte.js +0 -19
  123. package/src/mesh-graph-clone.js +16 -2
  124. package/src/mesh-sniff.js +0 -1
  125. package/src/monotone-cubic.js +40 -0
  126. package/src/motion/generation.js +1 -1
  127. package/src/motion-store.js +4 -4
  128. package/src/motion-trail.js +5 -11
  129. package/src/multimodel-ingest.js +1 -3
  130. package/src/object-catalog.jsx +7 -5
  131. package/src/object-gizmo.jsx +45 -31
  132. package/src/object-path.js +310 -67
  133. package/src/object-travel.js +224 -0
  134. package/src/otio.js +8 -3
  135. package/src/panels/CharacterTransformPanel.jsx +24 -6
  136. package/src/panels/ObjectTransformPanel.jsx +4 -3
  137. package/src/panels/PoseImagePanel.jsx +205 -0
  138. package/src/panels/PromptBlocksPanel.jsx +3 -1
  139. package/src/panels/ProxyFigurePanel.jsx +56 -0
  140. package/src/panels/ReferenceImageField.jsx +1 -1
  141. package/src/panels/RigControlPanel.jsx +24 -3
  142. package/src/panels/RigPanel.jsx +2 -1
  143. package/src/panels/SculptPanel.jsx +405 -0
  144. package/src/panels/SubjectBox.jsx +1 -1
  145. package/src/panels/SubjectsPanel.jsx +0 -2
  146. package/src/panels/VideoCapturePanel.jsx +2 -1
  147. package/src/panels/pose-image.css +42 -0
  148. package/src/panels/pose.css +3 -0
  149. package/src/panels/sculpt.css +162 -0
  150. package/src/plan-diagram.js +184 -0
  151. package/src/planview.jsx +127 -80
  152. package/src/playground.js +3 -5
  153. package/src/pose-fade.js +77 -0
  154. package/src/pose-gizmo.css +23 -0
  155. package/src/pose-gizmo.js +96 -0
  156. package/src/posestudio.jsx +134 -83
  157. package/src/project-browser.css +53 -0
  158. package/src/project-browser.jsx +52 -3
  159. package/src/project.js +55 -24
  160. package/src/props.jsx +177 -36
  161. package/src/proxy-figure.jsx +69 -0
  162. package/src/range-pin-panel.jsx +0 -1
  163. package/src/receipt-findings.js +20 -0
  164. package/src/render-passes.js +0 -1
  165. package/src/resource-status.jsx +1 -1
  166. package/src/root-path.js +48 -0
  167. package/src/route-owner.js +52 -0
  168. package/src/scene-asset-cache.js +1 -2
  169. package/src/scene-assets.js +12 -4
  170. package/src/scene-fbx.js +1 -1
  171. package/src/scene-mesh-cache.js +0 -1
  172. package/src/scene-objects.js +319 -19
  173. package/src/scenes.js +62 -17
  174. package/src/sculpt-blueprint.js +65 -0
  175. package/src/sculpt-compare.js +384 -0
  176. package/src/sculpt-geometry.js +141 -0
  177. package/src/sculpt-mode.js +168 -0
  178. package/src/sculpt-recipe.js +385 -0
  179. package/src/sculpt-run.js +351 -0
  180. package/src/sculpt-stage.jsx +221 -0
  181. package/src/settings-menu.jsx +1 -1
  182. package/src/shell/BoardSlot.jsx +341 -0
  183. package/src/shell/BottomDock.jsx +58 -14
  184. package/src/shell/DetailsSlot.jsx +26 -6
  185. package/src/shell/MenuBar.jsx +78 -6
  186. package/src/shell/OutlinerSlot.jsx +3 -1
  187. package/src/shell/TopBar.jsx +17 -3
  188. package/src/shell/ViewportToolbar.jsx +18 -3
  189. package/src/shell/dock.css +239 -0
  190. package/src/shell/glass-regions.css +3 -2
  191. package/src/shell/log-store.js +4 -4
  192. package/src/shell/mode.css +20 -0
  193. package/src/shell/studio-shell-context.js +4 -1
  194. package/src/shot-authoring.js +69 -6
  195. package/src/stable-items.js +1 -1
  196. package/src/stage-attach-frames.js +169 -0
  197. package/src/stage-canvas-rigs.jsx +402 -0
  198. package/src/stage-capture.jsx +216 -0
  199. package/src/stage-characters.jsx +302 -0
  200. package/src/stage-editor-gizmos.jsx +1229 -0
  201. package/src/stage-labels-ko.js +204 -0
  202. package/src/stage-startup-defaults.js +182 -0
  203. package/src/starter-scenes.js +1 -1
  204. package/src/storyboard.js +24 -5
  205. package/src/studio-actions.js +42 -9
  206. package/src/studio-agent-commands.js +186 -23
  207. package/src/studio-agent-context.js +11 -9
  208. package/src/studio-agent-motion.js +2 -1
  209. package/src/studio-agent-protocol.js +56 -23
  210. package/src/studio-app-binding.js +52 -23
  211. package/src/studio-contact-sheet.js +3 -3
  212. package/src/studio-elements.js +11 -2
  213. package/src/studio-geometry-facts.js +114 -0
  214. package/src/styles.css +111 -10
  215. package/src/theme.js +1 -1
  216. package/src/timeline-fps.js +13 -0
  217. package/src/ui-scrub.js +4 -4
  218. package/src/ui.jsx +6 -4
  219. package/src/use-asset-shelf.js +60 -0
  220. package/src/use-camera-tutorial.js +50 -0
  221. package/src/use-embed-bridge.js +12 -0
  222. package/src/use-first-success-prompt.js +7 -0
  223. package/src/use-i2v-capture-modal.js +15 -0
  224. package/src/use-sculpt-mode.js +95 -0
  225. package/src/workflow/AgentPanel.jsx +110 -20
  226. package/src/workflow/WorkflowBuilder.jsx +1 -1
  227. package/src/workflow/agent-client.js +113 -45
  228. package/src/workflow/agent-panel.css +35 -0
  229. package/src/workflow/attachment-image.js +1 -1
  230. package/src/workflow/canvas-commands.js +3 -3
  231. package/src/workflow/cozy-scene-node.js +3 -3
  232. package/src/workflow/local-workflow.js +2 -2
  233. package/src/workflow/motion-input.js +3 -3
  234. package/src/workflow/node-schema.js +1 -1
  235. package/src/workflow/scene-asset-sync.js +2 -9
  236. package/src/workflow/shot-prompt-node.js +1 -1
  237. package/src/workflow/video-contract.js +1 -1
  238. package/src/workflow/workflow-resources.js +0 -8
  239. package/src/workflow-send.js +87 -0
  240. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  241. package/tools/check-eslint.mjs +57 -0
  242. package/tools/check-knip.mjs +60 -0
  243. package/tools/check-loc.mjs +54 -0
  244. package/tools/demo-worker/api-client.mjs +7 -13
  245. package/tools/demo-worker/generate.mjs +2 -4
  246. package/tools/eslint-baseline.json +23 -0
  247. package/tools/kimodo/effector-constraints.mjs +4 -4
  248. package/tools/kimodo/generate.mjs +1 -1
  249. package/tools/knip-baseline.json +331 -0
  250. package/tools/loc-baseline.json +126 -0
  251. package/tools/process-supervisor.mjs +1 -1
  252. package/tools/projflow/replay.mjs +4 -4
  253. package/tools/projflow/runner.mjs +3 -3
  254. package/tools/qa/studio-control-count.mjs +2 -0
  255. package/tools/run-tests.mjs +96 -7
  256. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  257. package/dist/assets/app-C1sdVbg4.css +0 -1
  258. package/dist/assets/app-CA55tcI3.js +0 -4876
  259. package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
  260. package/dist/assets/workflow-dNPGY9xz.js +0 -179
@@ -1,12 +1,15 @@
1
1
  // Studio composites prepare private domain drafts; the App owns publication,
2
2
  // history, gesture fences and semantic revision/telemetry. No UI callbacks here.
3
- import { Euler, Vector3, PerspectiveCamera } from 'three';
3
+ import { Euler, Vector3, Quaternion, PerspectiveCamera } from 'three';
4
4
  import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, isEffectivelyHidden, supportHeightForObject, OBJECT_LIBRARY } from './scene-objects.js';
5
5
  import { createCharacterEntry } from './scenes.js';
6
6
  import { createShot, shotAtFrame } from './cuts.js';
7
7
  import { captureFraming } from './camera-move.js';
8
8
  import { createStableItemId } from './stable-items.js';
9
9
  import { focalMmToFov, SENSOR_FORMATS } from './shot.js';
10
+ import { coplanarConflicts } from './coplanar-depth.js';
11
+ import { clampWarning } from './receipt-findings.js';
12
+ import { occludersOf, primarySubjects, sideOfAxis } from './studio-geometry-facts.js';
10
13
  import { StudioProtocolError, StudioSchemas, STUDIO_PATCH_KINDS, STUDIO_PATCH_DESCRIPTORS, validateStudioSchema, validateStudioIdentity, validateReceipt, freezeStudioData } from './studio-agent-protocol.js';
11
14
 
12
15
  const DEG = Math.PI / 180, EPS = 1e-8, CHARACTER_SUPPORT_TOLERANCE = 5e-3;
@@ -20,6 +23,39 @@ const entityById = (state, id) => {
20
23
  if (!entity) fail('AMBIGUOUS_TARGET', 'Target ID is not present in the admitted scene.');
21
24
  return entity;
22
25
  };
26
+ /** Resolve a placement reference against the evolving batch draft: an id first,
27
+ * else the unique name of an object or character already in it. Parts created
28
+ * earlier in the same batch are in the draft, so they resolve by name. */
29
+ function resolveRef(ref, state, field) {
30
+ const all = [...state.objects, ...state.characters];
31
+ if (all.some(e => e.id === ref)) return ref;
32
+ const named = all.filter(e => normalizedName(e.name ?? e.subject ?? '') === normalizedName(ref));
33
+ if (named.length === 1) return named[0].id;
34
+ if (named.length) fail('AMBIGUOUS_TARGET', `${field} '${ref}' matches ${named.length} objects by name (${named.map(e => e.id).join(', ')}); reference one by id.`);
35
+ fail('AMBIGUOUS_TARGET', `${field} '${ref}' not found; reference an existing id, or a part created EARLIER in this batch by its name (create it before it is referenced).`);
36
+ }
37
+ /** The op with every reference field (relativeTo, onObject, between, support,
38
+ * facing targets) resolved to an id in the current draft. */
39
+ function resolveOpRefs(op, state, selfId) {
40
+ const { position: p, facing: f } = op;
41
+ if (!p && !f) return op;
42
+ const r = (value, field) => {
43
+ const resolved = resolveRef(value, state, field);
44
+ if (resolved === selfId) fail('INVALID_ARGUMENT', `${field} '${value}' is the object being placed; reference a different part.`);
45
+ return resolved;
46
+ };
47
+ const out = { ...op };
48
+ if (p) out.position = { ...p,
49
+ ...(p.relativeTo !== undefined ? { relativeTo: r(p.relativeTo, 'relativeTo') } : {}),
50
+ ...(p.onObject !== undefined ? { onObject: r(p.onObject, 'onObject') } : {}),
51
+ ...(p.between ? { between: p.between.map(value => r(value, 'between')) } : {}),
52
+ ...(p.support && typeof p.support === 'object' ? { support: { objectId: r(p.support.objectId, 'support.objectId') } } : {}) };
53
+ if (f) {
54
+ const key = ['towardId', 'awayFromId', 'sameAsId'].find(k => f[k] !== undefined);
55
+ if (key) out.facing = { [key]: r(f[key], `facing.${key}`) };
56
+ }
57
+ return out;
58
+ }
23
59
  function boxPoints(bounds) {
24
60
  if (!bounds || ['x', 'y', 'z'].some(a => !Number.isFinite(bounds.min?.[a]) || !Number.isFinite(bounds.max?.[a]) || bounds.min[a] > bounds.max[a])) fail('TARGET_NOT_READY', 'Evaluated bounds are unavailable.');
25
61
  return [bounds.min.x, bounds.max.x].flatMap(x => [bounds.min.y, bounds.max.y].flatMap(y => [bounds.min.z, bounds.max.z].map(z => new Vector3(x, y, z))));
@@ -55,7 +91,7 @@ function support(spec, state) {
55
91
  const id = spec.onObject ?? (typeof spec.support === 'object' ? spec.support.objectId : null);
56
92
  if (!id) return { y: state.floorY, label: 'floor' };
57
93
  const object = entityById(state, id);
58
- if (!object.renderer || object.renderer === 'cutout' || ['sphere', 'capsule', 'cone', 'car', 'small-plane'].includes(object.renderer) || object.path || object.attach || Math.abs(object.rotX) > EPS || Math.abs(object.rotZ) > EPS || isEffectivelyHidden(object, state.objects, state.characters)) fail('TARGET_NOT_READY', 'Support must be a stationary solid upright surface.');
94
+ if (!object.renderer || object.renderer === 'cutout' || ['sphere', 'capsule', 'cone', 'car', 'small-plane', 'empty', 'sculpt'].includes(object.renderer) || object.path || object.attach || Math.abs(object.rotX) > EPS || Math.abs(object.rotZ) > EPS || isEffectivelyHidden(object, state.objects, state.characters)) fail('TARGET_NOT_READY', 'Support must be a stationary solid upright surface.');
59
95
  return { y: object.y + supportHeightForObject(object) * object.scaleY, label: `object:${id}`, object };
60
96
  }
61
97
  function facingYaw(spec, entity, state) {
@@ -106,6 +142,9 @@ function place(entity, op, state, ports) {
106
142
  if (Math.hypot(result.x - previous.x, result.z - previous.z, result.rot - previous.rot) < EPS) { converged = true; break; }
107
143
  }
108
144
  if (!converged) fail('AMBIGUOUS_BASIS', 'Facing and placement cannot satisfy the requested relation.');
145
+ // A pitched/rolled part rests its lowest corner on the support.
146
+ if (result.renderer && !result.attach && !result.path && (Math.abs(result.rotX) > EPS || Math.abs(result.rotZ) > EPS))
147
+ result = patchEntity(result, { y: result.y + surface.y - Math.min(...geometry(result, state, ports).map(p => p.y)) });
109
148
  const points = geometry(result, state, ports);
110
149
  const supportTolerance = !result.renderer && surface.label === 'floor' ? CHARACTER_SUPPORT_TOLERANCE : EPS;
111
150
  if (Math.abs(Math.min(...points.map(p => p.y)) - surface.y) > supportTolerance) fail('TARGET_NOT_READY', 'Tilted or offset bounds cannot rest on the requested support.');
@@ -116,14 +155,90 @@ function place(entity, op, state, ports) {
116
155
  }
117
156
  return { entity: result, relation: { id: result.id, spec, axis, support: surface.label, baseY: surface.y, ...(axis ? { actualGapM, requestedGapM: spec.gapM, basis: spec.basis } : {}) } };
118
157
  }
158
+ // Parent-relative (local) space. Object rows are flat world-space; `parent` only
159
+ // groups. A parent's frame is its stored pivot (base, y = bottom) and Euler XYZ
160
+ // degrees; parent SCALE is deliberately not part of the frame.
161
+ const objectQuat = o => new Quaternion().setFromEuler(new Euler((o.rotX ?? 0) * DEG, (o.rot ?? 0) * DEG, (o.rotZ ?? 0) * DEG, 'XYZ'));
162
+ const wrapDeg = a => ((a + 540) % 360 + 360) % 360 - 180;
163
+ // XYZ Euler angles come in two equal-orientation spellings, (x,y,z) and
164
+ // (x+180, 180-y, z+180). Report the one with the least pitch/roll, so a pure
165
+ // yaw of 135 reads back as yaw 135 rather than (180, 45, 180).
166
+ function eulerDegOf(q) {
167
+ const e = new Euler().setFromQuaternion(q, 'XYZ'), a = { x: e.x / DEG, y: e.y / DEG, z: e.z / DEG };
168
+ const b = { x: wrapDeg(a.x + 180), y: wrapDeg(180 - a.y), z: wrapDeg(a.z + 180) };
169
+ return Math.abs(b.x) + Math.abs(b.z) < Math.abs(a.x) + Math.abs(a.z) - 1e-9 ? b : a;
170
+ }
171
+ const eulerQuat = r => new Quaternion().setFromEuler(new Euler(r.x * DEG, r.y * DEG, r.z * DEG, 'XYZ'));
172
+ /** world = parentPivot + R_parent * local; orientation = R_parent * R_local. */
173
+ export function localToWorld(parent, local) {
174
+ const p = vector(local.position ?? { x: 0, y: 0, z: 0 }).applyQuaternion(objectQuat(parent)).add(vector(pos(parent)));
175
+ const q = objectQuat(parent).multiply(eulerQuat(local.rotationDeg ?? { x: 0, y: 0, z: 0 }));
176
+ return { position: { x: p.x, y: p.y, z: p.z }, rotationDeg: eulerDegOf(q) };
177
+ }
178
+ /** Inverse of localToWorld for an object row under its parent row. */
179
+ export function worldToLocal(parent, object) {
180
+ const inverse = objectQuat(parent).invert();
181
+ const p = vector(pos(object)).sub(vector(pos(parent))).applyQuaternion(inverse);
182
+ return { position: { x: p.x, y: p.y, z: p.z }, rotationDeg: eulerDegOf(inverse.multiply(objectQuat(object))) };
183
+ }
184
+ const tidy = v => Math.round(v * 1e4) / 1e4 + 0;
185
+ /** Compact readback: the object's transform in its parent's frame, or null for
186
+ * a root, a missing parent or a parent whose stored pose is not its world pose. */
187
+ export function localTransformReadback(object, objects) {
188
+ const parent = object.parent ? objects.find(o => o.id === object.parent) : null;
189
+ if (!parent || parent.attach || parent.path || object.attach || object.path) return null;
190
+ const { position, rotationDeg } = worldToLocal(parent, object);
191
+ const round = v => ({ x: tidy(v.x), y: tidy(v.y), z: tidy(v.z) });
192
+ return { position: round(position), rotationDeg: round(rotationDeg) };
193
+ }
194
+ const resolveParentRef = (ref, rows, createdByName) => rows.some(e => e.id === ref) ? ref : createdByName.get(normalizedName(ref));
195
+ /** Rewrites an op given in the parent's local frame into the equivalent world op. */
196
+ function parentFrameOp(op, entity, rows, createdByName) {
197
+ const create = op.op === 'create';
198
+ if (create && op.parent === undefined) fail('INVALID_ARGUMENT', 'space "parent" needs `parent` on a create: an existing object id, or the name of an object created earlier in this batch.');
199
+ if (!create && !entity.parent) fail('INVALID_ARGUMENT', 'space "parent" needs an object that already has a parent; group it under one first, or omit space for world coordinates.');
200
+ const parentId = create ? resolveParentRef(op.parent, rows, createdByName) : entity.parent;
201
+ const parent = rows.find(e => e.id === parentId);
202
+ if (!parent) fail('AMBIGUOUS_TARGET', 'space "parent": the parent is neither an existing object id nor an object created earlier in this batch.');
203
+ if (parent.attach || parent.path) fail('CAPABILITY_MISSING', 'space "parent": an attached or routed parent has no fixed pose to place against.');
204
+ if (!op.position && !op.rotationDeg && !op.facing) fail('INVALID_ARGUMENT', 'space "parent" applies to position, rotationDeg or facing; none was given.');
205
+ if (op.position && !op.position.world) fail('INVALID_ARGUMENT', 'space "parent" takes position {world:{x,y,z}} as local metres from the parent pivot; relativeTo, between and onObject resolve in world space, so omit space for them.');
206
+ if (op.facing && !('yawDeg' in op.facing)) fail('INVALID_ARGUMENT', 'space "parent" takes facing {yawDeg} only (local yaw); towardId, awayFromId and sameAsId are world-space.');
207
+ const current = create ? { position: { x: 0, y: 0, z: 0 }, rotationDeg: { x: 0, y: 0, z: 0 } } : worldToLocal(parent, entity);
208
+ // Local rotation: rotationDeg, else the current local one with its yaw set by
209
+ // facing; a new child with neither takes the parent's orientation (local zero).
210
+ const rotation = op.rotationDeg ?? (op.facing ? { ...current.rotationDeg, y: op.facing.yawDeg } : create ? current.rotationDeg : null);
211
+ const world = localToWorld(parent, { position: op.position ? op.position.world : current.position, rotationDeg: rotation ?? current.rotationDeg });
212
+ const { space, facing, ...rest } = op;
213
+ return { ...rest, ...(op.position ? { position: { world: world.position } } : {}), ...(rotation ? { rotationDeg: world.rotationDeg } : {}) };
214
+ }
215
+ function groupRootOf(id, objects) {
216
+ const seen = new Set();
217
+ let current = objects.find(o => o.id === id);
218
+ while (current?.parent && !seen.has(current.id)) {
219
+ seen.add(current.id);
220
+ current = objects.find(o => o.id === current.parent) ?? current;
221
+ }
222
+ return current?.id ?? id;
223
+ }
119
224
  function overlapsFor(entity, state, ports) {
120
225
  const own = aabb(geometry(entity, state, ports));
121
- return [...state.objects, ...state.characters].filter(e => e.id !== entity.id && !isEffectivelyHidden(e, state.objects, state.characters)).map(other => ({ id: other.id, bounds: aabb(geometry(other, state, ports)) })).map(other => ({ ...other, depth: overlap(own, other.bounds) })).filter(o => o.depth > EPS);
226
+ // Parts of one grouped object are built to touch; only other bodies count.
227
+ const root = groupRootOf(entity.id, state.objects);
228
+ return [...state.objects, ...state.characters].filter(e => e.id !== entity.id && !isEffectivelyHidden(e, state.objects, state.characters)
229
+ && groupRootOf(e.id, state.objects) !== root).map(other => ({ id: other.id, bounds: aabb(geometry(other, state, ports)) })).map(other => ({ ...other, of: entity.id, depth: overlap(own, other.bounds) })).filter(o => o.depth > EPS);
122
230
  }
123
231
  function overlapsOf(ids, state, ports) {
124
232
  const entities = [...state.objects, ...state.characters];
125
233
  return ids.flatMap(id => { const e = entities.find(row => row.id === id); return e ? overlapsFor(e, state, ports) : []; });
126
234
  }
235
+ /** World AABB of one entity as {min,max}, or null when its evaluated bounds are unavailable (a prop whose renderer is not mounted). */
236
+ export function entityWorldBounds(entity, state, ports) {
237
+ try {
238
+ const box = aabb(geometry(entity, state, ports)), round = v => Number(v.toFixed(4));
239
+ return { min: Object.fromEntries(['x', 'y', 'z'].map(a => [a, round(box[a].min)])), max: Object.fromEntries(['x', 'y', 'z'].map(a => [a, round(box[a].max)])) };
240
+ } catch { return null; }
241
+ }
127
242
  const overlapEvidence = overlaps => ({ coverage: 'same-frame-world-AABB-proxies', overlapIds: [...new Set(overlaps.map(o => o.id))].slice(0, 100), maximumFootprintOverlapM: Math.max(0, ...overlaps.map(o => o.depth)) });
128
243
  /** Placement evidence for entity ids in the given scene: the same-frame AABB
129
244
  * overlap an arrangement receipt reports for the entities it moved. */
@@ -152,32 +267,49 @@ function transformPatch(op, isObject) {
152
267
  export function arrangement(command, before, ports) {
153
268
  const isObject = command.name === 'arrange_objects', key = isObject ? 'objects' : 'characters';
154
269
  let rows = before[key];
155
- const relations = [], warnings = [];
156
- for (const op of command.args.ops) {
157
- const id = op.id ?? op.characterId;
270
+ const relations = [], warnings = [], clamps = [], createdByName = new Map();
271
+ // References resolve against the evolving draft (`rows`), in op order.
272
+ const draft = () => ({ ...before, [key]: rows });
273
+ for (const rawOp of command.args.ops) {
274
+ const id = rawOp.id ?? rawOp.characterId;
158
275
  let entity = id ? rows.find(e => e.id === id) : null;
159
- if (id && !entity) fail('AMBIGUOUS_TARGET', 'Edited target is not present in the draft.');
276
+ if (id && !entity) fail('AMBIGUOUS_TARGET', `Edited target '${id}' is not present in the draft; ids of parts created in this batch are not known yet, so use the part name in position references and create it before it is referenced.`);
277
+ let op = resolveOpRefs(rawOp, draft(), id);
160
278
  if (op.op === 'create') {
161
279
  if (op.name && rows.some(e => normalizedName(e.name ?? e.subject) === normalizedName(op.name))) fail('DUPLICATE_NAME', 'Create name already exists in the domain.');
280
+ let parentId;
281
+ if (isObject && op.parent !== undefined) {
282
+ parentId = rows.some(e => e.id === op.parent) ? op.parent : createdByName.get(normalizedName(op.parent));
283
+ if (!parentId) fail('AMBIGUOUS_TARGET', `Create parent '${op.parent}' is neither an existing object id nor an object created earlier in this batch; create the parent first.`);
284
+ }
162
285
  entity = isObject ? createSceneObject(op.source.kind, rows) : createCharacterEntry({ id: createStableItemId('character'), subject: normalizedName(op.name) });
163
286
  if (!entity) fail('INVALID_ARGUMENT', 'Unsupported object library kind.');
164
287
  rows = [...rows, entity];
288
+ // Parent before placing, so avoidance and overlap checks see one body.
289
+ if (parentId) { rows = setSceneObjectParent(rows, entity.id, parentId); entity = rows.find(e => e.id === entity.id); }
165
290
  }
166
291
  if (['create', 'update'].includes(op.op)) {
167
292
  if (entity.attach) fail('CAPABILITY_MISSING', 'Attached transforms require a world-preserving attachment adapter.');
293
+ if (op.space === 'parent') op = parentFrameOp(op, entity, rows, createdByName);
168
294
  entity = patchEntity(entity, transformPatch(op, isObject));
169
- const placed = place(entity, op, before, ports);
295
+ const placed = place(entity, op, draft(), ports);
170
296
  entity = placed.entity;
171
- if (command.args.collisionPolicy === 'avoid') {
172
- entity = avoid(entity, placed.relation, { ...before, [key]: rows }, ports);
173
- entity = patchEntity(entity, { rot: facingYaw(op.facing, entity, before) });
174
- const reference = entityById(before, op.position.relativeTo);
175
- placed.relation.actualGapM = interval(geometry(entity, before, ports), placed.relation.axis).min - interval(geometry(reference, before, ports), placed.relation.axis).max;
297
+ // What was asked against what the domain kept: scale and position limits move values silently.
298
+ const asked = isObject ? { ...(op.position?.world ?? {}), ...(op.scale ? { scaleX: op.scale.x, scaleY: op.scale.y, scaleZ: op.scale.z } : {}) }
299
+ : { ...(op.position?.world ?? {}), ...(op.scale === undefined ? {} : { scale: op.scale }) };
300
+ const clamp = clampWarning(entity.id, Object.fromEntries(Object.entries(asked).filter(([k]) => ['x', 'y', 'z', 'scale', 'scaleX', 'scaleY', 'scaleZ'].includes(k))), entity);
301
+ if (clamp) clamps.push(clamp);
302
+ if (command.args.collisionPolicy === 'avoid' && placed.relation?.axis) {
303
+ entity = avoid(entity, placed.relation, draft(), ports);
304
+ entity = patchEntity(entity, { rot: facingYaw(op.facing, entity, draft()) });
305
+ const reference = entityById(draft(), op.position.relativeTo);
306
+ placed.relation.actualGapM = interval(geometry(entity, draft(), ports), placed.relation.axis).min - interval(geometry(reference, draft(), ports), placed.relation.axis).max;
176
307
  if (placed.relation.actualGapM < op.position.gapM - EPS) fail('VERIFICATION_FAILED', 'Avoidance cannot preserve the requested facing and minimum clearance.');
177
308
  }
178
309
  const patch = Object.fromEntries(Object.entries(entity).filter(([k, v]) => !equal(v, rows.find(e => e.id === entity.id)[k])));
179
310
  rows = isObject ? updateSceneObject(rows, entity.id, patch) : rows.map(e => e.id === entity.id ? entity : e);
180
311
  if (placed.relation) relations.push(placed.relation);
312
+ if (op.op === 'create' && op.name) createdByName.set(normalizedName(op.name), entity.id);
181
313
  } else if (op.op === 'remove') {
182
314
  if (!isObject && (rows.length <= 1 || entity.id === before.activeCharacterId)) fail('INVALID_ARGUMENT', 'Cannot remove the final or active character without a separate selection operation.');
183
315
  rows = isObject ? removeSceneObject(rows, id) : rows.filter(e => e.id !== id);
@@ -205,7 +337,24 @@ export function arrangement(command, before, ports) {
205
337
  relation.actualGapM = actualGapM;
206
338
  }
207
339
  if (command.args.collisionPolicy === 'avoid' && overlaps.length) fail('VERIFICATION_FAILED', 'The final batch is still blocked after its outward adjustments.');
208
- for (const item of overlaps.slice(0, 10)) warnings.push({ code: 'FOOTPRINT_OVERLAP', id: item.id });
340
+ // Every finding the receipt carries: a value moved to a limit first, then what the final scene shows wrong.
341
+ const nameOf = id => { const e = [...after.objects, ...after.characters].find(row => row.id === id); return e?.name ?? e?.subject ?? id; };
342
+ warnings.push(...clamps.slice(0, 3));
343
+ for (const item of overlaps.slice(0, 5)) warnings.push({ code: 'FOOTPRINT_OVERLAP', id: item.id, message: [...`${nameOf(item.of)} overlaps ${nameOf(item.id)} by ${Number(item.depth.toFixed(2))} m`].slice(0, 120).join('') });
344
+ if (overlaps.length > 5) warnings.push({ code: 'FOOTPRINT_OVERLAP', count: overlaps.length - 5, message: `${overlaps.length - 5} more footprint overlaps omitted` });
345
+ if (isObject) {
346
+ // Parts of one assembly touch on purpose, but faces sharing one plane flicker as the camera moves.
347
+ const touched = new Set(affectedIds), groups = new Map();
348
+ for (const row of rows) if (touched.has(row.id)) { const root = groupRootOf(row.id, rows); if (root !== row.id || rows.some(o => o.parent === row.id)) groups.set(root, null); }
349
+ const coplanar = [];
350
+ for (const root of groups.keys()) for (const pair of coplanarConflicts(rows.filter(row => groupRootOf(row.id, rows) === root))) {
351
+ if (!touched.has(pair.a) && !touched.has(pair.b)) continue;
352
+ const planes = pair.planes.map(plane => `${plane.face} coincide at ${plane.axis ? `${plane.axis}=${plane.at}` : `offset ${plane.at}`}`).join(', ');
353
+ coplanar.push({ code: 'COPLANAR_FACES', id: pair.b, message: [...`${nameOf(pair.a)} and ${nameOf(pair.b)} ${planes}`].slice(0, 120).join('') });
354
+ }
355
+ warnings.push(...coplanar.slice(0, 2));
356
+ if (coplanar.length > 2) warnings.push({ code: 'COPLANAR_FACES', count: coplanar.length - 2, message: `${coplanar.length - 2} more coplanar face pairs omitted` });
357
+ }
209
358
  if (relations.some(r => r.adjustmentM)) warnings.push({ code: 'OUTWARD_ADJUSTMENT', count: relations.filter(r => r.adjustmentM).length });
210
359
  const relation = relations.length === 1 ? relations[0] : null;
211
360
  return { domain: isObject ? 'objects' : 'cast', draft: equal(rows, before[key]) ? before[key] : rows, affectedIds,
@@ -232,12 +381,23 @@ function projectSubject(points, camera, fov, aspectRatio) {
232
381
  const derivedSize = [['extreme close-up', 2.8], ['close-up', 1.6], ['medium close-up', 1.15], ['medium shot', 0.8], ['medium-wide shot', 0.52], ['wide shot', 0.3], ['extreme wide shot', 0]].find(([, threshold]) => screenFraction >= threshold)[0];
233
382
  return { screenBounds, checks: { coverage: 'same-frame-subject-bounds-projection', screenFraction, derivedSize, behindCamera, clipped: behindCamera || screen.some(p => Math.abs(p.x) > 1 || Math.abs(p.y) > 1 || p.z < -1 || p.z > 1) } };
234
383
  }
384
+ const boundsOf = points => { const box = aabb(points); return { min: { x: box.x.min, y: box.y.min, z: box.z.min }, max: { x: box.x.max, y: box.y.max, z: box.z.max } }; };
385
+ /** What stands in the way of the camera and where it stands: occluded when a prop
386
+ * crosses the sight line to any subject, and the camera's side of the line between
387
+ * the two leading characters (the subjects, else the ones nearest the camera). */
388
+ function sightChecks(subjects, boxes, cameraPosition, state, ports) {
389
+ const occluders = subjects.flatMap((subject, index) => occludersOf(subject, cameraPosition, state, ports, boundsOf(boxes[index])));
390
+ const pair = primarySubjects(subjects.map(subject => subject.id), cameraPosition, state);
391
+ const cameraSide = pair.length === 2 ? sideOfAxis(pair[0], pair[1], cameraPosition) : null;
392
+ return { occluders, checks: { occluded: occluders.length > 0, ...(cameraSide ? { cameraSide } : {}) } };
393
+ }
235
394
  /** Framing evidence for entity ids through the current shot camera at the
236
- * current frame: the same subject-bounds projection frame_shot reports. */
395
+ * current frame: the same subject-bounds projection and sight line frame_shot reports. */
237
396
  export function framingChecks(ids, state, ports) {
238
397
  if (!state.camera || !SENSOR_FORMATS[state.filmback?.sensorId] || !(state.filmback.aspectRatio > 0)) fail('TARGET_NOT_READY', 'The shot camera and filmback must be available.');
239
- const points = ids.flatMap(id => geometry(entityById(state, id), state, ports));
240
- return projectSubject(points, state.camera, focalMmToFov(state.camera.focalMm, state.filmback.sensorId, state.filmback.aspectRatio), state.filmback.aspectRatio).checks;
398
+ const subjects = ids.map(id => entityById(state, id)), boxes = subjects.map(entity => geometry(entity, state, ports));
399
+ const projected = projectSubject(boxes.flat(), state.camera, focalMmToFov(state.camera.focalMm, state.filmback.sensorId, state.filmback.aspectRatio), state.filmback.aspectRatio).checks;
400
+ return { ...projected, ...sightChecks(subjects, boxes, state.camera.position, state, ports).checks };
241
401
  }
242
402
  export function frameDraft(command, state, ports) {
243
403
  if (state.frameCount <= 0) fail('TARGET_NOT_READY', 'A nonempty timeline is required.');
@@ -249,7 +409,9 @@ export function frameDraft(command, state, ports) {
249
409
  const created = !shot && !state.shotDocument.shots.length && !command.args.shotId;
250
410
  if (created) shot = createShot('Shot 1', 0, state.frameCount - 1);
251
411
  if (!shot) fail('AMBIGUOUS_TARGET', 'No shot owns this frame; select an existing shot explicitly.');
252
- const frame = command.args.keyAtFrame ?? state.frame;
412
+ const still = shot.kind === 'still';
413
+ const existingKey = shot.cameraKeys[0];
414
+ const frame = still ? (existingKey?.frame ?? shot.startFrame) : command.args.keyAtFrame ?? state.frame;
253
415
  if (frame < shot.startFrame || frame > shot.endFrame || shot.endFrame >= state.frameCount) fail('INVALID_RANGE', 'Framing/key frame must be inside the shot range.');
254
416
  const intent = command.args.framing.intent;
255
417
  let camera = command.args.framing.exact;
@@ -270,18 +432,19 @@ export function frameDraft(command, state, ports) {
270
432
  const framing = captureFraming({ pos: camera.position, yaw: Math.atan2(-direction.x, -direction.z), pitch: Math.atan2(direction.y, Math.hypot(direction.x, direction.z)), fovDeg: fov / DEG });
271
433
  let keyId;
272
434
  let keys = shot.cameraKeys;
273
- if (command.args.keyAtFrame !== undefined) {
274
- keyId = keys.find(k => k.frame === frame)?.id ?? createStableItemId('camera-key');
275
- keys = [...keys.filter(k => k.frame !== frame), { id: keyId, frame, framing }].sort((a, b) => a.frame - b.frame);
435
+ if (still || command.args.keyAtFrame !== undefined) {
436
+ keyId = (still ? existingKey?.id : keys.find(k => k.frame === frame)?.id) ?? createStableItemId('camera-key');
437
+ keys = still ? [{ id: keyId, frame, framing }] : [...keys.filter(k => k.frame !== frame), { id: keyId, frame, framing }].sort((a, b) => a.frame - b.frame);
276
438
  }
277
439
  const nextShot = { ...shot, cameraKeys: keys, camera: { ...shot.camera, mode: 'keys' } };
278
440
  const shots = created ? [nextShot] : state.shotDocument.shots.map(s => s.id === shot.id ? nextShot : s);
279
- const { screenBounds, checks } = projectSubject(points, camera, fov, state.filmback.aspectRatio), derivedSize = checks.derivedSize;
441
+ const { screenBounds, checks: projected } = projectSubject(points, camera, fov, state.filmback.aspectRatio), derivedSize = projected.derivedSize;
442
+ const sight = sightChecks([subject], [points], camera.position, state, ports), checks = { ...projected, ...sight.checks };
280
443
  const resolvedCamera = { ...camera, sensorId: state.filmback.sensorId, slate: derivedSize };
281
444
  const draft = { shotDocument: { ...state.shotDocument, shots }, camera: resolvedCamera, manual: true };
282
445
  return { domain: 'shot', draft, affectedIds: [shot.id, ...(keyId ? [keyId] : [])],
283
446
  checks,
284
- warnings: [{ code: 'OCCLUSION_UNMEASURED' }], details: { created, shotId: shot.id, keyId, frame, framing, screenBounds, subjectIds: [subject.id] } };
447
+ warnings: sight.occluders.slice(0, 5).map(item => ({ code: 'OCCLUDED', id: item.byObjectId })), details: { created, shotId: shot.id, keyId, frame, framing, screenBounds, subjectIds: [subject.id] } };
285
448
  }
286
449
 
287
450
  /** Local journal. Unsettled/protected records are never evicted. Call prune on
@@ -10,10 +10,6 @@ export function encodeStudioContext(context) {
10
10
  if (utf8ByteLength(encoded) > STUDIO_CONTEXT_MAX_BYTES) fail("CONTEXT_TOO_LARGE", `Escaped Studio context exceeds ${STUDIO_CONTEXT_MAX_BYTES / 1024} KiB.`);
11
11
  return encoded;
12
12
  }
13
- export function buildStudioHistoryItem(context, userText) {
14
- if (typeof userText !== "string") fail("INVALID_REQUEST", "User text must be a string.");
15
- return { role: "user", content: [{ type: "input_text", text: `<studio-context>\n${encodeStudioContext(context)}\n</studio-context>` }, { type: "input_text", text: userText }] };
16
- }
17
13
  export function studioCacheKey(context, projectionKind = "scene") {
18
14
  const c = validateStudioContext(context);
19
15
  if (typeof projectionKind !== "string" || !projectionKind || projectionKind.length > 120) fail("INVALID_ARGUMENT", "Projection kind must be a bounded string.");
@@ -38,8 +34,9 @@ export function validateStudioCursor(cursor, context) {
38
34
  /** The compact command index the turn carries: id and label for every
39
35
  * registered command, plus the declared generation and hub timeout. The
40
36
  * description and input schema stay out; the agent reads them on request. */
41
- export function studioActionIndex(actions) {
42
- return actions.map(({ id, label, generation, timeoutMs }) => ({ id, ...(label ? { label: [...label].slice(0, 120).join("") } : {}),
37
+ export function studioActionIndex(actions, { previsMode } = {}) {
38
+ const available = actions.filter(action => !action.agentHidden && (previsMode !== "storyboard" || (action.generation !== "motion" && action.domain !== "motion")));
39
+ return available.map(({ id, label, generation, timeoutMs }) => ({ id, ...(label ? { label: [...label].slice(0, 120).join("") } : {}),
43
40
  ...(generation ? { generation } : {}), ...(timeoutMs === undefined ? {} : { timeoutMs }) }));
44
41
  }
45
42
 
@@ -49,7 +46,7 @@ export function studioActionIndex(actions) {
49
46
  // truncation so forbidden data cannot hide in an omitted row.
50
47
  export function buildStudioContext({ actions, ...input }) {
51
48
  const c = structuredClone(input);
52
- if (actions) c.actionIndex = studioActionIndex(actions);
49
+ if (actions) c.actionIndex = studioActionIndex(actions, { previsMode: c.scene?.previsMode });
53
50
  const boundDisplay = value => {
54
51
  if (!value || typeof value !== "object") return;
55
52
  for (const [key, child] of Object.entries(value)) {
@@ -117,13 +114,18 @@ const object = fields => ({ type: "object", properties: fields, required: Object
117
114
  const array = (items, maxItems = 10000, minItems = 0) => ({ type: "array", items, minItems, maxItems });
118
115
  const vec = StudioSchemas.Vec3;
119
116
  const timing = object({ cuts: array(object({ t: nonnegative, d: nonnegative }), 64), envelopes: array(array(nonnegative, 24, 24), 65, 1) });
120
- const path = object({ points: array(vec, 64, 2), speed: nonnegative, faceTravel: bool, loop: bool, extend: bool, timing: nullable(timing) });
117
+ // `marks` (the editor's grab dots, arc fractions) is optional: it is UI state on
118
+ // the route, and older fixtures and agent-written routes carry none.
119
+ const pathFields = { points: array(vec, 64, 2), speed: nonnegative, faceTravel: bool, loop: bool, extend: bool, timing: nullable(timing) };
120
+ // A mark is an arc fraction, or { t, bank, pitch } (degrees) when it carries a lean.
121
+ const routeMark = { oneOf: [nonnegative, { type: "object", properties: { t: nonnegative, bank: num, pitch: num }, required: ["t"], additionalProperties: false }] };
122
+ const path = { ...object({ ...pathFields, marks: array(routeMark, 6) }), required: Object.keys(pathFields) };
121
123
  const physicalObject = object({ id, renderer: id, position: vec, rotationDeg: vec, scale: object({ x: positive, y: positive, z: positive }),
122
124
  footprint: object({ width: nonnegative, depth: nonnegative }), height: nonnegative, supportY: num, parentId: nullable(id), attachment: nullable(object({ characterId: id, bone: nullable(id) })), path: nullable(path), hidden: bool });
123
125
  const physicalCharacter = object({ id, incarnation: id, modelId: nullable(id), rigId: nullable(id), rigReady: bool, hidden: bool, position: vec, yawDeg: num, scale: positive,
124
126
  takeId: nullable(id), sessionMotionId: nullable(id), motionRevision: int, calibrationRevision: int, ikRevision: int,
125
127
  waypoints: array(object({ frame: int, position: vec }), 10000) });
126
- export const PHYSICS_FINGERPRINT_SCHEMA = freezeStudioData(object({ objects: array(physicalObject), characters: array(physicalCharacter), floor: object({ model: id, y: num }), frameCount: int }));
128
+ const PHYSICS_FINGERPRINT_SCHEMA = freezeStudioData(object({ objects: array(physicalObject), characters: array(physicalCharacter), floor: object({ model: id, y: num }), frameCount: int }));
127
129
  export function physicsFingerprintInput(input) {
128
130
  // Ignore explicitly nonphysical source fields. Nested physical shapes remain
129
131
  // closed and validated, including path timing and every inactive actor stamp.
@@ -14,6 +14,7 @@ import { createGroundSampler } from './ardy/ground.js';
14
14
  import { OBJECT_LIBRARY } from './scene-objects.js';
15
15
  import { objectTransformAt } from './object-path.js';
16
16
  import { sampleAt } from './sample-at.js';
17
+ import { isProxyFigure } from './scenes.js';
17
18
 
18
19
  const PROFILE = 'studio-motion-v1', BLEND = 6;
19
20
  const fail = (code, message) => { throw new StudioProtocolError(code, message); };
@@ -76,7 +77,7 @@ const copyEnvironment = env => ({ ...env, host: structuredClone(env.host), floor
76
77
  * refused rig leaves every evaluator made before it disposable. */
77
78
  function stageCast(env, characterId, cast) {
78
79
  for (const member of env.cast) {
79
- if (member.character.id === characterId || member.character.hidden) continue;
80
+ if (member.character.id === characterId || member.character.hidden || isProxyFigure(member.character)) continue;
80
81
  // Unsupported bystanders remain explicit missing coverage, never vanish
81
82
  // into characterBlockers' otherwise legitimate best-effort skip path.
82
83
  const entry = { character: structuredClone(member.character), motion: member.motion ? structuredClone(member.motion) : null, layer: copyLayer(member.ikState), evaluator: null };
@@ -1,5 +1,8 @@
1
1
  // Shared by the browser and sidecar. No Node, React, renderer or provider imports.
2
2
  import { STUDIO_ELEMENTS, isSettableElement } from "./studio-elements.js";
3
+ // Mirrors PREVIS_MODES (project.js) and POSTURES (scenes.js). Neither is imported:
4
+ // both pull in three.js, which the agent sidecar must not load. The protocol test pins the lists to their owners.
5
+ const PREVIS_MODES = ["storyboard", "animation"], POSTURES = ["stand", "sit", "lie"];
3
6
 
4
7
  export const STUDIO_PROTOCOL_VERSION = "studio-agent-v1";
5
8
  // Sized for the compact index of up to 400 entities (~100 bytes each) beside
@@ -76,6 +79,8 @@ const positiveVec3 = object({ x: positive, y: positive, z: positive });
76
79
  const range = { ...object({ startFrame: integer(), endFrameExclusive: integer(1) }), "x-studio-range": true };
77
80
  const ids = (max = 100, min = 1) => array(id, max, min, true);
78
81
  const name = text(120);
82
+ // A placement reference: an id, or the name of a part created earlier in the same ops list.
83
+ const ref = { ...text(120), description: "Object id, or the name of a part created EARLIER in this ops list (create it first)." };
79
84
  const identityFields = { workspaceId: id, documentEpoch: id, sceneId: id, sceneEpoch: id };
80
85
  const identity = object(identityFields);
81
86
  const host = object({ surface: literal("studio"), ...identityFields, workspaceHandle: nullable(id) });
@@ -84,18 +89,22 @@ const selection = nullable(object({ kind: choices(STUDIO_VARIANTS.selectionKinds
84
89
  const view = object({ mode: choices(STUDIO_VARIANTS.modes), frame: integer(), playing: bool, lookThrough: bool, grid: bool, autoColor: bool });
85
90
  const position = union(
86
91
  object({ world: vec3 }),
87
- object({ relativeTo: id, basis: choices(STUDIO_VARIANTS.positionBases), side: choices(STUDIO_VARIANTS.positionSides), gapM: number(0), support: union(literal("floor"), object({ objectId: id })) }),
88
- object({ between: ids(2, 2), fraction: number(0, 1), support: literal("floor") }),
89
- object({ onObject: id }, { offsetXZ: object({ x: number(), z: number() }) }),
92
+ object({ relativeTo: ref, basis: choices(STUDIO_VARIANTS.positionBases), side: choices(STUDIO_VARIANTS.positionSides), gapM: number(0), support: union(literal("floor"), object({ objectId: ref })) }),
93
+ object({ between: array(ref, 2, 2, true), fraction: number(0, 1), support: literal("floor") }),
94
+ object({ onObject: ref }, { offsetXZ: object({ x: number(), z: number() }) }),
90
95
  );
91
- const facing = union(object({ yawDeg: number() }), object({ towardId: id }), object({ sameAsId: id }), object({ awayFromId: id }));
96
+ const facing = union(object({ yawDeg: number() }), object({ towardId: ref }), object({ sameAsId: ref }), object({ awayFromId: ref }));
92
97
  const framing = union(
93
98
  object({ intent: object({ size: choices(STUDIO_VARIANTS.framingSizes), view: choices(STUDIO_VARIANTS.framingViews), level: choices(STUDIO_VARIANTS.framingLevels), side: choices(STUDIO_VARIANTS.framingSides) }, { focalMm: positive }) }),
94
99
  object({ exact: object({ position: vec3, lookAt: vec3, focalMm: positive }) }),
95
100
  );
101
+ const localSpace = { ...choices(["parent"]), description: "\"parent\" reads position.world, rotationDeg and facing.yawDeg in the parent's local frame (its pivot and rotation, not its scale); omit for world space." };
96
102
  const objectOp = union(
97
- object({ op: literal("create"), source: object({ kind: id }), position }, { name, facing, scale: positiveVec3 }),
98
- object({ op: literal("update"), id }, { position, facing, rotationDeg: vec3, scale: positiveVec3, color: text(32), name, hidden: bool }),
103
+ object({ op: literal("create"), source: object({ kind: id }), position }, { name, facing, scale: positiveVec3,
104
+ rotationDeg: { ...vec3, description: "Euler XYZ degrees (x pitch, y yaw, z roll), as in update. Exclusive with facing. A pitched/rolled part rests its lowest corner on its support." },
105
+ parent: { ...name, description: "Group this new object under a parent: an existing object id, or the name of an object created earlier in this same ops list. Moving the parent then moves it too." },
106
+ space: localSpace }),
107
+ object({ op: literal("update"), id }, { position, facing, rotationDeg: vec3, scale: positiveVec3, color: text(32), name, hidden: bool, space: localSpace }),
99
108
  object({ op: literal("remove"), id }),
100
109
  object({ op: literal("group"), parentId: id, childIds: ids() }),
101
110
  object({ op: literal("ungroup"), childIds: ids() }),
@@ -105,7 +114,8 @@ const characterOp = union(
105
114
  object({ op: literal("update"), characterId: id }, { position, facing, scale: positive, name, hidden: bool }),
106
115
  object({ op: literal("remove"), characterId: id }),
107
116
  );
108
- const generateSource = object({ kind: literal("generate"), beats: array(object({ text: text(2000) }, { seconds: number(0.5, 60) }), 8, 1) }, { durationSeconds: number(2, 60), seed: integer(-2147483648, 2147483647) });
117
+ export const BEAT_TEXT_MAX = 500;
118
+ const generateSource = object({ kind: literal("generate"), beats: array(object({ text: { ...text(BEAT_TEXT_MAX), description: 'An English description of the body movement starting with "A person". The editor refuses prompts over 500 characters.' } }, { seconds: number(0.5, 60) }), 8, 1) }, { durationSeconds: number(2, 60), seed: integer(-2147483648, 2147483647) });
109
119
  const source = union(generateSource, object({ kind: literal("reuse"), artifactId: id }));
110
120
 
111
121
  /* ----------------------------------------------- element patches ----
@@ -118,7 +128,11 @@ const source = union(generateSource, object({ kind: literal("reuse"), artifactId
118
128
  const dataImage = { ...text(2 * 1024 * 1024), pattern: "^data:image/[A-Za-z0-9.+-]+[;,]" };
119
129
  const promptBlock = object({ startFrame: integer(), endFrame: integer(1), text: text(2000) }, { id });
120
130
  const cameraKey = object({ frame: integer(), framing: object({ pos: vec3, yaw: number(), pitch: number(), fovDeg: number(1, 179) }) }, { id });
121
- const objectRoute = nullable(object({ points: array(vec3, 64, 2) }, { speed: number(0, 50), faceTravel: bool, loop: bool, extend: bool }));
131
+ // A mark is an arc fraction, or { t, bank, pitch } when the object should lean
132
+ // there (degrees, +-90). The route normaliser (src/object-path.js) owns the
133
+ // rules; this only carries the shape through untouched.
134
+ const routeMark = union(number(0, 1), object({ t: number(0, 1) }, { bank: number(-90, 90), pitch: number(-90, 90) }));
135
+ const objectRoute = nullable(object({ points: array(vec3, 64, 2) }, { speed: number(0, 50), faceTravel: bool, loop: bool, extend: bool, marks: array(routeMark, 6) }));
122
136
  const PATCH_VALUE_SCHEMAS = {
123
137
  "character.pose": nullable(id), "character.identityImage": nullable(dataImage), "character.promptBlocks": array(promptBlock, 64),
124
138
  "object.parent": nullable(id), "object.path": objectRoute, "stage.environmentImage": nullable(dataImage),
@@ -186,12 +200,12 @@ const patchOp = union(
186
200
  const toolSchemas = {
187
201
  inspect_studio: object({ scope: choices(STUDIO_VARIANTS.inspectionScopes) }, { ids: ids(32), select: ids(32), query: name, cursor: text(512), limit: { ...integer(1, 32), default: 12 } }),
188
202
  operate_studio: object({}, { selection, shotId: id, frame: integer(), playing: bool, mode: choices(STUDIO_VARIANTS.modes), view: object({}, { lookThrough: bool, grid: bool, autoColor: bool }) }),
189
- arrange_objects: object({ ops: array(objectOp, 100, 1) }, { collisionPolicy: { ...choices(STUDIO_VARIANTS.collisionPolicies), default: "report" } }),
203
+ arrange_objects: object({ ops: { ...array(objectOp, 100, 1), description: "Applied in order, as one undo step. Each op sees the parts created by earlier ops, so build an assembly in one call: create the main part, then place the rest with position.onObject / relativeTo(+side,gapM) / between naming earlier parts (e.g. relativeTo \"Hood\"). Reference parts created in this call by name, never by id." } }, { collisionPolicy: { ...choices(STUDIO_VARIANTS.collisionPolicies), default: "report" } }),
190
204
  arrange_characters: object({ ops: array(characterOp, 8, 1) }),
191
205
  patch_elements: object({ ops: array(patchOp, 32, 1) }),
192
- frame_shot: object({ subjectIds: ids(1), framing }, { shotId: id, keyAtFrame: integer() }),
206
+ frame_shot: object({ subjectIds: ids(1), framing }, { shotId: id, caption: { type: "string", maxLength: 500 }, keyAtFrame: integer() }),
193
207
  generate_motion: object({ characterId: id, source }, { repair: { ...choices(["bounded", "none"]), default: "bounded" } }),
194
- verify_result: object({ checks: array(choices(["placement", "framing", "motion"]), 3, 1, true) }, { receiptId: id, targets: ids(), range: union(literal("whole_clip"), range), visual: { ...choices(["none", "frame", "contact_sheet"]), default: "none" } }),
208
+ verify_result: object({ checks: array(choices(["placement", "framing", "motion"]), 3, 1, true) }, { receiptId: id, targets: ids(), range: union(literal("whole_clip"), range), visual: { ...choices(["none", "frame", "contact_sheet", "plan"]), default: "none" } }),
195
209
  undo_edit: object({ receiptId: id }),
196
210
  run_action: object({ action: id }, { args: openObject, confirmationToken: id }),
197
211
  };
@@ -203,9 +217,13 @@ export const STUDIO_CATALOGUE = freezeStudioData(STUDIO_TOOLS.map(name => ({ nam
203
217
  const bounds = object({ min: vec3, max: vec3 });
204
218
  const entity = object({ id, kind: choices(["object", "character", "rig"]), token: id }, {
205
219
  name, detailsOmitted: bool, position: vec3, yawDeg: number(), rotationDeg: vec3, scale: union(positive, positiveVec3), bounds: nullable(bounds),
206
- libraryKind: id, renderer: id, color: nullable(text(32)), tint: nullable(text(32)), modelId: nullable(text(120)), assetId: id, parentId: nullable(id), attachment: nullable(object({ characterId: id, bone: nullable(id) })), pathPointCount: integer(0, 64),
220
+ libraryKind: id, renderer: id, color: nullable(text(32)), tint: nullable(text(32)), modelId: nullable(text(120)), assetId: id, parentId: nullable(id), attachment: nullable(object({ characterId: id, bone: nullable(id) })), pathPointCount: integer(0, 64), pathMarks: array(union(number(0, 1), object({ t: number(0, 1) }, { bank: number(-90, 90), pitch: number(-90, 90) })), 6),
221
+ local: object({ position: vec3, rotationDeg: vec3 }),
207
222
  motion: object({ takeId: nullable(id), frames: integer(), ikKeyCount: integer(), promptBlockCount: integer() }, { poseId: nullable(id), keyIds: ids(8, 0) }),
208
223
  capabilities: object({ rigReady: bool, ik: bool, measuredFeet: bool }),
224
+ // `kind` above already names the entity class ("character"); a character's own
225
+ // kind is `characterKind`: a rigged model, or a rig-free capsule figure.
226
+ characterKind: choices(["rig", "proxy"]), posture: choices(POSTURES),
209
227
  });
210
228
  // One compact row per entity, so the model sees the whole scene even when
211
229
  // only 24 rows carry full detail.
@@ -214,8 +232,8 @@ const indexRow = object({ id, kind: choices(["object", "character", "rig"]) }, {
214
232
  // Its schema is on request (inspect_studio scope "actions" with ids); the
215
233
  // declared generation and hub timeout ride along for the sidecar's gate.
216
234
  const actionIndexRow = object({ id }, { label: name, generation: literal("motion"), timeoutMs: integer(1, 300_000) });
217
- const shotSummary = object({ id, name, range, keyCount: integer() }, { subjectIds: ids(24, 0) });
218
- const currentShot = object({ id, name, range, mode: choices(STUDIO_VARIANTS.shotModes) }, { subjectIds: ids(24, 0) });
235
+ const shotSummary = object({ id, name, range, keyCount: integer() }, { subjectIds: ids(24, 0), kind: choices(["clip", "still"]), caption: text(500) });
236
+ const currentShot = object({ id, name, range, mode: choices(STUDIO_VARIANTS.shotModes) }, { subjectIds: ids(24, 0), kind: choices(["clip", "still"]), caption: text(500) });
219
237
  const camera = object({ position: vec3, lookAt: vec3, focalMm: positive, sensorId: id, slate: name });
220
238
  // What the agent can place: a catalogue kind, or an asset imported into the scene.
221
239
  const assetTypes = choices(["primitive", "set-piece", "image", "mesh"]);
@@ -224,12 +242,12 @@ const jobSummary = object({ id, characterId: id, state: choices(STUDIO_VARIANTS.
224
242
  const contextSchema = object({
225
243
  schema: literal("studio-context-v1"), host, revision,
226
244
  units: object({ distance: literal("m"), angle: literal("deg"), up: literal("+Y"), yawZero: literal("+Z"), yawPositiveToward: literal("+X"), pivot: literal("base"), fps: literal(24), rangeEnd: literal("exclusive") }),
227
- scene: object({ name, aspect: text(40), floorY: number(), frameCount: integer(), objectCount: integer(), characterCount: integer() }),
245
+ scene: object({ name, aspect: text(40), floorY: number(), frameCount: integer(), objectCount: integer(), characterCount: integer() }, { previsMode: choices(PREVIS_MODES) }),
228
246
  selection, activeCharacterId: nullable(id), view, shot: nullable(currentShot), camera: nullable(camera),
229
247
  entities: array(entity, 24), entityPage: object({ returned: integer(0, 24), total: integer(), truncated: bool, nextCursor: nullable(text(512)) }),
230
248
  shots: array(shotSummary, 8), shotsTruncated: bool, assets: array(assetSummary, STUDIO_CONTEXT_LIMITS.assets),
231
249
  recentReceipts: array(object({ id, summary: name, canUndoDirect: bool }), 3), jobs: array(jobSummary, 8),
232
- capabilities: object({ profile: literal("studio-slice-1"), tools: array(choices(STUDIO_TOOLS), STUDIO_TOOLS.length, 0, true) }, { rigReady: bool, cameraReady: bool, bridgeReady: bool }),
250
+ capabilities: object({ profile: literal("studio-slice-1"), tools: array(choices(STUDIO_TOOLS), STUDIO_TOOLS.length, 0, true) }, { rigReady: bool, cameraReady: bool, bridgeReady: bool, storyboard: bool }),
233
251
  }, { entityIndex: array(indexRow, STUDIO_CONTEXT_LIMITS.entityIndex), actionIndex: array(actionIndexRow, 512) });
234
252
  const guardSchema = object({ ...identityFields, targetId: id, token: id });
235
253
  const efforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
@@ -238,7 +256,7 @@ const efforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ul
238
256
  // and the three inline types are the ones the model actually reads. Four, and
239
257
  // six megabytes of base64 each, is what one turn may carry.
240
258
  const attachment = object({ dataUrl: { ...text(6_000_000), pattern: "^data:image/(png|jpeg|webp);base64," } }, { name: text(120) });
241
- const turnSchema = object({ surface: literal("studio"), sessionId: uuidSchema, turnId: uuidSchema, text: text(16000), context: contextSchema }, { model: text(120), effort: choices(efforts), attachFrame: bool, attachments: array(attachment, 4, 1) });
259
+ const turnSchema = object({ surface: literal("studio"), sessionId: uuidSchema, turnId: uuidSchema, text: text(16000), context: contextSchema }, { model: text(120), effort: choices(efforts), attachFrame: bool, attachments: array(attachment, 4, 1), supervisor: bool });
242
260
  const stopSchema = object({ surface: literal("studio"), sessionId: uuidSchema, turnId: uuidSchema }, { jobId: id });
243
261
  const revisions = object({ before: integer(), after: integer() });
244
262
  const undo = object({ historyEntryId: id, entries: literal(1), canUndoDirect: bool });
@@ -254,10 +272,21 @@ const verification = object({ id, status: choices(["verified", "unverified"]), p
254
272
  // not a missing member.
255
273
  const patchedValue = object({ path: text(120) }, { number: number(), text: nullable(text(512)), flag: bool, vec: vec3, count: integer(), bytes: integer() });
256
274
  const readback = object({}, { position: vec3, yawDeg: number(), rotationDeg: vec3, scale: union(positive, positiveVec3), name, color: text(32), hidden: bool, modelId: id, renderer: id,
257
- parentId: nullable(id), childIds: ids(100, 0), removed: bool, range, camera, keyId: id, frame: integer(), subjectIds: ids(24, 0), selection, activeCharacterId: nullable(id), shotId: nullable(id), view, token: id, takeId: nullable(id), statureM: positive,
275
+ parentId: nullable(id), childIds: ids(100, 0), childCount: integer(0), bounds: nullable(bounds), removed: bool, range, camera, keyId: id, frame: integer(), subjectIds: ids(24, 0), selection, activeCharacterId: nullable(id), shotId: nullable(id), view, token: id, takeId: nullable(id), statureM: positive,
258
276
  patched: array(patchedValue, 32, 1) });
277
+ const CAMERA_SIDES = ["left", "right", "on-axis"];
259
278
  const checks = object({ coverage: name }, { relationSatisfied: bool, overlapIds: ids(100, 0), actualGapM: number(), requestedGapM: number(0), maximumFootprintOverlapM: number(0),
260
- basis: choices(STUDIO_VARIANTS.positionBases), clipped: bool, occluded: bool, behindCamera: bool, screenFraction: number(0), derivedSize: choices(STUDIO_VARIANTS.framingSizes), support: name, baseY: number(), facesTargetId: id });
279
+ basis: choices(STUDIO_VARIANTS.positionBases), clipped: bool, occluded: bool, behindCamera: bool, screenFraction: number(0), derivedSize: choices(STUDIO_VARIANTS.framingSizes), support: name, baseY: number(), facesTargetId: id,
280
+ cameraSide: choices(CAMERA_SIDES), axisConsistent: bool });
281
+ // Code-computed shot geometry verify_result carries beside a framing check
282
+ // (src/studio-geometry-facts.js): bounded to 8 shots and 24 subjects.
283
+ const geometryFacts = object({
284
+ subjects: array(object({ id, position: vec3, yawDeg: number() }), 24),
285
+ pairs: array(object({ a: id, b: id, distanceM: number(0) }), 24),
286
+ shots: array(object({ shotId: id, subjectIds: ids(2, 0), camera: nullable(object({ position: vec3, lookAt: vec3 })), cameraSide: nullable(choices(CAMERA_SIDES)),
287
+ occluders: array(object({ subjectId: id, byObjectId: id }), 8) }), 8),
288
+ axisConsistent: nullable(bool),
289
+ });
261
290
  const warning = object({ code: id }, { id, message: name, suggestedOutwardDeltaM: number(), count: integer() });
262
291
  const receiptBase = { ok: literal(true), commandId: id, receiptId: id, host: identity, status: choices(STUDIO_VARIANTS.receiptStatuses), authored: bool, revision: revisions, affectedIds: ids(100, 0),
263
292
  delta: array(object({ id, after: readback }), 8), checks, undo: nullable(undo), warnings: array(warning, 12) };
@@ -293,7 +322,7 @@ const failureSchema = object({ ok: literal(false), commandId: id, host: identity
293
322
  // x-studio-range is the sole relational schema annotation: end > start.
294
323
  export const StudioSchemas = freezeStudioData({ catalogue: STUDIO_CATALOGUE, variants: STUDIO_VARIANTS, Vec3: vec3, FrameRange: range, PositionSpec: position, FacingSpec: facing,
295
324
  ObjectOp: objectOp, CharacterOp: characterOp, Framing: framing, GenerateSource: generateSource, Source: source, Identity: identity, Host: host, TargetGuard: guardSchema,
296
- Entity: entity, ShotSummary: shotSummary, StudioContextV1: contextSchema, StudioTurn: turnSchema, StudioStop: stopSchema, Receipt: receiptSchema, ReceiptVariants: receiptVariants, Failure: failureSchema });
325
+ Entity: entity, ShotSummary: shotSummary, StudioContextV1: contextSchema, StudioTurn: turnSchema, StudioStop: stopSchema, Receipt: receiptSchema, ReceiptVariants: receiptVariants, Failure: failureSchema, GeometryFacts: geometryFacts });
297
326
 
298
327
  /** Validate the closed JSON Schema subset used above; return a detached value
299
328
  * with declared defaults. Errors contain schema paths, never payload values. */
@@ -341,7 +370,7 @@ export function validateStudioSchema(schema, value, code = "INVALID_ARGUMENT", p
341
370
  return result;
342
371
  }
343
372
  if (schema.type === "string") {
344
- if (typeof value !== "string" || !value.trim() || (schema.maxLength !== undefined && [...value].length > schema.maxLength) || (schema.pattern && !new RegExp(schema.pattern).test(value))) fail(code, "Invalid or oversized string.", path);
373
+ if (typeof value !== "string" || !value.trim() || (schema.maxLength !== undefined && [...value].length > schema.maxLength) || (schema.pattern && !new RegExp(schema.pattern).test(value))) fail(code, typeof value === "string" && schema.description && [...value].length > schema.maxLength ? `Over ${schema.maxLength} characters. ${schema.description}` : "Invalid or oversized string.", path);
345
374
  if (schema.enum && !schema.enum.includes(value)) fail(code, "Unsupported enum value.", path);
346
375
  return value;
347
376
  }
@@ -463,10 +492,14 @@ export function validateStudioCommand(command) {
463
492
  if (new Set(names).size !== names.length) fail("DUPLICATE_NAME", "Create names must be unique; resolve existing targets by ID.");
464
493
  for (const op of args.ops) {
465
494
  if (op.op === "update" && Object.keys(op).length === 2) fail("INVALID_ARGUMENT", "Update has no fields.");
466
- if (op.facing && op.rotationDeg) fail("INVALID_ARGUMENT", "Facing and exact rotation are exclusive.");
495
+ if (op.facing && op.rotationDeg) fail("INVALID_ARGUMENT", "Facing and rotationDeg are exclusive: facing sets yaw only (toward/away/same-as/yawDeg); rotationDeg {x,y,z} sets the full rotation, with y as the yaw.");
467
496
  if (op.op === "group" && op.childIds.includes(op.parentId)) fail("INVALID_ARGUMENT", "Cannot group an object under itself.");
468
- if (args.collisionPolicy === "avoid" && (!["create", "update"].includes(op.op) || !op.position?.relativeTo)) fail("INVALID_ARGUMENT", "Avoid requires a side-relative position for every operation.");
497
+ if (op.op === "create" && op.parent !== undefined && op.name !== undefined && op.parent.normalize("NFC").trim() === op.name.normalize("NFC").trim()) fail("INVALID_ARGUMENT", "Cannot group an object under itself.");
469
498
  }
499
+ // Avoidance nudges outward from a relativeTo reference, so at least one op needs one;
500
+ // the others (world/onObject/between, remove, group) are placed as given and the
501
+ // final batch is still checked for overlap.
502
+ if (args.collisionPolicy === "avoid" && !args.ops.some(op => ["create", "update"].includes(op.op) && op.position?.relativeTo)) fail("INVALID_ARGUMENT", "collisionPolicy avoid needs at least one op with position.relativeTo (it nudges outward from that reference); other ops are placed as given.");
470
503
  }
471
504
  if (command.name === "generate_motion" && args.source.kind === "generate") {
472
505
  args.source.beats = args.source.beats.map(beat => ({ ...beat, text: beat.text.trim().replace(/\s+/g, " ") }));