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
@@ -3,7 +3,11 @@ import { studioActionDeclaration } from "../studio-actions.js";
3
3
  import { characterOf, fail } from "./shared.js";
4
4
  import { elementSetSchema, registerElementSet } from './elements.js';
5
5
  import './elements/object.js';
6
- import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, normalizeSceneObject } from '../scene-objects.js';
6
+ import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, normalizeSceneObject, groupUnderNewEmpty, objectPatchFields, createSculptObject, SCULPT_KIND } from '../scene-objects.js';
7
+ import { expandSculptParts, normalizeSculptRecipe, patchSculptRecipe } from '../sculpt-recipe.js';
8
+ import { rankingRow, scoreSculptAgainstReference } from '../sculpt-compare.js';
9
+ import { browserSculptImages, shrinkRgba } from './sculpt-images.js';
10
+ import { clampWarning } from '../receipt-findings.js';
7
11
  import { STUDIO_TOOL_SCHEMAS, StudioSchemas } from '../studio-agent-protocol.js';
8
12
 
9
13
  const id = StudioSchemas.TargetGuard.properties.targetId;
@@ -14,15 +18,24 @@ const placement = input({ x: number, y: number, z: number, rot: number }, []);
14
18
  const setInput = elementSetSchema('object');
15
19
  const scale = setInput.properties.set.properties.scale;
16
20
  setInput.properties.set.properties.scale = { oneOf: [number, scale] };
21
+ const PATCH_HINTS = { scale: 'use scaleX, scaleY, scaleZ', rotY: 'use rot for yaw', yaw: 'use rot for yaw', rotation: 'use rot, rotX, rotZ', position: 'use x, y, z', parent: 'use object.group or object.set parent', parentId: 'use object.group or object.set parent' };
17
22
  const mutation = (id, label, input) => ({ id, label, description: label, kind: 'mutation', undoDomain: 'objects', input });
18
23
  const semantic = [
19
24
  mutation('object.set', 'Set object fields', setInput),
20
- mutation('object.add', 'Add object', input({ kind: { type: 'string' }, placement, name: { type: 'string' }, parent: id }, ['kind'])),
25
+ mutation('object.add', 'Add object', input({ kind: { type: 'string' }, placement, name: { type: 'string' }, parent: id, first: { type: 'boolean' } }, ['kind'])),
26
+ // A clay prop from a recipe of soft parts (docs/sculpt-from-image.md). With `id` it re-sculpts that
27
+ // sculpt in place (one undo); without, it creates one. The recipe is checked by sculpt-recipe.js.
28
+ { ...mutation('object.sculpt', 'Sculpt a clay prop from a recipe', input({ recipe: { type: 'object', properties: {}, additionalProperties: true }, edits: { type: 'array', minItems: 1, maxItems: 48, items: { type: 'object', properties: {}, additionalProperties: true } }, id, name: { type: 'string', maxLength: 240 }, placement, parent: id }, [])),
29
+ description: 'Create a sculpt (or re-sculpt one by id) from a recipe, or patch a sculpt by id with edits [{ set: partId, ...fields } | { add: part } | { remove: partId }]. Recipe { version: 1, parts: [{ id, shape: blob|box|cylinder|torus|frame, size: [x,y,z] m, position, rotation (deg), color, parent, mirror, roundness, taper, border }] }. Floor is y=0, front +z, the subject\'s left +x; mirror reflects across x=0.' },
21
30
  mutation('object.remove', 'Remove objects', input({ ids })),
22
31
  mutation('object.rename', 'Rename object', input({ id, name: { type: 'string', maxLength: 240 } })),
23
32
  mutation('object.group', 'Group objects', input({ parent: id, children: ids })),
24
33
  mutation('object.ungroup', 'Ungroup objects', input({ children: ids })),
25
- mutation('object.update', 'Update object', input({ id, patch: { type: 'object', properties: {}, additionalProperties: true } }, [])),
34
+ // The Outliner's "Group under new Empty": the Empty and the reparent are one write, so one undo.
35
+ { ...mutation('object.groupUnderEmpty', 'Group object under a new Empty', input({ id })), exposure: 'ui-only' },
36
+ { ...mutation('object.update', 'Update object', input({ id, patch: { type: 'object', properties: {}, additionalProperties: true } }, [])), agentHidden: true },
37
+ // agentHidden: object.update silently ignores unknown keys, so the agent's action index and inspect_studio leave it out
38
+ // and the prompt points at object.set / arrange_objects; MCP, the CLI and the UI keep it.
26
39
  mutation('objects.arrange', 'Arrange objects', STUDIO_TOOL_SCHEMAS.arrange_objects),
27
40
  ];
28
41
  const batch = { id: 'objects.batch', label: 'Batch objects', description: 'Apply legacy object operations in one retained entry.', kind: 'job', domain: 'objects',
@@ -37,8 +50,18 @@ const assetImport = { ...studioActionDeclaration('asset.import'), kind: 'job', d
37
50
  input({ assetId: id, placement, placeAs: assetProperties.placeAs }, ['assetId', 'placeAs']),
38
51
  input({ fileToken: id, placeAs: { type: 'string', enum: ['mesh', 'cutout'] } }, ['fileToken', 'placeAs']),
39
52
  ] } };
