cozyclay 2.0.2 → 2.1.1

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 (262) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +211 -36
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +4 -4
  19. package/bin/live/cli.mjs +23 -8
  20. package/bin/live/client.mjs +1 -1
  21. package/bin/live/studio.mjs +5 -2
  22. package/bin/mcp-runtime.mjs +2 -2
  23. package/bin/telemetry-state.mjs +3 -3
  24. package/dist/app/index.html +6 -6
  25. package/dist/assets/analytics-C9pMpW7s.js +1 -0
  26. package/dist/assets/app-B_2XB9rw.css +1 -0
  27. package/dist/assets/app-DAuvKPP8.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-DKkpjKBV.js +19 -0
  32. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  33. package/dist/assets/{ticket-BcmsnHn5.js → ticket-BPk2Ni95.js} +1 -1
  34. package/dist/assets/workflow-jJRBR94Z.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/llms.txt +14 -0
  38. package/dist/sitemap.xml +1 -1
  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 +813 -278
  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 +184 -6
  72. package/src/ardy/timeline.jsx +121 -36
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +0 -3
  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 +6 -7
  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 +15 -2
  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 +79 -28
  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-store.js +4 -4
  127. package/src/motion-trail.js +5 -11
  128. package/src/multimodel-ingest.js +1 -3
  129. package/src/object-catalog.jsx +7 -5
  130. package/src/object-gizmo.jsx +45 -31
  131. package/src/object-path.js +310 -67
  132. package/src/object-travel.js +224 -0
  133. package/src/otio.js +8 -3
  134. package/src/panels/CharacterTransformPanel.jsx +24 -6
  135. package/src/panels/ObjectTransformPanel.jsx +4 -3
  136. package/src/panels/PoseImagePanel.jsx +205 -0
  137. package/src/panels/PromptBlocksPanel.jsx +3 -1
  138. package/src/panels/ProxyFigurePanel.jsx +56 -0
  139. package/src/panels/ReferenceImageField.jsx +1 -1
  140. package/src/panels/RigControlPanel.jsx +24 -3
  141. package/src/panels/RigPanel.jsx +2 -1
  142. package/src/panels/SculptPanel.jsx +405 -0
  143. package/src/panels/SubjectBox.jsx +1 -1
  144. package/src/panels/SubjectsPanel.jsx +0 -2
  145. package/src/panels/VideoCapturePanel.jsx +2 -1
  146. package/src/panels/pose-image.css +42 -0
  147. package/src/panels/pose.css +3 -0
  148. package/src/panels/sculpt.css +162 -0
  149. package/src/plan-diagram.js +184 -0
  150. package/src/planview.jsx +127 -80
  151. package/src/playground.js +3 -5
  152. package/src/pose-fade.js +77 -0
  153. package/src/pose-gizmo.css +23 -0
  154. package/src/pose-gizmo.js +96 -0
  155. package/src/posestudio.jsx +134 -83
  156. package/src/project-browser.css +53 -0
  157. package/src/project-browser.jsx +49 -4
  158. package/src/project.js +55 -24
  159. package/src/props.jsx +177 -36
  160. package/src/proxy-figure.jsx +69 -0
  161. package/src/range-pin-panel.jsx +0 -1
  162. package/src/receipt-findings.js +20 -0
  163. package/src/render-passes.js +0 -1
  164. package/src/resource-status.jsx +1 -1
  165. package/src/root-path.js +48 -0
  166. package/src/route-owner.js +52 -0
  167. package/src/scene-asset-cache.js +1 -2
  168. package/src/scene-assets.js +12 -4
  169. package/src/scene-fbx.js +1 -1
  170. package/src/scene-mesh-cache.js +0 -1
  171. package/src/scene-objects.js +319 -19
  172. package/src/scenes.js +58 -9
  173. package/src/sculpt-blueprint.js +65 -0
  174. package/src/sculpt-compare.js +384 -0
  175. package/src/sculpt-geometry.js +141 -0
  176. package/src/sculpt-mode.js +168 -0
  177. package/src/sculpt-recipe.js +385 -0
  178. package/src/sculpt-run.js +351 -0
  179. package/src/sculpt-stage.jsx +221 -0
  180. package/src/settings-menu.jsx +1 -1
  181. package/src/shell/BoardSlot.jsx +341 -0
  182. package/src/shell/BottomDock.jsx +60 -16
  183. package/src/shell/CompositionGuideButton.jsx +20 -0
  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/ShotPreviewHeader.jsx +17 -0
  188. package/src/shell/TopBar.jsx +18 -3
  189. package/src/shell/ViewportToolbar.jsx +36 -31
  190. package/src/shell/dock.css +239 -0
  191. package/src/shell/glass-regions.css +3 -2
  192. package/src/shell/log-store.js +4 -4
  193. package/src/shell/mode.css +20 -0
  194. package/src/shell/studio-shell-context.js +4 -1
  195. package/src/shell/viewport.css +12 -0
  196. package/src/shot-authoring.js +69 -6
  197. package/src/stable-items.js +1 -1
  198. package/src/stage-attach-frames.js +169 -0
  199. package/src/stage-canvas-rigs.jsx +402 -0
  200. package/src/stage-capture.jsx +216 -0
  201. package/src/stage-characters.jsx +302 -0
  202. package/src/stage-editor-gizmos.jsx +1229 -0
  203. package/src/stage-labels-ko.js +204 -0
  204. package/src/stage-startup-defaults.js +182 -0
  205. package/src/starter-scenes.js +1 -1
  206. package/src/storyboard.js +24 -5
  207. package/src/studio-actions.js +42 -9
  208. package/src/studio-agent-commands.js +213 -23
  209. package/src/studio-agent-context.js +11 -9
  210. package/src/studio-agent-motion.js +2 -1
  211. package/src/studio-agent-protocol.js +60 -24
  212. package/src/studio-app-binding.js +56 -23
  213. package/src/studio-contact-sheet.js +3 -3
  214. package/src/studio-elements.js +11 -2
  215. package/src/studio-geometry-facts.js +114 -0
  216. package/src/styles.css +92 -9
  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 +106 -18
  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/gt-render/browser.mjs +23 -0
  248. package/tools/headless-studio.mjs +85 -0
  249. package/tools/kimodo/effector-constraints.mjs +4 -4
  250. package/tools/kimodo/generate.mjs +1 -1
  251. package/tools/knip-baseline.json +331 -0
  252. package/tools/loc-baseline.json +126 -0
  253. package/tools/process-supervisor.mjs +1 -1
  254. package/tools/projflow/replay.mjs +4 -4
  255. package/tools/projflow/runner.mjs +3 -3
  256. package/tools/qa/studio-control-count.mjs +2 -0
  257. package/tools/run-tests.mjs +95 -7
  258. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  259. package/dist/assets/app-BxBLC6GT.js +0 -4876
  260. package/dist/assets/app-CJdxvnrl.css +0 -1
  261. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  262. package/dist/assets/workflow-B5XZwVfe.js +0 -179
