cozyclay 3.1.0 → 3.1.2

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 (154) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/bin/agent/agent-routes.mjs +12 -11
  3. package/bin/agent/main-agent.mjs +21 -7
  4. package/bin/agent/main-prompt.mjs +7 -6
  5. package/bin/agent/plan-tool.mjs +114 -0
  6. package/bin/agent/script-tool.mjs +4 -1
  7. package/bin/agent/sculpt-sketch.mjs +190 -0
  8. package/bin/agent/session-store.mjs +2 -1
  9. package/bin/agent/sketch-code.mjs +151 -0
  10. package/bin/agent/sketch-sandbox.mjs +102 -0
  11. package/bin/agent/studio-prompt.mjs +1 -1
  12. package/bin/agent/studio-rebase.mjs +5 -4
  13. package/bin/agent/studio-task-routes.mjs +1 -1
  14. package/bin/agent/studio-tasks.mjs +116 -25
  15. package/bin/agent/studio-tools.mjs +3 -2
  16. package/bin/agent/turn-frames.mjs +4 -0
  17. package/bin/cozyclay.mjs +4 -2
  18. package/bin/live-endpoint.mjs +2 -0
  19. package/dist/app/index.html +6 -6
  20. package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
  21. package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
  22. package/dist/assets/app-C4QTcDcb.js +5144 -0
  23. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  24. package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
  25. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  26. package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
  27. package/dist/assets/task-board-BqReHzOi.js +15 -0
  28. package/dist/assets/workflow-C87p6E1E.js +179 -0
  29. package/dist/cozyclay-package.json +1 -1
  30. package/dist/index.html +1 -1
  31. package/dist/sitemap.xml +1 -1
  32. package/dist/workflow/index.html +4 -4
  33. package/mcp/README.md +1 -1
  34. package/mcp/live-hub.mjs +5 -3
  35. package/mcp/tool-handlers.mjs +7 -7
  36. package/package.json +3 -1
  37. package/src/App.jsx +44 -66
  38. package/src/bus-task-scopes.js +59 -11
  39. package/src/bus-task-targets.js +35 -9
  40. package/src/bus-tasks.js +23 -10
  41. package/src/command-bus.js +7 -4
  42. package/src/commands/ai.js +1 -1
  43. package/src/commands/elements.js +4 -3
  44. package/src/commands/export.js +1 -1
  45. package/src/commands/motion3.js +27 -7
  46. package/src/commands/objects.js +1 -1
  47. package/src/export-workspace.js +244 -0
  48. package/src/keyframe-pack.js +5 -1
  49. package/src/live-control.js +14 -7
  50. package/src/material-look.js +189 -0
  51. package/src/material-presets.js +78 -0
  52. package/src/material-textures.js +157 -0
  53. package/src/motion3/compile-job.js +15 -3
  54. package/src/motion3/compiler/channels.js +74 -24
  55. package/src/motion3/compiler/compile.js +2 -1
  56. package/src/motion3/compiler/idle.js +2 -0
  57. package/src/motion3/compiler/overlap.js +180 -0
  58. package/src/motion3/compiler/pose.js +6 -4
  59. package/src/motion3/compiler/validate-common.js +1 -0
  60. package/src/motion3/compiler/validate-parts.js +5 -1
  61. package/src/motion3/compiler/validate.js +30 -10
  62. package/src/motion3/correct.js +236 -0
  63. package/src/motion3/docs.js +8 -6
  64. package/src/motion3/face-lean.js +31 -0
  65. package/src/motion3/fix/index.js +5 -1
  66. package/src/motion3/fix/reach-body.js +172 -0
  67. package/src/motion3/hint-check.js +44 -0
  68. package/src/motion3/json-patch.js +120 -12
  69. package/src/motion3/lenient.js +46 -4
  70. package/src/motion3/object-keys.js +17 -5
  71. package/src/motion3/pipeline.js +46 -9
  72. package/src/motion3/qa/arm-checks.js +96 -0
  73. package/src/motion3/qa/asked.js +113 -0
  74. package/src/motion3/qa/goals.js +37 -8
  75. package/src/motion3/qa/hints-goal.js +6 -5
  76. package/src/motion3/qa/index.js +5 -2
  77. package/src/motion3/qa/key-checks.js +4 -3
  78. package/src/motion3/qa/motion-life.js +243 -0
  79. package/src/motion3/qa/spec-checks.js +15 -9
  80. package/src/motion3/qa/verdict.js +50 -0
  81. package/src/motion3/refs.js +38 -14
  82. package/src/motion3/repair-watch.js +132 -0
  83. package/src/motion3/repair.js +52 -19
  84. package/src/motion3/report.js +50 -9
  85. package/src/motion3/schema/topics.js +6 -4
  86. package/src/motion3/schema/types.js +1 -1
  87. package/src/motion3/skills/basic.js +15 -8
  88. package/src/motion3/skills/index.js +52 -18
  89. package/src/motion3/skills/info.js +1 -1
  90. package/src/motion3/skills/stance.js +6 -0
  91. package/src/motion3/skills/walk-fit.js +170 -0
  92. package/src/object-look.js +9 -5
  93. package/src/panels/PoseImagePanel.jsx +3 -2
  94. package/src/panels/SculptPanel.jsx +2 -2
  95. package/src/primitive-look.js +38 -0
  96. package/src/props.jsx +20 -28
  97. package/src/render-passes.js +7 -1
  98. package/src/result-modal.jsx +3 -3
  99. package/src/scene-objects.js +2 -2
  100. package/src/sculpt-colours.js +65 -0
  101. package/src/sculpt-compare.js +13 -50
  102. package/src/sculpt-csg.js +112 -0
  103. package/src/sculpt-curve.js +123 -0
  104. package/src/sculpt-cutters.js +43 -0
  105. package/src/sculpt-detail.js +207 -0
  106. package/src/sculpt-geometry-loft-convex.js +100 -0
  107. package/src/sculpt-geometry-shapes.js +250 -0
  108. package/src/sculpt-geometry.js +33 -15
  109. package/src/sculpt-loft-rings.js +225 -0
  110. package/src/sculpt-noise.js +65 -0
  111. package/src/sculpt-part-fields.js +27 -0
  112. package/src/sculpt-recipe-read.js +65 -0
  113. package/src/sculpt-recipe.js +33 -75
  114. package/src/sculpt-run.js +1 -1
  115. package/src/sculpt-scatter-items.js +183 -0
  116. package/src/sculpt-scatter-shape.js +57 -0
  117. package/src/sculpt-scatter.js +124 -0
  118. package/src/sculpt-shapes-curved.js +211 -0
  119. package/src/sculpt-shapes-loft-convex.js +218 -0
  120. package/src/sculpt-shapes.js +138 -0
  121. package/src/sculpt-sketch-state.js +76 -0
  122. package/src/sculpt-sketch.jsx +124 -0
  123. package/src/sculpt-surface-fields.js +65 -0
  124. package/src/shell/ExportFolderPreference.jsx +103 -0
  125. package/src/shell/PreferencesDialog.jsx +3 -166
  126. package/src/shell/StatusBar.jsx +1 -1
  127. package/src/shell/preference-controls.jsx +114 -0
  128. package/src/shell/preference-pages.jsx +72 -0
  129. package/src/shell/preferences.css +46 -0
  130. package/src/shot-prompt.js +29 -9
  131. package/src/studio-actions.js +2 -2
  132. package/src/studio-agent-motion.js +19 -9
  133. package/src/studio-agent-protocol.js +1 -1
  134. package/src/studio-app-binding.js +3 -2
  135. package/src/studio-elements.js +5 -1
  136. package/src/workflow/AgentPanel.jsx +6 -6
  137. package/src/workflow/AgentPlanCard.jsx +41 -0
  138. package/src/workflow/CozySceneNode.jsx +1 -1
  139. package/src/workflow/WorkflowBuilder.jsx +2 -2
  140. package/src/workflow/agent-client.js +14 -11
  141. package/src/workflow/agent-plan.css +93 -0
  142. package/src/workflow/agent-plan.js +59 -0
  143. package/src/workflow/keyframe-pack-request.js +3 -10
  144. package/src/workflow/task-board.css +1 -1
  145. package/src/workflow/task-board.js +7 -5
  146. package/tools/loc-baseline.json +1 -2
  147. package/tools/run-tests.mjs +8 -8
  148. package/dist/assets/app-B2BpfCl2.js +0 -5088
  149. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  150. package/dist/assets/motion3-compile-B887n77J.js +0 -1
  151. package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
  152. package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
  153. package/dist/assets/task-board-BKaM7d-9.js +0 -14
  154. package/dist/assets/workflow-B8Dhzexj.js +0 -179
