cozyclay 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/LIVE-PROTOCOL.md +1 -1
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. package/src/scene-history.js +0 -129
@@ -0,0 +1,379 @@
1
+ // The Studio's agent binding: inspection, admission and the agent's commands
2
+ // over the editor's native state. App.jsx supplies the ports (reads, commits,
3
+ // history, the action registry); this module owns no React or renderer state.
4
+ import { createCommandBus } from "./command-bus.js";
5
+ import { readElementDocument, elementReadback, elementPatchArgs, elementTarget, elementPatchReceipt } from "./commands/elements.js";
6
+ import { physicsKeyStamp } from "./ardy/physics-review.js";
7
+ import { shotAtFrame } from "./cuts.js";
8
+ import { CUTOUT_KIND, MESH_KIND, OBJECT_LIBRARY, supportHeightForObject } from "./scene-objects.js";
9
+ import { buildStudioContext, physicsFingerprintInput, studioEntityCursor, validateStudioCursor } from "./studio-agent-context.js";
10
+ import { createStudioCommandJournal, framingChecks, placementChecks, studioObjectCatalogue } from "./studio-agent-commands.js";
11
+ import { verifyInstalledTake } from "./studio-agent-motion.js";
12
+ import { STUDIO_TOOL_FAMILIES, StudioProtocolError, validateReceipt, validateStudioCommand, validateStudioIdentity } from "./studio-agent-protocol.js";
13
+ import { CONTACT_SHEET_LAYOUT, buildContactSheet, sampleContactSheetFrames } from "./studio-contact-sheet.js";
14
+
15
+ // App-owned adapter: the shared command modules remain the only
16
+ // planners and validators. Ports below publish through the native editor stores.
17
+ export function createStudioAppBinding(ports) {
18
+ const fail = (code, message) => { throw new StudioProtocolError(code, message); };
19
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
20
+ const identities = new WeakMap(); let identitySequence = 0, tokenSequence = 0;
21
+ const motionStamps = new Map(), calibrationStamps = new Map();
22
+ const identityOf = value => {
23
+ if (!value || typeof value !== "object") return 0;
24
+ if (!identities.has(value)) identities.set(value, ++identitySequence);
25
+ return identities.get(value);
26
+ };
27
+ const stableStamp = (stamps, key) => {
28
+ if (key === null) return 0;
29
+ if (!stamps.has(key)) stamps.set(key, stamps.size + 1);
30
+ return stamps.get(key);
31
+ };
32
+ const motionContentKey = value => {
33
+ if (!value || typeof value !== "object") return null;
34
+ if (typeof value.studioTakeId === "string" && value.studioTakeId) return value.studioTakeId;
35
+ if (typeof value.motionRef?.motionId === "string" && value.motionRef.motionId) return value.motionRef.motionId;
36
+ return `${value.frames ?? 0}:${value.fps ?? 0}:${value.rotMats?.length ?? 0}:${value.rootPos?.length ?? 0}:${value.posedJoints?.length ?? 0}`;
37
+ };
38
+ const calibrationContentKey = value => value && typeof value === "object" ? JSON.stringify(value) : null;
39
+ const tokens = new Map(), receipts = new Map(), images = new Map();
40
+ let owner = null, journal = null, actionBus = null;
41
+ const domainKeys = new Map(), domainRevisions = {};
42
+ let authoredKey, physicsKey, viewKey, observedSceneRevision = ports.revision.current;
43
+ let physicsRevision = 0, viewRevision = 0;
44
+ function refresh() {
45
+ const document = Object.assign({}, ...(ports.storeDomains?.() ?? []).map(domain => domain.document()));
46
+ const raw = { ...ports.read(), ...document, document };
47
+ const host = validateStudioIdentity(raw.host);
48
+ if (!same(owner, host)) {
49
+ owner = host; tokens.clear(); receipts.clear(); images.clear();
50
+ authoredKey = physicsKey = viewKey = undefined;
51
+ journal = createStudioCommandJournal({ host, isRetained: receipt => ports.isRetained(receipt) });
52
+ }
53
+ const characters = raw.characters.map(character => {
54
+ const target = raw.targets.get(character.id);
55
+ return { ...character, sessionMotion: identityOf(target?.motion),
56
+ ik: physicsKeyStamp(target?.ikState?.keys ?? new Map()), rig: target?.rig?.uuid ?? null };
57
+ });
58
+ // Runtime motion, IK and rig fields are derived from the active editor
59
+ // buffers, not authored document state. A fresh equivalent buffer object
60
+ // must not advance the scene clock on a read.
61
+ const authoredCharacters = raw.characters.map(({ sessionMotion, ik, rig, ...character }) => character);
62
+ // The stage is authored state too: a key-light or environment edit from any
63
+ // surface bumps the scene revision exactly like a cast or object edit.
64
+ const authored = JSON.stringify([raw.objects, authoredCharacters, raw.shots, raw.frameCount, raw.stage, document]);
65
+ if (authoredKey !== undefined && authoredKey !== authored && observedSceneRevision === ports.revision.current) ports.revision.current++;
66
+ authoredKey = authored; observedSceneRevision = ports.revision.current;
67
+ const liveIds = new Set([...raw.objects, ...raw.characters, ...raw.shots].map(row => row.id));
68
+ for (const id of tokens.keys()) if (!liveIds.has(id)) tokens.delete(id);
69
+ for (const entry of [...raw.objects, ...characters, ...raw.shots]) {
70
+ // Display-only name/tint changes never revoke a motion target.
71
+ const { name, subject, tint, identityImage, ...content } = entry;
72
+ const key = JSON.stringify(content), previous = tokens.get(entry.id);
73
+ if (!previous || previous.key !== key) tokens.set(entry.id, { key, token: `target-${++tokenSequence}`, incarnation: previous?.incarnation ?? crypto.randomUUID() });
74
+ }
75
+ const physical = physicsFingerprintInput({ floor: { model: "flat", y: 0 }, frameCount: raw.frameCount,
76
+ objects: raw.objects.map(o => ({ id: o.id, renderer: o.renderer,
77
+ position: { x: o.x, y: o.y ?? 0, z: o.z }, rotationDeg: { x: o.rotX ?? 0, y: o.rot ?? 0, z: o.rotZ ?? 0 },
78
+ scale: { x: o.scaleX, y: o.scaleY, z: o.scaleZ }, footprint: o.footprint, height: o.height,
79
+ supportY: supportHeightForObject(o), parentId: o.parent ?? null, attachment: o.attach ?? null, path: o.path ?? null, hidden: o.hidden === true })),
80
+ characters: raw.characters.map(c => {
81
+ const t = raw.targets.get(c.id), summary = characters.find(row => row.id === c.id);
82
+ const motionKey = motionContentKey(t?.motion), calibrationKey = calibrationContentKey(t?.motion?.sceneCalibration);
83
+ return { id: c.id, incarnation: tokens.get(c.id).incarnation, modelId: c.model ?? null,
84
+ rigId: t?.rig?.uuid ?? null, rigReady: Boolean(t?.rig), hidden: Boolean(c.hidden),
85
+ position: { x: c.x, y: c.y ?? 0, z: c.z }, yawDeg: c.rot ?? 0, scale: c.scale ?? 1,
86
+ takeId: t?.motion?.studioTakeId ?? null, sessionMotionId: motionKey ? `motion-${motionKey}` : null,
87
+ motionRevision: stableStamp(motionStamps, motionKey), calibrationRevision: stableStamp(calibrationStamps, calibrationKey),
88
+ ikRevision: ports.ikRevision(c.id, summary.ik),
89
+ waypoints: (c.layer?.waypoints ?? []).map(p => ({ frame: p.frame, position: { x: p.x, y: p.y ?? 0, z: p.z } })) };
90
+ }) });
91
+ const physicalKey = JSON.stringify(physical);
92
+ if (physicsKey !== undefined && physicsKey !== physicalKey) physicsRevision++;
93
+ physicsKey = physicalKey;
94
+ const nextViewKey = JSON.stringify([raw.selection, raw.activeCharacterId, raw.selectedShotId, raw.view, raw.camera]);
95
+ if (viewKey !== undefined && viewKey !== nextViewKey) viewRevision++;
96
+ viewKey = nextViewKey;
97
+ for (const [domain, value] of Object.entries({ objects: raw.objects, shot: raw.shots, stage: raw.stage, cast: authoredCharacters, motion: characters, ...document })) {
98
+ const key = JSON.stringify(value);
99
+ if (domainKeys.get(domain) !== key) domainRevisions[domain] = (domainRevisions[domain] ?? 0) + 1;
100
+ domainKeys.set(domain, key);
101
+ }
102
+ return { ...raw, host, revision: ports.revision.current, physicsRevision, viewRevision, domainRevisions: { ...domainRevisions } };
103
+ }
104
+ function guard(id) {
105
+ const raw = refresh(), token = tokens.get(id)?.token;
106
+ if (!token) fail("STALE_TARGET", "The exact target no longer exists.");
107
+ return { ...raw.host, targetId: id, token };
108
+ }
109
+ function readCommand() {
110
+ const s = refresh();
111
+ return { host: s.host, revision: s.revision, frame: s.view.frame, frameCount: s.frameCount,
112
+ objects: s.objects, characters: s.characters, activeCharacterId: s.activeCharacterId,
113
+ selectedShotId: s.selectedShotId, shotDocument: { shots: s.shots }, camera: s.camera, stage: s.stage,
114
+ filmback: s.filmback, manual: s.manual, floorY: 0, busy: s.busy };
115
+ }
116
+ function entityProjection(s) {
117
+ return [...s.characters.map(c => {
118
+ const t = s.targets.get(c.id);
119
+ return { id: c.id, kind: "character", token: tokens.get(c.id).token, name: c.subject || c.id,
120
+ 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
+ motion: { takeId: t?.motion?.studioTakeId ?? null, frames: t?.motion?.frames ?? 0,
122
+ 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
+ position: { x: o.x, y: o.y ?? 0, z: o.z }, yawDeg: o.rot ?? 0,
126
+ rotationDeg: { x: o.rotX ?? 0, y: o.rot ?? 0, z: o.rotZ ?? 0 }, scale: { x: o.scaleX, y: o.scaleY, z: o.scaleZ },
127
+ 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 }))];
129
+ }
130
+ const frameRange = row => ({ startFrame: row.startFrame, endFrameExclusive: row.endFrame + 1 });
131
+ function assetList(s) {
132
+ const catalogue = studioObjectCatalogue().objects.map(({ kind }) => {
133
+ const entry = OBJECT_LIBRARY.find(row => row.kind === kind);
134
+ return { kind, name: entry?.label ?? kind, type: entry?.group === "Primitives" ? "primitive" : "set-piece" };
135
+ });
136
+ const imported = new Map();
137
+ for (const o of s.objects) {
138
+ const assetId = o.renderer === CUTOUT_KIND ? o.sourceAssetId || o.assetId : o.renderer === MESH_KIND ? o.assetId : null;
139
+ if (assetId && !imported.has(assetId)) imported.set(assetId, { id: assetId, name: o.name || assetId, type: o.renderer === CUTOUT_KIND ? "image" : "mesh" });
140
+ }
141
+ return [...catalogue, ...imported.values()];
142
+ }
143
+ function context() {
144
+ const s = refresh(), entities = entityProjection(s), registry = ports.actions?.();
145
+ const shot = s.shots.find(row => row.id === s.selectedShotId) ?? shotAtFrame(s.shots, s.view.frame);
146
+ const range = frameRange;
147
+ return buildStudioContext({ schema: "studio-context-v1", host: { surface: "studio", ...s.host, workspaceHandle: s.workspaceHandle },
148
+ revision: { scene: s.revision, physics: s.physicsRevision, view: s.viewRevision },
149
+ 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 },
151
+ selection: s.selection, activeCharacterId: s.activeCharacterId, view: s.view,
152
+ shot: shot ? { id: shot.id, name: shot.name, range: range(shot), mode: shot.camera?.mode ?? "keys" } : null, camera: s.camera,
153
+ // buildStudioContext selects the detailed rows and writes the real page.
154
+ entities, entityPage: { returned: 0, total: 0, truncated: false, nextCursor: null },
155
+ shots: s.shots.map(row => ({ id: row.id, name: row.name, range: range(row), keyCount: row.cameraKeys.length })), shotsTruncated: false,
156
+ 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
+ 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 },
159
+ // Every registered command; buildStudioContext keeps only its id/label index.
160
+ ...(registry ? { actions: registry.list() } : {}) });
161
+ }
162
+ function readEnvironment() {
163
+ const s = refresh();
164
+ return { host: s.host, physicsRevision: s.physicsRevision, floor: { model: "flat", y: 0 }, objects: s.objects, frameCount: s.frameCount,
165
+ cast: s.characters.map(character => ({ character, ...s.targets.get(character.id) })) };
166
+ }
167
+ function remember(receipt) { if (receipt?.receiptId) receipts.set(receipt.receiptId, receipt); return receipt; }
168
+ function rejection(request, error, phase = "admission") {
169
+ // The refusal's own words are what the model acts on; the receipt keeps
170
+ // the first 120 characters the protocol carries.
171
+ const words = [...String(error?.message ?? "").trim()];
172
+ return validateReceipt({ ok: false, commandId: request.commandId, host: request.host ?? request.binding?.host,
173
+ code: error.code ?? "INVALID_ARGUMENT", phase, affectedIds: [], expectedTargets: [], currentTargets: [], mutated: false,
174
+ preserved: { authoredState: "unchanged" }, recovery: { action: "inspect", retryAllowed: false },
175
+ ...(words.length ? { message: words.length > 120 ? `${words.slice(0, 119).join("")}…` : words.join("") } : {}) });
176
+ }
177
+ function admit(request) {
178
+ const s = refresh();
179
+ if (!same(validateStudioIdentity(request.host), s.host)) fail("STALE_SCENE", "The live document changed.");
180
+ if (request.expectedRevision !== s.revision) fail("STALE_SCENE", "Authored state changed; obtain fresh intent.");
181
+ if (s.busy) fail("TARGET_BUSY", "Finish the current editor gesture first.");
182
+ return s;
183
+ }
184
+ /** Actual state of one action target after it ran. */
185
+ function actionReadback(id, s) {
186
+ const patched = Object.entries(s.document).flatMap(([kind, value]) => {
187
+ const target = elementTarget(kind, value, id, s.host.sceneId);
188
+ return target ? elementReadback(kind, target) : [];
189
+ });
190
+ if (id === s.host.sceneId) return { selection: s.selection, activeCharacterId: s.activeCharacterId, shotId: s.selectedShotId, view: s.view,
191
+ ...(patched.length ? { patched } : {}) };
192
+ if (patched.length) return { patched };
193
+ const shot = s.shots.find(row => row.id === id);
194
+ 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 };
198
+ }
199
+ /** One registered Studio action, run for the agent through the same
200
+ * registry the UI controls call. A mutation is bound to the native history
201
+ * entry it pushed, so its receipt is an ordinary journal receipt that
202
+ * undo_edit reverts; a job answers "started" and lands later. */
203
+ const patchRequests = new Map();
204
+ function commandBus() {
205
+ if (!actionBus) actionBus = createCommandBus({ registry: ports.actions(), ports: {
206
+ read: refresh, journal: () => {
207
+ const admitted = journal;
208
+ return { ...admitted, record: receipt => {
209
+ const patch = patchRequests.get(receipt.commandId);
210
+ return admitted.record(patch ? elementPatchReceipt(receipt, patch, refresh().document) : receipt);
211
+ } };
212
+ }, recordAction: (...args) => ports.recordAction(...args), beginAction: (...args) => ports.beginAction(...args),
213
+ readback: actionReadback, remember, receipt: id => receipts.get(id), isRetained: receipt => ports.isRetained(receipt),
214
+ canUndo: receipt => ports.canUndo(receipt), undo: () => ports.undo(), redo: () => ports.redo(),
215
+ history: redo => ports.history?.(redo), finishHistoryGesture: () => ports.finishHistoryGesture?.(),
216
+ readTarget: id => { refresh(); return tokens.get(id)?.token; },
217
+ captureToasts: listener => ports.captureToasts?.(listener), showRefusal: message => ports.showRefusal?.(message), emit: event => ports.emitCommandEvent?.(event),
218
+ } });
219
+ return actionBus;
220
+ }
221
+ function runAction(request, args) {
222
+ const { name: _name, args: _args, ...options } = request;
223
+ const result = commandBus().run(args.action, args.args, { ...options, origin: "agent", confirmationToken: args.confirmationToken ?? request.confirmationToken });
224
+ const answer = receipt => receipt.nextHost ? { ...receipt, host: receipt.nextHost } : receipt;
225
+ return result?.then ? result.then(answer) : answer(result);
226
+ }
227
+ function execute(request) {
228
+ refresh();
229
+ if (request.name === "run_action") return runAction(request, validateStudioCommand({ name: request.name, args: request.args }).args);
230
+ const alias = ports.actions?.().toolAlias?.(request.name);
231
+ if (alias) {
232
+ try { return runAction(request, { action: typeof alias.action === 'function' ? alias.action(request.args) : alias.action, args: alias.args(request.args) }); }
233
+ catch (error) { return rejection(request, error); }
234
+ }
235
+ const patchKind = request.name === "patch_elements" && request.args?.ops?.[0]?.target?.kind;
236
+ if (patchKind) {
237
+ try {
238
+ const args = elementPatchArgs(patchKind, request.args);
239
+ patchRequests.set(request.commandId, request);
240
+ return runAction(request, { action: `${patchKind}.set`, args });
241
+ } catch (error) { return rejection(request, error); }
242
+ finally { patchRequests.delete(request.commandId); }
243
+ }
244
+ const signature = JSON.stringify(request);
245
+ if (!same(request.host, owner)) return rejection(request, new StudioProtocolError("STALE_SCENE", "Document changed."));
246
+ try {
247
+ if (!journal.begin(request.commandId, signature)) return journal.get(request.commandId);
248
+ // Verification only observes: the document identity (checked above) is
249
+ // its whole fence, so a later edit never refuses it.
250
+ const { args } = validateStudioCommand({ name: request.name, args: request.args }), s = request.name === "verify_result" ? refresh() : admit(request);
251
+ if (request.name === "verify_result") {
252
+ const receipt = args.receiptId ? receipts.get(args.receiptId) : null;
253
+ if (args.receiptId && !receipt) fail("STALE_TARGET", "Receipt is not retained in this document.");
254
+ for (const id of args.targets ?? []) guard(id);
255
+ // A receipt edited over since is still evidence of what it did: return it
256
+ // marked stale with the revision it describes beside the current one.
257
+ const evidenceRevision = receipt ? receipt.revision.after : s.revision;
258
+ // Targets, and a receipt without evidence of its own, are measured now with
259
+ // the helpers the arrange/frame_shot receipts and the motion candidate use.
260
+ // Only a check nothing could compute is unsupported, and says why; a motion
261
+ // check computed for some characters still names the ones it skipped.
262
+ 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);
264
+ 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: {} };
266
+ const reasons = result.unsupportedReasons, verified = [], skipped = [], pending = [];
267
+ for (const check of args.checks.filter(check => check !== "motion" && measured)) {
268
+ if (!measured.length) { reasons[check] = "No target is an object or character in the current scene."; continue; }
269
+ try { result.checks[check] = check === "placement" ? placementChecks(measured, scene, { bounds: ports.bounds }) : framingChecks(measured, scene, { bounds: ports.bounds }); }
270
+ catch (error) { if (!(error instanceof StudioProtocolError)) throw error; reasons[check] = error.message; }
271
+ }
272
+ if (args.checks.includes("motion") && !receipt?.verification) {
273
+ const subjects = args.targets ?? receipt.affectedIds.filter(id => s.characters.some(c => c.id === id));
274
+ if (!subjects.length) skipped.push("The receipt affected no character.");
275
+ subjects.forEach((id, index) => {
276
+ const character = s.characters.find(c => c.id === id), target = s.targets.get(id), name = character?.subject || id;
277
+ if (!character) return skipped.push(`${id} is not a character; motion checks a character's take.`);
278
+ if (!target?.motion) return skipped.push(`${name} has no motion take to check.`);
279
+ if (!target.rig) return skipped.push(`${name}'s rig is not loaded yet.`);
280
+ pending.push(verifyInstalledTake({ target: { ...target, character }, environment: readEnvironment(), range: args.range === "whole_clip" ? undefined : args.range, poseCast: ports.poseCast })
281
+ .then(verification => { verified[index] = verification; }, error => { if (!(error instanceof StudioProtocolError)) throw error; skipped.push(`${name}: ${error.message}`); }));
282
+ });
283
+ }
284
+ if (args.visual !== "none") {
285
+ if (args.visual === "contact_sheet") {
286
+ // One image of frames across the range, each rendered through the export
287
+ // path, which puts the playhead pose, shot camera and props back.
288
+ const frames = sampleContactSheetFrames(args.range, s.frameCount), sheet = buildContactSheet(frames, ports.renderFrameBuffer), imageId = crypto.randomUUID();
289
+ 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
+ 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 }); }
292
+ }
293
+ const finish = () => {
294
+ const computed = verified.filter(Boolean);
295
+ if (computed.length) result.verification = computed.length === 1 ? computed[0] : computed;
296
+ if (skipped.length) reasons.motion = skipped.join(" ");
297
+ result.unsupportedChecks = args.checks.filter(check => reasons[check] && (check !== "motion" || !computed.length));
298
+ return result;
299
+ };
300
+ if (!pending.length) return finish();
301
+ // Motion evaluation yields between frames: answer when it settles, with the
302
+ // same rejection receipt a synchronous failure would journal.
303
+ return Promise.all(pending).then(finish).catch(error => {
304
+ const rejected = rejection(request, error);
305
+ return same(rejected.host, journal.host) ? journal.record(rejected) : rejected;
306
+ });
307
+ }
308
+ fail("CAPABILITY_MISSING", "Generation is owned by the server runtime.");
309
+ } catch (error) { const receipt = rejection(request, error); return journal.record(receipt); }
310
+ }
311
+ const handlers = {
312
+ 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
+ inspect_studio(args) {
314
+ 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",
319
+ ...readElementDocument(refresh().document, command.args, c.host.sceneId) };
320
+ // Discovery for run_action: every registered action with its label, kind,
321
+ // exposure and availability (the reason when unavailable). Schemas are on
322
+ // request: ids answer those actions' full declarations, input included.
323
+ 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) };
326
+ }
327
+ const s = refresh();
328
+ const wanted = row => (!args.ids || args.ids.includes(row.id)) && (!args.query || Boolean(row.name?.includes(args.query)));
329
+ if (["scene", "shot", "motion", "selection"].includes(command.args.scope)) {
330
+ const select = { shot: ["shot"], motion: ["motion", "character"] }[command.args.scope];
331
+ const ids = args.ids ?? (command.args.scope === "selection" ? [s.selection?.id ?? s.host.sceneId]
332
+ : 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) };
334
+ }
335
+ // Build each page from the same complete authoritative projection; never
336
+ // page by slicing an already-truncated Send context.
337
+ // Stable id order, so an offset cursor survives unrelated edits.
338
+ const filtered = entityProjection(s).filter(wanted).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
339
+ 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,
341
+ nextCursor: offset + limit < filtered.length ? studioEntityCursor(c, offset + limit) : null };
342
+ },
343
+ operate_studio: request => execute({ ...request, name: "operate_studio" }),
344
+ arrange_objects: request => execute({ ...request, name: "arrange_objects" }),
345
+ arrange_characters: request => execute({ ...request, name: "arrange_characters" }),
346
+ patch_elements: request => execute({ ...request, name: "patch_elements" }),
347
+ frame_shot: request => execute({ ...request, name: "frame_shot" }),
348
+ generate_motion: request => execute({ ...request, name: "generate_motion" }),
349
+ verify_result: request => execute({ ...request, name: "verify_result" }),
350
+ undo_edit: request => runAction(request, { action: "edit.undo", args: request.args }),
351
+ run_action: request => execute({ ...request, name: "run_action" }),
352
+ resolve_studio_image(request) {
353
+ refresh(); const image = images.get(request.imageId);
354
+ if (!image || (request.receiptId && request.receiptId !== image.receiptId) || (request.revision !== undefined && request.revision !== image.revision)) fail("STALE_TARGET", "Image observation does not belong to this receipt.");
355
+ return image;
356
+ },
357
+ reconcile_studio_command(request) { refresh(); const value = journal.reconcile({ commandId: request.commandId, host: request.host ?? request.binding?.host }); return value.status === "not_applied" ? { ...value, evidence: value.receipt } : value; },
358
+ };
359
+ function invalidate(domain, before, after) {
360
+ if (before === after) return;
361
+ if (domain === "pose") {
362
+ const id = ports.read().activeCharacterId, previous = tokens.get(id);
363
+ if (previous) tokens.set(id, { ...previous, key: null });
364
+ return;
365
+ }
366
+ if (!["characters", "objects"].includes(domain) || !Array.isArray(before) || !Array.isArray(after)) return;
367
+ const content = row => {
368
+ if (!row) return null;
369
+ const { subject, name, tint, identityImage, sessionMotion, ...rest } = row;
370
+ return { ...rest, motionIdentity: identityOf(sessionMotion) };
371
+ };
372
+ for (const row of before) {
373
+ const next = after.find(c => c.id === row.id), previous = tokens.get(row.id);
374
+ if (!next) tokens.delete(row.id);
375
+ else if (previous && !same(content(row), content(next))) tokens.set(row.id, { ...previous, key: null });
376
+ }
377
+ }
378
+ return { handlers, context, guard, refresh, invalidate, get bus() { refresh(); return commandBus(); }, dispose: () => { actionBus?.dispose(); } };
379
+ }
@@ -0,0 +1,73 @@
1
+ // verify_result's contact sheet: which frames of the range it shows, how each
2
+ // shot render shrinks, and where it lands on the one image the agent sees.
3
+ // Pure over RGBA byte buffers; App.jsx supplies the render and the encoder.
4
+
5
+ export const CONTACT_SHEET_COLUMNS = 3;
6
+ export const CONTACT_SHEET_ROWS = 2;
7
+ export const CONTACT_SHEET_LAYOUT = "3x2 row-major";
8
+ export const CONTACT_SHEET_MAX_SIDE = 1600;
9
+
10
+ /** Up to 6 frames spread evenly over a half-open range, first and last
11
+ * included; "whole_clip" (or no range) is timeline frames 0..frameCount-1. */
12
+ export function sampleContactSheetFrames(range, frameCount, count = CONTACT_SHEET_COLUMNS * CONTACT_SHEET_ROWS) {
13
+ const { startFrame, endFrameExclusive } = range && range !== "whole_clip" ? range : { startFrame: 0, endFrameExclusive: frameCount };
14
+ const length = endFrameExclusive - startFrame;
15
+ if (length <= count) return Array.from({ length }, (_, i) => startFrame + i);
16
+ return Array.from({ length: count }, (_, i) => startFrame + Math.round((i * (length - 1)) / (count - 1)));
17
+ }
18
+
19
+ /** Box filter: each output pixel is the mean of the source pixels it covers. */
20
+ export function boxDownscaleRgba(data, width, height, outWidth, outHeight) {
21
+ const out = new Uint8Array(outWidth * outHeight * 4);
22
+ for (let y = 0; y < outHeight; y += 1) {
23
+ const y0 = Math.floor((y * height) / outHeight), y1 = Math.max(y0 + 1, Math.floor(((y + 1) * height) / outHeight));
24
+ for (let x = 0; x < outWidth; x += 1) {
25
+ const x0 = Math.floor((x * width) / outWidth), x1 = Math.max(x0 + 1, Math.floor(((x + 1) * width) / outWidth));
26
+ let r = 0, g = 0, b = 0, a = 0;
27
+ for (let sy = y0; sy < y1; sy += 1) {
28
+ for (let sx = x0; sx < x1; sx += 1) {
29
+ const i = (sy * width + sx) * 4;
30
+ r += data[i]; g += data[i + 1]; b += data[i + 2]; a += data[i + 3];
31
+ }
32
+ }
33
+ const n = (y1 - y0) * (x1 - x0), o = (y * outWidth + x) * 4;
34
+ out[o] = Math.round(r / n); out[o + 1] = Math.round(g / n); out[o + 2] = Math.round(b / n); out[o + 3] = Math.round(a / n);
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /** Equal tiles row-major into one buffer; empty cells stay opaque black. With
41
+ * bottomUp the buffer's rows run bottom-up (WebGL read-back order), so the
42
+ * first row of tiles is the last block of rows. */
43
+ export function tileRgba(tiles, tileWidth, tileHeight, { columns = CONTACT_SHEET_COLUMNS, rows = CONTACT_SHEET_ROWS, bottomUp = false } = {}) {
44
+ const width = tileWidth * columns, height = tileHeight * rows, data = new Uint8Array(width * height * 4);
45
+ for (let i = 3; i < data.length; i += 4) data[i] = 255;
46
+ tiles.forEach((tile, index) => {
47
+ const column = index % columns, row = Math.floor(index / columns);
48
+ const top = (bottomUp ? rows - 1 - row : row) * tileHeight;
49
+ for (let y = 0; y < tileHeight; y += 1) {
50
+ data.set(tile.subarray(y * tileWidth * 4, (y + 1) * tileWidth * 4), ((top + y) * width + column * tileWidth) * 4);
51
+ }
52
+ });
53
+ return { data, width, height };
54
+ }
55
+
56
+ /** Render each frame (render(frame) -> { data, width, height }, rows bottom-up),
57
+ * shrink it so the whole sheet's longest side is at most maxSide, and tile
58
+ * the frames 3x2 row-major into one bottom-up buffer. */
59
+ export function buildContactSheet(frames, render, { maxSide = CONTACT_SHEET_MAX_SIDE } = {}) {
60
+ const tiles = [];
61
+ let tileWidth, tileHeight;
62
+ for (const frame of frames) {
63
+ const shot = render(frame);
64
+ if (tileWidth === undefined) {
65
+ const scale = Math.min(1, maxSide / Math.max(shot.width * CONTACT_SHEET_COLUMNS, shot.height * CONTACT_SHEET_ROWS));
66
+ tileWidth = Math.max(1, Math.floor(shot.width * scale));
67
+ tileHeight = Math.max(1, Math.floor(shot.height * scale));
68
+ }
69
+ // The renderer reuses one read-back buffer: shrink this frame before the next render.
70
+ tiles.push(boxDownscaleRgba(shot.data, shot.width, shot.height, tileWidth, tileHeight));
71
+ }
72
+ return tileRgba(tiles, tileWidth, tileHeight, { bottomUp: true });
73
+ }
@@ -1,6 +1,7 @@
1
1
  // The authored Studio surface, declared once for future patch, schema and
2
2
  // history layers. This module is intentionally data-only: it does not import
3
- // React or any persistence implementation.
3
+ // React or any persistence implementation. Fields use generic set/read;
4
+ // lifecycle and composite elements name the commands that own them.
4
5
 
5
6
  const freezeBounds = (bounds) => bounds && typeof bounds === "object"
6
7
  ? Object.freeze({ ...bounds })
@@ -9,62 +10,65 @@ const freezeBounds = (bounds) => bounds && typeof bounds === "object"
9
10
  const freezeEntry = (entry) => Object.freeze({
10
11
  ...entry,
11
12
  ...(entry.enum ? { enum: Object.freeze([...entry.enum]) } : {}),
13
+ ...(entry.actions ? { actions: Object.freeze([...entry.actions]) } : {}),
12
14
  ...(entry.min && typeof entry.min === "object" ? { min: freezeBounds(entry.min) } : {}),
13
15
  ...(entry.max && typeof entry.max === "object" ? { max: freezeBounds(entry.max) } : {}),
14
16
  ...(entry.gizmo ? { gizmo: Object.freeze({ min: freezeBounds(entry.gizmo.min), max: freezeBounds(entry.gizmo.max) }) } : {}),
15
17
  });
16
18
 
17
19
  const entries = [
18
- { path: "character.position", type: "vec3", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry", min: { x: -240, y: 0, z: -240 }, max: { x: 240, y: 240, z: 240 }, gizmo: { min: { x: -4, y: 0, z: -4 }, max: { x: 4, y: 240, z: 4 } }, note: "y is the feet" },
19
- { path: "character.rot", type: "number", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry", min: -180, max: 180, angle: true, note: "yaw degrees, wrapped at the upper bound" },
20
- { path: "character.scale", type: "number", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry", min: 0.2, max: 3 },
21
- { path: "character.subject", type: "string", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry" },
22
- { path: "character.hidden", type: "boolean", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry" },
23
- { path: "character.model", type: "enum", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry", enum: ["y-bot-tpose", "x-bot-tpose"] },
24
- { path: "character.tint", type: "color", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry" },
25
- { path: "character.identityImage", type: "image", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry", note: "data:image only" },
26
- { path: "character.pose", type: "id", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry" },
27
- { path: "character.waypoints", type: "array", persisted: true, undoDomain: "cast", agentExposure: "todo", normalizer: "createCharacterEntry", note: "agent exposure gap" },
28
- { path: "character.promptBlocks", type: "array", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry", note: "stored at layer.promptClips" },
29
- { path: "character.motionRef.url", type: "string", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry" },
30
- { path: "character.motionRef.motionId", type: "id", persisted: true, undoDomain: "cast", agentExposure: "patch", normalizer: "createCharacterEntry" },
31
- { path: "character.sessionMotion", type: "array", persisted: false, undoDomain: "cast", agentExposure: "readonly", normalizer: "createCharacterEntry", note: "dropped by createCharacterEntry" },
32
- { path: "character.ikKeys", type: "array", persisted: false, undoDomain: "cast", agentExposure: "todo", normalizer: "createCharacterEntry", note: "dropped by createCharacterEntry" },
33
- { path: "object.renderer", type: "enum", persisted: true, undoDomain: "objects", agentExposure: "patch", normalizer: "normalizeSceneObject", enum: ["cube", "sphere", "capsule", "cylinder", "cone", "plane", "chair", "car", "small-plane"] },
34
- { path: "object.position", type: "vec3", persisted: true, undoDomain: "objects", agentExposure: "patch", 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" },
35
- { path: "object.rotation", type: "vec3", persisted: true, undoDomain: "objects", agentExposure: "patch", 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" },
36
- { path: "object.scale", type: "vec3", persisted: true, undoDomain: "objects", agentExposure: "patch", normalizer: "normalizeSceneObject", min: { x: 0.1, y: 0.1, z: 0.1 }, max: { x: 100, y: 100, z: 100 } },
37
- { path: "object.name", type: "string", persisted: true, undoDomain: "objects", agentExposure: "patch", normalizer: "normalizeSceneObject" },
38
- { path: "object.color", type: "color", persisted: true, undoDomain: "objects", agentExposure: "patch", normalizer: "normalizeSceneObject" },
39
- { path: "object.parent", type: "id", persisted: true, undoDomain: "objects", agentExposure: "patch", normalizer: "normalizeSceneObject" },
40
- { path: "object.attach", type: "id", persisted: true, undoDomain: "objects", agentExposure: "todo", normalizer: "normalizeSceneObject", note: "agent exposure gap" },
41
- { path: "object.path", type: "array", persisted: true, undoDomain: "objects", agentExposure: "patch", normalizer: "normalizeSceneObject" },
42
- { path: "object.remove", type: "boolean", persisted: true, undoDomain: "objects", agentExposure: "patch", normalizer: null, note: "lifecycle operation, not a document field" },
43
- { path: "object.cutout", type: "image", persisted: true, undoDomain: "objects", agentExposure: "composite", normalizer: "normalizeSceneObject", note: "assetId-backed cutout record" },
44
- { path: "stage.keyLight.x", type: "number", persisted: true, undoDomain: "stage", agentExposure: "patch", normalizer: "createSceneStage", min: -30, max: 30 },
45
- { path: "stage.keyLight.y", type: "number", persisted: true, undoDomain: "stage", agentExposure: "patch", normalizer: "createSceneStage", min: 0.5, max: 30 },
46
- { path: "stage.keyLight.z", type: "number", persisted: true, undoDomain: "stage", agentExposure: "patch", normalizer: "createSceneStage", min: -30, max: 30 },
47
- { path: "stage.keyLight.intensity", type: "number", persisted: true, undoDomain: "stage", agentExposure: "patch", normalizer: "createSceneStage", min: 0, max: 4 },
48
- { path: "stage.keyLight.warmth", type: "number", persisted: true, undoDomain: "stage", agentExposure: "patch", normalizer: "createSceneStage", min: 0, max: 1 },
49
- { path: "stage.environmentImage", type: "image", persisted: true, undoDomain: "stage", agentExposure: "patch", normalizer: "createSceneStage", note: "data:image only" },
50
- { path: "stage.environment", type: "string", persisted: true, undoDomain: "stage", agentExposure: "patch", normalizer: "createSceneStage", note: "location description every shot prompt is built from" },
51
- { path: "stage.style", type: "string", persisted: true, undoDomain: "stage", agentExposure: "patch", normalizer: "createSceneStage", note: "look / style line for shot prompts" },
52
- { path: "stage.hasEnvSheet", type: "boolean", persisted: true, undoDomain: "stage", agentExposure: "patch", normalizer: "createSceneStage", note: "author supplies an environment sheet instead of a description" },
53
- { path: "stage.camera", type: "enum", persisted: true, undoDomain: "stage", agentExposure: "patch", normalizer: "createSceneStage", enum: ["16:9", "2.39:1", "9:16", "1:1", "4:3", "12:7", "fal 480P"], note: "shotAspect/cameraPresetId/sensorId" },
54
- { path: "shot.crud", type: "array", persisted: true, undoDomain: "shot", agentExposure: "todo", normalizer: null, note: "create/split/duplicate/reorder/remove/range; agent exposure gap" },
55
- { path: "shot.cameraKeys", type: "array", persisted: true, undoDomain: "shot", agentExposure: "patch", normalizer: null, frameMin: 0 },
56
- { path: "shot.cameraRail", type: "array", persisted: true, undoDomain: "shot", agentExposure: "todo", normalizer: "repairCamera", note: "rail/crane/dolly timing; agent exposure gap" },
57
- { path: "shot.targetModel", type: "id", persisted: true, undoDomain: "shot", agentExposure: "patch", normalizer: "repairCamera" },
58
- { path: "shot.freeCamera", type: "vec3", persisted: false, undoDomain: "shot", agentExposure: "readonly", normalizer: null, note: "transient until keyed" },
59
- { path: "scenes", type: "array", persisted: true, undoDomain: null, agentExposure: "composite", normalizer: null },
60
- { path: "project", type: "string", persisted: true, undoDomain: null, agentExposure: "composite", normalizer: null, note: "save/open project file" },
61
- { path: "selection", type: "id", persisted: false, undoDomain: null, agentExposure: "readonly", normalizer: null },
62
- { path: "timeline", type: "number", persisted: false, undoDomain: null, agentExposure: "readonly", normalizer: null },
63
- { path: "view.mode", type: "enum", persisted: false, undoDomain: null, agentExposure: "readonly", normalizer: null, enum: ["scene", "camera", "motion"] },
64
- { path: "view.partColoursGuideModeInset", type: "array", persisted: false, undoDomain: null, agentExposure: "todo", normalizer: null, note: "partColours/guideMode/inset; not a document field" },
65
- { path: "read.sceneDescription", type: "string", persisted: false, undoDomain: null, agentExposure: "readonly", normalizer: null },
66
- { path: "read.captureFrame", type: "image", persisted: false, undoDomain: null, agentExposure: "readonly", normalizer: null },
67
- { path: "undo", type: "boolean", persisted: false, undoDomain: null, agentExposure: "readonly", normalizer: null },
20
+ { path: "character.position", type: "vec3", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", min: { x: -240, y: 0, z: -240 }, max: { x: 240, y: 240, z: 240 }, gizmo: { min: { x: -4, y: 0, z: -4 }, max: { x: 4, y: 240, z: 4 } }, note: "y is the feet" },
21
+ { path: "character.rot", type: "number", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", min: -180, max: 180, angle: true, note: "yaw degrees, wrapped at the upper bound" },
22
+ { path: "character.scale", type: "number", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", min: 0.2, max: 3 },
23
+ { path: "character.subject", type: "string", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
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"] },
26
+ { path: "character.tint", type: "color", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
27
+ { path: "character.identityImage", type: "image", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", note: "data:image only" },
28
+ { path: "character.pose", type: "id", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
29
+ { path: "character.waypoints", type: "array", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", actions: ["character.addWaypoint", "character.moveWaypoint", "character.removeWaypoint", "character.clearWaypoints"], note: "root path pins, addressed by frame" },
30
+ { path: "character.promptBlocks", type: "array", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", note: "stored at layer.promptClips" },
31
+ { path: "character.motionRef.url", type: "string", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
32
+ { path: "character.motionRef.motionId", type: "id", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
33
+ { path: "character.sessionMotion", type: "array", persisted: false, undoDomain: "cast", normalizer: "createCharacterEntry", note: "dropped by createCharacterEntry" },
34
+ { 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"] },
36
+ { 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
+ { 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
+ { 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
+ { path: "object.name", type: "string", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
40
+ { path: "object.color", type: "color", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
41
+ { path: "object.parent", type: "id", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
42
+ { 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
+ { path: "object.path", type: "array", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
44
+ { path: "object.remove", type: "boolean", persisted: true, undoDomain: "objects", normalizer: null, note: "lifecycle operation, not a document field" },
45
+ { path: "object.cutout", type: "image", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", actions: ["asset.import"], note: "assetId-backed cutout record; imported pictures and models are placed by asset.import" },
46
+ { path: "stage.keyLight.x", type: "number", persisted: true, undoDomain: "stage", normalizer: "createSceneStage", min: -30, max: 30 },
47
+ { path: "stage.keyLight.y", type: "number", persisted: true, undoDomain: "stage", normalizer: "createSceneStage", min: 0.5, max: 30 },
48
+ { path: "stage.keyLight.z", type: "number", persisted: true, undoDomain: "stage", normalizer: "createSceneStage", min: -30, max: 30 },
49
+ { path: "stage.keyLight.intensity", type: "number", persisted: true, undoDomain: "stage", normalizer: "createSceneStage", min: 0, max: 4 },
50
+ { path: "stage.keyLight.warmth", type: "number", persisted: true, undoDomain: "stage", normalizer: "createSceneStage", min: 0, max: 1 },
51
+ { path: "stage.environmentImage", type: "image", persisted: true, undoDomain: "stage", normalizer: "createSceneStage", note: "data:image only" },
52
+ { path: "stage.environment", type: "string", persisted: true, undoDomain: "stage", normalizer: "createSceneStage", note: "location description every shot prompt is built from" },
53
+ { path: "stage.style", type: "string", persisted: true, undoDomain: "stage", normalizer: "createSceneStage", note: "look / style line for shot prompts" },
54
+ { path: "stage.hasEnvSheet", type: "boolean", persisted: true, undoDomain: "stage", normalizer: "createSceneStage", note: "author supplies an environment sheet instead of a description" },
55
+ { path: "stage.camera", documentPath: "shotAspect", type: "enum", persisted: true, undoDomain: "stage", normalizer: "createSceneStage", enum: ["16:9", "2.39:1", "9:16", "1:1", "4:3", "12:7", "fal 480P"], note: "output aspect ratio" },
56
+ { path: "stage.cameraPresetId", type: "id", nullable: true, persisted: true, undoDomain: "stage", normalizer: "createSceneStage" },
57
+ { path: "stage.sensorId", type: "enum", persisted: true, undoDomain: "stage", normalizer: "createSceneStage", enum: ["super16", "super35", "fullFrame", "65mm"] },
58
+ { path: "shot.crud", type: "array", persisted: true, undoDomain: "shot", normalizer: null, actions: ["shot.create", "shot.split", "shot.duplicate", "shot.remove", "shot.setRange", "shot.reorder"], note: "create/split/duplicate/reorder/remove/range through the shared action registry" },
59
+ { path: "shot.cameraKeys", type: "array", persisted: true, undoDomain: "shot", normalizer: null, frameMin: 0 },
60
+ { 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
+ { path: "shot.targetModel", type: "id", persisted: true, undoDomain: "shot", normalizer: "repairCamera" },
62
+ { path: "shot.freeCamera", type: "vec3", persisted: false, undoDomain: "shot", normalizer: null, note: "transient until keyed" },
63
+ { 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
+ { 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" },
65
+ { path: "selection", type: "id", persisted: false, undoDomain: null, normalizer: null },
66
+ { path: "timeline", type: "number", persisted: false, undoDomain: null, normalizer: null },
67
+ { path: "view.mode", type: "enum", persisted: false, undoDomain: null, normalizer: null, enum: ["scene", "pose", "camera", "motion"] },
68
+ { path: "view.partColoursGuideModeInset", type: "array", persisted: false, undoDomain: null, normalizer: null, actions: ["view.setPartColours", "view.setGuideMode", "view.setInset"], note: "partColours/guideMode/inset; viewer settings, not document fields" },
69
+ { path: "read.sceneDescription", type: "string", persisted: false, undoDomain: null, normalizer: null },
70
+ { path: "read.captureFrame", type: "image", persisted: false, undoDomain: null, normalizer: null },
71
+ { path: "undo", type: "boolean", persisted: false, undoDomain: null, normalizer: null },
68
72
  ];
69
73
 
70
74
  export const STUDIO_ELEMENTS = Object.freeze(entries.map(freezeEntry));
@@ -76,3 +80,6 @@ export function elementByPath(path) {
76
80
  export function elementsFor(normalizer) {
77
81
  return STUDIO_ELEMENTS.filter((entry) => entry.normalizer === normalizer);
78
82
  }
83
+
84
+ // One writable vocabulary for UI and agent commands, not a second allowlist.
85
+ export const isSettableElement = element => element.persisted !== false && !element.actions && !element.readOnly;