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.
- package/CHANGELOG.md +64 -0
- package/README.md +1 -1
- package/bin/agent/agent-routes.mjs +4 -4
- package/bin/agent/agent-runner.mjs +160 -151
- package/bin/agent/agent-stream.mjs +86 -0
- package/bin/agent/anthropic-subscription-events.mjs +120 -0
- package/bin/agent/anthropic-subscription-prompt.mjs +116 -0
- package/bin/agent/anthropic-subscription-stream.mjs +251 -0
- package/bin/agent/anthropic-subscription.mjs +73 -0
- package/bin/agent/claude-code-executable.mjs +93 -0
- package/bin/agent/cliproxy-provider.mjs +25 -11
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/model-roles.mjs +112 -0
- package/bin/agent/openai-compatible-provider.mjs +95 -0
- package/bin/agent/pi-tools.mjs +19 -10
- package/bin/agent/provider-login.mjs +3 -1
- package/bin/agent/provider-routes.mjs +2 -0
- package/bin/agent/providers.mjs +35 -130
- package/bin/agent/script-tool.mjs +2 -1
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-rebase.mjs +5 -4
- package/bin/agent/studio-task-routes.mjs +1 -1
- package/bin/agent/studio-tasks.mjs +116 -25
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/TaskBoard-U1Yl-4F1.js +1 -0
- package/dist/assets/app-BdRlMf4J.css +1 -0
- package/dist/assets/app-DwETmoQZ.js +5144 -0
- package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
- package/dist/assets/motion3-compile-DDG3wklD.js +1 -0
- package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
- package/dist/assets/scene-asset-sync-Bz59Ghdz.js +13 -0
- package/dist/assets/task-board-DFfNa0xg.js +15 -0
- package/dist/assets/workflow-BMJGd_gZ.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +5 -3
- package/mcp/tool-handlers.mjs +5 -5
- package/package.json +6 -4
- package/src/App.jsx +65 -87
- package/src/agent-image-client.js +2 -2
- package/src/asset-pane.jsx +14 -14
- package/src/bus-task-scopes.js +59 -11
- package/src/bus-task-targets.js +35 -9
- package/src/bus-tasks.js +23 -10
- package/src/character-kind.js +13 -12
- package/src/command-bus.js +7 -4
- package/src/commands/ai.js +1 -1
- package/src/commands/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/commands/project.js +2 -1
- package/src/commands/shot.js +1 -1
- package/src/commands/view.js +2 -2
- package/src/domains/cast.js +15 -15
- package/src/domains/motion/motion-range-pin-rules.js +1 -1
- package/src/domains/motion.js +11 -11
- package/src/domains/objects.js +20 -30
- package/src/domains/scenes.js +14 -21
- package/src/domains/shots.js +3 -3
- package/src/export-workspace.js +244 -0
- package/src/first-success-guide.jsx +19 -3
- package/src/hierarchy-panel.jsx +6 -7
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- package/src/locale-zh/app.js +124 -0
- package/src/locale-zh/domains.js +36 -0
- package/src/locale-zh/labels.js +148 -0
- package/src/locale-zh/panels.js +225 -0
- package/src/locale-zh/shell.js +230 -0
- package/src/locale-zh/timeline.js +145 -0
- package/src/locale-zh/workflow.js +104 -0
- package/src/locale-zh/workspace.js +190 -0
- package/src/locale-zh.js +21 -0
- package/src/locale.js +16 -6
- package/src/main.jsx +1 -1
- package/src/motion/beat-board-model.js +4 -4
- package/src/motion/beat-board.jsx +1 -1
- package/src/motion/ik-pick.js +24 -0
- package/src/motion/take-segments.jsx +5 -5
- package/src/motion/timeline.jsx +30 -40
- package/src/motion/waypoints.js +6 -6
- package/src/motion3/compile-job.js +15 -3
- package/src/motion3/compiler/channels.js +74 -24
- package/src/motion3/compiler/compile.js +2 -1
- package/src/motion3/compiler/idle.js +2 -0
- package/src/motion3/compiler/overlap.js +180 -0
- package/src/motion3/compiler/pose.js +6 -4
- package/src/motion3/compiler/validate-common.js +1 -0
- package/src/motion3/compiler/validate-parts.js +5 -1
- package/src/motion3/compiler/validate.js +30 -10
- package/src/motion3/correct.js +236 -0
- package/src/motion3/docs.js +8 -6
- package/src/motion3/face-lean.js +31 -0
- package/src/motion3/fix/index.js +5 -1
- package/src/motion3/fix/reach-body.js +172 -0
- package/src/motion3/hint-check.js +44 -0
- package/src/motion3/json-patch.js +120 -12
- package/src/motion3/lenient.js +46 -4
- package/src/motion3/object-keys.js +17 -5
- package/src/motion3/pipeline.js +46 -9
- package/src/motion3/qa/arm-checks.js +96 -0
- package/src/motion3/qa/asked.js +113 -0
- package/src/motion3/qa/goals.js +37 -8
- package/src/motion3/qa/hints-goal.js +6 -5
- package/src/motion3/qa/index.js +5 -2
- package/src/motion3/qa/key-checks.js +4 -3
- package/src/motion3/qa/motion-life.js +243 -0
- package/src/motion3/qa/spec-checks.js +15 -9
- package/src/motion3/qa/verdict.js +50 -0
- package/src/motion3/refs.js +38 -14
- package/src/motion3/repair-watch.js +132 -0
- package/src/motion3/repair.js +52 -19
- package/src/motion3/report.js +50 -9
- package/src/motion3/schema/topics.js +6 -4
- package/src/motion3/schema/types.js +1 -1
- package/src/motion3/skills/basic.js +15 -8
- package/src/motion3/skills/index.js +52 -18
- package/src/motion3/skills/info.js +1 -1
- package/src/motion3/skills/stance.js +6 -0
- package/src/motion3/skills/walk-fit.js +170 -0
- package/src/object-catalog.jsx +4 -5
- package/src/panels/CameraPanel.jsx +3 -3
- package/src/panels/HdrEnvironmentControls.jsx +2 -1
- package/src/panels/ObjectTransformPanel.jsx +19 -29
- package/src/panels/PoseImagePanel.jsx +11 -10
- package/src/panels/PosePanel.jsx +2 -2
- package/src/panels/ReferenceImageField.jsx +2 -2
- package/src/panels/RigControlPanel.jsx +2 -2
- package/src/panels/SculptPanel.jsx +11 -11
- package/src/panels/SubjectBox.jsx +3 -3
- package/src/panels/SubjectsPanel.jsx +1 -1
- package/src/posestudio.jsx +10 -8
- package/src/project-browser.css +7 -6
- package/src/project-browser.jsx +7 -6
- package/src/project-open-file.js +19 -0
- package/src/qa-label.js +1 -1
- package/src/range-pin-panel.jsx +14 -14
- package/src/resource-status.jsx +3 -3
- package/src/result-modal.jsx +13 -13
- package/src/settings-menu.jsx +4 -1
- package/src/shell/BoardSlot.jsx +5 -5
- package/src/shell/CompositionGuideButton.jsx +1 -1
- package/src/shell/ExportFolderPreference.jsx +103 -0
- package/src/shell/GhostsMenuGroup.jsx +1 -1
- package/src/shell/PreferencesDialog.jsx +3 -166
- package/src/shell/StatusBar.jsx +4 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +2 -2
- package/src/shell/details-selection.js +1 -1
- package/src/shell/preference-controls.jsx +115 -0
- package/src/shell/preference-pages.jsx +72 -0
- package/src/shell/preferences.css +46 -0
- package/src/shot-guides.js +5 -5
- package/src/shot-prompt.js +29 -9
- package/src/stage-hdr.js +3 -3
- package/src/stage-labels-ko.js +2 -2
- package/src/stage-startup-defaults.js +3 -3
- package/src/studio-actions.js +4 -4
- package/src/studio-agent-motion.js +19 -9
- package/src/studio-agent-protocol.js +1 -1
- package/src/studio-app-binding.js +3 -2
- package/src/styles/tokens.css +1 -1
- package/src/styles.css +1 -1
- package/src/use-case-question.jsx +18 -18
- package/src/workflow/AgentPanel.jsx +6 -6
- package/src/workflow/AgentPlanCard.jsx +1 -1
- package/src/workflow/AgentProgressCard.jsx +4 -4
- package/src/workflow/AgentTurnSteps.jsx +1 -1
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/LoginPanel.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +1 -1
- package/src/workflow/TaskInline.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- package/src/workflow/agent-client.js +1 -1
- package/src/workflow/agent-turn.js +5 -5
- package/src/workflow/keyframe-pack-request.js +3 -10
- package/src/workflow/task-board.css +1 -1
- package/src/workflow/task-board.js +11 -9
- package/tools/loc-baseline.json +2 -3
- package/tools/run-tests.mjs +9 -9
- package/dist/assets/TaskBoard-ngdqiu7u.js +0 -1
- package/dist/assets/app-BlwOpest.css +0 -1
- package/dist/assets/app-DmR7jBkz.js +0 -5144
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-BB3_1-EB.js +0 -1
- package/dist/assets/scene-asset-sync-CBx7XRti.css +0 -1
- package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +0 -13
- package/dist/assets/task-board-ErQvfoaz.js +0 -14
- package/dist/assets/workflow-zQM-EKW1.js +0 -179
package/src/asset-pane.jsx
CHANGED
|
@@ -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})
|
|
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}
|
|
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>
|
package/src/bus-task-scopes.js
CHANGED
|
@@ -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> (
|
|
3
|
-
//
|
|
4
|
-
// timeline (frame count, shot order and ranges) and scene (documents, the cast library, a replace of
|
|
5
|
-
// timeline and scene are exclusive: no other task runs beside them. A command that only creates
|
|
6
|
-
// character.add) needs no key: what it makes joins its task. A command this table does not know
|
|
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
|
|
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' ? [] :
|
|
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 [
|
|
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
|
|
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 =>
|
|
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;
|
package/src/bus-task-targets.js
CHANGED
|
@@ -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
|
|
14
|
-
|
|
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)
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
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.
|
|
148
|
-
if (scopeConflict(task.taskId, [key], open)) warnings.push({ code: 'SCOPE_DENIED', message: `${key} changed outside the task's scope.` });
|
|
149
|
-
|
|
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));
|
package/src/character-kind.js
CHANGED
|
@@ -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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
}
|
package/src/command-bus.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/src/commands/ai.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/commands/export.js
CHANGED
|
@@ -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);
|
|
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
|
}
|
package/src/commands/motion3.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
162
|
-
const
|
|
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 }) {
|
package/src/commands/project.js
CHANGED
|
@@ -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
|
-
|
|
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" };
|
package/src/commands/shot.js
CHANGED
|
@@ -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.`);
|
package/src/commands/view.js
CHANGED
|
@@ -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'
|