@@ -1 +1 @@
1
- {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.0\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"a82f58c8669edb7800fcdb79939b1cfc242616a17675fdf278927d7f4f5d016e\"}","signature":"EmIwI46B/uZyNSY0QYgaR43ElVC4MFt2ROKPMaDioTSwJytnF2kwqj3qWkaJIPvqeVR4sN5qglBO6Ya+mOprCQ=="}
1
+ {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.2\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"4d8d2ca94381f4bdb9a2983a6c3f0378ea513c73e1408c6c18baef2b2db6fc05\"}","signature":"bb1tql+IuEnYxCIbq+Xh/MJa3bL9WYcnI3Bw7STxCTwRoi7NB/L2VNd0/jR1d3qVEK4E3MGLA5GavrN5E+/7DA=="}
package/dist/index.html CHANGED
@@ -394,7 +394,7 @@
394
394
  <div class="wrap start">
395
395
  <div class="start-head">
396
396
  <h2>Quick start</h2>
397
- <p>Node.js 22.13+ and a Chromium browser.</p>
397
+ <p>Node.js 22.19+ and a Chromium browser.</p>
398
398
  <a href="#controls" id="controls-link">Full control list</a>
399
399
  </div>
400
400
  <div class="start-body">
package/dist/sitemap.xml CHANGED
@@ -2,7 +2,7 @@
2
2
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
3
  <url>
4
4
  <loc>https://cozyclay.org/</loc>
5
- <lastmod>2026-10-21</lastmod>
5
+ <lastmod>2026-10-23</lastmod>
6
6
  </url>
7
7
  <url>
8
8
  <loc>https://cozyclay.org/greybox-to-video/</loc>
@@ -8,13 +8,13 @@
8
8
  <meta name="theme-color" content="#101116" />
9
9
  <meta name="description" content="CozyClay visual workflow builder" />
10
10
  <title>CozyClay Workflow</title>
11
- <script type="module" crossorigin src="/assets/workflow-B8Dhzexj.js"></script>
11
+ <script type="module" crossorigin src="/assets/workflow-C87p6E1E.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-P2Xu9kJm.js">
14
- <link rel="modulepreload" crossorigin href="/assets/task-board-BKaM7d-9.js">
14
+ <link rel="modulepreload" crossorigin href="/assets/task-board-BqReHzOi.js">
15
15
  <link rel="modulepreload" crossorigin href="/assets/fflate.module-KbGj5-H6.js">
16
- <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-CQzEdsqu.js">
17
- <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-COGOlF5T.css">
16
+ <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-qoxcdrhj.js">
17
+ <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-BEjjhNEo.css">
18
18
  <link rel="stylesheet" crossorigin href="/assets/workflow-DRdwoDBT.css">
19
19
  </head>
20
20
  <body>
package/mcp/README.md CHANGED
@@ -35,7 +35,7 @@ Then point a client at it. For Claude Desktop, in `claude_desktop_config.json`:
35
35
  }
36
36
  ```
37
37
 
38
- Restart the client; 28 tools appear.
38
+ Restart the client; 27 tools appear.
39
39
 
40
40
  Prefer a long-lived endpoint? `node server.mjs --http 5183` serves Streamable HTTP at
41
41
  `http://127.0.0.1:5183/mcp` (one isolated session per client), with a plain status page at `/`.
package/mcp/live-hub.mjs CHANGED
@@ -436,10 +436,12 @@ export class LiveHub {
436
436
  void this.serveController(socket, frame);
437
437
  return;
438
438
  }