@@ -1,13 +1,17 @@
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';
14
+ import { sampleContactSheetFrames } from './studio-contact-sheet.js';
11
15
 
12
16
  const DEG = Math.PI / 180, EPS = 1e-8, CHARACTER_SUPPORT_TOLERANCE = 5e-3;
13
17
  const fail = (code, message) => { throw new StudioProtocolError(code, message); };
@@ -20,6 +24,39 @@ const entityById = (state, id) => {
20
24
  if (!entity) fail('AMBIGUOUS_TARGET', 'Target ID is not present in the admitted scene.');
21
25
  return entity;
22
26
  };
27
+ /** Resolve a placement reference against the evolving batch draft: an id first,
28
+ * else the unique name of an object or character already in it. Parts created
29
+ * earlier in the same batch are in the draft, so they resolve by name. */
30
+ function resolveRef(ref, state, field) {
31
+ const all = [...state.objects, ...state.characters];
32
+ if (all.some(e => e.id === ref)) return ref;
33
+ const named = all.filter(e => normalizedName(e.name ?? e.subject ?? '') === normalizedName(ref));
34
+ if (named.length === 1) return named[0].id;
35
+ if (named.length) fail('AMBIGUOUS_TARGET', `${field} '${ref}' matches ${named.length} objects by name (${named.map(e => e.id).join(', ')}); reference one by id.`);
36
+ 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).`);
37
+ }
38
+ /** The op with every reference field (relativeTo, onObject, between, support,
39
+ * facing targets) resolved to an id in the current draft. */
40
+ function resolveOpRefs(op, state, selfId) {
41
+ const { position: p, facing: f } = op;
42
+ if (!p && !f) return op;
43
+ const r = (value, field) => {
44
+ const resolved = resolveRef(value, state, field);
45
+ if (resolved === selfId) fail('INVALID_ARGUMENT', `${field} '${value}' is the object being placed; reference a different part.`);
46
+ return resolved;
47
+ };
48
+ const out = { ...op };
49
+ if (p) out.position = { ...p,
50
+ ...(p.relativeTo !== undefined ? { relativeTo: r(p.relativeTo, 'relativeTo') } : {}),
51
+ ...(p.onObject !== undefined ? { onObject: r(p.onObject, 'onObject') } : {}),
52
+ ...(p.between ? { between: p.between.map(value => r(value, 'between')) } : {}),
53
+ ...(p.support && typeof p.support === 'object' ? { support: { objectId: r(p.support.objectId, 'support.objectId') } } : {}) };
54
+ if (f) {
55
+ const key = ['towardId', 'awayFromId', 'sameAsId'].find(k => f[k] !== undefined);
56
+ if (key) out.facing = { [key]: r(f[key], `facing.${key}`) };
57
+ }
58
+ return out;
59
+ }
23
60
  function boxPoints(bounds) {
24
61
  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
62
  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 +92,7 @@ function support(spec, state) {
55
92
  const id = spec.onObject ?? (typeof spec.support === 'object' ? spec.support.objectId : null);
56
93
  if (!id) return { y: state.floorY, label: 'floor' };
57
94
  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.');
95
+ 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
96
  return { y: object.y + supportHeightForObject(object) * object.scaleY, label: `object:${id}`, object };
60
97
  }
61
98
  function facingYaw(spec, entity, state) {
@@ -106,6 +143,9 @@ function place(entity, op, state, ports) {
106
143
  if (Math.hypot(result.x - previous.x, result.z - previous.z, result.rot - previous.rot) < EPS) { converged = true; break; }
107
144
  }
108
145
  if (!converged) fail('AMBIGUOUS_BASIS', 'Facing and placement cannot satisfy the requested relation.');
146
+ // A pitched/rolled part rests its lowest corner on the support.
147
+ if (result.renderer && !result.attach && !result.path && (Math.abs(result.rotX) > EPS || Math.abs(result.rotZ) > EPS))
148
+ result = patchEntity(result, { y: result.y + surface.y - Math.min(...geometry(result, state, ports).map(p => p.y)) });
109
149
  const points = geometry(result, state, ports);
110
150
  const supportTolerance = !result.renderer && surface.label === 'floor' ? CHARACTER_SUPPORT_TOLERANCE : EPS;
111
151
  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 +156,90 @@ function place(entity, op, state, ports) {
116
156
  }
117
157
  return { entity: result, relation: { id: result.id, spec, axis, support: surface.label, baseY: surface.y, ...(axis ? { actualGapM, requestedGapM: spec.gapM, basis: spec.basis } : {}) } };
118
158
  }
159
+ // Parent-relative (local) space. Object rows are flat world-space; `parent` only
160
+ // groups. A parent's frame is its stored pivot (base, y = bottom) and Euler XYZ
161
+ // degrees; parent SCALE is deliberately not part of the frame.
162
+ const objectQuat = o => new Quaternion().setFromEuler(new Euler((o.rotX ?? 0) * DEG, (o.rot ?? 0) * DEG, (o.rotZ ?? 0) * DEG, 'XYZ'));
163
+ const wrapDeg = a => ((a + 540) % 360 + 360) % 360 - 180;
164
+ // XYZ Euler angles come in two equal-orientation spellings, (x,y,z) and
165
+ // (x+180, 180-y, z+180). Report the one with the least pitch/roll, so a pure
166
+ // yaw of 135 reads back as yaw 135 rather than (180, 45, 180).
167
+ function eulerDegOf(q) {
168
+ const e = new Euler().setFromQuaternion(q, 'XYZ'), a = { x: e.x / DEG, y: e.y / DEG, z: e.z / DEG };
169
+ const b = { x: wrapDeg(a.x + 180), y: wrapDeg(180 - a.y), z: wrapDeg(a.z + 180) };
170
+ return Math.abs(b.x) + Math.abs(b.z) < Math.abs(a.x) + Math.abs(a.z) - 1e-9 ? b : a;
171
+ }
172
+ const eulerQuat = r => new Quaternion().setFromEuler(new Euler(r.x * DEG, r.y * DEG, r.z * DEG, 'XYZ'));
173
+ /** world = parentPivot + R_parent * local; orientation = R_parent * R_local. */
174
+ export function localToWorld(parent, local) {
175
+ const p = vector(local.position ?? { x: 0, y: 0, z: 0 }).applyQuaternion(objectQuat(parent)).add(vector(pos(parent)));
176
+ const q = objectQuat(parent).multiply(eulerQuat(local.rotationDeg ?? { x: 0, y: 0, z: 0 }));
177
+ return { position: { x: p.x, y: p.y, z: p.z }, rotationDeg: eulerDegOf(q) };
178
+ }
179
+ /** Inverse of localToWorld for an object row under its parent row. */
180
+ export function worldToLocal(parent, object) {
181
+ const inverse = objectQuat(parent).invert();
182
+ const p = vector(pos(object)).sub(vector(pos(parent))).applyQuaternion(inverse);
183
+ return { position: { x: p.x, y: p.y, z: p.z }, rotationDeg: eulerDegOf(inverse.multiply(objectQuat(object))) };
184
+ }
185
+ const tidy = v => Math.round(v * 1e4) / 1e4 + 0;
186
+ /** Compact readback: the object's transform in its parent's frame, or null for
187
+ * a root, a missing parent or a parent whose stored pose is not its world pose. */
188
+ export function localTransformReadback(object, objects) {
189
+ const parent = object.parent ? objects.find(o => o.id === object.parent) : null;
190
+ if (!parent || parent.attach || parent.path || object.attach || object.path) return null;
191
+ const { position, rotationDeg } = worldToLocal(parent, object);
192
+ const round = v => ({ x: tidy(v.x), y: tidy(v.y), z: tidy(v.z) });
193
+ return { position: round(position), rotationDeg: round(rotationDeg) };
194
+ }
195
+ const resolveParentRef = (ref, rows, createdByName) => rows.some(e => e.id === ref) ? ref : createdByName.get(normalizedName(ref));
196
+ /** Rewrites an op given in the parent's local frame into the equivalent world op. */
197
+ function parentFrameOp(op, entity, rows, createdByName) {
198
+ const create = op.op === 'create';
199
+ 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.');
200
+ 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.');
201
+ const parentId = create ? resolveParentRef(op.parent, rows, createdByName) : entity.parent;
202
+ const parent = rows.find(e => e.id === parentId);
203
+ if (!parent) fail('AMBIGUOUS_TARGET', 'space "parent": the parent is neither an existing object id nor an object created earlier in this batch.');
204
+ if (parent.attach || parent.path) fail('CAPABILITY_MISSING', 'space "parent": an attached or routed parent has no fixed pose to place against.');
205
+ if (!op.position && !op.rotationDeg && !op.facing) fail('INVALID_ARGUMENT', 'space "parent" applies to position, rotationDeg or facing; none was given.');
206
+ 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.');
207
+ 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.');
208
+ const current = create ? { position: { x: 0, y: 0, z: 0 }, rotationDeg: { x: 0, y: 0, z: 0 } } : worldToLocal(parent, entity);
209
+ // Local rotation: rotationDeg, else the current local one with its yaw set by
210
+ // facing; a new child with neither takes the parent's orientation (local zero).
211
+ const rotation = op.rotationDeg ?? (op.facing ? { ...current.rotationDeg, y: op.facing.yawDeg } : create ? current.rotationDeg : null);
212
+ const world = localToWorld(parent, { position: op.position ? op.position.world : current.position, rotationDeg: rotation ?? current.rotationDeg });
213
+ const { space, facing, ...rest } = op;
214
+ return { ...rest, ...(op.position ? { position: { world: world.position } } : {}), ...(rotation ? { rotationDeg: world.rotationDeg } : {}) };
215
+ }
216
+ function groupRootOf(id, objects) {
217
+ const seen = new Set();
218
+ let current = objects.find(o => o.id === id);
219
+ while (current?.parent && !seen.has(current.id)) {
220
+ seen.add(current.id);
221
+ current = objects.find(o => o.id === current.parent) ?? current;
222
+ }
223
+ return current?.id ?? id;
224
+ }
119
225
  function overlapsFor(entity, state, ports) {
120
226
  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);
227
+ // Parts of one grouped object are built to touch; only other bodies count.
228
+ const root = groupRootOf(entity.id, state.objects);
229
+ return [...state.objects, ...state.characters].filter(e => e.id !== entity.id && !isEffectivelyHidden(e, state.objects, state.characters)
230
+ && 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
231
  }
123
232
  function overlapsOf(ids, state, ports) {
124
233
  const entities = [...state.objects, ...state.characters];
125
234
  return ids.flatMap(id => { const e = entities.find(row => row.id === id); return e ? overlapsFor(e, state, ports) : []; });
126
235
  }
236
+ /** World AABB of one entity as {min,max}, or null when its evaluated bounds are unavailable (a prop whose renderer is not mounted). */
237
+ export function entityWorldBounds(entity, state, ports) {
238
+ try {
239
+ const box = aabb(geometry(entity, state, ports)), round = v => Number(v.toFixed(4));
240
+ 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)])) };
241
+ } catch { return null; }
242
+ }
127
243
  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
244
  /** Placement evidence for entity ids in the given scene: the same-frame AABB
129
245
  * overlap an arrangement receipt reports for the entities it moved. */
@@ -152,32 +268,49 @@ function transformPatch(op, isObject) {
152
268
  export function arrangement(command, before, ports) {
153
269
  const isObject = command.name === 'arrange_objects', key = isObject ? 'objects' : 'characters';
154
270
  let rows = before[key];
155
- const relations = [], warnings = [];
156
- for (const op of command.args.ops) {
157
- const id = op.id ?? op.characterId;
271
+ const relations = [], warnings = [], clamps = [], createdByName = new Map();
272
+ // References resolve against the evolving draft (`rows`), in op order.
273
+ const draft = () => ({ ...before, [key]: rows });
274
+ for (const rawOp of command.args.ops) {
275
+ const id = rawOp.id ?? rawOp.characterId;
158
276
  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.');
277
+ 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.`);
278
+ let op = resolveOpRefs(rawOp, draft(), id);
160
279
  if (op.op === 'create') {
161
280
  if (op.name && rows.some(e => normalizedName(e.name ?? e.subject) === normalizedName(op.name))) fail('DUPLICATE_NAME', 'Create name already exists in the domain.');
281
+ let parentId;
282
+ if (isObject && op.parent !== undefined) {
283
+ parentId = rows.some(e => e.id === op.parent) ? op.parent : createdByName.get(normalizedName(op.parent));
284
+ 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.`);
285
+ }
162
286
  entity = isObject ? createSceneObject(op.source.kind, rows) : createCharacterEntry({ id: createStableItemId('character'), subject: normalizedName(op.name) });
163
287
  if (!entity) fail('INVALID_ARGUMENT', 'Unsupported object library kind.');
164
288
  rows = [...rows, entity];
289
+ // Parent before placing, so avoidance and overlap checks see one body.
290
+ if (parentId) { rows = setSceneObjectParent(rows, entity.id, parentId); entity = rows.find(e => e.id === entity.id); }
165
291
  }
166
292
  if (['create', 'update'].includes(op.op)) {
167
293
  if (entity.attach) fail('CAPABILITY_MISSING', 'Attached transforms require a world-preserving attachment adapter.');
294
+ if (op.space === 'parent') op = parentFrameOp(op, entity, rows, createdByName);
168
295
  entity = patchEntity(entity, transformPatch(op, isObject));
169
- const placed = place(entity, op, before, ports);
296
+ const placed = place(entity, op, draft(), ports);
170
297
  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;
298
+ // What was asked against what the domain kept: scale and position limits move values silently.
299
+ const asked = isObject ? { ...(op.position?.world ?? {}), ...(op.scale ? { scaleX: op.scale.x, scaleY: op.scale.y, scaleZ: op.scale.z } : {}) }
300
+ : { ...(op.position?.world ?? {}), ...(op.scale === undefined ? {} : { scale: op.scale }) };
301
+ const clamp = clampWarning(entity.id, Object.fromEntries(Object.entries(asked).filter(([k]) => ['x', 'y', 'z', 'scale', 'scaleX', 'scaleY', 'scaleZ'].includes(k))), entity);
302
+ if (clamp) clamps.push(clamp);
303
+ if (command.args.collisionPolicy === 'avoid' && placed.relation?.axis) {
304
+ entity = avoid(entity, placed.relation, draft(), ports);
305
+ entity = patchEntity(entity, { rot: facingYaw(op.facing, entity, draft()) });
306
+ const reference = entityById(draft(), op.position.relativeTo);
307
+ placed.relation.actualGapM = interval(geometry(entity, draft(), ports), placed.relation.axis).min - interval(geometry(reference, draft(), ports), placed.relation.axis).max;
176
308
  if (placed.relation.actualGapM < op.position.gapM - EPS) fail('VERIFICATION_FAILED', 'Avoidance cannot preserve the requested facing and minimum clearance.');
177
309
  }
178
310
  const patch = Object.fromEntries(Object.entries(entity).filter(([k, v]) => !equal(v, rows.find(e => e.id === entity.id)[k])));
179
311
  rows = isObject ? updateSceneObject(rows, entity.id, patch) : rows.map(e => e.id === entity.id ? entity : e);
180
312
  if (placed.relation) relations.push(placed.relation);
313
+ if (op.op === 'create' && op.name) createdByName.set(normalizedName(op.name), entity.id);
181
314
  } else if (op.op === 'remove') {
182
315
  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
316
  rows = isObject ? removeSceneObject(rows, id) : rows.filter(e => e.id !== id);
@@ -205,7 +338,24 @@ export function arrangement(command, before, ports) {
205
338
  relation.actualGapM = actualGapM;
206
339
  }
207
340
  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 });
341
+ // Every finding the receipt carries: a value moved to a limit first, then what the final scene shows wrong.
342
+ const nameOf = id => { const e = [...after.objects, ...after.characters].find(row => row.id === id); return e?.name ?? e?.subject ?? id; };
343
+ warnings.push(...clamps.slice(0, 3));
344
+ 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('') });
345
+ if (overlaps.length > 5) warnings.push({ code: 'FOOTPRINT_OVERLAP', count: overlaps.length - 5, message: `${overlaps.length - 5} more footprint overlaps omitted` });
346
+ if (isObject) {
347
+ // Parts of one assembly touch on purpose, but faces sharing one plane flicker as the camera moves.
348
+ const touched = new Set(affectedIds), groups = new Map();
349
+ 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); }
350
+ const coplanar = [];
351
+ for (const root of groups.keys()) for (const pair of coplanarConflicts(rows.filter(row => groupRootOf(row.id, rows) === root))) {
352
+ if (!touched.has(pair.a) && !touched.has(pair.b)) continue;
353
+ const planes = pair.planes.map(plane => `${plane.face} coincide at ${plane.axis ? `${plane.axis}=${plane.at}` : `offset ${plane.at}`}`).join(', ');
354
+ coplanar.push({ code: 'COPLANAR_FACES', id: pair.b, message: [...`${nameOf(pair.a)} and ${nameOf(pair.b)} ${planes}`].slice(0, 120).join('') });
355
+ }
356
+ warnings.push(...coplanar.slice(0, 2));
357
+ if (coplanar.length > 2) warnings.push({ code: 'COPLANAR_FACES', count: coplanar.length - 2, message: `${coplanar.length - 2} more coplanar face pairs omitted` });
358
+ }
209
359
  if (relations.some(r => r.adjustmentM)) warnings.push({ code: 'OUTWARD_ADJUSTMENT', count: relations.filter(r => r.adjustmentM).length });
210
360
  const relation = relations.length === 1 ? relations[0] : null;
211
361
  return { domain: isObject ? 'objects' : 'cast', draft: equal(rows, before[key]) ? before[key] : rows, affectedIds,
@@ -232,12 +382,49 @@ function projectSubject(points, camera, fov, aspectRatio) {
232
382
  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
383
  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
384
  }
385
+ 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 } }; };
386
+ /** What stands in the way of the camera and where it stands: occluded when a prop
387
+ * crosses the sight line to any subject, and the camera's side of the line between
388
+ * the two leading characters (the subjects, else the ones nearest the camera). */
389
+ function sightChecks(subjects, boxes, cameraPosition, state, ports) {
390
+ const occluders = subjects.flatMap((subject, index) => occludersOf(subject, cameraPosition, state, ports, boundsOf(boxes[index])));
391
+ const pair = primarySubjects(subjects.map(subject => subject.id), cameraPosition, state);
392
+ const cameraSide = pair.length === 2 ? sideOfAxis(pair[0], pair[1], cameraPosition) : null;
393
+ return { occluders, checks: { occluded: occluders.length > 0, ...(cameraSide ? { cameraSide } : {}) } };
394
+ }
235
395
  /** Framing evidence for entity ids through the current shot camera at the
236
- * current frame: the same subject-bounds projection frame_shot reports. */
396
+ * current frame: the same subject-bounds projection and sight line frame_shot reports. */
237
397
  export function framingChecks(ids, state, ports) {
238
398
  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;
399
+ const subjects = ids.map(id => entityById(state, id)), boxes = subjects.map(entity => geometry(entity, state, ports));
400
+ const projected = projectSubject(boxes.flat(), state.camera, focalMmToFov(state.camera.focalMm, state.filmback.sensorId, state.filmback.aspectRatio), state.filmback.aspectRatio).checks;
401
+ return { ...projected, ...sightChecks(subjects, boxes, state.camera.position, state, ports).checks };
402
+ }
403
+
404
+ /** Framing evidence over explicit timeline samples. The caller supplies the
405
+ * sampled state so camera keys and animated subject bounds use the same frame.
406
+ * A sample is evidence for that frame only; it is never advertised as an
407
+ * all-frame proof. */
408
+ export function sampledFramingChecks(ids, state, ports, frames) {
409
+ if (!Array.isArray(frames) || !frames.length) fail('INVALID_RANGE', 'Framing sampling needs at least one timeline frame.');
410
+ if (typeof ports.sampleStateAt !== 'function') fail('CAPABILITY_MISSING', 'This editor cannot sample framing at timeline frames.');
411
+ const samples = frames.map(frame => {
412
+ const sampledState = ports.sampleStateAt(frame, state);
413
+ if (!sampledState) fail('TARGET_NOT_READY', `The editor could not read timeline frame ${frame}.`);
414
+ const checks = framingChecks(ids, sampledState, ports), codes = [checks.clipped ? 'CLIPPED' : null, checks.behindCamera ? 'BEHIND_CAMERA' : null, checks.occluded ? 'OCCLUDED' : null].filter(Boolean);
415
+ return { frame, checks, codes };
416
+ });
417
+ const sampledChecks = samples.map(({ frame, checks }) => ({ frame, screenFraction: checks.screenFraction, clipped: checks.clipped, behindCamera: checks.behindCamera, occluded: checks.occluded }));
418
+ const issueFrames = samples.filter(sample => sample.codes.length).map(({ frame, checks, codes }) => ({ frame, codes, screenFraction: checks.screenFraction }));
419
+ return { ...samples.at(-1).checks, coverage: 'sampled-timeline-subject-bounds-projection', evaluatedFrames: frames.length, sampledFrames: frames, sampledChecks, issueFrames };
420
+ }
421
+ export function sampledFramingResult(args, scene, ports, frameCount, measured, receipt) {
422
+ const range = args.range === 'whole_clip' ? { startFrame: 0, endFrameExclusive: frameCount } : args.range;
423
+ if (range.endFrameExclusive > frameCount) fail('INVALID_RANGE', 'Framing verification range exceeds the scene timeline.');
424
+ const ids = measured ?? (args.targets ?? receipt?.affectedIds ?? []).filter(id => scene.objects.some(o => o.id === id) || scene.characters.some(c => c.id === id));
425
+ if (!ids.length) return { reason: 'No target is available for sampled framing verification.' };
426
+ try { return sampledFramingChecks(ids, scene, ports, sampleContactSheetFrames(args.range, frameCount)); }
427
+ catch (error) { if (!(error instanceof StudioProtocolError)) throw error; return { reason: error.message }; }
241
428
  }
242
429
  export function frameDraft(command, state, ports) {
243
430
  if (state.frameCount <= 0) fail('TARGET_NOT_READY', 'A nonempty timeline is required.');
@@ -249,7 +436,9 @@ export function frameDraft(command, state, ports) {
249
436
  const created = !shot && !state.shotDocument.shots.length && !command.args.shotId;
250
437
  if (created) shot = createShot('Shot 1', 0, state.frameCount - 1);
251
438
  if (!shot) fail('AMBIGUOUS_TARGET', 'No shot owns this frame; select an existing shot explicitly.');
252
- const frame = command.args.keyAtFrame ?? state.frame;
439
+ const still = shot.kind === 'still';
440
+ const existingKey = shot.cameraKeys[0];
441
+ const frame = still ? (existingKey?.frame ?? shot.startFrame) : command.args.keyAtFrame ?? state.frame;
253
442
  if (frame < shot.startFrame || frame > shot.endFrame || shot.endFrame >= state.frameCount) fail('INVALID_RANGE', 'Framing/key frame must be inside the shot range.');
254
443
  const intent = command.args.framing.intent;
255
444
  let camera = command.args.framing.exact;
@@ -270,18 +459,19 @@ export function frameDraft(command, state, ports) {
270
459
  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
460
  let keyId;
272
461
  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);
462
+ if (still || command.args.keyAtFrame !== undefined) {
463
+ keyId = (still ? existingKey?.id : keys.find(k => k.frame === frame)?.id) ?? createStableItemId('camera-key');
464
+ keys = still ? [{ id: keyId, frame, framing }] : [...keys.filter(k => k.frame !== frame), { id: keyId, frame, framing }].sort((a, b) => a.frame - b.frame);
276
465
  }
277
466
  const nextShot = { ...shot, cameraKeys: keys, camera: { ...shot.camera, mode: 'keys' } };
278
467
  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;
468
+ const { screenBounds, checks: projected } = projectSubject(points, camera, fov, state.filmback.aspectRatio), derivedSize = projected.derivedSize;
469
+ const sight = sightChecks([subject], [points], camera.position, state, ports), checks = { ...projected, ...sight.checks };
280
470
  const resolvedCamera = { ...camera, sensorId: state.filmback.sensorId, slate: derivedSize };
281
471
  const draft = { shotDocument: { ...state.shotDocument, shots }, camera: resolvedCamera, manual: true };
282
472
  return { domain: 'shot', draft, affectedIds: [shot.id, ...(keyId ? [keyId] : [])],
283
473
  checks,
284
- warnings: [{ code: 'OCCLUSION_UNMEASURED' }], details: { created, shotId: shot.id, keyId, frame, framing, screenBounds, subjectIds: [subject.id] } };
474
+ 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
475
  }
286
476
 
287
477
  /** 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 };