cozyclay 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +219 -38
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +6 -5
  19. package/bin/cozyclay.mjs +12 -3
  20. package/bin/live/cli.mjs +2 -3
  21. package/bin/live/client.mjs +1 -1
  22. package/bin/live/studio.mjs +5 -2
  23. package/bin/mcp-runtime.mjs +12 -2
  24. package/bin/telemetry-state.mjs +3 -3
  25. package/dist/app/index.html +6 -6
  26. package/dist/assets/analytics-B41FmPYI.js +1 -0
  27. package/dist/assets/app-Do-Og5Pu.css +1 -0
  28. package/dist/assets/app-DwP-tl2x.js +4920 -0
  29. package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
  30. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
  31. package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
  32. package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
  33. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  34. package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
  35. package/dist/assets/workflow-Br-K7Srs.js +179 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/index.html +328 -222
  38. package/dist/sitemap.xml +5 -5
  39. package/dist/workflow/index.html +4 -4
  40. package/mcp/LIVE-PROTOCOL.md +23 -2
  41. package/mcp/README.md +37 -2
  42. package/mcp/ardy-prompts.mjs +1 -1
  43. package/mcp/image-file.mjs +132 -0
  44. package/mcp/live-hub.mjs +2 -2
  45. package/mcp/package.json +1 -0
  46. package/mcp/runtime/package-lock.json +7 -0
  47. package/mcp/runtime/package.json +1 -0
  48. package/mcp/server.mjs +0 -6
  49. package/mcp/tool-handlers.mjs +289 -87
  50. package/package.json +11 -2
  51. package/src/App.jsx +801 -259
  52. package/src/agent-image-client.js +94 -0
  53. package/src/analytics.js +47 -3
  54. package/src/app-stage.jsx +129 -2745
  55. package/src/ardy/auto-physics.js +6 -16
  56. package/src/ardy/collision-blockers.js +3 -7
  57. package/src/ardy/fix-collisions.js +5 -35
  58. package/src/ardy/ik-drag.js +86 -5
  59. package/src/ardy/ik-key-json.js +3 -1
  60. package/src/ardy/ik.js +15 -13
  61. package/src/ardy/motion-calibration.js +1 -1
  62. package/src/ardy/npz.js +0 -8
  63. package/src/ardy/physics-panel.jsx +3 -2
  64. package/src/ardy/physics-review.js +3 -2
  65. package/src/ardy/physics-support.js +1 -1
  66. package/src/ardy/physics-surface.js +1 -1
  67. package/src/ardy/platform-fit-panel.jsx +2 -1
  68. package/src/ardy/playback.js +1 -5
  69. package/src/ardy/range-pin.js +2 -2
  70. package/src/ardy/root-drop.js +0 -1
  71. package/src/ardy/timeline.css +196 -3
  72. package/src/ardy/timeline.jsx +117 -29
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +1 -4
  75. package/src/ardy-bridge-contract.js +281 -0
  76. package/src/asset-pane.jsx +19 -9
  77. package/src/auto-color.js +1 -1
  78. package/src/burn-in.js +1 -1
  79. package/src/camera-block.js +1 -1
  80. package/src/camera-follow.js +75 -32
  81. package/src/camera-move.js +0 -6
  82. package/src/camera-tutorial.jsx +43 -24
  83. package/src/character-kind.js +79 -0
  84. package/src/command-bus.js +76 -17
  85. package/src/commands/cast.js +31 -7
  86. package/src/commands/elements/object.js +1 -1
  87. package/src/commands/elements/shot.js +1 -1
  88. package/src/commands/elements.js +1 -1
  89. package/src/commands/export.js +3 -3
  90. package/src/commands/motion.js +2 -2
  91. package/src/commands/objects.js +105 -10
  92. package/src/commands/project.js +16 -3
  93. package/src/commands/sculpt-images.js +53 -0
  94. package/src/commands/shot.js +88 -10
  95. package/src/commands/view.js +10 -4
  96. package/src/coplanar-depth.js +216 -0
  97. package/src/cuts.js +7 -6
  98. package/src/domains/cast.js +60 -43
  99. package/src/domains/motion/motion-intent-codec.js +50 -0
  100. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  101. package/src/domains/motion/motion-video-capture.js +664 -0
  102. package/src/domains/motion.js +253 -742
  103. package/src/domains/objects.js +106 -5
  104. package/src/domains/scenes.js +89 -32
  105. package/src/domains/shots.js +33 -7
  106. package/src/error-boundary.jsx +1 -1
  107. package/src/execution-telemetry.js +5 -5
  108. package/src/facing-marks.js +14 -1
  109. package/src/first-success-guide.jsx +35 -4
  110. package/src/gizmo-claim.js +3 -1
  111. package/src/grid-floor.jsx +1 -4
  112. package/src/hierarchy-model.js +48 -35
  113. package/src/hierarchy-panel.css +1 -0
  114. package/src/hierarchy-panel.jsx +54 -102
  115. package/src/history.js +0 -2
  116. package/src/i2v-motion-client.js +1 -4
  117. package/src/ime.js +6 -0
  118. package/src/keyframe-pack.js +15 -6
  119. package/src/line-edit.js +4 -4
  120. package/src/live-control.js +6 -6
  121. package/src/matte-editor.js +2 -8
  122. package/src/matte.js +0 -19
  123. package/src/mesh-graph-clone.js +16 -2
  124. package/src/mesh-sniff.js +0 -1
  125. package/src/monotone-cubic.js +40 -0
  126. package/src/motion/generation.js +1 -1
  127. package/src/motion-store.js +4 -4
  128. package/src/motion-trail.js +5 -11
  129. package/src/multimodel-ingest.js +1 -3
  130. package/src/object-catalog.jsx +7 -5
  131. package/src/object-gizmo.jsx +45 -31
  132. package/src/object-path.js +310 -67
  133. package/src/object-travel.js +224 -0
  134. package/src/otio.js +8 -3
  135. package/src/panels/CharacterTransformPanel.jsx +24 -6
  136. package/src/panels/ObjectTransformPanel.jsx +4 -3
  137. package/src/panels/PoseImagePanel.jsx +205 -0
  138. package/src/panels/PromptBlocksPanel.jsx +3 -1
  139. package/src/panels/ProxyFigurePanel.jsx +56 -0
  140. package/src/panels/ReferenceImageField.jsx +1 -1
  141. package/src/panels/RigControlPanel.jsx +24 -3
  142. package/src/panels/RigPanel.jsx +2 -1
  143. package/src/panels/SculptPanel.jsx +405 -0
  144. package/src/panels/SubjectBox.jsx +1 -1
  145. package/src/panels/SubjectsPanel.jsx +0 -2
  146. package/src/panels/VideoCapturePanel.jsx +2 -1
  147. package/src/panels/pose-image.css +42 -0
  148. package/src/panels/pose.css +3 -0
  149. package/src/panels/sculpt.css +162 -0
  150. package/src/plan-diagram.js +184 -0
  151. package/src/planview.jsx +127 -80
  152. package/src/playground.js +3 -5
  153. package/src/pose-fade.js +77 -0
  154. package/src/pose-gizmo.css +23 -0
  155. package/src/pose-gizmo.js +96 -0
  156. package/src/posestudio.jsx +134 -83
  157. package/src/project-browser.css +53 -0
  158. package/src/project-browser.jsx +52 -3
  159. package/src/project.js +55 -24
  160. package/src/props.jsx +177 -36
  161. package/src/proxy-figure.jsx +69 -0
  162. package/src/range-pin-panel.jsx +0 -1
  163. package/src/receipt-findings.js +20 -0
  164. package/src/render-passes.js +0 -1
  165. package/src/resource-status.jsx +1 -1
  166. package/src/root-path.js +48 -0
  167. package/src/route-owner.js +52 -0
  168. package/src/scene-asset-cache.js +1 -2
  169. package/src/scene-assets.js +12 -4
  170. package/src/scene-fbx.js +1 -1
  171. package/src/scene-mesh-cache.js +0 -1
  172. package/src/scene-objects.js +319 -19
  173. package/src/scenes.js +62 -17
  174. package/src/sculpt-blueprint.js +65 -0
  175. package/src/sculpt-compare.js +384 -0
  176. package/src/sculpt-geometry.js +141 -0
  177. package/src/sculpt-mode.js +168 -0
  178. package/src/sculpt-recipe.js +385 -0
  179. package/src/sculpt-run.js +351 -0
  180. package/src/sculpt-stage.jsx +221 -0
  181. package/src/settings-menu.jsx +1 -1
  182. package/src/shell/BoardSlot.jsx +341 -0
  183. package/src/shell/BottomDock.jsx +58 -14
  184. package/src/shell/DetailsSlot.jsx +26 -6
  185. package/src/shell/MenuBar.jsx +78 -6
  186. package/src/shell/OutlinerSlot.jsx +3 -1
  187. package/src/shell/TopBar.jsx +17 -3
  188. package/src/shell/ViewportToolbar.jsx +18 -3
  189. package/src/shell/dock.css +239 -0
  190. package/src/shell/glass-regions.css +3 -2
  191. package/src/shell/log-store.js +4 -4
  192. package/src/shell/mode.css +20 -0
  193. package/src/shell/studio-shell-context.js +4 -1
  194. package/src/shot-authoring.js +69 -6
  195. package/src/stable-items.js +1 -1
  196. package/src/stage-attach-frames.js +169 -0
  197. package/src/stage-canvas-rigs.jsx +402 -0
  198. package/src/stage-capture.jsx +216 -0
  199. package/src/stage-characters.jsx +302 -0
  200. package/src/stage-editor-gizmos.jsx +1229 -0
  201. package/src/stage-labels-ko.js +204 -0
  202. package/src/stage-startup-defaults.js +182 -0
  203. package/src/starter-scenes.js +1 -1
  204. package/src/storyboard.js +24 -5
  205. package/src/studio-actions.js +42 -9
  206. package/src/studio-agent-commands.js +186 -23
  207. package/src/studio-agent-context.js +11 -9
  208. package/src/studio-agent-motion.js +2 -1
  209. package/src/studio-agent-protocol.js +56 -23
  210. package/src/studio-app-binding.js +52 -23
  211. package/src/studio-contact-sheet.js +3 -3
  212. package/src/studio-elements.js +11 -2
  213. package/src/studio-geometry-facts.js +114 -0
  214. package/src/styles.css +111 -10
  215. package/src/theme.js +1 -1
  216. package/src/timeline-fps.js +13 -0
  217. package/src/ui-scrub.js +4 -4
  218. package/src/ui.jsx +6 -4
  219. package/src/use-asset-shelf.js +60 -0
  220. package/src/use-camera-tutorial.js +50 -0
  221. package/src/use-embed-bridge.js +12 -0
  222. package/src/use-first-success-prompt.js +7 -0
  223. package/src/use-i2v-capture-modal.js +15 -0
  224. package/src/use-sculpt-mode.js +95 -0
  225. package/src/workflow/AgentPanel.jsx +110 -20
  226. package/src/workflow/WorkflowBuilder.jsx +1 -1
  227. package/src/workflow/agent-client.js +113 -45
  228. package/src/workflow/agent-panel.css +35 -0
  229. package/src/workflow/attachment-image.js +1 -1
  230. package/src/workflow/canvas-commands.js +3 -3
  231. package/src/workflow/cozy-scene-node.js +3 -3
  232. package/src/workflow/local-workflow.js +2 -2
  233. package/src/workflow/motion-input.js +3 -3
  234. package/src/workflow/node-schema.js +1 -1
  235. package/src/workflow/scene-asset-sync.js +2 -9
  236. package/src/workflow/shot-prompt-node.js +1 -1
  237. package/src/workflow/video-contract.js +1 -1
  238. package/src/workflow/workflow-resources.js +0 -8
  239. package/src/workflow-send.js +87 -0
  240. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  241. package/tools/check-eslint.mjs +57 -0
  242. package/tools/check-knip.mjs +60 -0
  243. package/tools/check-loc.mjs +54 -0
  244. package/tools/demo-worker/api-client.mjs +7 -13
  245. package/tools/demo-worker/generate.mjs +2 -4
  246. package/tools/eslint-baseline.json +23 -0
  247. package/tools/kimodo/effector-constraints.mjs +4 -4
  248. package/tools/kimodo/generate.mjs +1 -1
  249. package/tools/knip-baseline.json +331 -0
  250. package/tools/loc-baseline.json +126 -0
  251. package/tools/process-supervisor.mjs +1 -1
  252. package/tools/projflow/replay.mjs +4 -4
  253. package/tools/projflow/runner.mjs +3 -3
  254. package/tools/qa/studio-control-count.mjs +2 -0
  255. package/tools/run-tests.mjs +96 -7
  256. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  257. package/dist/assets/app-C1sdVbg4.css +0 -1
  258. package/dist/assets/app-CA55tcI3.js +0 -4876
  259. package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
  260. package/dist/assets/workflow-dNPGY9xz.js +0 -179
