cozyclay 3.1.1 → 3.1.3

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 (195) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +1 -1
  3. package/bin/agent/agent-routes.mjs +4 -4
  4. package/bin/agent/agent-runner.mjs +160 -151
  5. package/bin/agent/agent-stream.mjs +86 -0
  6. package/bin/agent/anthropic-subscription-events.mjs +120 -0
  7. package/bin/agent/anthropic-subscription-prompt.mjs +116 -0
  8. package/bin/agent/anthropic-subscription-stream.mjs +251 -0
  9. package/bin/agent/anthropic-subscription.mjs +73 -0
  10. package/bin/agent/claude-code-executable.mjs +93 -0
  11. package/bin/agent/cliproxy-provider.mjs +25 -11
  12. package/bin/agent/main-agent.mjs +21 -7
  13. package/bin/agent/main-prompt.mjs +7 -6
  14. package/bin/agent/model-roles.mjs +112 -0
  15. package/bin/agent/openai-compatible-provider.mjs +95 -0
  16. package/bin/agent/pi-tools.mjs +19 -10
  17. package/bin/agent/provider-login.mjs +3 -1
  18. package/bin/agent/provider-routes.mjs +2 -0
  19. package/bin/agent/providers.mjs +35 -130
  20. package/bin/agent/script-tool.mjs +2 -1
  21. package/bin/agent/studio-prompt.mjs +1 -1
  22. package/bin/agent/studio-rebase.mjs +5 -4
  23. package/bin/agent/studio-task-routes.mjs +1 -1
  24. package/bin/agent/studio-tasks.mjs +116 -25
  25. package/bin/agent/studio-tools.mjs +3 -2
  26. package/bin/cozyclay.mjs +4 -2
  27. package/bin/live-endpoint.mjs +2 -0
  28. package/dist/app/index.html +6 -6
  29. package/dist/assets/TaskBoard-U1Yl-4F1.js +1 -0
  30. package/dist/assets/app-BdRlMf4J.css +1 -0
  31. package/dist/assets/app-DwETmoQZ.js +5144 -0
  32. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  33. package/dist/assets/motion3-compile-DDG3wklD.js +1 -0
  34. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  35. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +13 -0
  36. package/dist/assets/task-board-DFfNa0xg.js +15 -0
  37. package/dist/assets/workflow-BMJGd_gZ.js +179 -0
  38. package/dist/cozyclay-package.json +1 -1
  39. package/dist/index.html +1 -1
  40. package/dist/sitemap.xml +1 -1
  41. package/dist/workflow/index.html +4 -4
  42. package/mcp/README.md +1 -1
  43. package/mcp/live-hub.mjs +5 -3
  44. package/mcp/tool-handlers.mjs +5 -5
  45. package/package.json +6 -4
  46. package/src/App.jsx +65 -87
  47. package/src/agent-image-client.js +2 -2
  48. package/src/asset-pane.jsx +14 -14
  49. package/src/bus-task-scopes.js +59 -11
  50. package/src/bus-task-targets.js +35 -9
  51. package/src/bus-tasks.js +23 -10
  52. package/src/character-kind.js +13 -12
  53. package/src/command-bus.js +7 -4
  54. package/src/commands/ai.js +1 -1
  55. package/src/commands/export.js +1 -1
  56. package/src/commands/motion3.js +27 -7
  57. package/src/commands/project.js +2 -1
  58. package/src/commands/shot.js +1 -1
  59. package/src/commands/view.js +2 -2
  60. package/src/domains/cast.js +15 -15
  61. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  62. package/src/domains/motion.js +11 -11
  63. package/src/domains/objects.js +20 -30
  64. package/src/domains/scenes.js +14 -21
  65. package/src/domains/shots.js +3 -3
  66. package/src/export-workspace.js +244 -0
  67. package/src/first-success-guide.jsx +19 -3
  68. package/src/hierarchy-panel.jsx +6 -7
  69. package/src/keyframe-pack.js +5 -1
  70. package/src/live-control.js +14 -7
  71. package/src/locale-zh/app.js +124 -0
  72. package/src/locale-zh/domains.js +36 -0
  73. package/src/locale-zh/labels.js +148 -0
  74. package/src/locale-zh/panels.js +225 -0
  75. package/src/locale-zh/shell.js +230 -0
  76. package/src/locale-zh/timeline.js +145 -0
  77. package/src/locale-zh/workflow.js +104 -0
  78. package/src/locale-zh/workspace.js +190 -0
  79. package/src/locale-zh.js +21 -0
  80. package/src/locale.js +16 -6
  81. package/src/main.jsx +1 -1
  82. package/src/motion/beat-board-model.js +4 -4
  83. package/src/motion/beat-board.jsx +1 -1
  84. package/src/motion/ik-pick.js +24 -0
  85. package/src/motion/take-segments.jsx +5 -5
  86. package/src/motion/timeline.jsx +30 -40
  87. package/src/motion/waypoints.js +6 -6
  88. package/src/motion3/compile-job.js +15 -3
  89. package/src/motion3/compiler/channels.js +74 -24
  90. package/src/motion3/compiler/compile.js +2 -1
  91. package/src/motion3/compiler/idle.js +2 -0
  92. package/src/motion3/compiler/overlap.js +180 -0
  93. package/src/motion3/compiler/pose.js +6 -4
  94. package/src/motion3/compiler/validate-common.js +1 -0
  95. package/src/motion3/compiler/validate-parts.js +5 -1
  96. package/src/motion3/compiler/validate.js +30 -10
  97. package/src/motion3/correct.js +236 -0
  98. package/src/motion3/docs.js +8 -6
  99. package/src/motion3/face-lean.js +31 -0
  100. package/src/motion3/fix/index.js +5 -1
  101. package/src/motion3/fix/reach-body.js +172 -0
  102. package/src/motion3/hint-check.js +44 -0
  103. package/src/motion3/json-patch.js +120 -12
  104. package/src/motion3/lenient.js +46 -4
  105. package/src/motion3/object-keys.js +17 -5
  106. package/src/motion3/pipeline.js +46 -9
  107. package/src/motion3/qa/arm-checks.js +96 -0
  108. package/src/motion3/qa/asked.js +113 -0
  109. package/src/motion3/qa/goals.js +37 -8
  110. package/src/motion3/qa/hints-goal.js +6 -5
  111. package/src/motion3/qa/index.js +5 -2
  112. package/src/motion3/qa/key-checks.js +4 -3
  113. package/src/motion3/qa/motion-life.js +243 -0
  114. package/src/motion3/qa/spec-checks.js +15 -9
  115. package/src/motion3/qa/verdict.js +50 -0
  116. package/src/motion3/refs.js +38 -14
  117. package/src/motion3/repair-watch.js +132 -0
  118. package/src/motion3/repair.js +52 -19
  119. package/src/motion3/report.js +50 -9
  120. package/src/motion3/schema/topics.js +6 -4
  121. package/src/motion3/schema/types.js +1 -1
  122. package/src/motion3/skills/basic.js +15 -8
  123. package/src/motion3/skills/index.js +52 -18
  124. package/src/motion3/skills/info.js +1 -1
  125. package/src/motion3/skills/stance.js +6 -0
  126. package/src/motion3/skills/walk-fit.js +170 -0
  127. package/src/object-catalog.jsx +4 -5
  128. package/src/panels/CameraPanel.jsx +3 -3
  129. package/src/panels/HdrEnvironmentControls.jsx +2 -1
  130. package/src/panels/ObjectTransformPanel.jsx +19 -29
  131. package/src/panels/PoseImagePanel.jsx +11 -10
  132. package/src/panels/PosePanel.jsx +2 -2
  133. package/src/panels/ReferenceImageField.jsx +2 -2
  134. package/src/panels/RigControlPanel.jsx +2 -2
  135. package/src/panels/SculptPanel.jsx +11 -11
  136. package/src/panels/SubjectBox.jsx +3 -3
  137. package/src/panels/SubjectsPanel.jsx +1 -1
  138. package/src/posestudio.jsx +10 -8
  139. package/src/project-browser.css +7 -6
  140. package/src/project-browser.jsx +7 -6
  141. package/src/project-open-file.js +19 -0
  142. package/src/qa-label.js +1 -1
  143. package/src/range-pin-panel.jsx +14 -14
  144. package/src/resource-status.jsx +3 -3
  145. package/src/result-modal.jsx +13 -13
  146. package/src/settings-menu.jsx +4 -1
  147. package/src/shell/BoardSlot.jsx +5 -5
  148. package/src/shell/CompositionGuideButton.jsx +1 -1
  149. package/src/shell/ExportFolderPreference.jsx +103 -0
  150. package/src/shell/GhostsMenuGroup.jsx +1 -1
  151. package/src/shell/PreferencesDialog.jsx +3 -166
  152. package/src/shell/StatusBar.jsx +4 -4
  153. package/src/shell/ViewportToolbar.jsx +3 -3
  154. package/src/shell/add-camera-shot.js +2 -2
  155. package/src/shell/details-selection.js +1 -1
  156. package/src/shell/preference-controls.jsx +115 -0
  157. package/src/shell/preference-pages.jsx +72 -0
  158. package/src/shell/preferences.css +46 -0
  159. package/src/shot-guides.js +5 -5
  160. package/src/shot-prompt.js +29 -9
  161. package/src/stage-hdr.js +3 -3
  162. package/src/stage-labels-ko.js +2 -2
  163. package/src/stage-startup-defaults.js +3 -3
  164. package/src/studio-actions.js +4 -4
  165. package/src/studio-agent-motion.js +19 -9
  166. package/src/studio-agent-protocol.js +1 -1
  167. package/src/studio-app-binding.js +3 -2
  168. package/src/styles/tokens.css +1 -1
  169. package/src/styles.css +1 -1
  170. package/src/use-case-question.jsx +18 -18
  171. package/src/workflow/AgentPanel.jsx +6 -6
  172. package/src/workflow/AgentPlanCard.jsx +1 -1
  173. package/src/workflow/AgentProgressCard.jsx +4 -4
  174. package/src/workflow/AgentTurnSteps.jsx +1 -1
  175. package/src/workflow/CozySceneNode.jsx +1 -1
  176. package/src/workflow/LoginPanel.jsx +1 -1
  177. package/src/workflow/TaskBoard.jsx +1 -1
  178. package/src/workflow/TaskInline.jsx +1 -1
  179. package/src/workflow/WorkflowBuilder.jsx +2 -2
  180. package/src/workflow/agent-client.js +1 -1
  181. package/src/workflow/agent-turn.js +5 -5
  182. package/src/workflow/keyframe-pack-request.js +3 -10
  183. package/src/workflow/task-board.css +1 -1
  184. package/src/workflow/task-board.js +11 -9
  185. package/tools/loc-baseline.json +2 -3
  186. package/tools/run-tests.mjs +9 -9
  187. package/dist/assets/TaskBoard-ngdqiu7u.js +0 -1
  188. package/dist/assets/app-BlwOpest.css +0 -1
  189. package/dist/assets/app-DmR7jBkz.js +0 -5144
  190. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  191. package/dist/assets/motion3-compile-BB3_1-EB.js +0 -1
  192. package/dist/assets/scene-asset-sync-CBx7XRti.css +0 -1
  193. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +0 -13
  194. package/dist/assets/task-board-ErQvfoaz.js +0 -14
  195. package/dist/assets/workflow-zQM-EKW1.js +0 -179