53
+ // Read-only: a job that never commits, so its receipt is `completed` with the score as output,
54
+ // authored false and no undo. The scoring is MCP compare_sculpt's (sculpt-compare.js).
55
+ const openObject = { type: 'object', properties: {}, additionalProperties: true };
56
+ const sculptCompare = { id: 'sculpt.compare', label: 'Compare a sculpt with its reference', kind: 'job', timeoutMs: 60000,
57
+ description: 'Score a sculpt\'s silhouette against a reference image without changing anything: IoU, width-to-height delta, missing area by height band (top, middle, bottom), parts that stick out, and a reference | overlay sheet (grey both, red missing, blue extra) as sheetDataUrl. reference is { assetId } of a project image or { dataUrl }. yaw is the reference camera azimuth in degrees (0 in front, positive toward the subject\'s own left); omitted tries -45..45 and keeps the best. candidates (up to 12 { label, edits }) are scored from the same view and ranked against the current sculpt; none is applied. Apply the winner with object.sculpt { id, edits }.',
58
+ input: input({ id, reference: { oneOf: [input({ assetId: id }), input({ dataUrl: { type: 'string', pattern: '^data:image/(png|jpeg|webp|gif);base64,', maxLength: 6000000 } })] },
59
+ yaw: { type: 'number', minimum: -180, maximum: 180 }, elevation: { type: 'number', minimum: -60, maximum: 60 },
60
+ candidates: { type: 'array', minItems: 1, maxItems: 12, items: input({ label: { type: 'string', maxLength: 60 }, edits: { type: 'array', minItems: 1, maxItems: 48, items: openObject } }) } }, ['id', 'reference']) };
61
+ // A receipt is at most 8 KiB, so the sheet is shrunk until the whole output fits this.
62
+ const SCULPT_COMPARE_OUTPUT_BYTES = 6500;
40
63
  const matte = { id: 'object.matte', label: 'Apply object matte', description: 'Prepare derived assets, then publish one fenced object edit.', kind: 'job', domain: 'objects', exposure: 'ui-only', input: input({ objectId: id }) };
41
- export const declarations = Object.freeze([...semantic, replace, batch, ...['object.attach', 'object.detach', 'object.duplicate'].map(studioActionDeclaration), matte, assetImport]);
64
+ export const declarations = Object.freeze([...semantic, replace, batch, ...['object.attach', 'object.detach', 'object.duplicate'].map(studioActionDeclaration), matte, sculptCompare, assetImport]);
42
65
 