@@ -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, 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,9 +279,14 @@ 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; }
@@ -281,6 +305,8 @@ export function createStudioAppBinding(ports) {
281
305
  .then(verification => { verified[index] = verification; }, error => { if (!(error instanceof StudioProtocolError)) throw error; skipped.push(`${name}: ${error.message}`); }));
282
306
  });
283
307
  }
308
+ // A frame is the shot camera's view; a plan is the Top View, the set from above
309
+ // with names and the shot camera's wedge.
284
310
  if (args.visual !== "none") {
285
311
  if (args.visual === "contact_sheet") {
286
312
  // One image of frames across the range, each rendered through the export
@@ -288,7 +314,7 @@ export function createStudioAppBinding(ports) {
288
314
  const frames = sampleContactSheetFrames(args.range, s.frameCount), sheet = buildContactSheet(frames, ports.renderFrameBuffer), imageId = crypto.randomUUID();
289
315
  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
316
  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 }); }
317
+ } 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
318
  }
293
319
  const finish = () => {
294
320
  const computed = verified.filter(Boolean);
@@ -312,17 +338,20 @@ export function createStudioAppBinding(ports) {
312
338
  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
339
  inspect_studio(args) {
314
340
  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",
341
+ // Every scope carries the revision and document identity, not the whole
342
+ // context (the turn already holds that, and resending it per call is what
343
+ // the model pays for): the revision is what the agent's next command is
344
+ // admitted at, so a scope without it leaves that admission stale.
345
+ const head = { revision: c.revision, host: (({ workspaceId, documentEpoch, sceneId, sceneEpoch }) => ({ workspaceId, documentEpoch, sceneId, sceneEpoch }))(c.host) };
346
+ if (command.args.scope === "catalogue") return { ...head, ...studioObjectCatalogue() };
347
+ if (command.args.scope === "document") return { ...head, scope: "document",
319
348
  ...readElementDocument(refresh().document, command.args, c.host.sceneId) };
320
349
  // Discovery for run_action: every registered action with its label, kind,
321
350
  // exposure and availability (the reason when unavailable). Schemas are on
322
351
  // request: ids answer those actions' full declarations, input included.
323
352
  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) };
353
+ const actions = (ports.actions?.()?.list() ?? []).filter(row => !row.agentHidden);
354
+ return { ...head, actions: command.args.ids ? actions.filter(row => command.args.ids.includes(row.id)) : actions.map(({ input, description, ...row }) => row) };
326
355
  }