@@ -162,8 +162,8 @@ function StorageAssetRow({ id, onDelete, deleting, usageCount = 0, graphSignatur
162
162
  const failed = thumb === null;
163
163
  const mesh = Boolean(thumb?.mesh);
164
164
  const name = thumb?.name || (failed ? ko("(unreadable image)", "(읽을 수 없는 이미지)") : mesh ? ko("Untitled model", "이름 없는 모델") : ko("Untitled image", "이름 없는 이미지"));
165
- const usageLabel = ko(`Used by ${usageCount} scene object${usageCount === 1 ? "" : "s"}`, `${usageCount}개 씬 오브젝트에서 사용 중`);
166
- const deleteLabel = ko(`Delete ${name} from storage`, `${name}을(를) 저장소에서 삭제`);
165
+ const usageLabel = ko(`Used by ${usageCount} scene object${usageCount === 1 ? "" : "s"}`, `${usageCount}개 씬 오브젝트에서 사용 중`, `被 ${usageCount} 个场景物体使用`);
166
+ const deleteLabel = ko(`Delete ${name} from storage`, `${name}을(를) 저장소에서 삭제`, `从存储中删除 ${name}`);
167
167
  const confirmationId = `asset-storage-warning-${id}`;
168
168
  const kindLabel = thumb?.kind === "mesh" ? ko("Model", "모델") : thumb?.kind === "environment" ? ko("HDR environment", "HDR 환경") : thumb?.kind === "matte" ? ko("Matte", "매트") : ko("Image", "이미지");
169
169
  return (
@@ -183,7 +183,7 @@ function StorageAssetRow({ id, onDelete, deleting, usageCount = 0, graphSignatur
183
183
  {confirmation ? (
184
184
  <div className="asset-storage-confirm">
185
185
  <span id={confirmationId} role="alert">{inUse
186
- ? ko(`Permanently delete this image from storage? It is used by ${usageCount} scene object${usageCount === 1 ? "" : "s"} and those objects will lose it.`, `저장소에서 이 이미지를 영구 삭제할까요? ${usageCount}개 씬 오브젝트가 사용 중이며 해당 오브젝트에서 사라집니다.`)
186
+ ? ko(`Permanently delete this image from storage? It is used by ${usageCount} scene object${usageCount === 1 ? "" : "s"} and those objects will lose it.`, `저장소에서 이 이미지를 영구 삭제할까요? ${usageCount}개 씬 오브젝트가 사용 중이며 해당 오브젝트에서 사라집니다.`, `要从存储中永久删除此图片吗?它正被 ${usageCount} 个场景物体使用,那些物体会失去它。`)
187
187
  : ko("Unused by every scene. Delete it?", "모든 씬에서 사용되지 않아요. 삭제할까요?")}</span>
188
188
  <button type="button" className="asset-storage-delete" aria-label={deleteLabel} aria-describedby={confirmationId} disabled={deleting} onClick={async () => {
189
189
  if (await onDelete(id, inUse ? usageCount : undefined, confirmation.graphSignature)) setConfirmation(null);
@@ -209,7 +209,7 @@ function StorageManager({ unusedAssetIds, usedAssetIds, usageCounts, graphSignat
209
209
  <h3>{ko("Manage storage", "저장 공간 관리")}</h3>
210
210
  <p>{ko("Review unused and in-use stored images. Deleted images can be restored until this page is reloaded.", "사용하지 않는 이미지와 사용 중인 저장 이미지를 확인하세요. 삭제한 이미지는 이 페이지를 새로 고치기 전까지 복원할 수 있어요.")}</p>
211
211
  </div>
212
- {trashCount > 0 && <button type="button" className="asset-storage-undo" onClick={onUndo}>{ko(`Undo last delete (${trashCount})`, `마지막 삭제 실행 취소 (${trashCount})`)}</button>}
212
+ {trashCount > 0 && <button type="button" className="asset-storage-undo" onClick={onUndo}>{ko(`Undo last delete (${trashCount})`, `마지막 삭제 실행 취소 (${trashCount})`, `撤销上次删除(${trashCount})`)}</button>}
213
213
  </div>
214
214
  {loading ? (
215
215
  <div className="asset-storage-list" aria-busy="true">
@@ -301,8 +301,8 @@ function ImageAssetTile({ id, onAssetGrab, onAssetPlace, selectedKey, onSelect,
301
301
  type={failed ? ko("Unreadable", "읽을 수 없음") : ko("Image", "이미지")}
302
302
  failed={failed}
303
303
  title={failed
304
- ? ko(`${label} — could not decode; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`)
305
- : ko(`Drag ${label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
304
+ ? ko(`${label} — could not decode; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`, `${label} — 无法解码;请在存储管理中删除`)
305
+ : ko(`Drag ${label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`, `将 ${label} 拖入场景,或双击将其放在原点`)}
306
306
  preview={thumb
307
307
  ? <img className="content-tile-img" src={thumb.url} alt="" draggable={false} />
308
308
  : <span className="content-tile-skeleton" aria-hidden="true" />}
@@ -329,8 +329,8 @@ function MeshAssetTile({ id, onAssetGrab, onAssetPlace, selectedKey, onSelect, q
329
329
  type={failed ? ko("Unreadable", "읽을 수 없음") : ko("Model", "모델")}
330
330
  failed={failed}
331
331
  title={failed
332
- ? ko(`${label} — could not read; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`)
333
- : ko(`Drag ${label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
332
+ ? ko(`${label} — could not read; delete it from Manage storage`, `${label} — 불러오지 못했어요. 저장소 관리에서 삭제할 수 있어요`, `${label} — 无法读取;请在存储管理中删除`)
333
+ : ko(`Drag ${label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`, `将 ${label} 拖入场景,或双击将其放在原点`)}
334
334
  preview={failed ? <span className="content-tile-skeleton" aria-hidden="true" /> : <MeshPreview />}
335
335
  selected={selectedKey === key}
336
336
  onSelect={onSelect}
@@ -386,7 +386,7 @@ function catalogueTile(entry, props) {
386
386
  assetKey={key}
387
387
  label={label}
388
388
  type={ko("Shape", "도형")}
389
- title={ko(`Drag ${entry.label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
389
+ title={ko(`Drag ${entry.label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`, `将 ${entry.label} 拖入场景,或双击将其放在原点`)}
390
390
  preview={<ObjectPreview kind={entry.kind} color={entry.color} />}
391
391
  selected={props.selectedKey === key}
392
392
  onSelect={props.onSelect}
@@ -418,7 +418,7 @@ export function FolderGrid({ folder, query, ...props }) {
418
418
  assetKey={key}
419
419
  label={asset.label}
420
420
  type={ko("Character", "인물")}
421
- title={ko(`Drag ${asset.label} into the scene, or double-click to place it at the origin`, `${asset.label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
421
+ title={ko(`Drag ${asset.label} into the scene, or double-click to place it at the origin`, `${asset.label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`, `将 ${asset.label} 拖入场景,或双击将其放在原点`)}
422
422
  preview={<CharacterPreview model={asset.id} />}
423
423
  selected={selectedKey === key}
424
424
  onSelect={onSelect}
@@ -454,7 +454,7 @@ export function FolderGrid({ folder, query, ...props }) {
454
454
  assetKey={key}
455
455
  label={label}
456
456
  type={ko("Camera", "카메라")}
457
- title={ko(`${label} — double-click to open it in Camera mode`, `${label} — 더블클릭하면 카메라 모드에서 엽니다`)}
457
+ title={ko(`${label} — double-click to open it in Camera mode`, `${label} — 더블클릭하면 카메라 모드에서 엽니다`, `${label} — 双击可在摄影机模式中打开`)}
458
458
  preview={<CameraPreview />}
459
459
  selected={selectedKey === key}
460
460
  onSelect={onSelect}
@@ -474,7 +474,7 @@ export function FolderGrid({ folder, query, ...props }) {
474
474
  assetKey={key}
475
475
  label={label}
476
476
  type={ko("Motion", "모션")}
477
- title={ko(`${label} — double-click to load this take`, `${label} — 더블클릭하면 이 테이크를 불러옵니다`)}
477
+ title={ko(`${label} — double-click to load this take`, `${label} — 더블클릭하면 이 테이크를 불러옵니다`, `${label} — 双击加载此动作片段`)}
478
478
  preview={<MotionPreview />}
479
479
  selected={selectedKey === key}
480
480
  onSelect={onSelect}
@@ -484,7 +484,7 @@ export function FolderGrid({ folder, query, ...props }) {
484
484
  });
485
485
  if (!takeVersions.length) empty = ko("No takes for this character yet. Ask the agent to compile one.", "이 인물의 테이크가 아직 없어요. 에이전트에게 모션 컴파일을 요청하세요.");
486
486
  } else if (folder === "poses") {
487
- tiles = poses.map((pose, index) => ({ pose, index, label: pose?.name || pose?.label || ko(`Pose ${index + 1}`, `포즈 ${index + 1}`) }))
487
+ tiles = poses.map((pose, index) => ({ pose, index, label: pose?.name || pose?.label || ko(`Pose ${index + 1}`, `포즈 ${index + 1}`, `姿势 ${index + 1}`) }))
488
488
  .filter(({ label }) => matchesQuery(label, query))
489
489
  .map(({ pose, index, label }) => {
490
490
  const key = `pose:${pose?.id ?? index}`;
@@ -634,7 +634,7 @@ export default function AssetPane({
634
634
  data-testid="content-search"
635
635
  value={search}
636
636
  placeholder={tab === "content" ? ko("Search objects", "오브젝트 검색") : ko("Search", "검색")}
637
- aria-label={tab === "content" ? ko(`Search ${folderLabel}`, `${folderLabel} 검색`) : ko("Search the log", "로그 검색")}
637
+ aria-label={tab === "content" ? ko(`Search ${folderLabel}`, `${folderLabel} 검색`, `搜索 ${folderLabel}`) : ko("Search the log", "로그 검색")}
638
638
  onChange={(event) => setSearch(event.target.value)}
639
639
  />
640
640
  </header>
@@ -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 };
package/src/bus-tasks.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // next command on a target the user changed answers STALE_TARGET once. Ctrl+Z stops at a running task's entry.
9
9
  // A single agent turn (agent.turn.*) is untouched: tasks and a turn never run together.
10
10
  import { StudioProtocolError, validateReceipt } from './studio-agent-protocol.js';
11
- import { commandKeys, keyOfId, scopeConflict, isScopeKey } from './bus-task-scopes.js';
11
+ import { commandKeys, keyOfId, scopeConflict, isScopeKey, layerOf, expandScope, keysOverlap } from './bus-task-scopes.js';
12
12
 
13
13
  const fail = (code, message) => { throw new StudioProtocolError(code, message); };
14
14
  const VERBS = ['begin', 'requestScope', 'mark', 'rollback', 'finish', 'cancel', 'undo'];
@@ -17,7 +17,11 @@ const listOf = keys => [...keys].slice(0, 6).join(', ') + (keys.length > 6 ? ` a
17
17
 
18
18
  export function createBusTasks({ ports, emit }) {
19
19
  const open = new Map(), finished = new Map(), entryTask = new Map();
20
+ // A character layer has its own token (its fields only), so another task's edit of her other layers never makes it
21
+ // stale; an editor without layer reads falls back to the whole character's.
20
22
  const tokenOf = key => {
23
+ const of = layerOf(key);
24
+ if (of?.layer) return ports.readLayer?.(key) ?? ports.readTarget?.(of.id) ?? 'absent';
21
25
  const [kind, id] = key.split(/:(.*)/s);
22
26
  if (id !== undefined) return ports.readTarget?.(id) ?? 'absent';
23
27
  const state = ports.read();
@@ -32,14 +36,16 @@ export function createBusTasks({ ports, emit }) {
32
36
  if (!ports.restoreTargets) fail('CAPABILITY_MISSING', 'This editor cannot restore task targets.');
33
37
  const { historyEntryId = null } = ports.restoreTargets(values) ?? {};
34
38
  if (historyEntryId) entryTask.set(historyEntryId, task.taskId);
35
- task.restored = [...new Set(values.map(({ key }) => key.includes(':') ? key.slice(key.indexOf(':') + 1) : ports.read().host.sceneId))];
39
+ task.restored = [...new Set(values.map(({ key }) => layerOf(key)?.id ?? (key.includes(':') ? key.slice(key.indexOf(':') + 1) : ports.read().host.sceneId)))];
36
40
  for (const { key } of values) task.tokens.set(key, tokenOf(key));
37
41
  emit?.({ type: `task.${action}`, taskId: task.taskId, keys: values.map(v => v.key) });
38
42
  return historyEntryId;
39
43
  }
40
- function grant(task, keys) {
41
- const bad = keys.find(key => !isScopeKey(key));
42
- if (bad !== undefined) fail('INVALID_ARGUMENT', `"${bad}" is not a scope key: object:<id>, character:<id>, shot:<id>, stage, timeline or scene.`);
44
+ // A plain character:<id> is held as its four layers: the commands name layers, and each layer keeps its own token.
45
+ function grant(task, scope) {
46
+ const bad = scope.find(key => !isScopeKey(key));
47
+ if (bad !== undefined) fail('INVALID_ARGUMENT', `"${bad}" is not a scope key: object:<id>, character:<id> (or one layer: character:<id>:placement, :take, :ik, :props), shot:<id>, stage, timeline or scene.`);
48
+ const keys = expandScope(scope);
43
49
  const conflict = scopeConflict(task.taskId, keys, open);
44
50
  if (conflict) fail('SCOPE_BUSY', `${conflict}; wait for it, or ask for what is free.`);
45
51
  for (const key of keys) if (!task.keys.has(key)) { task.keys.add(key); task.tokens.set(key, tokenOf(key)); }
@@ -121,7 +127,7 @@ export function createBusTasks({ ports, emit }) {
121
127
  if (!task && !open.size) return null;
122
128
  const keys = entry?.id ? commandKeys(entry.id, args, before, entry) : [];
123
129
  // The user's own edit is never refused; a running task whose scope it touches hears of it (the panel's card).
124
- if (!task && request.origin === 'ui') { for (const other of open.values()) { const mine = keys.filter(key => other.keys.has(key)); if (mine.length) emit?.({ type: 'task.userEdit', taskId: other.taskId, keys: mine }); } return null; }
130
+ if (!task && request.origin === 'ui') { for (const other of open.values()) { const mine = keys.filter(key => [...other.keys].some(held => keysOverlap(key, held))); if (mine.length) emit?.({ type: 'task.userEdit', taskId: other.taskId, keys: mine }); } return null; }
125
131
  if (!task) {
126
132
  const held = keys.find(key => scopeConflict(null, [key], open));
127
133
  if (held) fail('SCOPE_BUSY', `${scopeConflict(null, [held], open)}; this command waits for that task.`);
@@ -135,18 +141,25 @@ export function createBusTasks({ ports, emit }) {
135
141
  fail('STALE_TARGET', `${listOf(moved)} changed outside this task (the user, or an undo) since it last looked; read it again, then retry.`);
136
142
  }
137
143
  for (const key of keys) if (!task.pre.has(key)) task.pre.set(key, snapshot(key));
138
- return { task, keys, values: keys.map(snapshot), mark: task.mark };
144
+ return { task, keys, values: keys.map(snapshot), mark: task.mark, fence: new Map(keys.map(key => [key, tokenOf(key)])) };
139
145
  },
146
+ /**
147
+ * A task's job fences on its own keys, not the whole target: another task placing the character while this one
148
+ * compiles her take is not a change under the compile. moved(point) names what changed since admit or rebase(point).
149
+ */
150
+ moved: point => [...point.fence].filter(([key, token]) => tokenOf(key) !== token).map(([key]) => key),
151
+ rebase(point) { for (const key of point.fence.keys()) point.fence.set(key, tokenOf(key)); },
140
152
  /** Files a task's receipt: ids it created join its scope, it may be undone newest first, readers hear of it. */
141
153
  record(point, recorded) {
142
154
  if (!point || !recorded?.ok) return recorded;
143
155
  const { task } = point, state = ports.read(), warnings = [];
144
156
  let grew = false;
157
+ // A character the task holds one layer of is its own: the command was admitted on that layer.
145
158
  for (const id of recorded.affectedIds ?? []) {
146
159
  const key = keyOfId(id, state);
147
- if (!key || task.keys.has(key)) continue;
148
- if (scopeConflict(task.taskId, [key], open)) warnings.push({ code: 'SCOPE_DENIED', message: `${key} changed outside the task's scope.` });
149
- else { task.keys.add(key); grew = true; if (!point.keys.includes(key)) { task.pre.set(key, { key, value: undefined }); point.values.push({ key, value: undefined }); } }
160
+ if (!key || [...task.keys].some(held => keysOverlap(key, held))) continue;
161
+ if (scopeConflict(task.taskId, [key], open)) { warnings.push({ code: 'SCOPE_DENIED', message: `${key} changed outside the task's scope.` }); continue; }
162
+ for (const made of expandScope([key])) { task.keys.add(made); grew = true; if (!point.keys.includes(made)) { task.pre.set(made, { key: made, value: undefined }); point.values.push({ key: made, value: undefined }); } }
150
163
  }
151
164
  if (grew) emit?.({ type: 'task.scope', taskId: task.taskId, scope: [...task.keys] });
152
165
  for (const key of task.keys) task.tokens.set(key, tokenOf(key));
@@ -3,7 +3,7 @@
3
3
  // (PROXY_FIGURE_MODEL) is a placement-only stand-in with no bones at all, so
4
4
  // those tools refuse it by name instead of failing on a missing rig.
5
5
  import { isProxyFigure } from "./scenes.js";
6
- import { isKo } from "./locale.js";
6
+ import { isKo, isZh } from "./locale.js";
7
7
  import { StudioProtocolError } from "./studio-agent-protocol.js";
8
8
 
9
9
  export const CHARACTER_CAPABILITIES = Object.freeze(["rig", "ik", "pose", "motion"]);
@@ -45,29 +45,30 @@ export function characterCapabilities(character) {
45
45
 
46
46
  // The noun each refusal names. A feature id not listed here is used verbatim.
47
47
  const FEATURE_LABELS = {
48
- rig: ["the rig", "리그"],
49
- ik: ["IK", "IK"],
50
- pose: ["Pose mode", "포즈 모드"],
51
- motion: ["Compiled motion", "모션 컴파일"],
52
- take: ["Loading a take", "테이크 불러오기"],
48
+ rig: ["the rig", "리그", "骨骼"],
49
+ ik: ["IK", "IK", "IK"],
50
+ pose: ["Pose mode", "포즈 모드", "姿势模式"],
51
+ motion: ["Compiled motion", "모션 컴파일", "编译动作"],
52
+ take: ["Loading a take", "테이크 불러오기", "加载动作片段"],
53
53
  };
54
54
 
55
55
  // A character's kind (rig or capsule figure) is fixed at creation; character.update refuses a kind change.
56
56
  export const KIND_FIXED_REFUSAL = Object.freeze({
57
57
  en: "A character's kind is chosen when it is created; add a new character instead.",
58
58
  ko: "캐릭터 종류는 만들 때 정합니다. 새 캐릭터를 추가하세요.",
59
+ zh: "角色的类型在创建时确定;请改为添加新角色。",
59
60
  });
60
61
 
61
- export function kindRefusal(feature, ko = false) {
62
- const label = FEATURE_LABELS[feature]?.[ko ? 1 : 0] ?? feature;
63
- return ko
64
- ? `캡슐 인물은 리그가 없어요 - ${label}는 리그 캐릭터에서만 됩니다.`
65
- : `Capsule figures have no rig - ${label} works on rigged characters only.`;
62
+ export function kindRefusal(feature, ko = false, zh = false) {
63
+ const label = FEATURE_LABELS[feature]?.[ko ? 1 : zh ? 2 : 0] ?? feature;
64
+ if (ko) return `캡슐 인물은 리그가 없어요 - ${label}는 리그 캐릭터에서만 됩니다.`;
65
+ if (zh) return `胶囊人物没有骨骼 — ${label}仅适用于带骨骼的角色。`;
66
+ return `Capsule figures have no rig - ${label} works on rigged characters only.`;
66
67
  }
67
68
 
68
69
  /** Throw TARGET_NOT_READY when `character` is a capsule figure. The protocol
69
70
  * message is English (the agent reads it); `uiMessage` is the locale's. */
70
71
  export function refuseRigOnly(character, feature) {
71
72
  if (!isProxyFigure(character)) return;
72
- throw Object.assign(new StudioProtocolError("TARGET_NOT_READY", kindRefusal(feature, false)), { uiMessage: kindRefusal(feature, isKo) });
73
+ throw Object.assign(new StudioProtocolError("TARGET_NOT_READY", kindRefusal(feature, false)), { uiMessage: kindRefusal(feature, isKo, isZh) });
73
74
  }
@@ -173,12 +173,13 @@ export function createCommandBus({ registry, ports }) {
173
173
  affectedIds: [...tx.affectedIds], delta: [], checks: { coverage: 'wire-transaction' }, warnings: toastWarnings(tx.toasts),
174
174
  undo: tx.historyEntryId ? { historyEntryId: tx.historyEntryId, entries: 1, canUndoDirect: true } : null });
175
175
  }
176
+ // a refusal may list every problem of the request at once (a motion spec: up to 12 lines), so it keeps 2000 characters
176
177
  function refusal(request, before, error, mutated) {
177
178
  const changed = mutated ?? (ports.read().revision !== before.revision);
178
179
  return validateReceipt({ ok: false, commandId: request.commandId, host: before.host,
179
180
  code: refusalCode(error), phase: changed ? 'commit' : 'admission', affectedIds: [], expectedTargets: [], currentTargets: [],
180
181
  mutated: changed, preserved: { authoredState: changed ? 'changed' : 'unchanged' }, recovery: { action: 'inspect', retryAllowed: false },
181
- message: [...String(error.message || error)].slice(0, 500).join('') });
182
+ message: [...String(error.message || error)].slice(0, 2000).join('') });
182
183
  }
183
184
  /** Findings first, toasts after; past the schema's twelve, say how many were left out. */
184
185
  const receiptWarnings = (findings = [], toasts = [], omitted = 0, limit = 12) => {
@@ -283,17 +284,19 @@ export function createCommandBus({ registry, ports }) {
283
284
  const rebase = () => {
284
285
  const current = ports.read();
285
286
  token = targetId ? ports.readTarget?.(targetId) : null;
286
- domainRevision = domain ? current.domainRevisions?.[domain] : null;
287
+ domainRevision = domain ? current.domainRevisions?.[domain] : null; if (scoped?.keys.length) tasks.rebase(scoped);
287
288
  };
288
289
  const nestedIds = new Set();
289
- const context = { origin: request.origin, signal: controller.signal, taskId: request.taskId ?? null,
290
+ // callId: the agent's tool call (a script's nested commands share it), for motion.compile's stop rule (repair-watch.js)
291
+ const context = { origin: request.origin, signal: controller.signal, taskId: request.taskId ?? null, turnId: request.turnId ?? null, callId: typeof request.callId === 'string' ? request.callId : null,
290
292
  check() {
291
293
  controller.signal.throwIfAborted();
292
294
  const current = ports.read();
293
295
  // Name the fence that tripped: the three share one code, and a bare "something changed" leaves the user and the agent guessing.
294
296
  if (!same(current.host, before.host)) fail('STALE_TARGET', 'The open document or scene changed while the job was running.');
295
297
  if (domain && !task && current.domainRevisions?.[domain] !== domainRevision) fail('STALE_TARGET', `The ${domain} data changed while the job was running (revision ${domainRevision} to ${current.domainRevisions?.[domain]}); an edit landed between the request and its result.`);
296
- if (targetId && ports.readTarget?.(targetId) !== token) fail('STALE_TARGET', `${targetId} changed while the job was running.`);
298
+ // Inside a task the fence is the task's own keys (bus-tasks.js moved): another task's layer of the target is not a change under it.
299
+ const moved = scoped?.keys.length ? tasks.moved(scoped) : targetId && ports.readTarget?.(targetId) !== token ? [targetId] : []; if (moved.length) fail('STALE_TARGET', `${moved.join(', ')} changed while the job was running.`);
297
300
  },
298
301
  commit(apply) {
299
302
  context.check();
@@ -26,6 +26,6 @@ export function register(registry, ports) {
26
26
  const shot = result.shot && { id: result.shot.id, name: result.shot.name, range: { startFrame: result.shot.startFrame, endFrameExclusive: result.shot.endFrame + 1 } };
27
27
  const referenceFrames = (result.frame ? 1 : 0) + (result.frameB ? 1 : 0);
28
28
  return { affectedIds: [], output: { prompt: result.prompt, mode: result.mode, modelLabel: result.modelLabel ?? null, shot, aspectRatio: result.aspectRatio, cameraMode: result.camera?.mode ?? null, referenceFrames },
29
- summary: `Prepared the ${result.mode} prompt${result.modelLabel ? ` for ${result.modelLabel}` : ""} for ${shot ? `${shot.name} [${shot.range.startFrame}, ${shot.range.endFrameExclusive})` : "the current camera"}; the Studio's result panel shows it with ${referenceFrames} reference frame${referenceFrames === 1 ? "" : "s"} for the user to copy and download.` };
29
+ summary: `Prepared the ${result.mode} prompt${result.modelLabel ? ` for ${result.modelLabel}` : ""} for ${shot ? `${shot.name} [${shot.range.startFrame}, ${shot.range.endFrameExclusive})` : "the current camera"}; the Studio's result panel shows it with ${referenceFrames} reference frame${referenceFrames === 1 ? "" : "s"} for the user to copy and export.` };
30
30
  } });
31
31
  }
@@ -15,6 +15,6 @@ export function register(registry, ports) {
15
15
  const result = await ports.exportShotVideo({ shotId: shotId ?? null, ...(scope ? { scope } : {}) }, context);
16
16
  if (!result?.fileName) fail("TARGET_NOT_READY", "The video export did not finish; the editor's export panel shows why and offers Retry.");
17
17
  return { affectedIds: shot ? [shot.id] : [], output: { fileName: result.fileName, frameCount: result.frameCount },
18
- summary: `Recorded ${scope === "timeline" ? "the whole timeline" : shot ? shotLabel(shot) : "the shot"} to ${result.fileName} (${result.frameCount} frames); the browser was asked to download it.` };
18
+ summary: `Recorded ${scope === "timeline" ? "the whole timeline" : shot ? shotLabel(shot) : "the shot"} to ${result.fileName} (${result.frameCount} frames); it was handed to the configured export folder or the browser download.` };
19
19
  } });
20
20
  }
@@ -22,6 +22,7 @@ import { qaSay } from "../qa-label.js";
22
22
  import { carryEdit } from "../motion3/edit-carry.js";
23
23
  import { normalizeCompileArgs } from "../motion3/lenient.js";
24
24
  import { createCompileRunner } from "./motion3-compile.js";
25
+ import { createRepairWatch } from "../motion3/repair-watch.js";
25
26
  import { peopleOf } from "../motion3/people.js";
26
27
 
27
28
  const TIMELINE_FPS = 24;
@@ -84,6 +85,8 @@ function asArgument(run) {
84
85
 
85
86
  export function register(registry, ports) {
86
87
  const store = createSpecStore();
88
+ // the stop rule: output.stuck when more compiles stopped paying (repair-watch.js)
89
+ const watch = createRepairWatch();
87
90
  const owner = () => ports.storeDomain("motion");
88
91
  const mounted = () => Boolean(ports.storeDomain?.("motion")) || "The motion owner is not mounted.";
89
92
  const always = () => true;
@@ -105,10 +108,21 @@ export function register(registry, ports) {
105
108
  * spec on the character's rig and the current scene (compile-job.js) -> { stored (the spec a take keeps and its specId
106
109
  * names), qa, ..., take when bake is given }, at once or as a promise. opts: probes, checks, lenient, repair.
107
110
  */
108
- const compile = (character, spec, opts = {}, bake, taskId = null) => {
111
+ const compile = (character, spec, opts = {}, bake, taskId = null, scene = sceneFor(character)) => {
109
112
  rigOf(character);
110
113
  const placed = bake && { position: [character.x ?? 0, character.y ?? 0, character.z ?? 0], yaw: character.rot ?? 0, ...bake };
111
- return runner.run({ model: character.model, scene: sceneFor(character), spec, ...opts, ...(placed ? { bake: placed } : {}) }, { taskId, characterId: character.id });
114
+ return runner.run({ model: character.model, scene, spec, ...opts, ...(placed ? { bake: placed } : {}) }, { taskId, characterId: character.id });
115
+ };
116
+ // where a take was baked: the body's place and a hash of the scene's bodies (a take kept for the stop rule fits only
117
+ // there): 0.14 ms for 860 bodies, where their JSON took 0.9 ms and 295 KB a key
118
+ const placeKey = (character, scene) => {
119
+ let h = 2166136261;
120
+ const mix = v => { h = Math.imul(h ^ Math.round(v * 1e4), 16777619); };
121
+ for (const [id, o] of Object.entries(scene.objects ?? {})) {
122
+ for (let i = 0; i < id.length; i++) mix(id.charCodeAt(i) / 1e4);
123
+ for (const list of [o.pos, o.half, ...o.R]) for (let i = 0; i < list.length; i++) mix(list[i]);
124
+ }
125
+ return `${character.model} ${[character.x ?? 0, character.y ?? 0, character.z ?? 0, character.rot ?? 0].join(" ")} ${(h >>> 0).toString(36)}`;
112
126
  };
113
127
  /** The whole output, kept in the store (motion.report, delta) and cut to the receipt budget (report.js). */
114
128
  const report = (r, head, extra = {}) => {
@@ -144,8 +158,9 @@ export function register(registry, ports) {
144
158
  // a base the store forgot (a reload) may still be the installed take's spec: a document read names it by specId
145
159
  const installedSpec = () => { const take = owner().motionFor(characterId); return take?.spec && specIdOf(take.spec) === base ? structuredClone(take.spec) : null; };
146
160
  const patched = [], source = spec ?? asArgument(() => applyPatch(store.get(base) ?? installedSpec() ?? unknownSpec("base", base), patch ?? [], patched));
161
+ const scene = sceneFor(character), at = placeKey(character, scene);
147
162
  // the take is always baked: the user watches every draft, installed or not
148
- return after(compile(character, source, { probes, repair }, {}, context?.taskId), r => {
163
+ return after(compile(character, source, { probes, repair }, {}, context?.taskId, scene), r => {
149
164
  const { qa, take } = r, notes = [...patched, ...(r.notes ?? [])];
150
165
  // ifClean: install only a clean final result (qa.ok includes the goals; after repair when asked)
151
166
  const wanted = install === "ifClean" ? qa.ok : install;
@@ -158,11 +173,16 @@ export function register(registry, ports) {
158
173
  if (installs) timing = put(character, take, context);
159
174
  }
160
175
  const delta = base === undefined ? {} : { delta: deltaOf(store.parts(base)?.groups ?? [], qa.groups, base) };
161
- const how = kept ? "; already installed, unchanged" : install === "ifClean" ? (installs ? "; installed" : "; not installed") : "";
162
- const output = report(r, { frames: r.frameCount, fps: TIMELINE_FPS, installed: wanted, ...adjustedOf(notes) }, { ...delta, ...(r.repair ? { repair: r.repair } : {}), ...(timing ? { timing } : {}) });
176
+ // the stop rule (repair-watch.js): a note, and the best take so far put on when the note would leave the take empty or worse
177
+ const own = specIdOf(r.stored), carried = owner().motionFor(characterId)?.spec, current = carried ? specIdOf(carried) : null;
178
+ const { say: stuck, install: best } = watch.note({ characterId, turnId: context?.turnId, callId: context?.callId, specId: own, base, qa, at, take, installed: Boolean(installs || kept), current });
179
+ if (best) timing = put(character, best.take, context) ?? timing;
180
+ const self = best?.specId === own, how = kept ? "; already installed, unchanged" : self ? "; installed as the best take so far"
181
+ : best ? `; not installed; the best take so far, ${best.specId}, installed` : install === "ifClean" ? (installs ? "; installed" : "; not installed") : "";
182
+ const output = report(r, { frames: r.frameCount, fps: TIMELINE_FPS, installed: Boolean(wanted) || self, ...(stuck ? { stuck } : {}), ...adjustedOf(notes) }, { ...delta, ...(r.repair ? { repair: r.repair } : {}), ...(timing ? { timing } : {}) });
163
183
  // agent-progress.js: viewport ghost, timeline beats, agent panel
164
- publishDraft(characterId, { take, installed: installs || kept, specId: output.specId, label: r.label, qa, timeline: r.timeline });
165
- return { affectedIds: installs ? [characterId] : [], summary: `Compiled ${r.frameCount} frames; QA ${qaSay(qa)}${how}${adjustedSay(notes)}.`, output, ...workerWarning() };
184
+ publishDraft(characterId, { take, installed: installs || kept || self, specId: output.specId, label: r.label, qa, timeline: r.timeline });
185
+ return { affectedIds: installs || best ? [characterId] : [], summary: `Compiled ${r.frameCount} frames; QA ${qaSay(qa)}${how}${adjustedSay(notes)}.`, output, ...workerWarning() };
166
186
  });
167
187
  } });
168
188
  registry.register({ ...declarations[1], available: mounted, run({ characterId, checks }) {
@@ -10,7 +10,8 @@ const rename = { id: "project.rename", label: "Rename project", description: "Re
10
10
  const save = { ...studioActionDeclaration("project.save"), input: input({ name }) };
11
11
  const saveAs = { ...save, id: "project.saveAs", label: "Save project as" };
12
12
  const exportAsAnimation = studioActionDeclaration("project.exportAsAnimation");
13
- const opening = document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token });
13
+ // The deadline covers the user in the file picker, so it is the hub's ceiling, not the 30 s default.
14
+ const opening = { ...document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token }), timeoutMs: 300_000 };
14
15
  const fresh = document("project.new", "Start a new project", { name, previsMode: { type: "string", enum: [...PREVIS_MODES] } });
15
16
  const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "launch"] }, name });
16
17
  const restore = { ...document("project.restore", "Restore the remembered project", { handleToken: token }), exposure: "ui-only" };
@@ -68,7 +68,7 @@ export function register(registry, ports) {
68
68
  const next = reflowStillShots(shots), frameCount = stillFrameCount(next);
69
69
  if (frameCount > STILL_FRAME_COUNT_MAX) {
70
70
  const en = `Storyboard is full (${STILL_FRAME_COUNT_MAX} frames); shorten a hold or remove a panel.`;
71
- throw studioActionRefusal('INVALID_ARGUMENT', en, ko(en, `스토리보드가 가득 찼습니다 (${STILL_FRAME_COUNT_MAX}프레임). 홀드를 줄이거나 패널을 지우세요.`));
71
+ throw studioActionRefusal('INVALID_ARGUMENT', en, ko(en, `스토리보드가 가득 찼습니다 (${STILL_FRAME_COUNT_MAX}프레임). 홀드를 줄이거나 패널을 지우세요.`, `故事板已满(${STILL_FRAME_COUNT_MAX} 帧);请缩短停留时长或删除一个面板。`));
72
72
  }
73
73
  const clip = next.find(shot => shot.kind !== 'still' && next.some(still => still.kind === 'still' && still.startFrame <= shot.endFrame && still.endFrame >= shot.startFrame));
74
74
  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.`);
@@ -14,7 +14,7 @@ const operations = Object.entries({ ...groups, 'view.update': Object.keys(fields
14
14
  }));
15
15
  export const declarations = Object.freeze([...operations, ...["view.setPartColours", "view.setGuideMode", "view.setInset", "view.setGhosts"].map(studioActionDeclaration)]);
16
16
  // A storyboard project has no Motion department; every door says so the same way.
17
- export const STORYBOARD_MOTION_REFUSAL = Object.freeze({ en: 'Motion tools are not part of a Storyboard project.', ko: '모션 도구는 스토리보드 프로젝트에 없어요.' });
17
+ export const STORYBOARD_MOTION_REFUSAL = Object.freeze({ en: 'Motion tools are not part of a Storyboard project.', ko: '모션 도구는 스토리보드 프로젝트에 없어요.', zh: '故事板项目不包含动作工具。' });
18
18
  export const viewCommand = args => Object.entries(groups).find(([, keys]) => Object.keys(args).every(key => keys.includes(key)))?.[0] ?? 'view.update';
19
19
 
20
20
  export function register(registry, ports) {
@@ -24,7 +24,7 @@ export function register(registry, ports) {
24
24
  const state = ports.readView();
25
25
  // Pose mode is IK editing: without a rig to solve there is nothing to pose.
26
26
  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.');
27
- 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));
27
+ 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, STORYBOARD_MOTION_REFUSAL.zh));
28
28
  const selection = args.selection === undefined ? state.selection : args.selection;
29
29
  if (selection) {
30
30
  const found = selection.kind === 'scene' ? selection.id === state.host.sceneId : selection.kind === 'camera' ? selection.id === 'camera'