43
66
  export function register(registry, ports) {
44
67
  const objectOf = objectId => ports.state().objects.find(object => object.id === objectId)
@@ -53,10 +76,43 @@ export function register(registry, ports) {
53
76
  const object = createSceneObject(args.kind, before, args.placement);
54
77
  if (!object) fail('INVALID_ARGUMENT', `Unknown object kind: ${args.kind}`);
55
78
  const placed = updateSceneObject([object], object.id, { ...(args.placement ?? {}), ...(args.name === undefined ? {} : { name: args.name }) })[0];
56
- const next = [...before, placed];
79
+ // `first`: the Outliner lists Props in record order, so a UI-made object
80
+ // goes to the top where it is seen, not below a long expanded group.
81
+ const next = args.first ? [placed, ...before] : [...before, placed];
57
82
  owned().write(args.parent === undefined ? next : setSceneObjectParent(next, placed.id, args.parent));
58
83
  return result(before, 'Added object.');
59
84
  },
85
+ 'object.sculpt': args => {
86
+ const before = owned().read();
87
+ if ((args.recipe === undefined) === (args.edits === undefined)) fail('INVALID_ARGUMENT', 'Give exactly one of recipe (whole) or edits (part by part).');
88
+ if (args.edits !== undefined && args.id === undefined) fail('INVALID_ARGUMENT', 'edits patch an existing sculpt: give its id.');
89
+ let refused, changedParts = null;
90
+ if (args.edits !== undefined) {
91
+ const target = objectOf(args.id);
92
+ if (target.renderer !== SCULPT_KIND) fail('INVALID_ARGUMENT', `${args.id} is a ${target.renderer}, not a sculpt.`);
93
+ refused = patchSculptRecipe(target.recipe, args.edits);
94
+ changedParts = refused.changed;
95
+ } else refused = normalizeSculptRecipe(args.recipe);
96
+ if (!refused.ok) fail('INVALID_ARGUMENT', `Recipe refused at ${refused.error}`);
97
+ const patch = { ...(args.placement ?? {}), ...(args.name === undefined ? {} : { name: args.name }) };
98
+ let objectId = args.id;
99
+ if (objectId !== undefined) {
100
+ const target = objectOf(objectId);
101
+ if (target.renderer !== SCULPT_KIND) fail('INVALID_ARGUMENT', `${objectId} is a ${target.renderer}, not a sculpt; omit id to create a new sculpt.`);
102
+ owned().write(updateSceneObject(before, objectId, { ...patch, recipe: refused.recipe }));
103
+ } else {
104
+ if (args.parent !== undefined) objectOf(args.parent);
105
+ const made = createSculptObject({ recipe: refused.recipe, name: args.name }, before, args.placement);
106
+ objectId = made.object.id;
107
+ const next = [...before, updateSceneObject([made.object], objectId, patch)[0]];
108
+ owned().write(args.parent === undefined ? next : setSceneObjectParent(next, objectId, args.parent));
109
+ }
110
+ const sculpted = owned().read().find(row => row.id === objectId);
111
+ const parts = expandSculptParts(sculpted.recipe).length;
112
+ const size = `${sculpted.footprint.width} × ${sculpted.footprint.depth} m, ${sculpted.height} m tall`;
113
+ return { affectedIds: [objectId, ...result(before, '').affectedIds.filter(row => row !== objectId)],
114
+ summary: `${args.id === undefined ? 'Sculpted' : changedParts ? `Patched ${changedParts.join(', ')} on` : 'Re-sculpted'} ${sculpted.name} (${objectId}): ${parts} parts, ${size}.` };
115
+ },
60
116
  'object.remove': ({ ids }) => {
61
117
  const before = owned().read(); ids.forEach(objectOf);
62
118
  owned().write(ids.reduce((rows, id) => removeSceneObject(rows, id), before));
@@ -67,10 +123,16 @@ export function register(registry, ports) {
67
123
  owned().write(updateSceneObject(before, id, { name }));
68
124
  return result(before, 'Renamed object.');
69
125
  },
70
- 'object.update': ({ id, patch }) => {
71
- objectOf(id); const before = owned().read();
72
- owned().write(updateSceneObject(before, id, patch));
73
- return result(before, 'Updated object.');
126
+ 'object.update': ({ id, patch }, context) => {
127
+ const target = objectOf(id), before = owned().read();
128
+ // Wire callers get an error for a key nothing reads; the UI's own patches stay lenient.
129
+ const unknown = Object.keys(patch ?? {}).filter(key => !objectPatchFields(target).includes(key));
130
+ if (unknown.length && context?.origin && context.origin !== 'ui') fail('INVALID_ARGUMENT', `object.update cannot set ${unknown.join(', ')}${unknown.some(key => PATCH_HINTS[key]) ? ` (${unknown.filter(key => PATCH_HINTS[key]).map(key => `${key}: ${PATCH_HINTS[key]}`).join('; ')})` : ''}. Supported fields: ${objectPatchFields(target).join(', ')}.`);
131
+ const next = updateSceneObject(before, id, patch);
132
+ owned().write(next);
133
+ const kept = next.find(row => row.id === id);
134
+ const warning = clampWarning(id, Object.fromEntries(['x', 'y', 'z', 'scaleX', 'scaleY', 'scaleZ'].filter(key => Object.hasOwn(patch ?? {}, key)).map(key => [key, Number(patch[key])])), kept);
135
+ return { ...result(before, 'Updated object.'), ...(warning ? { warnings: [warning] } : {}) };
74
136
  },
75
137
  'object.group': ({ parent, children }) => {
76
138
  objectOf(parent); children.forEach(objectOf);
@@ -84,9 +146,17 @@ export function register(registry, ports) {
84
146
  owned().write(children.reduce((rows, id) => setSceneObjectParent(rows, id, null), before));
85
147
  return result(before, 'Ungrouped objects.');
86
148
  },
149
+ 'object.groupUnderEmpty': ({ id }) => {
150
+ const object = objectOf(id); const before = owned().read();
151
+ if (object.attach) fail('CAPABILITY_MISSING', 'Detach the object from its character before grouping it.');
152
+ const grouped = groupUnderNewEmpty(before, id);
153
+ if (!grouped) fail('INVALID_ARGUMENT', 'Cannot group this object.');
154
+ owned().write(grouped.objects);
155
+ return { affectedIds: [grouped.emptyId, id], summary: 'Grouped the object under a new Empty.' };
156
+ },
87
157
  'objects.arrange': args => {
88
158
  const plan = owned().arrange(args);
89
- return { affectedIds: plan.affectedIds, summary: 'Arranged objects.' };
159
+ return { affectedIds: plan.affectedIds, summary: 'Arranged objects.', checks: plan.checks, warnings: plan.warnings };
90
160
  },
91
161
  };
92
162
  // Scalar scale is a convenience at the command boundary; the registry still
@@ -157,6 +227,31 @@ export function register(registry, ports) {
157
227
  await owned().applyMatte(objectId, context);
158
228
  return { affectedIds: [objectId], summary: 'Applied the object matte.' };
159
229
  } });
230
+ registry.register({ ...sculptCompare, available, run: async ({ id: objectId, reference, yaw, elevation = 0, candidates }, context) => {
231
+ const target = objectOf(objectId);
232
+ if (target.renderer !== SCULPT_KIND || !target.recipe) fail('INVALID_ARGUMENT', `${objectId} is a ${target.renderer}, not a sculpt.`);
233
+ const images = ports.sculptImages ?? browserSculptImages;
234
+ let image, scored;
235
+ try { image = await images.decode(reference); }
236
+ catch (error) { fail('INVALID_ARGUMENT', `Could not read the reference image: ${error?.message || error}`); }
237
+ context?.signal?.throwIfAborted();
238
+ try { scored = scoreSculptAgainstReference(target.recipe, image, { yaw, elevation, candidates }); }
239
+ catch (error) { fail('INVALID_ARGUMENT', error?.message || String(error)); }
240
+ const { result, ranked, refused } = scored;
241
+ const output = { id: objectId, ...result.report };
242
+ if (ranked) Object.assign(output, { ranking: [...ranked.map(row => rankingRow(row, result)), ...refused.map(({ label, error }) => ({ label, refused: error }))],
243
+ sheetLabel: ranked[0].label, note: `Nothing was applied. Apply the winner with object.sculpt { id: "${objectId}", edits }.` });
244
+ const sheet = (ranked ? ranked[0].result : result).sheet;
245
+ const room = SCULPT_COMPARE_OUTPUT_BYTES - JSON.stringify(output).length;
246
+ // The full-size sheet is always tried, however narrow; shrinking stops at 64 px.
247
+ for (let factor = 1; factor === 1 || sheet.width / factor >= 64; factor *= 2) {
248
+ const dataUrl = await images.encodePng(factor === 1 ? sheet : shrinkRgba(sheet, factor));
249
+ if (dataUrl.length + 40 <= room) { Object.assign(output, { sheetDataUrl: dataUrl, ...(factor > 1 ? { sheetScale: 1 / factor } : {}) }); break; }
250
+ }
251
+ if (!output.sheetDataUrl) output.sheetOmitted = 'The overlay sheet does not fit in a receipt at a readable size; read missingByBand and extraByPart.';
252
+ const lead = ranked ? `best ${ranked[0].label} at IoU ${ranked[0].result.report.iou} (current ${result.report.iou}), ${candidates.length} candidates scored, none applied` : `IoU ${result.report.iou}`;
253
+ return { affectedIds: [], output, summary: `Compared ${target.name || objectId} with the reference at yaw ${result.report.view.yaw}: ${lead}.` };
254
+ } });
160
255
  registry.register({ ...assetImport, available: () => true,
161
256
  run: async (args, context) => {
162
257
  const { source, ...options } = args;
@@ -1,5 +1,6 @@
1
1
  import { studioActionDeclaration } from "../studio-actions.js";
2
2
  import { fail } from "./shared.js";
3
+ import { PREVIS_MODES } from "../project.js";
3
4
 
4
5
  const input = (properties = {}, required = []) => ({ type: "object", properties, required, additionalProperties: false });
5
6
  const name = { type: "string", minLength: 1, maxLength: 240 };
@@ -8,12 +9,13 @@ const document = (id, label, properties = {}) => ({ id, label, description: labe
8
9
  const rename = { id: "project.rename", label: "Rename project", description: "Rename the project in one retained undo entry.", kind: "mutation", undoDomain: "scenes", input: input({ name }, ["name"]) };
9
10
  const save = { ...studioActionDeclaration("project.save"), input: input({ name }) };
10
11
  const saveAs = { ...save, id: "project.saveAs", label: "Save project as" };
12
+ const exportAsAnimation = studioActionDeclaration("project.exportAsAnimation");
11
13
  const opening = document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token });
12
- const fresh = document("project.new", "Start a new project", { name });
13
- const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "tutorial", "launch"] } });
14
+ const fresh = document("project.new", "Start a new project", { name, previsMode: { type: "string", enum: [...PREVIS_MODES] } });
15
+ const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "tutorial", "launch"] }, name });
14
16
  const restore = { ...document("project.restore", "Restore the remembered project", { handleToken: token }), exposure: "ui-only" };