327
356
  const s = refresh();
328
357
  const wanted = row => (!args.ids || args.ids.includes(row.id)) && (!args.query || Boolean(row.name?.includes(args.query)));
@@ -330,14 +359,14 @@ export function createStudioAppBinding(ports) {
330
359
  const select = { shot: ["shot"], motion: ["motion", "character"] }[command.args.scope];
331
360
  const ids = args.ids ?? (command.args.scope === "selection" ? [s.selection?.id ?? s.host.sceneId]
332
361
  : 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) };
362
+ return { ...head, scope: "document", ...readElementDocument(s.document, { ...command.args, ids, select }, c.host.sceneId) };
334
363
  }
335
364
  // Build each page from the same complete authoritative projection; never
336
365
  // page by slicing an already-truncated Send context.
337
366
  // Stable id order, so an offset cursor survives unrelated edits.
338
367
  const filtered = entityProjection(s).filter(wanted).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
339
368
  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,
369
+ return { ...head, entities: filtered.slice(offset, offset + limit), total: filtered.length,
341
370
  nextCursor: offset + limit < filtered.length ? studioEntityCursor(c, offset + limit) : null };
342
371
  },
343
372
  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
+ }
package/src/styles.css CHANGED
@@ -847,6 +847,9 @@ select {
847
847
  display: none;
848
848
  }