439
- if (frame?.type === "event" && typeof frame.name === "string" && frame.payload && typeof frame.payload === "object") {
440
- const workspaceHandle = [...this.editors.entries()].find(([, editor]) => editor === socket)?.[0];
439
+ // Meta and event frames both act for this socket's workspace. A meta frame
440
+ // relabels the tab after it opens another project; the handle stays.
441
+ const workspaceHandle = [...this.editors.entries()].find(([, editor]) => editor === socket)?.[0];
442
+ if (frame?.type === "meta" && workspaceHandle && frame.meta && typeof frame.meta === "object") this.workspaceMeta.set(workspaceHandle, frame.meta);
443
+ else if (frame?.type === "event" && typeof frame.name === "string" && frame.payload && typeof frame.payload === "object") {
441
444
  if (workspaceHandle) this.onEvent?.({ workspaceHandle, workspaceId: this.workspaceId(workspaceHandle), name: frame.name, payload: frame.payload });
442
- return;
443
445
  }
444
446
  if (frame?.type !== "result" || typeof frame.id !== "string") return;
445
447
  const pending = this.pending.get(frame.id);
@@ -71,6 +71,7 @@ import {
71
71
  import { classifyMove, captureFraming, moveSlate } from "../src/camera-move.js";
72
72
  import { DEFAULT_PREVIS_MODE, PREVIS_MODES, createProjectDocument, readProjectDocument } from "../src/project.js";
73
73
  import { kindRefusal } from "../src/character-kind.js";
74
+ import { documentWithCamera, splitSavedCamera } from "./project-camera.mjs";
74
75
 
75
76
  /** The authoring state. One scene document, one camera, one project name. */
76
77
  export const state = {
@@ -1155,10 +1156,10 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1155
1156
  title: "Sculpt a clay prop from a recipe of soft parts",
1156
1157
  description:
1157
1158
  "Unlike place_object, import_mesh and compare_sculpt, sculpt_object builds or changes a clay prop from a recipe of parts you write, typically after reading a reference image. It needs no file and no catalogue entry. " +
1158
- "recipe = { version: 1, parts: [{ id, shape, size: [x, y, z], position?, rotation?, color?, parent?, mirror?, roundness?, taper?, border? }] }. " +
1159
+ "recipe = { version: 1, parts: [{ id, shape, size: [x, y, z], position?, rotation?, color?, parent?, mirror?, ...shape fields, material?, finish?, detail?, gradient?, op? }] }. " +
1159
1160
  "Units are metres and degrees. The floor is y = 0, front is +z, and the subject's own left is +x. size is the full extent; position is the part's centre, in its parent part's frame when parent is set. " +
1160
1161
  "shape: blob (soft superellipsoid: roundness 0 is an ellipsoid, 1 is a rounded box; taper narrows the top when positive and the bottom when negative), box (roundness bevels the edges), " +
1161
- "cylinder (taper 1 is a cone), torus (size [outer, outer, tube]), frame (a rectangular ring in x/y with a border width, for glasses rims, windows and picture frames). " +
1162
+ "cylinder (taper 1 is a cone), torus (size [outer, outer, tube]), frame (a rectangular ring in x/y with a border width, for glasses rims, windows and picture frames), " + "lathe { profile: [[r 0..1 of half-width, y 0..1 of height], ...] bottom to top, smooth? } for turned objects (pot, vase, bottle, bowl, lampshade, mushroom), " + "tube { path: [[x, y, z] each -0.5..0.5 of size], thickness (m, diameter), taper?, closed? } for ropes, vines, cables, handles, branches and bike frames, " + "extrude { outline: [[x, y] -0.5..0.5 of size], holes?, roundness? } for flat cut shapes pushed through size z (sign, leaf, gable, gear, star), " + "loft { sections: [{ y 0..1, points: [[x, z]...] } or { y, w, d, x?, z?, roundness? }] bottom to top (first y 0, last y 1), smooth? } for cross-sections that change (hull, car body, shoe, loaf; lay it down with rotation [-90,0,0]), " + "convex { points: [[x, y, z]...] or seed + facetCount?, facets? } for rocks, boulders, gems and crystals, " + "scatter { item: blade|tuft|flower|leaf|pebble|twig, count <= 4000, spread: ground|shell, seed? } for thousands of small items as ONE part (grass, flower beds, gravel; shell covers a crown or bush it is parented to; 12000 items per recipe). " + "Any part also takes material clay|wood|straw|stone|brick|concrete|metal|glass|ceramic|plastic|rubber|fabric|paper|foliage (real texture and shine; color tints it; never fake glass with opacity or thatch with stripes) and finish matte|satin|gloss; detail { amount 0..1, scale?, seed? } for a lumpy surface (clouds, bushes, rocks, hay, bread), gradient { color, axis? y|x|z, from?, to? } to blend into a second colour (sky-lit clouds, fruit, sunset walls), " + "and op: subtract|intersect, which makes the part an undrawn cutter of its parent (windows, doors, arches; hollow a lathe with a smaller lathe of the same profile; cut deeper than the wall; mirror cuts both sides). " +
1162
1163
  "mirror: true adds a copy reflected across x = 0 of the parent frame, with its children; author the subject's left (+x) side once. color is #rrggbb. At most 48 parts after mirrors. " +
1163
1164
  "To fix a sculpt, pass its id with edits instead of a whole recipe: [{ set: partId, ...fields }, { add: part }, { remove: partId }] (parent: null and mirror: false clear; remove takes the part's children). " +
1164
1165
  "capture: true also returns the editor's frame (connected editor only), so one call is one round. " +
@@ -1697,7 +1698,7 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1697
1698
  // the current stage shape rather than trusted as-is.
1698
1699
  const scenes = readSceneDocument(serializeSceneDocument(result.project.scenesDocument));
1699
1700
  if (!scenes.document) return text(`That project was written by a newer CozyClay: ${full}`);
1700
- const nextDocument = scenes.document;
1701
+ const { document: nextDocument, camera: savedCamera } = splitSavedCamera(scenes.document);
1701
1702
  if (liveHub?.connected) {
1702
1703
  try {
1703
1704
  const loaded = await executeStudioCommand({ ...admission, action: "load_scenes", args: { document: nextDocument } });
@@ -1713,11 +1714,10 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1713
1714
  }
1714
1715
  }
1715
1716
  state.doc = nextDocument;
1717
+ Object.assign(state.camera, savedCamera);
1716
1718
  state.previsMode = result.project.previsMode ?? DEFAULT_PREVIS_MODE;
1717
1719
  state.name = result.project.name;
1718
- state.focus = null;
1719
- state.focusLocked = false;
1720
- state.markedFraming = null;
1720
+ Object.assign(state, { focus: null, focusLocked: false, markedFraming: null });
1721
1721
  return text(`Opened ${full}.\n\n${sceneReport()}`);
1722
1722
  },
1723
1723
  ),
@@ -1753,7 +1753,7 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1753
1753
  return text(`Could not write project: ${error.message}`);
1754
1754
  }
