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,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, evaluatedFrames: integer(1), sampledFrames: array(integer(), 64, 1), sampledChecks: array(object({ frame: integer(), screenFraction: number(), clipped: bool, behindCamera: bool, occluded: bool }), 64, 1), issueFrames: array(object({ frame: integer(), codes: array(name, 4, 1), screenFraction: number() }), 64, 0) });
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, " ") }));
@@ -478,7 +511,10 @@ export function validateStudioCommand(command) {
478
511
  }
479
512
  if (command.name === "verify_result") {
480
513
  if (Boolean(args.receiptId) === Boolean(args.targets)) fail("INVALID_ARGUMENT", "Exactly one of receiptId or targets is required.");
481
- if (args.checks.includes("motion") && args.range === undefined) args.range = "whole_clip";
514
+ if (args.checks.includes("motion") && args.range === undefined) {
515
+ args.range = "whole_clip";
516
+ Object.defineProperty(args, "rangeWasDefaulted", { value: true, enumerable: false });
517
+ }
482
518
  }
483
519
  return { name: command.name, args };
484
520
  }
@@ -5,10 +5,12 @@ import { createCommandBus } from "./command-bus.js";
5
5
  import { readElementDocument, elementReadback, elementPatchArgs, elementTarget, elementPatchReceipt } from "./commands/elements.js";
6
6
  import { physicsKeyStamp } from "./ardy/physics-review.js";
7
7
  import { shotAtFrame } from "./cuts.js";
8
+ import { isProxyFigure } from "./scenes.js";
8
9
  import { CUTOUT_KIND, MESH_KIND, OBJECT_LIBRARY, supportHeightForObject } from "./scene-objects.js";
9
10
  import { buildStudioContext, physicsFingerprintInput, studioEntityCursor, validateStudioCursor } from "./studio-agent-context.js";
10
- import { createStudioCommandJournal, framingChecks, placementChecks, studioObjectCatalogue } from "./studio-agent-commands.js";
11
+ import { createStudioCommandJournal, entityWorldBounds, framingChecks, localTransformReadback, placementChecks, sampledFramingResult, studioObjectCatalogue } from "./studio-agent-commands.js";
11
12
  import { verifyInstalledTake } from "./studio-agent-motion.js";
13
+ import { shotGeometryFacts } from "./studio-geometry-facts.js";
12
14
  import { STUDIO_TOOL_FAMILIES, StudioProtocolError, validateReceipt, validateStudioCommand, validateStudioIdentity } from "./studio-agent-protocol.js";
13
15
  import { CONTACT_SHEET_LAYOUT, buildContactSheet, sampleContactSheetFrames } from "./studio-contact-sheet.js";
14
16
 
@@ -120,12 +122,15 @@ export function createStudioAppBinding(ports) {
120
122
  position: { x: c.x, y: c.y ?? 0, z: c.z }, yawDeg: c.rot ?? 0, scale: c.scale ?? 1, tint: c.tint ?? null, modelId: c.model ?? null,
121
123
  motion: { takeId: t?.motion?.studioTakeId ?? null, frames: t?.motion?.frames ?? 0,
122
124
  ikKeyCount: t?.ikState?.keys.size ?? 0, promptBlockCount: c.layer?.promptClips?.length ?? 0 },
123
- capabilities: { rigReady: Boolean(t?.rig), ik: Boolean(t?.rig?.userData?.poseBind), measuredFeet: false } };
124
- }), ...s.objects.map(o => ({ id: o.id, kind: "object", token: tokens.get(o.id).token, name: o.name || o.id,
125
+ capabilities: { rigReady: Boolean(t?.rig), ik: Boolean(t?.rig?.userData?.poseBind), measuredFeet: false },
126
+ characterKind: isProxyFigure(c) ? "proxy" : "rig", ...(isProxyFigure(c) ? { posture: c.posture ?? "stand" } : {}) };
127
+ }), ...s.objects.map(o => { const local = localTransformReadback(o, s.objects); return { id: o.id, kind: "object", token: tokens.get(o.id).token, name: o.name || o.id,
125
128
  position: { x: o.x, y: o.y ?? 0, z: o.z }, yawDeg: o.rot ?? 0,
126
129
  rotationDeg: { x: o.rotX ?? 0, y: o.rot ?? 0, z: o.rotZ ?? 0 }, scale: { x: o.scaleX, y: o.scaleY, z: o.scaleZ },
127
130
  renderer: o.renderer, color: o.color ?? null, ...(o.assetId ? { assetId: o.assetId } : {}),
128
- parentId: o.parent ?? null, attachment: o.attach ?? null, pathPointCount: o.path?.points.length ?? 0 }))];
131
+ parentId: o.parent ?? null, attachment: o.attach ?? null, pathPointCount: o.path?.points.length ?? 0,
132
+ // the dots on the route, with the lean (degrees) a mark carries
133
+ ...(o.path?.marks?.length ? { pathMarks: o.path.marks.map(mark => typeof mark === "number" ? mark : { t: mark.t, ...(mark.bank !== undefined ? { bank: mark.bank } : {}), ...(mark.pitch !== undefined ? { pitch: mark.pitch } : {}) }) } : {}), ...(local ? { local } : {}) }; })];
129
134
  }