849
849
  .app[data-embed-mode="playground"] .tl-view-tools { display: none !important; }
850
+ /* The v2 Sequencer styles sit outside the legacy layer, so the lane rule above
851
+ needs !important to keep only the Shots lane. */
852
+ .app[data-embed-mode="playground"] .tl-track:not(.shots) { display: none !important; }
850
853
  /* Title bar keeps nothing but the pane's own name: the drawn dolly plays
851
854
  full-screen through the shot camera's look-through button, and Esc is the
852
855
  way back to flying. */
@@ -855,6 +858,8 @@ select {
855
858
  /* The plan inset only matters while a rail is being drawn into it. */
856
859
  .app[data-embed-mode="playground"]:not([data-rail-draw="1"]) .vp-inset.plan { display: none !important; }
857
860
  .app[data-embed-mode="playground"][data-rail-draw="1"] .vp-inset.plan { width: 44% !important; height: 62% !important; }
861
+ /* The shot monitor would sit over the map's corner while the rail is drawn. */
862
+ .app[data-embed-mode="playground"][data-rail-draw="1"] .vp-shot-preview { display: none !important; }
858
863
  .app[data-embed-mode="playground"] .bottom-window,
859
864
  .app[data-embed-mode="playground"] .bottom-timeline {
860
865
  flex: 0 0 auto;
@@ -865,7 +870,7 @@ select {
865
870
  .app[data-embed-mode="playground"] .timeline.empty-shots { height: auto !important; min-height: 0 !important; max-height: none; }
866
871
  .app[data-embed-mode="playground"] .tl-body { flex: 0 0 auto; overflow: hidden; }
867
872
  .app[data-embed-mode="playground"] .tl-body { min-height: 0 !important; height: auto !important; }
868
- .app[data-embed-mode="playground"] .tl-surface { height: auto !important; min-height: 0; grid-template-rows: auto auto !important; }
873
+ .app[data-embed-mode="playground"] .tl-surface { height: auto !important; min-height: 0 !important; grid-template-rows: auto auto !important; }
869
874
  /* The Shots lane keeps its full-studio height so the block's hover actions
870
875
  (Duplicate / Delete) sit in a strip above the clickable body instead of
871
876
  covering it. */
@@ -875,7 +880,8 @@ select {
875
880
  /* The shot monitor is the pane the tutorial points at: keep it small. */
876
881
  .app[data-embed-mode="playground"] .vp-shot-preview { width: 22% !important; height: auto !important; aspect-ratio: 16 / 9; right: 12px; bottom: 12px; }
877
882
  .app[data-embed-mode="playground"] .vp-shot-preview-tag { font-size: 12px; }
878
- .app[data-embed-mode="playground"] .vp-look-through { width: 24px; height: 24px; }
883
+ /* The look-through button carries its label now; keep it on one line. */
884
+ .app[data-embed-mode="playground"] .vp-look-through { height: 24px; padding: 0 8px; white-space: nowrap; }
879
885
  .app[data-embed-mode="playground"] .vp-look-through svg { width: 15px; height: 15px; }
880
886
  /* Tutorial hints: the landing page names the control the current step needs. */
881
887
  .app[data-playground-hint="look"] .vp-look-through,
@@ -899,7 +905,8 @@ select {
899
905
  .app[data-tutorial-step="shot"] .tl-track.shots .tl-track-add,
900
906
  .app[data-tutorial-step="rail"] .tl-track.shots .tl-shot-block:not(.selected),
901
907
  .app[data-tutorial-step="rail"] .tl-rail-draw,
902
- .app[data-tutorial-step="play"] .vp-look-through {
908
+ .app[data-tutorial-step="play"] .vp-look-through,
909
+ .app[data-tutorial-step="play"] .dock-shot-look {
903
910
  animation: tutorial-spotlight 1.5s var(--ease) infinite;
904
911
  box-shadow: 0 0 0 3px var(--accent);
905
912
  }
@@ -3031,6 +3038,28 @@ input[type=range]::-moz-range-thumb {
3031
3038
  color: #eafff3;
3032
3039
  }
3033
3040
 
3041
+ /* The selected dot's lean: two small scrub fields, the word as their badge. */
3042
+ .objmo-lean {
3043
+ display: inline-flex;
3044
+ align-items: stretch;
3045
+ gap: 6px;
3046
+ }
3047
+
3048
+ .objmo-lean .number-field {
3049
+ flex: none;
3050
+ width: 104px;
3051
+ }
3052
+
3053
+ .objmo-lean .number-field .axis {
3054
+ width: auto;
3055
+ padding: 0 6px;
3056
+ font-size: 10px;
3057
+ }
3058
+
3059
+ .number-field.disabled { opacity: .45; }
3060
+ .number-field.disabled .axis,
3061
+ .number-field.disabled input { cursor: not-allowed; }
3062
+
3034
3063
  .tl-path-hint {
3035
3064
  margin-left: auto;
3036
3065
  color: #7fae95;
@@ -3135,9 +3164,22 @@ input[type=range]::-moz-range-thumb {
3135
3164
  vector-effect: non-scaling-stroke;
3136
3165
  }
3137
3166
 
3138
- .sg-cut-diamond { fill: #ffb454; }
3167
+ /* The pin head lives outside the SVG: the graph's 1×1 viewBox is stretched to
3168
+ the lane, so anything drawn inside it is as wide as the lane is long. */
3169
+ .sg-cut-pin {
3170
+ position: absolute;
3171
+ top: 3px;
3172
+ width: 9px;
3173
+ height: 9px;
3174
+ padding: 0;
3175
+ border: 0;
3176
+ border-radius: 2px;
3177
+ background: #ffb454;
3178
+ transform: translateX(-50%) rotate(45deg);
3179
+ cursor: pointer;
3180
+ }
3139
3181
 
3140
- .sg-pin.selected .sg-cut-diamond { fill: #ffd08a; }
3182
+ .sg-cut-pin.selected { background: #ffd08a; box-shadow: 0 0 0 2px rgba(255, 208, 138, .35); }
3141
3183
  .sg-pin.selected .sg-cut { stroke-width: 2; stroke-dasharray: none; }
3142
3184
 
3143
3185
  .sg-cut-pick {
@@ -3333,7 +3375,9 @@ input[type=range]::-moz-range-thumb {
3333
3375
  .sg-shot .sg-line { stroke: #b9a1e8; }
3334
3376
  .sg-shot .sg-axis, .sg-shot .sg-average { stroke: rgba(185, 161, 232, .4); }
3335
3377
  .sg-shot .sg-readout { background: #4a3d6e; }
3336
- .sg-shot .sg-scale-top, .sg-shot .sg-scale-avg, .sg-shot .sg-scale-zero { color: #7d6a9e; }
3378
+ /* The box's own width is its clock and it has no room for an axis: a dozen
3379
+ pixels tall, three marks would sit on top of one another. */
3380
+ .sg-bare .sg-scale-top, .sg-bare .sg-scale-avg, .sg-bare .sg-scale-zero { display: none; }
3337
3381
 
3338
3382
 
3339
3383
 
@@ -3389,10 +3433,13 @@ input[type=range]::-moz-range-thumb {
3389
3433
  pointer-events: none;
3390
3434
  }
3391
3435
 
3436
+ /* One axis, one edge: all three scale marks sit on the left. */
3392
3437
  .sg-scale-top,
3393
- .sg-scale-avg {
3438
+ .sg-scale-avg,
3439
+ .sg-scale-zero {
3394
3440
  position: absolute;
3395
3441
  left: 4px;
3442
+ right: auto;
3396
3443
  color: #7fae95;
3397
3444
  font-size: 8px;
3398
3445
  pointer-events: none;
@@ -5991,6 +6038,12 @@ input[type=range]::-moz-range-thumb {
5991
6038
  border-radius: 3px;
5992
6039
  }
5993
6040
 
6041
+ /* an Empty has no colour: a dashed, hollow swatch reads as "nothing to draw" */
6042
+ .add-object-swatch.empty {
6043
+ background: transparent !important;
6044
+ border-style: dashed;
6045
+ }
6046
+
5994
6047
  /* inspector cards clip their overflow, so the menu opens inline there */
5995
6048
  .card .add-object {
5996
6049
  display: block;
@@ -6802,10 +6855,9 @@ input[type=range]::-moz-range-thumb {
6802
6855
 
6803
6856
  /* Keep the timeline focused on the selected workflow. Pose-correction and
6804
6857
  take-generation controls are useful while authoring motion, but they are
6805
- noise during blocking and camera work. The controls stay mounted so the
6806
- mode switch is lossless; only their always-on chrome is hidden. */
6858
+ noise during blocking and camera work. The motion clear action stays
6859
+ visible in Stage so a loaded take is always reversible. */
6807
6860
  .app[data-workflow-mode="scene"] .tl-pose-tools,
6808
- .app[data-workflow-mode="scene"] .tl-motion-tools,
6809
6861
  .app[data-workflow-mode="scene"] .take-bar,
6810
6862
  .app[data-workflow-mode="camera"] .tl-pose-tools,
6811
6863
  .app[data-workflow-mode="camera"] .tl-motion-tools,
@@ -9427,6 +9479,23 @@ input[type=range] {
9427
9479
  width: clamp(220px, calc(50% - 92px), 360px);
9428
9480
  }
9429
9481
 
9482
+ /* The floating shell covers the full-bleed viewport with panels. Keep the
9483
+ instructions inside its open stage, clear of the tool rail and dock. */
9484
+ .app[data-shell="glass"]:not([data-embed-mode]) .camera-tutorial {
9485
+ top: calc(var(--shell-view-top, 52px) + 12px);
9486
+ left: calc(var(--shell-center-left) + 64px);
9487
+ width: min(430px, calc(100% - var(--shell-center-left) - var(--shell-center-right) - 128px));
9488
+ }
9489
+ .app[data-shell="glass"][data-tutorial-step="rail"] .camera-tutorial,
9490
+ .app[data-shell="glass"] .camera-tutorial[data-previewing="1"] {
9491
+ top: auto;
9492
+ bottom: calc(var(--shell-center-bottom) + 12px);
9493
+ }
9494
+ .app[data-shell="glass"]:not([data-embed-mode]) .tutorial-cue {
9495
+ left: calc((var(--shell-center-left) + 100% - var(--shell-center-right)) / 2);
9496
+ bottom: calc(var(--shell-center-bottom) + 18px);
9497
+ }
9498
+
9430
9499
  .camera-tutorial-steps {
9431
9500
  display: flex;
9432
9501
  flex-wrap: wrap;
@@ -10199,4 +10268,36 @@ input[type=range] {
10199
10268
  margin: 8px 0 0;
10200
10269
  color: var(--muted);
10201
10270
  }
10271
+
10272
+ /* Sculpt mode (#739): the turntable owns the main pane and the shot preview
10273
+ shows the scene, so the Top-View inset has nothing to add. */
10274
+ .app[data-workflow-mode="sculpt"] .vp-inset { display: none !important; }
10275
+
10276
+ /* The empty turntable says what the mode is waiting for, under the mode pill
10277
+ (the dock floats over the pane's foot). Overlay chrome, like the viewport
10278
+ pills: it never takes the pointer from the orbit. */
10279
+ .vp-sculpt-hint {
10280
+ position: absolute;
10281
+ left: 50%;
10282
+ top: calc(2 * var(--sp-2, 12px) + var(--h-control, 28px));
10283
+ z-index: 7;
10284
+ display: grid;
10285
+ gap: var(--sp-1, 4px);
10286
+ max-width: min(420px, calc(100% - 2 * var(--sp-3, 16px)));
10287
+ padding: var(--sp-2, 8px) var(--sp-3, 16px);
10288
+ border-radius: var(--r-2, 6px);
10289
+ background: var(--overlay);
10290
+ color: var(--text-2);
10291
+ font-size: 11px;
10292
+ line-height: 1.45;
10293
+ text-align: center;
10294
+ transform: translateX(-50%);
10295
+ pointer-events: none;
10296
+ }
10297
+
10298
+ .vp-sculpt-hint strong {
10299
+ color: var(--text-1);
10300
+ font-size: 12px;
10301
+ font-weight: 600;
10302
+ }
10202
10303
  }
package/src/theme.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // shell, its regions and body-level popovers all read the same token set.
3
3
  export const THEMES = ["dark", "light"];
4
4
  export const THEME_KEY = "cozyclay.theme.v1";
5
- export const DEFAULT_THEME = "dark";
5
+ export const DEFAULT_THEME = "light";
6
6
 
7
7
  import { useEffect, useState } from "react";
8
8
 
@@ -0,0 +1,13 @@
1
+ // The timeline clock, on its own so the agent sidecar (bin/agent) can read it
2
+ // without importing scenes.js, whose scene-object and motion-trail imports pull
3
+ // in `three` — a devDependency the npm package does not ship
4
+ // (test/verify-package-agent-imports.mjs).
5
+
6
+ /** v3 and older authored every frame number on ARDY's 20 fps clock; v4 reads
7
+ * them on the 24 fps production clock. The numbers are MULTIPLIED, never
8
+ * reinterpreted: a waypoint at frame 40 meant 2.0 s and must still mean 2.0 s,
9
+ * which is frame 48 — reinterpreting it would silently speed the scene up. */
10
+ export const LEGACY_FRAME_FPS = 20;
11
+ export const TIMELINE_FRAME_FPS = 24;
12
+ export const toTimelineFrame = (frame) =>
13
+ Math.round((frame * TIMELINE_FRAME_FPS) / LEGACY_FRAME_FPS);
package/src/ui-scrub.js CHANGED
@@ -28,7 +28,7 @@ export const SCRUB_THRESHOLD_PX = 4;
28
28
 
29
29
  /** Shift buys a quarter-speed pass for fine detail; Alt keeps its old 0.1x. */
30
30
  export const FINE_SCRUB_FACTOR = 0.25;
31
- export const EXTRA_FINE_SCRUB_FACTOR = 0.1;
31
+ const EXTRA_FINE_SCRUB_FACTOR = 0.1;
32
32
 
33
33
  /**
34
34
  * The meaningful span a full drag sweeps when the field does not name one.
@@ -39,8 +39,8 @@ export const SCRUB_RANGE_STEPS = 100;
39
39
 
40
40
  /** The span one full `SCRUB_TRAVEL_PX` drag covers for a field. */
41
41
  export function scrubRangeFor({ scrubRange, step } = {}) {
42
- if (typeof scrubRange === "number" && Number.isFinite(scrubRange) && scrubRange > 0) return scrubRange;
43
- const size = typeof step === "number" && Number.isFinite(step) && step > 0 ? step : 1;
42
+ if (Number.isFinite(scrubRange) && scrubRange > 0) return scrubRange;
43
+ const size = Number.isFinite(step) && step > 0 ? step : 1;
44
44
  return size * SCRUB_RANGE_STEPS;
45
45
  }
46
46
 
@@ -95,7 +95,7 @@ export function createScrubGesture({ x, value, step, precision, scrubRange }) {
95
95
  * 1.7000000000000002 in the input.
96
96
  */
97
97
  export function snapScrubValue(value, { step, precision = 2 } = {}) {
98
- const size = typeof step === "number" && Number.isFinite(step) && step > 0 ? step : 0;
98
+ const size = Number.isFinite(step) && step > 0 ? step : 0;
99
99
  const snapped = size > 0 ? Math.round(value / size) * size : value;
100
100
  const digits = Number.isFinite(precision) ? Math.max(0, Math.min(15, Math.round(precision))) : 2;
101
101
  return Number(snapped.toFixed(digits));