1755
1755
  const project = createProjectDocument({
1756
- scenesDocument: state.doc,
1756
+ scenesDocument: documentWithCamera(state.doc, state.camera),
1757
1757
  workspaceLayout: null,
1758
1758
  customPoses: [],
1759
1759
  name: state.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozyclay",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "Open source previs software in the browser: block a scene, pose characters, author camera moves and cuts, then take the same shots to an AI video model.",
5
5
  "scripts": {
6
6
  "dev": "node tools/dev-full.mjs --host 127.0.0.1 --port 5180",
@@ -82,6 +82,8 @@
82
82
  "react-icons": "^5.7.0",
83
83
  "reactflow": "^11.11.4",
84
84
  "three": "^0.185.1",
85
+ "three-bvh-csg": "^0.0.18",
86
+ "three-mesh-bvh": "^0.9.16",
85
87
  "vite": "^8.2.0"
86
88
  },
87
89
  "bin": {
package/src/App.jsx CHANGED
@@ -137,7 +137,7 @@ import {
137
137
  pathMetrics,
138
138
  strokeToPathPoints,
139
139
  } from "./object-path.js";
140
- import { carriedPointAt, sceneObjectsAt } from "./object-travel.js"; import { objectTracksOf } from "./motion3/object-tracks.js"; import { ViewGhosts } from "./view-ghosts.jsx"; import { DraftGhost } from "./draft-ghost.jsx";
140
+ import { carriedPointAt, sceneObjectsAt } from "./object-travel.js"; import { objectTracksOf } from "./motion3/object-tracks.js"; import { ViewGhosts } from "./view-ghosts.jsx"; import { DraftGhost } from "./draft-ghost.jsx"; import { SculptSketch } from "./sculpt-sketch.jsx";
141
141
  import { routeOwnerFor } from "./route-owner.js";
142
142
  import {
143
143
  analyticsActive,
@@ -152,6 +152,7 @@ import {
152
152
  trackFeature,
153
153
  } from "./analytics.js";
154
154
  import { ko, isKo } from "./locale.js";
155
+ import { saveExport } from "./export-workspace.js";
155
156
  import { characterCapabilities, kindRefusal, proxyFigureBounds } from "./character-kind.js";
156
157
  import { STARTER_SCENES, resolveSceneParam } from "./starter-scenes.js";
157
158
  import { PART_COLOURS } from "./part-colours.js";
@@ -2463,6 +2464,17 @@ export default function App() {
2463
2464
  capture_plan_png: (args = {}) => appContext.live.state.capturePlanPng(requestedCaptureOutput(args) ?? undefined),
2464
2465
  };
2465
2466
 
2467
+ const liveSceneName = scenes.find((entry) => entry.id === activeSceneId)?.name ?? "";
2468
+ const liveMeta = () => ({
2469
+ project: projectName ?? "Untitled", scene: liveSceneName, cast: appContext.live.characters.length,
2470
+ // The Workflow page's embedded preview is a live editor too; mark it apart from the authoring tab.
2471
+ // Commands: what this editor answers; a stale tab from an older build answers a different set.
2472
+ ...(embedMode ? { embed: true } : {}), commands: [...Object.keys(liveQueries), ...Object.keys(studioBindingRef.current.handlers)],
2473
+ });
2474
+ // The client is created once; opening a project must still relabel the tab
2475
+ // in live status and the --workspace <project> selector (#864).
2476
+ useEffect(() => { liveControlRef.current?.setMeta(liveMeta()); }, [projectName, liveSceneName, appContext.live.characters.length]);
2477
+
2466
2478
  useEffect(() => {
2467
2479
  const enabled = import.meta.env.DEV || window.__COZYCLAY_LIVE__ === true;
2468
2480
  if (!enabled) return undefined;
@@ -2474,19 +2486,7 @@ export default function App() {
2474
2486
  // Tapped so a sculpt run hears the agent's object.sculpt / sculpt.compare.
2475
2487
  handlers: sculptTap.wrap({ ...liveQueries, ...studioBindingRef.current.handlers }),
2476
2488
  workspaceId: liveWorkspaceIdRef.current,
2477
- meta: {
2478
- project: projectName ?? "Untitled",
2479
- scene: scenes.find((entry) => entry.id === activeSceneId)?.name ?? "",
2480
- cast: appContext.live.characters.length,
2481
- // The Workflow page embeds this same Studio as a preview. It is a
2482
- // live editor too, so an agent choosing a workspace must be able to
2483
- // tell the preview apart from the tab the user is authoring in.
2484
- ...(embedMode ? { embed: true } : {}),
2485
- // Which live commands this editor answers. A stale tab from an
2486
- // older build (or a different app on the same port) answers a
2487
- // different set; the agent picks a workspace that has what it needs.
2488
- commands: [...Object.keys(liveQueries), ...Object.keys(studioBindingRef.current.handlers)],
2489
- },
2489
+ meta: liveMeta(),
2490
2490
  // The shared client intentionally has no disconnect UI callback.
2491
2491
  // Observe only this owned socket; a stale handle must never be sent.
2492
2492
  WebSocketImpl: class extends WebSocket {
@@ -2776,7 +2776,7 @@ export default function App() {
2776
2776
  case "unsupported_codec": return ko("MP4 encoding is unavailable. Use a current browser with H.264 WebCodecs support (such as Chrome or Edge), enable hardware acceleration, then retry.", "MP4 인코딩을 사용할 수 없어요. H.264 WebCodecs를 지원하는 최신 Chrome·Edge 등에서 하드웨어 가속을 켠 뒤 다시 시도하세요.");
2777
2777
  case "encode_failed": return ko("Video encoding failed. Retry the same request. If resources are tight, shorten the shot range or lower output resolution before starting a new export.", "영상 인코딩에 실패했어요. 같은 요청을 다시 시도하세요. 리소스가 부족하면 샷 범위를 줄이거나 출력 해상도를 낮춘 뒤 새로 내보내세요.");
2778
2778
  case "render_failed": return ko("Frame rendering failed. Let the scene finish loading, then retry. If it repeats, shorten the range or reduce output resolution for a new export.", "프레임 렌더링에 실패했어요. 장면 로딩이 끝난 뒤 다시 시도하세요. 반복되면 범위나 출력 해상도를 줄여 새로 내보내세요.");
2779
- case "aborted": return ko("Export cancelled. No further downloads will be requested.", "내보내기를 취소했어요. 추가 다운로드는 요청하지 않아요.");
2779
+ case "aborted": return ko("Export cancelled. No further files will be handed off.", "내보내기를 취소했어요. 추가 파일은 전달하지 않아요.");
2780
2780
  default: return ko("Export could not finish. Retry the same request. For memory or resource problems, close other heavy tabs or use a shorter range / lower output resolution in a new export.", "내보내기를 완료하지 못했어요. 같은 요청을 다시 시도하세요. 메모리·리소스 문제라면 무거운 탭을 닫거나 범위·출력 해상도를 줄여 새로 내보내세요.");
2781
2781
  }
2782
2782
  }
@@ -2821,7 +2821,7 @@ export default function App() {
2821
2821
  job.controller.signal.throwIfAborted();
2822
2822
  attempt?.succeed();
2823
2823
  updateExportStatus(job, "completed", { message: request.download
2824
- ? ko("Export completed. Download requested; check your browser's downloads. The OS save is not confirmed.", "내보내기를 완료하고 다운로드를 요청했어요. 브라우저 다운로드를 확인하세요. OS 저장 완료는 확인할 수 없어요.")
2824
+ ? ko("Export completed. Check your export folder or browser downloads; the OS save is not confirmed.", "내보내기를 완료했어요. 내보내기 폴더나 브라우저 다운로드를 확인하세요. OS 저장 완료는 확인할 수 없어요.")
2825
2825
  : ko("Export pipeline completed. The requesting tool handles the file handoff.", "내보내기 처리를 완료했어요. 요청한 도구에서 파일 전달을 처리합니다.") });
2826
2826
  if (!request.external) {
2827
2827
  if (request.kind === "video") { track("export:video_succeeded", { format: "mp4" }); trackFeature("export_video"); }
@@ -3010,11 +3010,9 @@ export default function App() {
3010
3010
  const slate = (moveSequence?.slate ?? "shot").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "shot";
3011
3011
  // Default plate filename: const name = `cozyclay-${slate}.mp4`
3012
3012
  const name = fileName ?? `cozyclay-${slate}.mp4`;
3013
- const url = URL.createObjectURL(result.blob);
3014
- try { saveDownload(url, name); }
3015
- finally { setTimeout(() => URL.revokeObjectURL(url), 10_000); }
3013
+ await saveExport(result.blob, name);
3016
3014
  setRecordedVideoName(name);
3017
- setToast((isKo, ko) => isKo ? `${name} 다운로드 요청 · ${result.frameCount}프레임` : `Download requested: ${name} · ${result.frameCount} frames`);
3015
+ setToast((isKo, ko) => isKo ? `${name} 내보내기 완료 · ${result.frameCount}프레임` : `${name} exported · ${result.frameCount} frames`);
3018
3016
  return { ...result, fileName: name };
3019
3017
  }
3020
3018
  return result;
@@ -3079,13 +3077,13 @@ export default function App() {
3079
3077
  {busy && !message && <p>{stage === "mux"
3080
3078
  ? ko("Finalizing MP4. Progress is indeterminate; this stage cannot be interrupted.", "MP4 마무리 중이에요. 진행률은 알 수 없으며 이 단계는 중단할 수 없어요.")
3081
3079
  : stage === "flush" ? ko("Finishing encoder output. Progress is indeterminate.", "인코더 출력을 마무리하고 있어요. 진행률은 알 수 없어요.")
3082
- : kind === "frame" ? ko("Handing off existing PNGs. Synchronous downloads cannot be cancelled.", "기존 PNG를 전달하고 있어요. 동기 다운로드는 취소할 수 없어요.")
3083
- : stage === "archive" ? ko("Building the ZIP. Cancel takes effect after the current work unit, before download.", "ZIP을 만드는 중이에요. 취소는 현재 작업이 끝난 뒤 다운로드 전에 적용돼요.")
3084
- : stage === "depth" || stage === "frames" ? ko("Cancel stops at the next frame boundary, before download.", "취소하면 다운로드 전 다음 프레임 경계에서 멈춰요.")
3080
+ : kind === "frame" ? ko("Handing off existing PNGs. The file handoff cannot be cancelled.", "기존 PNG를 전달하고 있어요. 파일 전달은 취소할 수 없어요.")
3081
+ : stage === "archive" ? ko("Building the ZIP. Cancel takes effect after the current work unit, before the file handoff.", "ZIP을 만드는 중이에요. 취소는 현재 작업이 끝난 뒤 파일 전달 전에 적용돼요.")
3082
+ : stage === "depth" || stage === "frames" ? ko("Cancel stops at the next frame boundary, before the file handoff.", "취소하면 파일 전달 전 다음 프레임 경계에서 멈춰요.")
3085
3083
  : phase === "preparing" ? ko("Preparing the renderer and checking MP4 support…", "렌더러를 준비하고 MP4 지원을 확인하고 있어요…")
3086
3084
  : ko("Encoding the requested frames, not yet a completed file.", "요청한 프레임을 인코딩 중이에요. 아직 파일이 완성되지 않았어요.")}</p>}
3087
3085
  {retryable && <p>{ko("Retry keeps the original shot, camera, range and settings; it does not change your edits.", "다시 시도하면 원래 샷·카메라·범위·설정을 사용하며 편집 내용은 바꾸지 않아요.")}</p>}
3088
- {handedOff > 0 && <p>{ko(`${handedOff} file(s) already handed off. Retry skips those downloads; check browser downloads because OS saves cannot be confirmed.`, `파일 ${handedOff}개는 이미 전달했어요. 다시 시도할 때 해당 다운로드는 건너뛰어요. OS 저장은 확인할 수 없으니 브라우저 다운로드를 확인하세요.`)}</p>}
3086
+ {handedOff > 0 && <p>{ko(`${handedOff} file(s) already handed off. Retry skips those files; check your export folder or browser downloads because OS saves cannot be confirmed.`, `파일 ${handedOff}개는 이미 전달했어요. 다시 시도할 때 해당 파일은 건너뛰어요. OS 저장은 확인할 수 없으니 내보내기 폴더나 브라우저 다운로드를 확인하세요.`)}</p>}
3089
3087
  <div className="export-status-actions">
3090
3088
  {busy && cancellable && <button type="button" data-testid="export-cancel" onClick={stopShotRecording}>{ko("Cancel export", "내보내기 취소")}</button>}
3091
3089
  {retryable && <button type="button" data-testid="export-retry" disabled={recState === "recording"} onClick={() => void retryExport()}>{ko("Retry same export", "같은 내보내기 재시도")}</button>}
@@ -3094,7 +3092,7 @@ export default function App() {
3094
3092
  );
3095
3093
  }
3096
3094
 
3097
- function downloadOtioCutList() {
3095
+ async function downloadOtioCutList() {
3098
3096
  if (!shots.length) {
3099
3097
  setToast(ko("Add at least one Shot before exporting OTIO", "OTIO를 내보내려면 샷을 하나 이상 추가하세요"));
3100
3098
  return;
@@ -3113,16 +3111,11 @@ export default function App() {
3113
3111
  };
3114
3112
  const serialized = serializeOtio(exportScene, shots);
3115
3113
  const blob = new Blob([serialized], { type: "application/json" });
3116
- const url = URL.createObjectURL(blob);
3117
- const anchor = document.createElement("a");
3118
3114
  const slug = (activeScene?.name ?? "cut-list")
3119
3115
  .toLowerCase()
3120
3116
  .replace(/[^a-z0-9]+/g, "-")
3121
3117
  .replace(/^-+|-+$/g, "") || "cut-list";
3122
- anchor.href = url;
3123
- anchor.download = `cozyclay-${slug}.otio`;
3124
- anchor.click();
3125
- setTimeout(() => URL.revokeObjectURL(url), 10_000);
3118
+ await saveExport(blob, `cozyclay-${slug}.otio`);
3126
3119
  const frameCount = shots.reduce((total, shot) => total + shot.endFrame - shot.startFrame + 1, 0);
3127
3120
  setToast(isKo
3128
3121
  ? `OTIO 저장됨 · ${shots.length}샷 · ${frameCount}프레임`
@@ -3141,15 +3134,6 @@ export default function App() {
3141
3134
  * - a storyboard contact sheet of the whole cut.
3142
3135
  */
3143
3136
 
3144
- function saveDownload(href, name) {
3145
- const anchor = document.createElement("a");
3146
- anchor.href = href;
3147
- anchor.download = name;
3148
- document.body.appendChild(anchor);
3149
- anchor.click();
3150
- anchor.remove();
3151
- }
3152
-
3153
3137
  function loadImage(dataUrl) {
3154
3138
  return new Promise((resolve, reject) => {
3155
3139
  const image = new Image();
@@ -3279,21 +3263,20 @@ export default function App() {
3279
3263
  job.label = ko(`Shot ${order + 1} of ${targets.length}`, `샷 ${order + 1} / ${targets.length}`);
3280
3264
  const pack = await buildShotKeyframePack(job.request.context.shots[index], index, null, job);
3281
3265
  job.controller.signal.throwIfAborted();
3282
- const url = URL.createObjectURL(new Blob([pack.bytes], { type: "application/zip" }));
3283
- try { saveDownload(url, pack.name); job.request.handedOff.add(index); }
3284
- finally { setTimeout(() => URL.revokeObjectURL(url), 10_000); }
3285
- setToast(isKo ? `${pack.name} 다운로드 요청 · 파일 ${pack.entries.length}개` : `Download requested: ${pack.name} · ${pack.entries.length} files`);
3266
+ await saveExport(new Blob([pack.bytes], { type: "application/zip" }), pack.name);
3267
+ job.request.handedOff.add(index);
3268
+ setToast(isKo ? `${pack.name} 내보내기 완료 · 파일 ${pack.entries.length}개` : `${pack.name} exported · ${pack.entries.length} files`);
3286
3269
  await exportBoundary(job);
3287
3270
  }
3288
3271
  }));
3289
3272
  }
3290
3273
 
3291
3274
  /** Depth and normal conditioning passes of the framing on screen now. */
3292
- function exportRenderPasses() {
3275
+ async function exportRenderPasses() {
3293
3276
  try {
3294
3277
  const dataUrls = renderPassDataUrls();
3295
- for (const kind of ["depth", "normal"]) saveDownload(dataUrls[kind], passFileName(kind));
3296
- setToast(ko("Depth and normal passes downloaded", "뎁스·노멀 패스를 다운로드했어요"));
3278
+ for (const kind of ["depth", "normal"]) await saveExport(dataUrls[kind], passFileName(kind));
3279
+ setToast(ko("Depth and normal passes exported", "뎁스·노멀 패스를 내보냈어요"));
3297
3280
  trackFeature("export_render_pass");
3298
3281
  } catch (error) {
3299
3282
  setToast(error?.message || String(error));
@@ -3352,7 +3335,7 @@ export default function App() {
3352
3335
  cell: { width: 480, height: 300 },
3353
3336
  createCanvas: storyboardCanvas,
3354
3337
  });
3355
- saveDownload(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
3338
+ await saveExport(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
3356
3339
  setToast(isKo ? `스토리보드 저장됨 · ${cells.length}샷` : `Storyboard saved · ${cells.length} shots`);
3357
3340
  trackFeature("export_storyboard");
3358
3341
  } catch (error) {
@@ -3443,8 +3426,8 @@ export default function App() {
3443
3426
  }
3444
3427
  const canvas = composeStoryboard({ shots: cells, columns: Math.min(3, cells.length), cell: { width: 480, height: 300 }, createCanvas: storyboardCanvas });
3445
3428
  job.controller.signal.throwIfAborted();
3446
- saveDownload(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
3447
- setToast(isKo ? `콘택트 시트 다운로드 요청 · 패널 ${cells.length}개` : `Download requested: contact sheet · ${cells.length} panels`);
3429
+ await saveExport(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
3430
+ setToast(isKo ? `콘택트 시트 내보내기 완료 · 패널 ${cells.length}개` : `Contact sheet exported · ${cells.length} panels`);
3448
3431
  return { fileName: "cozyclay-storyboard.png", panelCount: cells.length };
3449
3432
  }));
3450
3433
  }
@@ -3497,10 +3480,8 @@ export default function App() {
3497
3480
  const bytes = every ? buildZip(entries) : buildKeyframePack(packs[0]);
3498
3481
  const name = every ? "cozyclay-panels.zip" : `cozyclay-${packs[0].folder}-${slugifyTitle(target.name)}.zip`;
3499
3482
  await exportBoundary(job);
3500
- const url = URL.createObjectURL(new Blob([bytes], { type: "application/zip" }));
3501
- try { saveDownload(url, name); }
3502
- finally { setTimeout(() => URL.revokeObjectURL(url), 10_000); }
3503
- setToast(isKo ? `${name} 다운로드 요청 · 파일 ${entries.length}개` : `Download requested: ${name} · ${entries.length} files`);
3483
+ await saveExport(new Blob([bytes], { type: "application/zip" }), name);
3484
+ setToast(isKo ? `${name} 내보내기 완료 · 파일 ${entries.length}개` : `${name} exported · ${entries.length} files`);
3504
3485
  return { fileName: name, entries: entries.map((entry) => entry.name) };
3505
3486
  }));
3506
3487
  }
@@ -4186,27 +4167,24 @@ export default function App() {
4186
4167
  }
4187
4168
  const snapshot = structuredClone(result);
4188
4169
  const frameJob = { source: result, done: false, request: null };
4189
- frameJob.request = exportRequest("frame", (job) => {
4170
+ frameJob.request = exportRequest("frame", async (job) => {
4190
4171
  if (!snapshot?.frame) throw Object.assign(new Error("The captured frame is not ready"), { exportFailureCode: "render_failed" });
4191
4172
  updateExportStatus(job, "finalizing", { stage: "download", cancellable: false });
4192
- const save = (href, name) => {
4173
+ const save = async (source, name) => {
4193
4174
  if (job.request.handedOff.has(name)) return;
4194
- saveDownload(href, name);
4175
+ await saveExport(source, name);
4195
4176
  job.request.handedOff.add(name);
4196
4177
  };
4197
4178
  if (snapshot.partColours && !job.request.handedOff.has("blocking-frame-palette.json")) {
4198
- const blob = new Blob([JSON.stringify({ partColours: snapshot.partColours }, null, 2)], { type: "application/json" });
4199
- const url = URL.createObjectURL(blob);
4200
- try { save(url, "blocking-frame-palette.json"); }
4201
- finally { setTimeout(() => URL.revokeObjectURL(url), 1000); }
4179
+ await save(new Blob([JSON.stringify({ partColours: snapshot.partColours }, null, 2)], { type: "application/json" }), "blocking-frame-palette.json");
4202
4180
  }
4203
4181
  if (snapshot.frameB) {
4204
- save(snapshot.frame, "blocking-frame-A-start.png");
4205
- save(snapshot.frameB, "blocking-frame-B-end.png");
4206
- } else save(snapshot.frame, "blocking-frame.png");
4182
+ await save(snapshot.frame, "blocking-frame-A-start.png");
4183
+ await save(snapshot.frameB, "blocking-frame-B-end.png");
4184
+ } else await save(snapshot.frame, "blocking-frame.png");
4207
4185
  frameJob.done = true;
4208
4186
  setResult((current) => current === frameJob.source ? { ...current, downloaded: true } : current);
4209
- setToast(ko("Frame download requested. Check your browser's downloads.", "프레임 다운로드를 요청했어요. 브라우저 다운로드를 확인하세요."));
4187
+ setToast(ko("Frame export finished. Check your export folder or browser downloads.", "프레임 내보내기가 끝났어요. 내보내기 폴더나 브라우저 다운로드를 확인하세요."));
4210
4188
  });
4211
4189
  frameExportRef.current = frameJob;
4212
4190
  return executeExportRequest(frameJob.request);
@@ -4791,7 +4769,7 @@ export default function App() {
4791
4769
  />
4792
4770
  </ObjectCarrier>
4793
4771
  ))}
4794
- <ViewGhosts rig={rigs[activeChar.id]} take={motion} frame={tlFrame} objects={sceneObjects} propTake={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }} /><DraftGhost rigs={rigs} objects={sceneObjects} />
4772
+ <ViewGhosts rig={rigs[activeChar.id]} take={motion} frame={tlFrame} objects={sceneObjects} propTake={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }} /><DraftGhost rigs={rigs} objects={sceneObjects} /><SculptSketch objects={sceneObjects} />
4795
4773
 
4796
4774
  {/* Selection marker: XYZ tripod + ring on the picked cast
4797
4775
  member; the X/Z arrows drag it across the deck. */}
@@ -1,18 +1,64 @@
1
1
  // The document targets a command writes, known before it runs, for tasks that edit one scene at once (parallel Studio
2
- // tasks, P1). A key names one target: object:<id> (a group carries its subtree), character:<id> (its placement, its
3
- // take, its IK), shot:<id> (its camera, rail and keys), or one of the singletons stage (light, environment, lens),
4
- // timeline (frame count, shot order and ranges) and scene (documents, the cast library, a replace of a whole kind).
5
- // timeline and scene are exclusive: no other task runs beside them. A command that only creates (object.add, a kit,
6
- // character.add) needs no key: what it makes joins its task. A command this table does not know writes scene, so a
7
- // new command is safe until it is named here.
2
+ // tasks, P1). A key names one target: object:<id> (a group carries its subtree), character:<id>:<layer> (one owned
3
+ // layer of a character, below), shot:<id> (its camera, rail and keys), or one of the singletons stage (light,
4
+ // environment, lens), timeline (frame count, shot order and ranges) and scene (documents, the cast library, a replace of
5
+ // a whole kind). timeline and scene are exclusive: no other task runs beside them. A command that only creates
6
+ // (object.add, a kit, character.add) needs no key: what it makes joins its task. A command this table does not know
7
+ // writes scene, so a new command is safe until it is named here.
8
+ // A character is four layers, so one task can place her while another makes her motion: placement (where she stands,
9
+ // her scale, parent, visibility, tint, static pose: every cast field the others do not own), take (the motion take, its
10
+ // timing edit and the root path), ik (the IK keys and range pins on top of the take) and props (what she holds; the
11
+ // held object's own key goes with it). Playback already stacks them placement < take < ik; the split only says who may
12
+ // write which. A plain character:<id> is all four (the scope a task names; a receipt's id): it overlaps every layer.
8
13
  const EXCLUSIVE_KEYS = new Set(['timeline', 'scene']);
9
- const KEY = /^(?:(?:object|character|shot):[A-Za-z0-9][A-Za-z0-9._:-]*|stage|timeline|scene)$/;
14
+ const CHARACTER_LAYERS = Object.freeze(['placement', 'take', 'ik', 'props']);
15
+ const ID = '[A-Za-z0-9][A-Za-z0-9._:-]*';
16
+ const KEY = new RegExp(`^(?:(?:object|character|shot):${ID}|stage|timeline|scene)$`);
10
17
  export const isScopeKey = key => typeof key === 'string' && key.length <= 140 && KEY.test(key);
18
+ const LAYERED = /^character:(.+?)(?::(placement|take|ik|props))?$/s;
19
+ /** { id, layer } of a character key (layer null for the plain key), or null for any other key. */
20
+ export function layerOf(key) {
21
+ const match = LAYERED.exec(key);
22
+ return match ? { id: match[1], layer: match[2] ?? null } : null;
23
+ }
24
+ const layers = (id, names = CHARACTER_LAYERS) => names.map(name => `character:${id}:${name}`);
25
+ /** The keys a scope names, with a plain character:<id> spelled out as its four layers. */
26
+ export const expandScope = keys => [...new Set(keys.flatMap(key => { const of = layerOf(key); return of && !of.layer ? layers(of.id) : [key]; }))];
27
+ /** True when one key's target covers part of the other's: the same key, or a plain character key and one of its layers. */
28
+ export function keysOverlap(a, b) {
29
+ if (a === b) return true;
30
+ const x = layerOf(a), y = layerOf(b);
31
+ return Boolean(x && y && x.id === y.id && (!x.layer || !y.layer));
32
+ }
11
33
 
12
34
  const SCENE = ['cast.replace', 'cast.setCustomPoses', 'cast.showExtras', 'cast.removePose', 'objects.replace', 'shot.replace', 'ai.prepareShot'];
13
35
  const TIMELINE = ['shot.create', 'shot.createStill', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.reorder', 'shot.setRange', 'shot.setTimeline'];
14
36
  const CREATE = ['object.add', 'object.createKit', 'asset.import', 'character.add'];
15
37
  const CHARACTER = /^(character\.|characters?\b|cast\.(savePose|setLayer)$|ik\.|motion\.)/;
38
+ // The layers each character command writes, read from what it changes: a compile installs a take and clears the IK
39
+ // keys on it (motion domain replace), a timing edit moves the IK keys with the frames they address, a pose that clears
40
+ // motion clears both, and attaching a prop changes what she holds. A command this table does not name writes all four.
41
+ const WRITES = {
42
+ 'character.move': ['placement'], 'characters.arrange': ['placement'], 'cast.setLayer': ['take'], 'motion.set': ['take'],
43
+ 'motion.compile': ['take', 'ik'], 'motion.clear': ['take', 'ik'], 'motion.keyObject': ['take', 'ik', 'props'], 'object.attach': ['props'],
44
+ ...Object.fromEntries(['addWaypoint', 'moveWaypoint', 'removeWaypoint', 'clearWaypoints'].map(verb => [`character.${verb}`, ['take']])),
45
+ ...Object.fromEntries(['cut', 'setSegmentSpeed', 'removeSegment', 'trim', 'resetTiming'].map(verb => [`motion.${verb}`, ['take', 'ik']])),
46
+ ...Object.fromEntries(['character.setIkKey', 'character.removeIkKey', 'character.clearIkKeys', 'ik.setKey', 'ik.removeKey', 'ik.clearKeys', 'ik.applyPose',
47
+ 'motion.rangePin.apply', 'motion.rangePin.remove'].map(id => [id, ['ik']])),
48
+ };
49
+ // A cast field the take or the IK layer owns (the rest is placement); a model swap is a different body under all four.
50
+ const FIELD_LAYER = { layer: 'take', motionTake: 'take', waypoints: 'take', ikEdits: 'ik', ikKeys: 'ik', model: null };
51
+ const fieldLayers = fields => fields.some(field => FIELD_LAYER[field] === null) ? CHARACTER_LAYERS
52
+ : [...new Set(fields.map(field => FIELD_LAYER[field] ?? 'placement'))];
53
+ const poseLayers = args => args.clearMotion ? ['placement', 'take', 'ik'] : ['placement'];
54
+ /** The layer keys a character command writes on character `id`. */
55
+ function characterKeys(id, args) {
56
+ if (WRITES[id]) return WRITES[id];
57
+ if (id === 'character.update') return fieldLayers(Object.keys(args.patch ?? {}));
58
+ if (id === 'character.set') return fieldLayers(Object.keys(args.set ?? {}));
59
+ if (id === 'character.setPose' || id === 'cast.savePose') return poseLayers(args);
60
+ return CHARACTER_LAYERS;
61
+ }
16
62
 
17
63
  /** object:<id> and every object under it. */
18
64
  function objectKeys(ids, state) {
@@ -34,7 +80,9 @@ export function commandKeys(id, args = {}, state = {}, entry = {}) {
34
80
  if (CREATE.includes(id)) return [];
35
81
  if (id === 'objects.batch') return [...new Set(args.ops.flatMap(op => commandKeys(op.name, op.args, state)))];
36
82
  if (id === 'objects.arrange') return [...new Set(arrangeKeys(args.ops, state))];
37
- if (id === 'characters.arrange') return args.ops.flatMap(op => op.op === 'create' ? [] : [`character:${op.characterId}`]);
83
+ if (id === 'characters.arrange') return args.ops.flatMap(op => op.op === 'create' ? [] : layers(op.characterId, characterKeys(id, op)));
84
+ // A set or a take edit of several characters at once names each one (an op is { id, set }).
85
+ if ((id === 'character.set' || id === 'motion.set') && args.ops) return [...new Set(args.ops.flatMap(op => layers(op.id, characterKeys(id, op))))];
38
86
  if (id.startsWith('stage.') || id === 'shot.setLens') return ['stage'];
39
87
  if (TIMELINE.includes(id)) return ['timeline'];
40
88
  if (id.startsWith('shot.')) {
@@ -44,9 +92,9 @@ export function commandKeys(id, args = {}, state = {}, entry = {}) {
44
92
  if (id === 'object.group') return objectKeys([args.parent, ...args.children], state);
45
93
  if (id === 'object.ungroup') return objectKeys(args.children, state);
46
94
  if (id === 'object.remove') return objectKeys(args.ids, state);
47
- if (id === 'object.attach' || id === 'motion.keyObject') return [`character:${args.characterId}`, ...objectKeys([args.objectId], state)];
95
+ if (id === 'object.attach' || id === 'motion.keyObject') return [...layers(args.characterId, characterKeys(id, args)), ...objectKeys([args.objectId], state)];
48
96
  if (id.startsWith('object.')) return objectKeys([args.id ?? args.objectId], state);
49
- if (CHARACTER.test(id) && (args.characterId ?? args.id)) return [`character:${args.characterId ?? args.id}`];
97
+ if (CHARACTER.test(id) && (args.characterId ?? args.id)) return layers(args.characterId ?? args.id, characterKeys(id, args));
50
98
  return ['scene'];
51
99
  }
52
100
 
@@ -62,7 +110,7 @@ export function keyOfId(id, state = {}) {
62
110
  export function scopeConflict(taskId, keys, tasks) {
63
111
  for (const other of tasks.values()) {
64
112
  if (other.taskId === taskId) continue;
65
- const held = keys.find(key => other.keys.has(key) || EXCLUSIVE_KEYS.has(key) || [...other.keys].some(k => EXCLUSIVE_KEYS.has(k)));
113
+ const held = keys.find(key => EXCLUSIVE_KEYS.has(key) || [...other.keys].some(k => EXCLUSIVE_KEYS.has(k) || keysOverlap(key, k)));
66
114
  if (held) return `${held} is held by task ${other.taskId}${other.title ? ` (${other.title})` : ''}`;
67
115
  }
68
116
  return null;
@@ -2,20 +2,37 @@
2
2
  // key is its rows (an object's row; a character's cast row and motion layer; a shot's row) or, for stage and timeline,
3
3
  // the whole slices; restoring puts every row back where it stood (or removes a row the task created) in one composed
4
4
  // entry, so undo, history and saves see an ordinary edit. scene is not restorable: documents have no undo.
5
+ // A character layer (bus-task-scopes.js) is the fields of those two rows it owns: restoring it writes only them back
6
+ // into the row as it stands now, so cancelling the task that placed her keeps the take another task made meanwhile.
5
7
  import { StudioProtocolError } from './studio-agent-protocol.js';
8
+ import { layerOf } from './bus-task-scopes.js';
6
9
 
7
10
  const ROWS = { object: [['objects', null]], character: [['cast', 'characters'], ['motion', null]], shot: [['shot', 'shots']] };
8
11
  const WHOLE = { stage: ['stage'], timeline: ['shot', 'motion'] };
9
12
  const split = key => { const at = key.indexOf(':'); return at < 0 ? [key] : [key.slice(0, at), key.slice(at + 1)]; };
13
+ // The fields of the cast row (scenes.js createCharacterEntry) and the motion layer (emptyMotionLayer) each layer owns;
14
+ // placement is every other cast field, props no field of either (what she holds lives on the objects).
15
+ const LAYER_FIELDS = {
16
+ cast: { take: ['layer', 'motionTake'], ik: ['ikEdits'], props: [] },
17
+ motion: { placement: [], take: ['take', 'fullTake', 'takeRecipe', 'takeVersions'], ik: ['ikKeys', 'ikPins', 'ikPinResiduals', 'committedIkEdits'], props: [] },
18
+ };
19
+ const CAST_OWNED = new Set(['id', ...Object.values(LAYER_FIELDS.cast).flat()]);
20
+ const fieldsOf = (domain, layer, row) => LAYER_FIELDS[domain][layer] ?? Object.keys(row).filter(field => !CAST_OWNED.has(field));
21
+ // A character with no motion layer yet reads as the empty one (motion-intent-codec.js emptyMotionLayer).
22
+ const EMPTY = { motion: { take: null, fullTake: null, takeRecipe: null, takeVersions: [], ikKeys: [], ikPins: [], ikPinResiduals: [], committedIkEdits: [] } };
23
+ const pick = (row, fields) => Object.fromEntries(fields.map(field => [field, row[field]]));
10
24
 
11
25
  function put(slice, part) {
12
26
  if ('whole' in part) return part.whole;
13
- const rows = (part.path ? slice[part.path] : slice).filter(row => row.id !== part.id);
14
- if (part.row) rows.splice(Math.min(part.index, rows.length), 0, part.row);
27
+ const all = part.path ? slice[part.path] : slice, at = all.findIndex(row => row.id === part.id), rows = all.filter(row => row.id !== part.id);
28
+ // A layer goes back into the row as it is now; a row removed since comes back whole; a layer never removes a row.
29
+ if ('fields' in part && at >= 0) rows.splice(at, 0, { ...all[at], ...part.fields });
30
+ else if (part.row) rows.splice(Math.min(part.index, rows.length), 0, part.row);
31
+ else if ('fields' in part) return slice;
15
32
  return part.path ? { ...slice, [part.path]: rows } : rows;
16
33
  }
17
34
 
18
- /** snapshotTarget(key) and restoreTargets([{ key, value }]) over the app's store domains (ports.storeDomain, ports.beginAction). */
35
+ /** snapshotTarget(key), restoreTargets([{ key, value }]) and readLayer(key) over the app's store domains (ports.storeDomain, ports.beginAction). */
19
36
  export function taskTargetPorts(ports) {
20
37
  const store = domain => ports.storeDomain?.(domain)?.documentStore;
21
38
  const rowParts = (kind, id, read) => ROWS[kind].filter(([domain]) => store(domain)).map(([domain, path]) => {
@@ -23,17 +40,26 @@ export function taskTargetPorts(ports) {
23
40
  const index = read ? rows.findIndex(row => row.id === id) : -1;
24
41
  return { domain, path, id, index, row: index < 0 ? undefined : rows[index] };
25
42
  });
43
+ // A layer's parts: its fields of each row that has some, beside the whole row (to put back a row removed since).
44
+ const layerParts = ({ id, layer }) => rowParts('character', id, true).flatMap(part => {
45
+ const row = part.row ?? EMPTY[part.domain], fields = row ? fieldsOf(part.domain, layer, row) : [];
46
+ return fields.length ? [{ ...part, fields: pick(row, fields) }] : [];
47
+ });
48
+ const snapshotTarget = key => {
49
+ const [kind, id] = split(key), of = layerOf(key);
50
+ if (of?.layer) return layerParts(of);
51
+ if (WHOLE[kind]) return WHOLE[kind].filter(store).map(domain => ({ domain, whole: store(domain).read(domain) }));
52
+ return ROWS[kind] ? rowParts(kind, id, true) : null;
53
+ };
26
54
  return {
27
- snapshotTarget(key) {
28
- const [kind, id] = split(key);
29
- if (WHOLE[kind]) return WHOLE[kind].filter(store).map(domain => ({ domain, whole: store(domain).read(domain) }));
30
- return ROWS[kind] ? rowParts(kind, id, true) : null;
31
- },
55
+ snapshotTarget,
56
+ /** A layer's token: its own fields only, so an edit of another layer of the same character leaves it as it was. */
57
+ readLayer: key => JSON.stringify(snapshotTarget(key).map(part => part.fields)),
32
58
  restoreTargets(values) {
33
59
  const parts = values.flatMap(({ key, value }) => {
34
60
  if (value === null) throw new StudioProtocolError('CAPABILITY_MISSING', `${key} cannot be restored: a document change has no undo.`);
35
61
  const [kind, id] = split(key);
36
- return value ?? rowParts(kind, id, false);
62
+ return value ?? rowParts(kind, layerOf(key)?.id ?? id, false);
37
63
  });
38
64
  const domains = [...new Set(parts.map(part => part.domain))];
39
65
  if (!domains.length) return { historyEntryId: null };