15
17
  const browse = { id: "project.browse", label: "Browse projects", description: "Show the project browser.", kind: "transient", exposure: "ui-only", input: input() };
16
- export const declarations = Object.freeze([rename, save, saveAs, opening, fresh, starter, restore, browse]);
18
+ export const declarations = Object.freeze([rename, save, saveAs, exportAsAnimation, opening, fresh, starter, restore, browse]);
17
19
 
18
20
  export function register(registry, ports) {
19
21
  const owner = () => ports.storeDomain?.("scenes");
@@ -44,6 +46,17 @@ export function register(registry, ports) {
44
46
  ? `This browser has no file access, so the project ${saved.name} was downloaded as ${saved.fileName}.`
45
47
  : `Saved the project ${saved.name} to ${saved.fileName}.` };
46
48
  } });
49
+ registry.register({ ...exportAsAnimation, available: () => true,
50
+ run: async (_args, context) => {
51
+ const saved = await owner().exportAsAnimationProject(context);
52
+ if (saved?.cancelled) fail("TARGET_NOT_READY", "Not exported: the user closed the file picker.");
53
+ if (!saved?.saved) fail("TARGET_NOT_READY", saved?.failure === "not-storyboard"
54
+ ? "Export as Animation project is available only for storyboard projects."
55
+ : "Not exported: writing the animation project file failed.");
56
+ return { affectedIds: [], output: { fileName: saved.fileName }, summary: saved.downloaded
57
+ ? `This browser has no file access, so the animation project ${saved.name} was downloaded as ${saved.fileName}.`
58
+ : `Exported the storyboard as ${saved.name} to ${saved.fileName}.` };
59
+ } });
47
60
  for (const declaration of [opening, fresh, starter, restore, browse]) registry.register({ ...declaration,
48
61
  available, run: async (args, context) => {
49
62
  const result = await owner().projectAction(declaration.id, args, context);
@@ -0,0 +1,53 @@
1
+ // The image port behind sculpt.compare: a reference ({ assetId } from the asset
2
+ // store, or { dataUrl }) decoded to RGBA, and an RGBA sheet encoded as a PNG data
3
+ // URL. The editor uses the browser one below; a Node test hands in its own as
4
+ // `ports.sculptImages`, so the scoring runs on the same pixels in both.
5
+ import { getAsset, openAssetDb } from "../scene-assets.js";
6
+
7
+ function canvas(width, height) {
8
+ if (typeof OffscreenCanvas === "function") return new OffscreenCanvas(width, height);
9
+ if (globalThis.document?.createElement) return Object.assign(document.createElement("canvas"), { width, height });
10
+ throw new Error("this browser cannot draw images: no canvas is available");
11
+ }
12
+
13
+ async function referenceBlob(reference) {
14
+ if (reference.dataUrl !== undefined) return (await fetch(reference.dataUrl)).blob();
15
+ const asset = await getAsset(await openAssetDb(), reference.assetId);
16
+ if (!asset || asset.width === undefined) throw new Error(`no image asset ${reference.assetId} in this project`);
17
+ return new Blob([asset.bytes], { type: asset.type });
18
+ }
19
+
20
+ export const browserSculptImages = Object.freeze({
21
+ async decode(reference) {
22
+ if (typeof createImageBitmap !== "function") throw new Error("this browser cannot decode images: createImageBitmap is unavailable");
23
+ const bitmap = await createImageBitmap(await referenceBlob(reference));
24
+ try {
25
+ const context = canvas(bitmap.width, bitmap.height).getContext("2d", { willReadFrequently: true });
26
+ context.drawImage(bitmap, 0, 0);
27
+ const { width, height, data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
28
+ return { width, height, data };
29
+ } finally {
30
+ bitmap.close?.();
31
+ }
32
+ },
33
+ async encodePng({ width, height, data }) {
34
+ const surface = canvas(width, height);
35
+ surface.getContext("2d").putImageData(new ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), width, height), 0, 0);
36
+ if (!surface.convertToBlob) return surface.toDataURL("image/png");
37
+ const bytes = new Uint8Array(await (await surface.convertToBlob({ type: "image/png" })).arrayBuffer());
38
+ let binary = "";
39
+ for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
40
+ return `data:image/png;base64,${btoa(binary)}`;
41
+ },
42
+ });
43
+
44
+ /** Nearest-neighbour shrink of an RGBA image by an integer factor. */
45
+ export function shrinkRgba({ width, height, data }, factor) {
46
+ const w = Math.max(1, Math.floor(width / factor)), h = Math.max(1, Math.floor(height / factor));
47
+ const out = new Uint8Array(w * h * 4);
48
+ for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) {
49
+ const s = ((y * factor) * width + x * factor) * 4;
50
+ out.set(data.subarray(s, s + 4), (y * w + x) * 4);
51
+ }
52
+ return { width: w, height: h, data: out };
53
+ }
@@ -1,20 +1,25 @@
1
1
  // One semantic entry point for timeline, camera controls and agent framing.
2
2
  import { createCameraBlock, updateCameraBlock, removeCameraRail } from '../camera-block.js';
3
- import { addShotAtFrame, cutAtFrame, duplicateShot, removeShot, reorderShot, resizeShot, renameShot, moveCameraKey, removeCameraKey } from '../cuts.js';
3
+ import { addShotAtFrame, createShot, cutAtFrame, duplicateShot, removeShot, reorderShot, resizeShot, renameShot, moveCameraKey, removeCameraKey } from '../cuts.js';
4
+ import { reflowStillShots, stillFrameCount, STILL_FRAME_COUNT_MAX, STILL_HOLD_DEFAULT } from '../shot-authoring.js';
5
+ import { ko } from '../locale.js';
6
+ import { track } from '../analytics.js';
7
+ import { isImageAssetId } from '../scene-assets.js';
4
8
  import { createStableItemId, updateStableItem } from '../stable-items.js';
5
9
  import { railFollowForNewGeometry } from '../camera-rail-schedule.js';
6
- import { studioActionDeclaration } from '../studio-actions.js';
10
+ import { studioActionDeclaration, studioActionRefusal } from '../studio-actions.js';
7
11
  import { STUDIO_TOOL_SCHEMAS, StudioSchemas } from '../studio-agent-protocol.js';
8
12
  import { elementSetSchema, registerElementSet } from './elements.js';
9
13
  import './elements/shot.js';
10
14
  import { changedIds, fail, shotLabel } from './shared.js';
11
15
 
12
16
  const id = StudioSchemas.TargetGuard.properties.targetId;
17
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
13
18
  const number = { type: 'number' };
14
19
  const frame = { type: 'integer', minimum: 0 };
15
20
  const input = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
16
21
  const mutation = (id, label, schema) => ({ id, label, description: label, kind: 'mutation', undoDomain: 'shot', input: schema });
17
- const existing = ['shot.create', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.setRange', 'shot.setCameraRail', 'shot.clearCameraRail', 'shot.reorder'].map(studioActionDeclaration);
22
+ const existing = ['shot.create', 'shot.createStill', 'shot.setCaption', 'shot.setCastOverride', 'shot.setHold', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.setRange', 'shot.setCameraRail', 'shot.clearCameraRail', 'shot.reorder'].map(studioActionDeclaration);
18
23
  const extra = [
19
24
  mutation('shot.set', 'Set shot fields', elementSetSchema('shot')),
20
25
  mutation('shot.rename', 'Rename shot', input({ shotId: id, name: { type: 'string', maxLength: 240 } })),
@@ -28,6 +33,9 @@ const extra = [
28
33
  mutation('shot.frame', 'Frame the shot', { ...input({ ...STUDIO_TOOL_SCHEMAS.frame_shot.properties, preset: { type: 'string' } }, []), oneOf: [STUDIO_TOOL_SCHEMAS.frame_shot, input({ preset: { type: 'string' } })] }),
29
34
  { ...mutation('shot.replace', 'Replace shot authoring', input({ shots: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } } })), exposure: 'ui-only' },
30
35
  { ...mutation('shot.captureCamera', 'Capture camera framing', input({ shotId: id }, [])), exposure: 'ui-only' },
36
+ // The Board's Stylize result (or null to clear it). UI-only: the picture has
37
+ // to be in the asset store first, which only the Stylize flow does.
38
+ { ...mutation('shot.setStylized', 'Set stylized panel image', input({ shotId: id, assetId: { oneOf: [{ type: 'string' }, { type: 'null' }] } })), exposure: 'ui-only' },
31
39
  { ...mutation('shot.placeCamera', 'Place camera', input(Object.fromEntries(['x', 'y', 'z', 'lookAtX', 'lookAtY', 'lookAtZ', 'focalMm'].map(key => [key, number])), [])), exposure: 'ui-only' },
32
40
  ];
33
41
  export const declarations = Object.freeze([extra[0], ...existing, ...extra.slice(1).map(entry => entry.id === 'shot.frame' ? entry : { ...entry, exposure: 'ui-only' })]);
@@ -37,16 +45,75 @@ export function register(registry, ports) {
37
45
  const mounted = () => Boolean(ports.storeDomain?.('shot')) || 'The shots document owner is not mounted.';
38
46
  const shotOf = shotId => owner().read().find(shot => shot.id === shotId) ?? fail('STALE_TARGET', `Shot ${shotId} is not in this scene.`);
39
47
  const patchShot = (shotId, update) => { shotOf(shotId); owner().write(current => updateStableItem(current, shotId, update, 'shots')); };
48
+ const storyboard = () => ports.storeDomain?.('scenes')?.metadata?.().previsMode === 'storyboard';
49
+ const stillOf = shotId => {
50
+ const shot = shotOf(shotId);
51
+ return shot.kind === 'still' ? shot : fail('INVALID_ARGUMENT', `${shot.name} is a clip shot; only stills have a hold and a place in the still order.`);
52
+ };
53
+ // Every still edit is one write: the stills (in their new array order) are
54
+ // laid end to end and the timeline is sized to them, or nothing changes.
55
+ function writeStills(shots) {
56
+ const next = reflowStillShots(shots), frameCount = stillFrameCount(next);
57
+ if (frameCount > STILL_FRAME_COUNT_MAX) {
58
+ const en = `Storyboard is full (${STILL_FRAME_COUNT_MAX} frames); shorten a hold or remove a panel.`;
59
+ throw studioActionRefusal('INVALID_ARGUMENT', en, ko(en, `스토리보드가 가득 찼습니다 (${STILL_FRAME_COUNT_MAX}프레임). 홀드를 줄이거나 패널을 지우세요.`));
60
+ }
61
+ const clip = next.find(shot => shot.kind !== 'still' && next.some(still => still.kind === 'still' && still.startFrame <= shot.endFrame && still.endFrame >= shot.startFrame));
62
+ if (clip) fail('TARGET_NOT_READY', `The stills would overlap the clip shot ${clip.name} (frames ${clip.startFrame}-${clip.endFrame}); move or remove it first.`);
63
+ owner().writeState(before => ({ ...before, shots: next, frameCount }));
64
+ return next;
65
+ }
66
+ function createStill({ caption = '', hold = STILL_HOLD_DEFAULT } = {}) {
67
+ const current = owner().read(), framing = owner().capture(), last = current.findLastIndex(shot => shot.kind === 'still');
68
+ const keys = framing ? [{ id: createStableItemId('camera-key'), frame: 0, framing }] : [];
69
+ const still = { ...createShot(`Shot ${current.length + 1}`, 0, hold - 1, keys), kind: 'still', caption };
70
+ const written = writeStills([...current.slice(0, last + 1), still, ...current.slice(last + 1)]);
71
+ // Land the playhead in the new panel (the timeline.seek path) so the placement
72
+ // actions that follow write this panel's cast override, not the previous one's.
73
+ const created = written.find(shot => shot.id === still.id), { selection, view } = ports.readView();
74
+ ports.publishView({ selection, view: { ...view, frame: created.startFrame }, shotId: created.id });
75
+ }
76
+ function duplicate(shotId) {
77
+ const source = shotOf(shotId), copied = { kind: source.kind, caption: source.caption, cast: structuredClone(source.cast), stylizedAssetId: source.stylizedAssetId };
78
+ if (source.kind !== 'still') {
79
+ const before = owner().read(), next = duplicateShot(before, shotId, owner().state().frameCount);
80
+ return owner().write(next.map(shot => before.includes(shot) ? shot : { ...shot, ...copied }));
81
+ }
82
+ const current = owner().read(), index = current.indexOf(source);
83
+ const copy = { ...createShot(`${source.name} copy`, source.startFrame, source.endFrame,
84
+ source.cameraKeys.map(key => ({ ...key, id: createStableItemId('camera-key') })), source.camera), ...copied };
85
+ writeStills([...current.slice(0, index + 1), copy, ...current.slice(index + 1)]);
86
+ }
87
+ function reorder({ shotId, startFrame, index }) {
88
+ if ((startFrame === undefined) === (index === undefined)) fail('INVALID_ARGUMENT', 'shot.reorder takes exactly one of startFrame or index.');
89
+ const shot = shotOf(shotId);
90
+ if (shot.kind !== 'still') {
91
+ if (index !== undefined) stillOf(shotId);
92
+ return owner().write(current => reorderShot(current, shotId, startFrame, owner().state().frameCount));
93
+ }
94
+ const current = owner().read(), others = current.filter(row => row.kind === 'still' && row.id !== shotId);
95
+ const target = index ?? others.filter(row => row.startFrame < startFrame).length;
96
+ const order = [...others.slice(0, target), shot, ...others.slice(target)];
97
+ let next = 0;
98
+ writeStills(current.map(row => row.kind === 'still' ? order[next++] : row));
99
+ }
40
100
  const methods = {
41
- 'shot.create': () => owner().write(current => addShotAtFrame(current, ports.state().frame, owner().state().frameCount, owner().capture())),
101
+ 'shot.create': () => storyboard() ? createStill() : owner().write(current => addShotAtFrame(current, ports.state().frame, owner().state().frameCount, owner().capture())),
102
+ 'shot.createStill': args => createStill(args),
103
+ 'shot.setCaption': ({ shotId, caption }) => patchShot(shotId, shot => ({ ...shot, caption })),
104
+ 'shot.setCastOverride': ({ shotId, characterId, override }) => owner().setCastOverride(shotId, characterId, override),
105
+ 'shot.setHold': ({ shotId, hold }) => {
106
+ const shot = stillOf(shotId), endFrame = shot.startFrame + hold - 1;
107
+ writeStills(owner().read().map(row => row === shot ? { ...row, endFrame, cameraKeys: row.cameraKeys.filter(key => key.frame <= endFrame) } : row));
108
+ },
42
109
  'shot.split': ({ shotId }) => {
43
110
  const shot = shotOf(shotId), { frame } = ports.state();
44
111
  if (frame <= shot.startFrame || frame > shot.endFrame) fail('TARGET_NOT_READY', `The playhead (frame ${frame}) is not inside ${shot.name} after its first frame.`);
45
112
  owner().write(current => cutAtFrame(current, shotId, frame, owner().capture()));
46
113
  },
47
- 'shot.duplicate': ({ shotId }) => { shotOf(shotId); owner().write(current => duplicateShot(current, shotId, owner().state().frameCount)); },
114
+ 'shot.duplicate': ({ shotId }) => duplicate(shotId),
48
115
  'shot.remove': ({ shotId }) => { shotOf(shotId); owner().write(current => removeShot(current, shotId)); },
49
- 'shot.reorder': ({ shotId, startFrame }) => { shotOf(shotId); owner().write(current => reorderShot(current, shotId, startFrame, owner().state().frameCount)); },
116
+ 'shot.reorder': args => reorder(args),
50
117
  'shot.setRange': ({ shotId, range }) => {
51
118
  const shot = shotOf(shotId), startFrame = range.startFrame, endFrame = range.endFrameExclusive - 1;
52
119
  const edges = startFrame > shot.endFrame ? [['end', endFrame], ['start', startFrame]] : [['start', startFrame], ['end', endFrame]];
@@ -77,24 +144,35 @@ export function register(registry, ports) {
77
144
  'shot.setLens': ({ fovDeg }) => owner().setLens(fovDeg),
78
145
  'shot.replace': ({ shots }) => owner().write(shots),
79
146
  'shot.captureCamera': args => owner().captureCamera(args.shotId),
147
+ 'shot.setStylized': ({ shotId, assetId }) => {
148
+ if (assetId !== null && !isImageAssetId(assetId)) fail('INVALID_ARGUMENT', `${assetId} is not an image asset id.`);
149
+ patchShot(shotId, shot => ({ ...shot, stylizedAssetId: assetId }));
150
+ },
80
151
  'shot.placeCamera': args => owner().placeCamera(args),
81
152
  };
82
153
  const hasShots = state => state.shots.length > 0 || 'There are no shots yet; add one with shot.create.';
83
154
  const availability = {
84
- 'shot.create': state => addShotAtFrame(state.shots, state.frame, state.frameCount, null) !== state.shots || `There is no free room for a new shot at the playhead (frame ${state.frame}); move it with operate_studio { frame } or shorten a shot.`,
155
+ 'shot.createStill': () => true,
156
+ 'shot.create': state => storyboard() || addShotAtFrame(state.shots, state.frame, state.frameCount, null) !== state.shots || `There is no free room for a new shot at the playhead (frame ${state.frame}); move it with operate_studio { frame } or shorten a shot.`,
85
157
  'shot.split': state => state.shots.some(shot => state.frame > shot.startFrame && state.frame <= shot.endFrame) || `The playhead (frame ${state.frame}) is not inside a shot after its first frame; move it with operate_studio { frame }.`,
86
158
  'shot.clearCameraRail': state => state.shots.some(shot => createCameraBlock(shot.camera).cameraRail) || 'No shot has a camera rail; lay one with shot.setCameraRail.',
87
159
  };
88
160
  registerElementSet(registry, ports, extra[0]);
89
161
  for (const declaration of declarations.filter(row => row.id !== 'shot.set')) registry.register({ ...declaration,
90
162
  available: state => mounted() === true ? (availability[declaration.id] ?? (existing.includes(declaration) ? hasShots : () => true))(state) : mounted(),
91
- run(args) {
163
+ run(args, context) {
92
164
  if (declaration.id === 'shot.frame') {
93
165
  const plan = owner().frame(args);
94
166
  return { affectedIds: plan.affectedIds, summary: 'Framed the shot.' };
95
167
  }
96
- const before = owner().read(); methods[declaration.id](args);
97
- const after = owner().read(), changed = changedIds(before, after);
168
+ const before = owner().read(), newPanel = declaration.id === 'shot.createStill' || (declaration.id === 'shot.create' && storyboard());
169
+ methods[declaration.id](args);
170
+ // Only the origin travels: never the caption, name or id of the panel.
171
+ if (newPanel) track('storyboard:panel_created', { source: context?.origin && context.origin !== 'ui' ? 'agent' : 'manual' });
172
+ // The owner re-normalizes every shot on write, so compare content, not
173
+ // identity; a storyboard reflow can still move more shots than a receipt
174
+ // names (100), and the first 100 stand for the batch.
175
+ const after = owner().read(), changed = changedIds(before.map(row => after.find(next => next.id === row.id && same(next, row)) ?? row), after).slice(0, 100);
98
176
  const affectedIds = changed.length ? changed : [args.shotId ?? ports.state().activeSceneId];
99
177
  return { affectedIds, summary: changed.length ? `${declaration.label}: ${changed.map(id => after.find(row => row.id === id)).filter(Boolean).map(shotLabel).join('; ')}.` : `${declaration.label}.` };
100
178
  },
@@ -1,7 +1,9 @@
1
1
  // Viewer preferences: transient, like the View menu they mirror.
2
- import { studioActionDeclaration } from "../studio-actions.js";
3
- import { StudioProtocolError, STUDIO_TOOL_SCHEMAS } from '../studio-agent-protocol.js';
2
+ import { studioActionDeclaration, studioActionRefusal } from "../studio-actions.js";
3
+ import { STUDIO_TOOL_SCHEMAS } from '../studio-agent-protocol.js';
4
+ import { ko } from '../locale.js';
4
5
  import { shotAtFrame } from '../cuts.js';
6
+ import { fail } from './shared.js';
5
7
 
6
8
  const fields = STUDIO_TOOL_SCHEMAS.operate_studio.properties;
7
9
  const groups = { 'view.select': ['selection', 'shotId'], 'timeline.seek': ['frame'], 'timeline.play': ['playing'], 'view.setMode': ['mode', 'view'] };
@@ -10,16 +12,18 @@ const operations = Object.entries({ ...groups, 'view.update': Object.keys(fields
10
12
  input: { type: 'object', properties: Object.fromEntries(keys.map(key => [key, fields[key]])), required: [], additionalProperties: false },
11
13
  }));
12
14
  export const declarations = Object.freeze([...operations, ...["view.setPartColours", "view.setGuideMode", "view.setInset"].map(studioActionDeclaration)]);
15
+ // A storyboard project has no Motion department; every door says so the same way.
16
+ export const STORYBOARD_MOTION_REFUSAL = Object.freeze({ en: 'Motion tools are not part of a Storyboard project.', ko: '모션 도구는 스토리보드 프로젝트에 없어요.' });
13
17
  export const viewCommand = args => Object.entries(groups).find(([, keys]) => Object.keys(args).every(key => keys.includes(key)))?.[0] ?? 'view.update';
14
18
 
15
19
  export function register(registry, ports) {
16
20
  registry.registerToolAlias('operate_studio', viewCommand);
17
21
  for (const declaration of operations) registry.register({ ...declaration, available: () => true, run(args) {
18
- const fail = (code, message) => { throw new StudioProtocolError(code, message); };
19
22
  if (!Object.keys(args).length || (args.view && !Object.keys(args.view).length)) fail('INVALID_ARGUMENT', 'Specify a view operation.');
20
23
  const state = ports.readView();
21
24
  // Pose mode is IK editing: without a rig to solve there is nothing to pose.
22
25
  if (args.mode === 'pose' && args.mode !== state.view.mode && !ports.canPose()) fail('TARGET_NOT_READY', 'Pose mode needs a loaded character rig to edit with IK.');
26
+ if (args.mode === 'motion' && state.previsMode === 'storyboard') throw studioActionRefusal('TARGET_NOT_READY', STORYBOARD_MOTION_REFUSAL.en, ko(STORYBOARD_MOTION_REFUSAL.en, STORYBOARD_MOTION_REFUSAL.ko));
23
27
  const selection = args.selection === undefined ? state.selection : args.selection;
24
28
  if (selection) {
25
29
  const found = selection.kind === 'scene' ? selection.id === state.host.sceneId : selection.kind === 'camera' ? selection.id === 'camera'
@@ -31,7 +35,9 @@ export function register(registry, ports) {
31
35
  const frame = args.frame ?? shot?.startFrame ?? state.view.frame;
32
36
  if (frame >= Math.max(1, state.frameCount)) fail('INVALID_RANGE', 'Frame is outside the timeline.');
33
37
  const view = { ...state.view, ...args.view, frame, mode: args.mode ?? state.view.mode, playing: args.playing ?? state.view.playing };
34
- ports.publishView({ selection, view, shotId: args.shotId ?? shotAtFrame(state.shots, frame)?.id ?? null });
38
+ // modeSet: only a command that names a mode moves the editor out of a
39
+ // UI-only mode (Sculpt, #739), which the published view reads as Stage.
40
+ ports.publishView({ selection, view, shotId: args.shotId ?? shotAtFrame(state.shots, frame)?.id ?? null, modeSet: args.mode !== undefined });
35
41
  return { affectedIds: [state.host.sceneId], summary: 'Updated editor view.' };
36
42
  } });
37
43
  const viewAction = (id, run) => registry.register({ ...studioActionDeclaration(id), available: () => true,