130
135
  const frameRange = row => ({ startFrame: row.startFrame, endFrameExclusive: row.endFrame + 1 });
131
136
  function assetList(s) {
@@ -147,7 +152,7 @@ export function createStudioAppBinding(ports) {
147
152
  return buildStudioContext({ schema: "studio-context-v1", host: { surface: "studio", ...s.host, workspaceHandle: s.workspaceHandle },
148
153
  revision: { scene: s.revision, physics: s.physicsRevision, view: s.viewRevision },
149
154
  units: { distance: "m", angle: "deg", up: "+Y", yawZero: "+Z", yawPositiveToward: "+X", pivot: "base", fps: 24, rangeEnd: "exclusive" },
150
- scene: { name: s.sceneName, aspect: s.aspect, floorY: 0, frameCount: s.frameCount, objectCount: s.objects.length, characterCount: s.characters.length },
155
+ scene: { name: s.sceneName, aspect: s.aspect, floorY: 0, frameCount: s.frameCount, objectCount: s.objects.length, characterCount: s.characters.length, ...(s.previsMode ? { previsMode: s.previsMode } : {}) },
151
156
  selection: s.selection, activeCharacterId: s.activeCharacterId, view: s.view,
152
157
  shot: shot ? { id: shot.id, name: shot.name, range: range(shot), mode: shot.camera?.mode ?? "keys" } : null, camera: s.camera,
153
158
  // buildStudioContext selects the detailed rows and writes the real page.
@@ -155,7 +160,8 @@ export function createStudioAppBinding(ports) {
155
160
  shots: s.shots.map(row => ({ id: row.id, name: row.name, range: range(row), keyCount: row.cameraKeys.length })), shotsTruncated: false,
156
161
  assets: assetList(s), recentReceipts: [...receipts.values()].filter(r => r.ok).reverse().slice(0, 3).map(r => ({ id: r.receiptId, summary: r.status, canUndoDirect: ports.canUndo(r) })),
157
162
  jobs: [], capabilities: { profile: "studio-slice-1", tools: STUDIO_TOOL_FAMILIES,
158
- rigReady: Boolean(s.targets.get(s.activeCharacterId)?.rig), cameraReady: Boolean(s.camera), bridgeReady: s.bridgeReady },
163
+ rigReady: Boolean(s.targets.get(s.activeCharacterId)?.rig), cameraReady: Boolean(s.camera), bridgeReady: s.bridgeReady,
164
+ ...(s.previsMode ? { storyboard: s.previsMode === "storyboard" } : {}) },
159
165
  // Every registered command; buildStudioContext keeps only its id/label index.
160
166
  ...(registry ? { actions: registry.list() } : {}) });
161
167
  }
@@ -182,19 +188,32 @@ export function createStudioAppBinding(ports) {
182
188
  return s;
183
189
  }
184
190
  /** Actual state of one action target after it ran. */
185
- function actionReadback(id, s) {
191
+ function actionReadback(id, s, compact = false) {
186
192
  const patched = Object.entries(s.document).flatMap(([kind, value]) => {
187
193
  const target = elementTarget(kind, value, id, s.host.sceneId);
188
194
  return target ? elementReadback(kind, target) : [];
189
195
  });
190
196
  if (id === s.host.sceneId) return { selection: s.selection, activeCharacterId: s.activeCharacterId, shotId: s.selectedShotId, view: s.view,
191
197
  ...(patched.length ? { patched } : {}) };
192
- if (patched.length) return { patched };
198
+ const object = s.objects.find(row => row.id === id), entity = object ?? s.characters.find(row => row.id === id);
199
+ // Beyond the transform an object's patched paths already carry: the world box it occupies and, for a
200
+ // group, who hangs off it. `compact` (a receipt near its size limit) drops back to the paths alone.
201
+ // A character's box means cloning and posing its rig, too dear for every receipt: inspect or verify has it.
202
+ const extras = () => {
203
+ if (!object || compact) return {};
204
+ const childIds = s.objects.filter(row => row.parent === id).map(row => row.id);
205
+ return { bounds: entityWorldBounds(object, { ...s, frame: s.view.frame }, { bounds: ports.bounds }),
206
+ ...(childIds.length ? { childIds: childIds.slice(0, 12), ...(childIds.length > 12 ? { childCount: childIds.length } : {}) } : {}) };
207
+ };
208
+ if (patched.length) return { patched, ...extras() };
193
209
  const shot = s.shots.find(row => row.id === id);
194
210
  if (shot) return { name: shot.name || shot.id, range: { startFrame: shot.startFrame, endFrameExclusive: shot.endFrame + 1 } };
195
- const entity = s.objects.find(row => row.id === id) ?? s.characters.find(row => row.id === id);
196
- if (entity) return { name: entity.name || entity.subject || entity.id, position: { x: entity.x, y: entity.y ?? 0, z: entity.z } };
197
- return { removed: true };
211
+ if (!entity) return { removed: true };
212
+ const brief = { name: entity.name || entity.subject || entity.id, position: { x: entity.x, y: entity.y ?? 0, z: entity.z } };
213
+ if (compact) return brief;
214
+ if (!object) return { ...brief, yawDeg: entity.rot ?? 0, scale: entity.scale ?? 1, ...extras() };
215
+ return { ...brief, rotationDeg: { x: object.rotX ?? 0, y: object.rot ?? 0, z: object.rotZ ?? 0 }, scale: { x: object.scaleX, y: object.scaleY, z: object.scaleZ },
216
+ parentId: object.parent ?? null, ...extras() };
198
217
  }
199
218
  /** One registered Studio action, run for the agent through the same
200
219
  * registry the UI controls call. A mutation is bound to the native history
@@ -220,7 +239,7 @@ export function createStudioAppBinding(ports) {
220
239
  }
221
240
  function runAction(request, args) {
222
241
  const { name: _name, args: _args, ...options } = request;
223
- const result = commandBus().run(args.action, args.args, { ...options, origin: "agent", confirmationToken: args.confirmationToken ?? request.confirmationToken });
242
+ const result = commandBus().run(args.action, args.args, { ...options, origin: "agent", turnId: request.turnId, confirmationToken: args.confirmationToken ?? request.confirmationToken });
224
243
  const answer = receipt => receipt.nextHost ? { ...receipt, host: receipt.nextHost } : receipt;
225
244
  return result?.then ? result.then(answer) : answer(result);
226
245
  }
@@ -260,15 +279,24 @@ export function createStudioAppBinding(ports) {
260
279
  // Only a check nothing could compute is unsupported, and says why; a motion
261
280
  // check computed for some characters still names the ones it skipped.
262
281
  const scene = readCommand(), entityIds = ids => ids.filter(id => scene.objects.some(o => o.id === id) || scene.characters.some(c => c.id === id));
263
- const measured = args.targets ? entityIds(args.targets) : receipt.checks ? null : entityIds(receipt.affectedIds);
282
+ // A receipt's checks are evidence only for what they measured: every receipt carries a coverage tag, but
283
+ // only an arrange/frame receipt carries overlap or framing numbers. Anything else is measured here.
284
+ const measuredBy = { placement: "overlapIds", framing: "screenFraction" }, unmeasured = receipt && args.checks.some(check => measuredBy[check] && receipt.checks?.[measuredBy[check]] === undefined);
285
+ const measured = args.targets ? entityIds(args.targets) : unmeasured ? entityIds(receipt.affectedIds) : null;
286
+ // A framing check also carries the cut's geometry, read from the scene: side of the action line
287
+ // and sight lines per shot, the receipt's or targets' shots first (8 at most).
264
288
  const result = { receiptId: receipt?.receiptId ?? null, revision: s.revision, evidenceRevision, stale: evidenceRevision !== s.revision, checks: measured ? { coverage: "current-scene-targets" } : receipt.checks,
265
- verification: receipt?.verification ?? null, semanticStatus: "unavailable", visualRefs: [], unsupportedChecks: [], unsupportedReasons: {} };
289
+ verification: receipt?.verification ?? null, semanticStatus: "unavailable", visualRefs: [], unsupportedChecks: [], unsupportedReasons: {}, ...(args.checks.includes("framing") ? { geometry: shotGeometryFacts(scene, { first: receipt ? receipt.affectedIds : args.targets, ports: { bounds: ports.bounds } }) } : {}) };
266
290
  const reasons = result.unsupportedReasons, verified = [], skipped = [], pending = [];
267
291
  for (const check of args.checks.filter(check => check !== "motion" && measured)) {
268
292
  if (!measured.length) { reasons[check] = "No target is an object or character in the current scene."; continue; }
269
293
  try { result.checks[check] = check === "placement" ? placementChecks(measured, scene, { bounds: ports.bounds }) : framingChecks(measured, scene, { bounds: ports.bounds }); }
270
294
  catch (error) { if (!(error instanceof StudioProtocolError)) throw error; reasons[check] = error.message; }
271
295
  }
296
+ if (args.checks.includes("framing") && args.range !== undefined && !args.rangeWasDefaulted) {
297
+ const sampled = sampledFramingResult(args, scene, ports, s.frameCount, measured, receipt);
298
+ if (sampled.reason) reasons.framing = sampled.reason; else result.checks.framing = sampled;
299
+ }
272
300
  if (args.checks.includes("motion") && !receipt?.verification) {
273
301
  const subjects = args.targets ?? receipt.affectedIds.filter(id => s.characters.some(c => c.id === id));
274
302
  if (!subjects.length) skipped.push("The receipt affected no character.");
@@ -281,6 +309,8 @@ export function createStudioAppBinding(ports) {
281
309
  .then(verification => { verified[index] = verification; }, error => { if (!(error instanceof StudioProtocolError)) throw error; skipped.push(`${name}: ${error.message}`); }));
282
310
  });
283
311
  }
312
+ // A frame is the shot camera's view; a plan is the Top View, the set from above
313
+ // with names and the shot camera's wedge.
284
314
  if (args.visual !== "none") {
285
315
  if (args.visual === "contact_sheet") {
286
316
  // One image of frames across the range, each rendered through the export
@@ -288,7 +318,7 @@ export function createStudioAppBinding(ports) {
288
318
  const frames = sampleContactSheetFrames(args.range, s.frameCount), sheet = buildContactSheet(frames, ports.renderFrameBuffer), imageId = crypto.randomUUID();
289
319
  images.set(imageId, { dataUrl: ports.encodePng(sheet.data, sheet), width: sheet.width, height: sheet.height, frames, layout: CONTACT_SHEET_LAYOUT, revision: s.revision, receiptId: result.receiptId });
290
320
  result.visualRefs.push({ imageId, frames, layout: CONTACT_SHEET_LAYOUT });
291
- } else { const capture = ports.capture(); const imageId = crypto.randomUUID(); images.set(imageId, { ...capture, revision: s.revision, receiptId: result.receiptId }); result.visualRefs.push({ imageId }); }
321
+ } else { const capture = args.visual === "plan" ? (ports.capturePlan ?? (() => fail("CAPABILITY_MISSING", "This editor cannot render the top view.")))() : ports.capture(); const imageId = crypto.randomUUID(); images.set(imageId, { ...capture, revision: s.revision, receiptId: result.receiptId }); result.visualRefs.push({ imageId, ...(args.visual === "plan" ? { kind: "plan" } : {}) }); }
292
322
  }
293
323
  const finish = () => {
294
324
  const computed = verified.filter(Boolean);
@@ -312,17 +342,20 @@ export function createStudioAppBinding(ports) {
312
342
  read_studio_context(request) { const c = context(); if (!same(validateStudioIdentity(request.host), owner)) fail("STALE_SCENE", "This is not the requested document."); return c; },
313
343
  inspect_studio(args) {
314
344
  const command = validateStudioCommand({ name: "inspect_studio", args }); const c = context();
315
- // Every scope carries the context: its revision is what the agent's next
316
- // command is admitted at, so a scope without it leaves that admission stale.
317
- if (command.args.scope === "catalogue") return { context: c, ...studioObjectCatalogue() };
318
- if (command.args.scope === "document") return { context: c, scope: "document",
345
+ // Every scope carries the revision and document identity, not the whole
346
+ // context (the turn already holds that, and resending it per call is what
347
+ // the model pays for): the revision is what the agent's next command is
348
+ // admitted at, so a scope without it leaves that admission stale.
349
+ const head = { revision: c.revision, host: (({ workspaceId, documentEpoch, sceneId, sceneEpoch }) => ({ workspaceId, documentEpoch, sceneId, sceneEpoch }))(c.host) };
350
+ if (command.args.scope === "catalogue") return { ...head, ...studioObjectCatalogue() };
351
+ if (command.args.scope === "document") return { ...head, scope: "document",
319
352
  ...readElementDocument(refresh().document, command.args, c.host.sceneId) };
320
353
  // Discovery for run_action: every registered action with its label, kind,
321
354
  // exposure and availability (the reason when unavailable). Schemas are on
322
355
  // request: ids answer those actions' full declarations, input included.
323
356
  if (command.args.scope === "actions") {
324
- const actions = ports.actions?.()?.list() ?? [];
325
- return { context: c, actions: command.args.ids ? actions.filter(row => command.args.ids.includes(row.id)) : actions.map(({ input, description, ...row }) => row) };
357
+ const actions = (ports.actions?.()?.list() ?? []).filter(row => !row.agentHidden);
358
+ return { ...head, actions: command.args.ids ? actions.filter(row => command.args.ids.includes(row.id)) : actions.map(({ input, description, ...row }) => row) };
326
359
  }
327
360
  const s = refresh();
328
361
  const wanted = row => (!args.ids || args.ids.includes(row.id)) && (!args.query || Boolean(row.name?.includes(args.query)));
@@ -330,14 +363,14 @@ export function createStudioAppBinding(ports) {
330
363
  const select = { shot: ["shot"], motion: ["motion", "character"] }[command.args.scope];
331
364
  const ids = args.ids ?? (command.args.scope === "selection" ? [s.selection?.id ?? s.host.sceneId]
332
365
  : args.query && select ? (command.args.scope === "shot" ? s.shots : entityProjection(s).filter(row => row.kind === "character")).filter(wanted).map(row => row.id) : undefined);
333
- return { context: c, scope: "document", ...readElementDocument(s.document, { ...command.args, ids, select }, c.host.sceneId) };
366
+ return { ...head, scope: "document", ...readElementDocument(s.document, { ...command.args, ids, select }, c.host.sceneId) };
334
367
  }
335
368
  // Build each page from the same complete authoritative projection; never
336
369
  // page by slicing an already-truncated Send context.
337
370
  // Stable id order, so an offset cursor survives unrelated edits.
338
371
  const filtered = entityProjection(s).filter(wanted).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
339
372
  const offset = args.cursor ? validateStudioCursor(args.cursor, c) : 0, limit = command.args.limit;
340
- return { context: c, entities: filtered.slice(offset, offset + limit), total: filtered.length,
373
+ return { ...head, entities: filtered.slice(offset, offset + limit), total: filtered.length,
341
374
  nextCursor: offset + limit < filtered.length ? studioEntityCursor(c, offset + limit) : null };
342
375
  },
343
376
  operate_studio: request => execute({ ...request, name: "operate_studio" }),
@@ -2,10 +2,10 @@
2
2
  // shot render shrinks, and where it lands on the one image the agent sees.
3
3
  // Pure over RGBA byte buffers; App.jsx supplies the render and the encoder.
4
4
 
5
- export const CONTACT_SHEET_COLUMNS = 3;
6
- export const CONTACT_SHEET_ROWS = 2;
5
+ const CONTACT_SHEET_COLUMNS = 3;
6
+ const CONTACT_SHEET_ROWS = 2;
7
7
  export const CONTACT_SHEET_LAYOUT = "3x2 row-major";
8
- export const CONTACT_SHEET_MAX_SIDE = 1600;
8
+ const CONTACT_SHEET_MAX_SIDE = 1600;
9
9
 
10
10
  /** Up to 6 frames spread evenly over a half-open range, first and last
11
11
  * included; "whole_clip" (or no range) is timeline frames 0..frameCount-1. */
@@ -22,7 +22,11 @@ const entries = [
22
22
  { path: "character.scale", type: "number", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", min: 0.2, max: 3 },
23
23
  { path: "character.subject", type: "string", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
24
24
  { path: "character.hidden", type: "boolean", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
25
- { path: "character.model", type: "enum", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", enum: ["y-bot-tpose", "x-bot-tpose"] },
25
+ { path: "character.parent", type: "id", nullable: true, persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", note: "scene object id the character is grouped under and rides; null stands in the world" },
26
+ // The model and posture enums mirror scenes.js CHARACTER_KIND_IDS and
27
+ // POSTURES; they are literal because scenes.js imports this module.
28
+ { path: "character.model", type: "enum", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", enum: ["y-bot-tpose", "x-bot-tpose", "proxy-figure"] },
29
+ { path: "character.posture", type: "enum", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", enum: ["stand", "sit", "lie"] },
26
30
  { path: "character.tint", type: "color", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
27
31
  { path: "character.identityImage", type: "image", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", note: "data:image only" },
28
32
  { path: "character.pose", type: "id", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
@@ -32,12 +36,13 @@ const entries = [
32
36
  { path: "character.motionRef.motionId", type: "id", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
33
37
  { path: "character.sessionMotion", type: "array", persisted: false, undoDomain: "cast", normalizer: "createCharacterEntry", note: "dropped by createCharacterEntry" },
34
38
  { path: "character.ikKeys", type: "array", persisted: false, undoDomain: "cast", normalizer: "createCharacterEntry", actions: ["character.setIkKey", "character.removeIkKey", "character.clearIkKeys"], note: "dropped by createCharacterEntry; keys travel as JSON quaternions/positions" },
35
- { path: "object.renderer", type: "enum", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", enum: ["cube", "sphere", "capsule", "cylinder", "cone", "plane", "chair", "car", "small-plane"] },
39
+ { path: "object.renderer", type: "enum", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", enum: ["cube", "sphere", "capsule", "cylinder", "cone", "plane", "chair", "car", "small-plane", "empty"] },
36
40
  { path: "object.position", type: "vec3", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", min: { x: -240, y: 0, z: -240 }, max: { x: 240, y: 240, z: 240 }, note: "y is the object's base: y=0 rests on the floor; height/supportY rise from it" },
37
41
  { path: "object.rotation", type: "vec3", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", min: { x: -180, y: -180, z: -180 }, max: { x: 180, y: 180, z: 180 }, note: "rot/rotX/rotZ degrees, wrapped at the upper bound" },
38
42
  { path: "object.scale", type: "vec3", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", min: { x: 0.1, y: 0.1, z: 0.1 }, max: { x: 100, y: 100, z: 100 } },
39
43
  { path: "object.name", type: "string", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
40
44
  { path: "object.color", type: "color", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
45
+ { path: "object.opacity", type: "number", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", min: 0.05, max: 1, note: "1 is solid; lower is see-through, e.g. 0.3 for clear glass" },
41
46
  { path: "object.parent", type: "id", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
42
47
  { path: "object.attach", type: "id", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", actions: ["object.attach", "object.detach"], note: "carried by a character's root or bone; channels convert so the prop stays put" },
43
48
  { path: "object.path", type: "array", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
@@ -59,6 +64,10 @@ const entries = [
59
64
  { path: "shot.cameraKeys", type: "array", persisted: true, undoDomain: "shot", normalizer: null, frameMin: 0 },
60
65
  { path: "shot.cameraRail", type: "array", persisted: true, undoDomain: "shot", normalizer: "repairCamera", actions: ["shot.setCameraRail", "shot.clearCameraRail"], note: "rail points; crane/dolly timing follow the shot's camera block" },
61
66
  { path: "shot.targetModel", type: "id", persisted: true, undoDomain: "shot", normalizer: "repairCamera" },
67
+ { path: "shot.kind", type: "enum", persisted: true, undoDomain: "shot", normalizer: null, readOnly: true, enum: ["clip", "still"], note: "clip or still; a still holds one picture for endFrame - startFrame + 1 frames" },
68
+ { path: "shot.caption", type: "string", persisted: true, undoDomain: "shot", normalizer: null, readOnly: true, note: "trimmed, at most 500 characters" },
69
+ { path: "shot.cast", type: "array", persisted: true, undoDomain: "shot", normalizer: null, readOnly: true, note: "per-shot placement overrides keyed by character id: { x, z, rot, posture?, pose? }" },
70
+ { path: "shot.stylizedAssetId", type: "id", nullable: true, persisted: true, undoDomain: "shot", normalizer: null, readOnly: true, note: "image asset id only" },
62
71
  { path: "shot.freeCamera", type: "vec3", persisted: false, undoDomain: "shot", normalizer: null, note: "transient until keyed" },
63
72
  { path: "scenes", type: "array", persisted: true, undoDomain: null, normalizer: null, actions: ["scene.create", "scene.duplicate", "scene.rename", "scene.delete", "scene.switch"], note: "the project's scene list and the open scene; outside the undo history" },
64
73
  { path: "project", type: "string", persisted: true, undoDomain: null, normalizer: null, actions: ["project.save"], note: "save to the current project file; opening one needs the user's file picker" },
@@ -0,0 +1,114 @@
1
+ // Shot geometry the Studio agent reads as facts instead of judging from a picture:
2
+ // where the cast stands, which side of the line between the two leading
3
+ // characters each shot camera sits on (the 180-degree rule), and which props
4
+ // stand between a camera and its subject. Pure: no renderer, no React.
5
+ import { entityWorldBounds } from './studio-agent-commands.js';
6
+ import { isEffectivelyHidden } from './scene-objects.js';
7
+ import { shotAtFrame } from './cuts.js';
8
+
9
+ export const GEOMETRY_FACTS_LIMITS = Object.freeze({ shots: 8, subjects: 24, pairs: 24, occluders: 8 });
10
+ /** Closer to the action line than this (m) is "on-axis", neither side. */
11
+ const ON_AXIS_M = 0.05;
12
+ const EPS = 1e-9;
13
+ const round = v => Math.round(v * 1000) / 1000 + 0;
14
+ const point = p => ({ x: round(p.x), y: round(p.y ?? 0), z: round(p.z) });
15
+ const visibleCharacters = state => state.characters.filter(c => !isEffectivelyHidden(c, state.objects, state.characters));
16
+
17
+ /** Does the segment p0->p1 pass through the box {min, max}? Slab test over t in [0, 1]. */
18
+ export function segmentIntersectsAabb(p0, p1, box) {
19
+ let t0 = 0, t1 = 1;
20
+ for (const a of ['x', 'y', 'z']) {
21
+ const d = p1[a] - p0[a];
22
+ if (Math.abs(d) < EPS) {
23
+ if (p0[a] < box.min[a] || p0[a] > box.max[a]) return false;
24
+ continue;
25
+ }
26
+ let near = (box.min[a] - p0[a]) / d, far = (box.max[a] - p0[a]) / d;
27
+ if (near > far) [near, far] = [far, near];
28
+ t0 = Math.max(t0, near); t1 = Math.min(t1, far);
29
+ if (t0 > t1) return false;
30
+ }
31
+ return true;
32
+ }
33
+ const contains = (box, p) => ['x', 'y', 'z'].every(a => p[a] >= box.min[a] && p[a] <= box.max[a]);
34
+
35
+ /** Which side of the horizontal line a->b the point p stands on, seen from above
36
+ * (+Y up) looking along a->b: the sign of (axis x (p - a)).y with the axis
37
+ * normalised, so the threshold is metres. Negative is right, positive left;
38
+ * within ON_AXIS_M it is "on-axis". Null when a and b share a spot. */
39
+ export function sideOfAxis(a, b, p) {
40
+ const ax = b.x - a.x, az = b.z - a.z, length = Math.hypot(ax, az);
41
+ if (length < EPS) return null;
42
+ const offset = (az * (p.x - a.x) - ax * (p.z - a.z)) / length;
43
+ return Math.abs(offset) < ON_AXIS_M ? 'on-axis' : offset > 0 ? 'left' : 'right';
44
+ }
45
+
46
+ /** The two characters a camera is read against: the given ids that are visible
47
+ * characters, then the visible characters nearest the camera. Ordered by id, so
48
+ * every shot of the same pair reads the line in the same direction. */
49
+ export function primarySubjects(ids, cameraPosition, state) {
50
+ const visible = visibleCharacters(state);
51
+ const chosen = ids.map(id => visible.find(c => c.id === id)).filter(Boolean);
52
+ const near = c => Math.hypot(c.x - cameraPosition.x, c.z - cameraPosition.z);
53
+ const rest = visible.filter(c => !chosen.includes(c)).sort((p, q) => near(p) - near(q));
54
+ return [...chosen, ...rest].slice(0, 2).sort((p, q) => (p.id < q.id ? -1 : p.id > q.id ? 1 : 0));
55
+ }
56
+
57
+ /** Props whose world box crosses the sight line from the camera to the centre of
58
+ * the subject's box. The subject itself, props attached to it, hidden props and
59
+ * an enclosure the camera stands inside (a room set) never count. */
60
+ export function occludersOf(subject, cameraPosition, state, ports, subjectBounds = entityWorldBounds(subject, state, ports)) {
61
+ if (!subjectBounds) return [];
62
+ const centre = Object.fromEntries(['x', 'y', 'z'].map(a => [a, (subjectBounds.min[a] + subjectBounds.max[a]) / 2]));
63
+ return state.objects.filter(o => o.id !== subject.id && o.attach?.characterId !== subject.id && !isEffectivelyHidden(o, state.objects, state.characters))
64
+ .flatMap(o => {
65
+ const box = entityWorldBounds(o, state, ports);
66
+ return box && !contains(box, cameraPosition) && segmentIntersectsAabb(cameraPosition, centre, box) ? [{ subjectId: subject.id, byObjectId: o.id }] : [];
67
+ });
68
+ }
69
+
70
+ /** A shot's camera as position + aim: the live camera for the shot under the
71
+ * playhead, else the shot's first camera key; null when it has neither. */
72
+ export function shotCamera(shot, state) {
73
+ if (state.camera && shotAtFrame(state.shotDocument.shots, state.frame)?.id === shot.id) return { position: state.camera.position, lookAt: state.camera.lookAt };
74
+ const framing = shot.cameraKeys?.[0]?.framing;
75
+ if (!framing) return null;
76
+ const { pos, yaw, pitch } = framing;
77
+ return { position: pos, lookAt: { x: pos.x - Math.sin(yaw) * Math.cos(pitch), y: pos.y + Math.sin(pitch), z: pos.z - Math.cos(yaw) * Math.cos(pitch) } };
78
+ }
79
+
80
+ /** The 180-degree rule over shots read against the same pair: false when any
81
+ * pair has cameras on both sides, true when some pair has two sided shots and
82
+ * none crosses, null when no pair has two sided shots to compare. */
83
+ function axisConsistency(shots) {
84
+ const byPair = new Map();
85
+ for (const shot of shots) {
86
+ if (shot.cameraSide !== 'left' && shot.cameraSide !== 'right') continue;
87
+ const key = shot.subjectIds.join('|');
88
+ byPair.set(key, [...(byPair.get(key) ?? []), shot.cameraSide]);
89
+ }
90
+ const compared = [...byPair.values()].filter(sides => sides.length >= 2);
91
+ return compared.length ? compared.every(sides => sides.every(side => side === sides[0])) : null;
92
+ }
93
+
94
+ /** Facts for the given shots, bounded by GEOMETRY_FACTS_LIMITS. Without shotIds:
95
+ * the shots named in `first` (other ids are skipped), then the rest of the cut, so
96
+ * axisConsistent compares the named shots with the others. */
97
+ export function shotGeometryFacts(state, { shotIds, first = [], ports } = {}) {
98
+ const cast = visibleCharacters(state).slice(0, GEOMETRY_FACTS_LIMITS.subjects);
99
+ const subjects = cast.map(c => ({ id: c.id, position: point(c), yawDeg: round(c.rot ?? 0) }));
100
+ const pairs = cast.flatMap((a, i) => cast.slice(i + 1).map(b => ({ a: a.id, b: b.id, distanceM: round(Math.hypot(b.x - a.x, b.z - a.z)) })))
101
+ .sort((p, q) => p.distanceM - q.distanceM).slice(0, GEOMETRY_FACTS_LIMITS.pairs);
102
+ const all = state.shotDocument.shots;
103
+ const named = all.filter(s => first.includes(s.id)).sort((p, q) => first.indexOf(p.id) - first.indexOf(q.id));
104
+ const wanted = (shotIds ? shotIds.map(id => all.find(s => s.id === id)) : [...named, ...all.filter(s => !named.includes(s))]).filter(Boolean).slice(0, GEOMETRY_FACTS_LIMITS.shots);
105
+ const shots = wanted.map(shot => {
106
+ const camera = shotCamera(shot, state);
107
+ if (!camera) return { shotId: shot.id, subjectIds: [], camera: null, cameraSide: null, occluders: [] };
108
+ const pair = primarySubjects(shot.subjectIds ?? [], camera.position, state);
109
+ return { shotId: shot.id, subjectIds: pair.map(c => c.id), camera: { position: point(camera.position), lookAt: point(camera.lookAt) },
110
+ cameraSide: pair.length === 2 ? sideOfAxis(pair[0], pair[1], camera.position) : null,
111
+ occluders: pair.flatMap(subject => occludersOf(subject, camera.position, state, ports)).slice(0, GEOMETRY_FACTS_LIMITS.occluders) };
112
+ });
113
+ return { subjects, pairs, shots, axisConsistent: axisConsistency(shots) };
114
+ }