cozyclay 2.0.2 → 2.1.1
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 +61 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +211 -36
- package/bin/agent/agent-runner.mjs +98 -4
- package/bin/agent/codex-client.mjs +2 -1
- package/bin/agent/fal-contract.mjs +0 -17
- package/bin/agent/h3-preservation.mjs +6 -8
- package/bin/agent/provider-keys.mjs +1 -1
- package/bin/agent/providers.mjs +124 -3
- package/bin/agent/script-tool.mjs +74 -0
- package/bin/agent/session-store.mjs +5 -5
- package/bin/agent/studio-prompt.mjs +2 -1
- package/bin/agent/studio-rebase.mjs +314 -0
- package/bin/agent/studio-tools.mjs +21 -14
- package/bin/agent/supervisor.mjs +311 -0
- package/bin/agent/video-adapters.mjs +0 -7
- package/bin/codex-auth.mjs +4 -4
- package/bin/live/cli.mjs +23 -8
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +2 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-C9pMpW7s.js +1 -0
- package/dist/assets/app-B_2XB9rw.css +1 -0
- package/dist/assets/app-DAuvKPP8.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
- package/dist/assets/scene-asset-sync-DKkpjKBV.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-BPk2Ni95.js} +1 -1
- package/dist/assets/workflow-jJRBR94Z.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/llms.txt +14 -0
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/LIVE-PROTOCOL.md +23 -2
- package/mcp/README.md +37 -2
- package/mcp/ardy-prompts.mjs +1 -1
- package/mcp/image-file.mjs +132 -0
- package/mcp/live-hub.mjs +2 -2
- package/mcp/package.json +1 -0
- package/mcp/runtime/package-lock.json +7 -0
- package/mcp/runtime/package.json +1 -0
- package/mcp/server.mjs +0 -6
- package/mcp/tool-handlers.mjs +289 -87
- package/package.json +11 -2
- package/src/App.jsx +813 -278
- package/src/agent-image-client.js +94 -0
- package/src/analytics.js +47 -3
- package/src/app-stage.jsx +129 -2745
- package/src/ardy/auto-physics.js +6 -16
- package/src/ardy/collision-blockers.js +3 -7
- package/src/ardy/fix-collisions.js +5 -35
- package/src/ardy/ik-drag.js +86 -5
- package/src/ardy/ik-key-json.js +3 -1
- package/src/ardy/ik.js +15 -13
- package/src/ardy/motion-calibration.js +1 -1
- package/src/ardy/npz.js +0 -8
- package/src/ardy/physics-panel.jsx +3 -2
- package/src/ardy/physics-review.js +3 -2
- package/src/ardy/physics-support.js +1 -1
- package/src/ardy/physics-surface.js +1 -1
- package/src/ardy/platform-fit-panel.jsx +2 -1
- package/src/ardy/playback.js +1 -5
- package/src/ardy/range-pin.js +2 -2
- package/src/ardy/root-drop.js +0 -1
- package/src/ardy/timeline.css +184 -6
- package/src/ardy/timeline.jsx +121 -36
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +0 -3
- package/src/ardy-bridge-contract.js +281 -0
- package/src/asset-pane.jsx +19 -9
- package/src/auto-color.js +1 -1
- package/src/burn-in.js +1 -1
- package/src/camera-block.js +1 -1
- package/src/camera-follow.js +75 -32
- package/src/camera-move.js +0 -6
- package/src/camera-tutorial.jsx +6 -7
- package/src/character-kind.js +79 -0
- package/src/command-bus.js +76 -17
- package/src/commands/cast.js +31 -7
- package/src/commands/elements/object.js +1 -1
- package/src/commands/elements/shot.js +1 -1
- package/src/commands/elements.js +1 -1
- package/src/commands/export.js +3 -3
- package/src/commands/motion.js +2 -2
- package/src/commands/objects.js +105 -10
- package/src/commands/project.js +15 -2
- package/src/commands/sculpt-images.js +53 -0
- package/src/commands/shot.js +88 -10
- package/src/commands/view.js +10 -4
- package/src/coplanar-depth.js +216 -0
- package/src/cuts.js +7 -6
- package/src/domains/cast.js +60 -43
- package/src/domains/motion/motion-intent-codec.js +50 -0
- package/src/domains/motion/motion-range-pin-rules.js +23 -0
- package/src/domains/motion/motion-video-capture.js +664 -0
- package/src/domains/motion.js +253 -742
- package/src/domains/objects.js +106 -5
- package/src/domains/scenes.js +79 -28
- package/src/domains/shots.js +33 -7
- package/src/error-boundary.jsx +1 -1
- package/src/execution-telemetry.js +5 -5
- package/src/facing-marks.js +14 -1
- package/src/first-success-guide.jsx +35 -4
- package/src/gizmo-claim.js +3 -1
- package/src/grid-floor.jsx +1 -4
- package/src/hierarchy-model.js +48 -35
- package/src/hierarchy-panel.css +1 -0
- package/src/hierarchy-panel.jsx +54 -102
- package/src/history.js +0 -2
- package/src/i2v-motion-client.js +1 -4
- package/src/ime.js +6 -0
- package/src/keyframe-pack.js +15 -6
- package/src/line-edit.js +4 -4
- package/src/live-control.js +6 -6
- package/src/matte-editor.js +2 -8
- package/src/matte.js +0 -19
- package/src/mesh-graph-clone.js +16 -2
- package/src/mesh-sniff.js +0 -1
- package/src/monotone-cubic.js +40 -0
- package/src/motion-store.js +4 -4
- package/src/motion-trail.js +5 -11
- package/src/multimodel-ingest.js +1 -3
- package/src/object-catalog.jsx +7 -5
- package/src/object-gizmo.jsx +45 -31
- package/src/object-path.js +310 -67
- package/src/object-travel.js +224 -0
- package/src/otio.js +8 -3
- package/src/panels/CharacterTransformPanel.jsx +24 -6
- package/src/panels/ObjectTransformPanel.jsx +4 -3
- package/src/panels/PoseImagePanel.jsx +205 -0
- package/src/panels/PromptBlocksPanel.jsx +3 -1
- package/src/panels/ProxyFigurePanel.jsx +56 -0
- package/src/panels/ReferenceImageField.jsx +1 -1
- package/src/panels/RigControlPanel.jsx +24 -3
- package/src/panels/RigPanel.jsx +2 -1
- package/src/panels/SculptPanel.jsx +405 -0
- package/src/panels/SubjectBox.jsx +1 -1
- package/src/panels/SubjectsPanel.jsx +0 -2
- package/src/panels/VideoCapturePanel.jsx +2 -1
- package/src/panels/pose-image.css +42 -0
- package/src/panels/pose.css +3 -0
- package/src/panels/sculpt.css +162 -0
- package/src/plan-diagram.js +184 -0
- package/src/planview.jsx +127 -80
- package/src/playground.js +3 -5
- package/src/pose-fade.js +77 -0
- package/src/pose-gizmo.css +23 -0
- package/src/pose-gizmo.js +96 -0
- package/src/posestudio.jsx +134 -83
- package/src/project-browser.css +53 -0
- package/src/project-browser.jsx +49 -4
- package/src/project.js +55 -24
- package/src/props.jsx +177 -36
- package/src/proxy-figure.jsx +69 -0
- package/src/range-pin-panel.jsx +0 -1
- package/src/receipt-findings.js +20 -0
- package/src/render-passes.js +0 -1
- package/src/resource-status.jsx +1 -1
- package/src/root-path.js +48 -0
- package/src/route-owner.js +52 -0
- package/src/scene-asset-cache.js +1 -2
- package/src/scene-assets.js +12 -4
- package/src/scene-fbx.js +1 -1
- package/src/scene-mesh-cache.js +0 -1
- package/src/scene-objects.js +319 -19
- package/src/scenes.js +58 -9
- package/src/sculpt-blueprint.js +65 -0
- package/src/sculpt-compare.js +384 -0
- package/src/sculpt-geometry.js +141 -0
- package/src/sculpt-mode.js +168 -0
- package/src/sculpt-recipe.js +385 -0
- package/src/sculpt-run.js +351 -0
- package/src/sculpt-stage.jsx +221 -0
- package/src/settings-menu.jsx +1 -1
- package/src/shell/BoardSlot.jsx +341 -0
- package/src/shell/BottomDock.jsx +60 -16
- package/src/shell/CompositionGuideButton.jsx +20 -0
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/ShotPreviewHeader.jsx +17 -0
- package/src/shell/TopBar.jsx +18 -3
- package/src/shell/ViewportToolbar.jsx +36 -31
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shell/viewport.css +12 -0
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +213 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +60 -24
- package/src/studio-app-binding.js +56 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +92 -9
- package/src/ui-scrub.js +4 -4
- package/src/ui.jsx +6 -4
- package/src/use-asset-shelf.js +60 -0
- package/src/use-camera-tutorial.js +50 -0
- package/src/use-embed-bridge.js +12 -0
- package/src/use-first-success-prompt.js +7 -0
- package/src/use-i2v-capture-modal.js +15 -0
- package/src/use-sculpt-mode.js +95 -0
- package/src/workflow/AgentPanel.jsx +106 -18
- package/src/workflow/WorkflowBuilder.jsx +1 -1
- package/src/workflow/agent-client.js +113 -45
- package/src/workflow/agent-panel.css +35 -0
- package/src/workflow/attachment-image.js +1 -1
- package/src/workflow/canvas-commands.js +3 -3
- package/src/workflow/cozy-scene-node.js +3 -3
- package/src/workflow/local-workflow.js +2 -2
- package/src/workflow/motion-input.js +3 -3
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/scene-asset-sync.js +2 -9
- package/src/workflow/shot-prompt-node.js +1 -1
- package/src/workflow/video-contract.js +1 -1
- package/src/workflow/workflow-resources.js +0 -8
- package/src/workflow-send.js +87 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/check-eslint.mjs +57 -0
- package/tools/check-knip.mjs +60 -0
- package/tools/check-loc.mjs +54 -0
- package/tools/demo-worker/api-client.mjs +7 -13
- package/tools/demo-worker/generate.mjs +2 -4
- package/tools/eslint-baseline.json +23 -0
- package/tools/gt-render/browser.mjs +23 -0
- package/tools/headless-studio.mjs +85 -0
- package/tools/kimodo/effector-constraints.mjs +4 -4
- package/tools/kimodo/generate.mjs +1 -1
- package/tools/knip-baseline.json +331 -0
- package/tools/loc-baseline.json +126 -0
- package/tools/process-supervisor.mjs +1 -1
- package/tools/projflow/replay.mjs +4 -4
- package/tools/projflow/runner.mjs +3 -3
- package/tools/qa/studio-control-count.mjs +2 -0
- package/tools/run-tests.mjs +95 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-BxBLC6GT.js +0 -4876
- package/dist/assets/app-CJdxvnrl.css +0 -1
- package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
- package/dist/assets/workflow-B5XZwVfe.js +0 -179
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// What a cast member can do depends on its kind. A rigged character has a
|
|
2
|
+
// skeleton every motion, IK and physics tool writes to; a capsule figure
|
|
3
|
+
// (PROXY_FIGURE_MODEL) is a placement-only stand-in with no bones at all, so
|
|
4
|
+
// those tools refuse it by name instead of failing on a missing rig.
|
|
5
|
+
import { isProxyFigure } from "./scenes.js";
|
|
6
|
+
import { isKo } from "./locale.js";
|
|
7
|
+
import { StudioProtocolError } from "./studio-agent-protocol.js";
|
|
8
|
+
|
|
9
|
+
export const CHARACTER_CAPABILITIES = Object.freeze(["rig", "ik", "pose", "motion", "mocap", "physics", "trails", "lineEdit"]);
|
|
10
|
+
const PROXY_BODY_RADIUS = 0.22;
|
|
11
|
+
const PROXY_BODY_HEIGHT = 1.45;
|
|
12
|
+
const PROXY_HEAD_RADIUS = 0.12;
|
|
13
|
+
|
|
14
|
+
/** Return a capsule figure's rendered world-space bounds without a rig. */
|
|
15
|
+
export function proxyFigureBounds(character, placement = character) {
|
|
16
|
+
const posture = character?.posture === "sit" || character?.posture === "lie" ? character.posture : "stand";
|
|
17
|
+
const bodyHeight = posture === "sit" ? PROXY_BODY_HEIGHT * 0.6 : PROXY_BODY_HEIGHT;
|
|
18
|
+
const stature = Number.isFinite(placement?.scale) ? placement.scale : Number.isFinite(character?.scale) ? character.scale : 1;
|
|
19
|
+
const x = Number.isFinite(placement?.x) ? placement.x : character?.x ?? 0;
|
|
20
|
+
const y = Number.isFinite(placement?.y) ? placement.y : character?.y ?? 0;
|
|
21
|
+
const z = Number.isFinite(placement?.z) ? placement.z : character?.z ?? 0;
|
|
22
|
+
const yaw = ((Number.isFinite(placement?.rot) ? placement.rot : character?.rot ?? 0) + (posture === "lie" ? 180 : 0)) * Math.PI / 180;
|
|
23
|
+
const [minCorner, maxCorner] = posture === "lie"
|
|
24
|
+
? [[-PROXY_BODY_RADIUS, PROXY_BODY_RADIUS - PROXY_HEAD_RADIUS, -(bodyHeight + 0.25)],
|
|
25
|
+
[PROXY_BODY_RADIUS, PROXY_BODY_RADIUS + PROXY_HEAD_RADIUS, 0]]
|
|
26
|
+
: [[-PROXY_BODY_RADIUS, 0, -PROXY_BODY_RADIUS], [PROXY_BODY_RADIUS, bodyHeight + 0.25, PROXY_BODY_RADIUS]];
|
|
27
|
+
const min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
|
|
28
|
+
for (const lx of [minCorner[0], maxCorner[0]]) for (const ly of [minCorner[1], maxCorner[1]]) for (const lz of [minCorner[2], maxCorner[2]]) {
|
|
29
|
+
const sx = lx * stature, sy = ly * stature, sz = lz * stature;
|
|
30
|
+
const wx = x + sx * Math.cos(yaw) + sz * Math.sin(yaw);
|
|
31
|
+
const wz = z - sx * Math.sin(yaw) + sz * Math.cos(yaw);
|
|
32
|
+
min.x = Math.min(min.x, wx); max.x = Math.max(max.x, wx);
|
|
33
|
+
min.y = Math.min(min.y, y + sy); max.y = Math.max(max.y, y + sy);
|
|
34
|
+
min.z = Math.min(min.z, wz); max.z = Math.max(max.z, wz);
|
|
35
|
+
}
|
|
36
|
+
return { min, max };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `{ rig, ik, pose, motion, mocap, physics, trails, lineEdit }` - all false
|
|
40
|
+
* for a capsule figure, all true for a rigged character. */
|
|
41
|
+
export function characterCapabilities(character) {
|
|
42
|
+
const rigged = !isProxyFigure(character);
|
|
43
|
+
return Object.freeze(Object.fromEntries(CHARACTER_CAPABILITIES.map((name) => [name, rigged])));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The noun each refusal names. A feature id not listed here is used verbatim.
|
|
47
|
+
const FEATURE_LABELS = {
|
|
48
|
+
rig: ["the rig", "리그"],
|
|
49
|
+
ik: ["IK", "IK"],
|
|
50
|
+
pose: ["Pose mode", "포즈 모드"],
|
|
51
|
+
motion: ["Motion generation", "모션 생성"],
|
|
52
|
+
mocap: ["Motion capture", "모션 캡처"],
|
|
53
|
+
photoPose: ["Photo pose", "사진 포즈"],
|
|
54
|
+
physics: ["Physics", "물리 보정"],
|
|
55
|
+
collision: ["Collision cleanup", "충돌 정리"],
|
|
56
|
+
trails: ["Motion trails", "궤적선"],
|
|
57
|
+
lineEdit: ["Line edit", "라인 편집"],
|
|
58
|
+
take: ["Loading a take", "테이크 불러오기"],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// A character's kind (rig or capsule figure) is fixed at creation; character.update refuses a kind change.
|
|
62
|
+
export const KIND_FIXED_REFUSAL = Object.freeze({
|
|
63
|
+
en: "A character's kind is chosen when it is created; add a new character instead.",
|
|
64
|
+
ko: "캐릭터 종류는 만들 때 정합니다. 새 캐릭터를 추가하세요.",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export function kindRefusal(feature, ko = false) {
|
|
68
|
+
const label = FEATURE_LABELS[feature]?.[ko ? 1 : 0] ?? feature;
|
|
69
|
+
return ko
|
|
70
|
+
? `캡슐 인물은 리그가 없어요 - ${label}는 리그 캐릭터에서만 됩니다.`
|
|
71
|
+
: `Capsule figures have no rig - ${label} works on rigged characters only.`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Throw TARGET_NOT_READY when `character` is a capsule figure. The protocol
|
|
75
|
+
* message is English (the agent reads it); `uiMessage` is the locale's. */
|
|
76
|
+
export function refuseRigOnly(character, feature) {
|
|
77
|
+
if (!isProxyFigure(character)) return;
|
|
78
|
+
throw Object.assign(new StudioProtocolError("TARGET_NOT_READY", kindRefusal(feature, false)), { uiMessage: kindRefusal(feature, isKo) });
|
|
79
|
+
}
|
package/src/command-bus.js
CHANGED
|
@@ -45,9 +45,10 @@ export const isBusRunActive = () => activeRunDepth > 0;
|
|
|
45
45
|
export function createCommandBus({ registry, ports }) {
|
|
46
46
|
const pending = new Map(), transactions = new Map(), jobs = new Map(), listeners = new Set();
|
|
47
47
|
const confirmations = new Map(), historyReceipts = new Map();
|
|
48
|
+
let agentTurn = null;
|
|
48
49
|
function exposure(entry, args, request) {
|
|
49
50
|
if (request.origin === 'ui') return;
|
|
50
|
-
if (entry.exposure === 'ui-only') fail('CAPABILITY_MISSING', 'This command is available only from the Studio UI.');
|
|
51
|
+
if (entry.exposure === 'ui-only') fail('CAPABILITY_MISSING', entry.uiOnlyHint ?? 'This command is available only from the Studio UI.');
|
|
51
52
|
if (entry.id === 'load_scenes') {
|
|
52
53
|
const ids = scenes => [...new Set(scenes.map(scene => scene.id))].sort();
|
|
53
54
|
if (same(ids(args.document.scenes), ids(registry.state().scenes))) return;
|
|
@@ -61,6 +62,9 @@ export function createCommandBus({ registry, ports }) {
|
|
|
61
62
|
const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
|
|
62
63
|
const argsSchema = { type: 'object', properties: {}, required: [], additionalProperties: true };
|
|
63
64
|
const controls = {
|
|
65
|
+
'agent.turn.begin': object({ turnId: identifier }),
|
|
66
|
+
'agent.turn.finish': object({ turnId: identifier }),
|
|
67
|
+
'agent.turn.cancel': object({ turnId: identifier }),
|
|
64
68
|
'run.begin': object({ id: identifier, args: argsSchema }),
|
|
65
69
|
'run.update': object({ txId: identifier, args: argsSchema }),
|
|
66
70
|
'run.commit': object({ txId: identifier }),
|
|
@@ -81,7 +85,29 @@ export function createCommandBus({ registry, ports }) {
|
|
|
81
85
|
tx.timer = (ports.setTimeout ?? setTimeout)(() => cancelTransaction(tx, true), ports.transactionIdleMs ?? 30_000);
|
|
82
86
|
}
|
|
83
87
|
function control(id, args, request, before) {
|
|
84
|
-
if (id
|
|
88
|
+
if (id.startsWith('agent.turn.')) {
|
|
89
|
+
if (request.origin !== 'agent' || request.turnId !== args.turnId) fail('CAPABILITY_MISSING', 'Only the owning agent turn can control this transaction.');
|
|
90
|
+
if (id === 'agent.turn.begin') {
|
|
91
|
+
if (agentTurn || transactions.size) fail('TARGET_BUSY', 'Finish the open transaction first.');
|
|
92
|
+
agentTurn = { txId: args.turnId, before, affectedIds: new Set(), toasts: [], session: null };
|
|
93
|
+
return transactionReceipt(id, agentTurn, request, before);
|
|
94
|
+
}
|
|
95
|
+
if (agentTurn?.txId !== args.turnId) fail('STALE_TARGET', 'The agent turn is no longer open.');
|
|
96
|
+
const turn = agentTurn;
|
|
97
|
+
// A rolled-back turn restores every owner it touched and leaves no entry.
|
|
98
|
+
if (id === 'agent.turn.cancel') { agentTurn = null; turn.session?.cancel(); return transactionReceipt(id, turn, request, before); }
|
|
99
|
+
try { turn.historyEntryId = turn.session?.commit().historyEntryId; }
|
|
100
|
+
catch (error) { turn.session?.cancel(); throw error; }
|
|
101
|
+
finally { agentTurn = null; }
|
|
102
|
+
return transactionReceipt(id, turn, request, turn.before);
|
|
103
|
+
}
|
|
104
|
+
// Every Studio turn opens a turn transaction (#715), so "undo the last thing" arrives
|
|
105
|
+
// inside one. Until the turn has authored anything (no session yet) history traversal
|
|
106
|
+
// is safe: the undo lands as its own entry and a later edit still groups into the turn.
|
|
107
|
+
// After the first authored edit the turn owns the frontier and traversal waits (#735).
|
|
108
|
+
const traversal = id === 'edit.undo' || id === 'edit.redo';
|
|
109
|
+
if (agentTurn && !id.startsWith('job.') && !(traversal && !agentTurn.session)) fail('TARGET_BUSY', 'Finish the agent turn before traversing history or opening another transaction.');
|
|
110
|
+
if (traversal) {
|
|
85
111
|
const redo = id === 'edit.redo', historyEntryId = ports.history?.(redo);
|
|
86
112
|
const previous = args.receiptId ? ports.receipt(args.receiptId) : historyReceipts.get(historyEntryId)
|
|
87
113
|
?? (historyEntryId ? { receiptId: historyEntryId, host: before.host, affectedIds: [before.host.sceneId],
|
|
@@ -180,23 +206,51 @@ export function createCommandBus({ registry, ports }) {
|
|
|
180
206
|
mutated: changed, preserved: { authoredState: changed ? 'changed' : 'unchanged' }, recovery: { action: 'inspect', retryAllowed: false },
|
|
181
207
|
message: [...String(error.message || error)].slice(0, 500).join('') });
|
|
182
208
|
}
|
|
183
|
-
|
|
209
|
+
/** Findings first, toasts after; past the schema's twelve, say how many were left out. */
|
|
210
|
+
const receiptWarnings = (findings = [], toasts = [], omitted = 0, limit = 12) => {
|
|
211
|
+
const all = [...(omitted ? [{ code: 'READBACK_TRUNCATED', count: omitted, message: `Readback lists 8 of ${8 + omitted} affected ids; inspect_studio scope entities with ids for the rest.` }] : []), ...findings, ...toastWarnings(toasts)];
|
|
212
|
+
return all.length <= limit ? all : [...all.slice(0, limit - 1), { code: 'WARNINGS_TRUNCATED', count: all.length - limit + 1, message: `${all.length - limit + 1} more warnings omitted.` }];
|
|
213
|
+
};
|
|
214
|
+
function receipt(entry, request, before, result, historyEntryId, toasts = [], turn = null) {
|
|
184
215
|
const after = ports.read(), changed = after.revision !== before.revision;
|
|
185
|
-
|
|
186
|
-
|
|
216
|
+
// A turn's transaction is the undo entry only for a command that changed the scene: a read-only
|
|
217
|
+
// job inside an agent turn (sculpt.compare) has nothing to undo.
|
|
218
|
+
const retainedHistoryEntryId = historyEntryId ?? (changed ? turn?.txId : null) ?? null;
|
|
219
|
+
if (entry.kind === 'mutation' && changed && !historyEntryId && !turn) fail('UNCERTAIN_APPLY', `${entry.id} changed the scene without one undoable entry.`);
|
|
220
|
+
const completed = entry.kind === 'job' || entry.kind === 'document' || (entry.kind === 'mutation' && changed && (turn || after.revision > before.revision + 1));
|
|
187
221
|
const ids = completed || changed ? result.affectedIds : entry.kind === 'transient' ? [before.host.sceneId] : [];
|
|
188
|
-
|
|
222
|
+
if (turn && changed) for (const id of ids) turn.affectedIds.add(id);
|
|
223
|
+
const build = level => validateReceipt({ ok: true, commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
|
|
189
224
|
action: entry.id, summary: result.summary, status: completed ? 'completed' : entry.kind === 'transient' ? 'transient' : changed ? 'applied' : 'noop',
|
|
190
225
|
authored: changed, ...(completed ? { kind: entry.kind, ...(result.output === undefined ? {} : { output: result.output }), ...(same(before.host, after.host) ? {} : { nextHost: after.host }) } : entry.kind === 'transient' ? { view: { before: before.viewRevision ?? 0, after: after.viewRevision ?? 0 } } : { mutated: changed }),
|
|
191
226
|
revision: { before: before.revision, after: after.revision }, affectedIds: ids,
|
|
192
|
-
delta: ids.slice(0, 8).map(id => ({ id, after: ports.readback?.(id, after) ?? { removed: true } })),
|
|
193
|
-
|
|
194
|
-
|
|
227
|
+
delta: ids.slice(0, 8).map(id => ({ id, after: ports.readback?.(id, after, level > 0) ?? { removed: true } })),
|
|
228
|
+
// The command's own measurements (an arrangement's overlap evidence) ride beside the coverage tag.
|
|
229
|
+
checks: { coverage: `studio-action:${entry.id}`, ...result.checks }, warnings: receiptWarnings(result.warnings, toasts, Math.max(0, ids.length - 8), level > 1 ? 4 : 12),
|
|
230
|
+
undo: retainedHistoryEntryId ? { historyEntryId: retainedHistoryEntryId, entries: 1, canUndoDirect: !turn } : null, ...(ids.length > 8 ? { detailCursor: request.commandId } : {}) });
|
|
231
|
+
// Full readback and warnings are richer than the 8 KiB receipt always allows; shed the extras
|
|
232
|
+
// (box and children, then all but the first warnings) rather than fail an edit that was applied.
|
|
233
|
+
for (let level = 0; ; level++) {
|
|
234
|
+
try { return build(level); }
|
|
235
|
+
catch (error) { if (level >= 2 || error?.code !== 'INVALID_RECEIPT' || !/8 KiB/.test(error.message)) throw error; }
|
|
236
|
+
}
|
|
195
237
|
}
|
|
196
238
|
function executeRun(id, args = {}, options = {}) {
|
|
197
239
|
const request = { origin: 'ui', commandId: crypto.randomUUID(), ...options };
|
|
198
240
|
if (request.origin === 'ui' && (id === 'edit.undo' || id === 'edit.redo')) ports.finishHistoryGesture?.();
|
|
199
241
|
const before = ports.read(), journal = ports.journal();
|
|
242
|
+
if (agentTurn && !same(agentTurn.before.host, before.host)) {
|
|
243
|
+
agentTurn.session?.cancel({ restore: false }); agentTurn = null;
|
|
244
|
+
}
|
|
245
|
+
const turn = request.origin === 'agent' && request.turnId === agentTurn?.txId ? agentTurn : null;
|
|
246
|
+
const recordAction = (domain, invoke, targetId, nested = false) => {
|
|
247
|
+
if (!turn) return ports.recordAction(domain, invoke, targetId, nested);
|
|
248
|
+
// Enrol each owner in the existing composed session. Do not commit (or
|
|
249
|
+
// cancel the parent on a tool refusal) at an individual command boundary.
|
|
250
|
+
turn.session ??= ports.beginAction(domain, targetId);
|
|
251
|
+
const result = turn.session.run(() => ports.beginAction(domain, targetId).run(invoke));
|
|
252
|
+
return { result, historyEntryId: null };
|
|
253
|
+
};
|
|
200
254
|
let begun = false, releaseToasts, timer, foregroundTimer, job, applied = false, committedHistoryId;
|
|
201
255
|
const controller = new AbortController();
|
|
202
256
|
const clearTimer = () => {
|
|
@@ -228,6 +282,7 @@ export function createCommandBus({ registry, ports }) {
|
|
|
228
282
|
};
|
|
229
283
|
try {
|
|
230
284
|
if (request.origin !== 'ui' && !same(validateStudioIdentity(request.host), before.host)) fail('STALE_SCENE', 'The live document changed.');
|
|
285
|
+
if (request.turnId && !turn && id !== 'agent.turn.begin') fail('STALE_TARGET', 'The agent turn is no longer open.');
|
|
231
286
|
const signature = JSON.stringify({ id, args, ...request });
|
|
232
287
|
if (!journal.begin(request.commandId, signature)) return journal.get(request.commandId) ?? pending.get(request.commandId) ?? refusal(request, before, new StudioProtocolError('UNCERTAIN_APPLY', 'Command is still executing.'));
|
|
233
288
|
begun = true;
|
|
@@ -237,11 +292,12 @@ export function createCommandBus({ registry, ports }) {
|
|
|
237
292
|
// Job controls observe/cancel an admitted identity; completion itself
|
|
238
293
|
// can advance the revision while their wire request is in transit.
|
|
239
294
|
// Document identity and the job's own publication fence still apply.
|
|
240
|
-
if (!['job.await', 'job.cancel'].includes(id) && request.expectedRevision !== before.revision) fail('STALE_SCENE', 'Authored state changed; obtain fresh intent.');
|
|
241
|
-
if (before.busy && !transactions.has(validated.txId) && !id.startsWith('job.')) fail('TARGET_BUSY', 'Finish the current editor gesture first.');
|
|
295
|
+
if (!['job.await', 'job.cancel', 'agent.turn.finish', 'agent.turn.cancel'].includes(id) && request.expectedRevision !== before.revision) fail('STALE_SCENE', 'Authored state changed; obtain fresh intent.');
|
|
296
|
+
if (before.busy && !turn && !transactions.has(validated.txId) && !id.startsWith('job.')) fail('TARGET_BUSY', 'Finish the current editor gesture first.');
|
|
242
297
|
}
|
|
243
298
|
if (controls[id]) return mapResult(control(id, validated, request, before), remember, rejected);
|
|
244
299
|
if (transactions.size) fail('TARGET_BUSY', 'Finish or cancel the open command transaction first.');
|
|
300
|
+
if (turn && entry.kind === 'document') fail('TARGET_BUSY', 'Finish the agent turn before changing documents.');
|
|
245
301
|
exposure(entry, validated, request);
|
|
246
302
|
releaseToasts = ports.captureToasts?.(toast => toasts.push(typeof toast === 'string' ? { message: toast } : toast));
|
|
247
303
|
const domain = entry.domain ?? entry.undoDomain;
|
|
@@ -258,12 +314,15 @@ export function createCommandBus({ registry, ports }) {
|
|
|
258
314
|
check() {
|
|
259
315
|
controller.signal.throwIfAborted();
|
|
260
316
|
const current = ports.read();
|
|
261
|
-
|
|
317
|
+
// Name the fence that tripped: the three share one code, and a bare "something changed" leaves the user and the agent guessing.
|
|
318
|
+
if (!same(current.host, before.host)) fail('STALE_TARGET', 'The open document or scene changed while the job was running.');
|
|
319
|
+
if (domain && 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.`);
|
|
320
|
+
if (targetId && ports.readTarget?.(targetId) !== token) fail('STALE_TARGET', `${targetId} changed while the job was running.`);
|
|
262
321
|
},
|
|
263
322
|
commit(apply) {
|
|
264
323
|
context.check();
|
|
265
324
|
if (!domain) fail('INVALID_ARGUMENT', 'A committing job must declare its domain.');
|
|
266
|
-
const recorded =
|
|
325
|
+
const recorded = recordAction(domain, apply, targetId ?? null);
|
|
267
326
|
if (recorded?.then) fail('INVALID_ARGUMENT', 'Job publication must be synchronous; prepare before commit.');
|
|
268
327
|
committedHistoryId = recorded.historyEntryId; applied ||= Boolean(committedHistoryId);
|
|
269
328
|
if (job) rebase();
|
|
@@ -273,7 +332,7 @@ export function createCommandBus({ registry, ports }) {
|
|
|
273
332
|
if (job) context.check(); else controller.signal.throwIfAborted();
|
|
274
333
|
const nested = registry.prepare(nestedId, nestedArgs); exposure(nested.entry, nested.args, request);
|
|
275
334
|
const invoke = () => registry.invoke(nested.entry, nested.args, context);
|
|
276
|
-
const value = nested.entry.kind === 'mutation' ?
|
|
335
|
+
const value = nested.entry.kind === 'mutation' ? recordAction(nested.entry.undoDomain, invoke, nested.args.characterId ?? null, true) : { result: invoke() };
|
|
277
336
|
// Only the synchronous owned step may advance the fence. Never absorb
|
|
278
337
|
// an external edit while an asynchronous nested step is suspended;
|
|
279
338
|
// its later publication must itself use context.run/commit.
|
|
@@ -297,13 +356,13 @@ export function createCommandBus({ registry, ports }) {
|
|
|
297
356
|
const deadline = new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true }));
|
|
298
357
|
return Promise.race([value, deadline]);
|
|
299
358
|
};
|
|
300
|
-
const value = entry.kind === 'mutation' ?
|
|
359
|
+
const value = entry.kind === 'mutation' ? recordAction(entry.undoDomain, invoke, validated.characterId ?? null) : { result: invoke(), historyEntryId: null };
|
|
301
360
|
const finish = ({ result, historyEntryId }) => mapResult(result, output => {
|
|
302
361
|
clearTimer();
|
|
303
362
|
releaseToasts?.(); releaseToasts = null;
|
|
304
363
|
const refused = toastRefusal();
|
|
305
364
|
if (refused) throw refused;
|
|
306
|
-
return remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts));
|
|
365
|
+
return remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts, turn));
|
|
307
366
|
});
|
|
308
367
|
const finished = value?.then ? value.then(finish) : finish(value);
|
|
309
368
|
const answer = finished?.then ? finished.catch(rejected) : finished;
|
|
@@ -345,5 +404,5 @@ export function createCommandBus({ registry, ports }) {
|
|
|
345
404
|
// Trusted UI adapter only: wire callers can consume, never mint a token.
|
|
346
405
|
confirm(id, args = {}) { const prepared = registry.prepare(id, args), token = crypto.randomUUID(); confirmations.set(token, { id, args: prepared.args, host: ports.read().host, expires: (ports.now ?? Date.now)() + 300_000 }); return token; },
|
|
347
406
|
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
348
|
-
dispose() { for (const tx of transactions.values()) cancelTransaction(tx); for (const job of jobs.values()) if (!job.outcome) job.controller.abort(new StudioProtocolError('CANCELLED', 'Studio closed.')); listeners.clear(); } };
|
|
407
|
+
dispose() { agentTurn?.session?.cancel(); agentTurn = null; for (const tx of transactions.values()) cancelTransaction(tx); for (const job of jobs.values()) if (!job.outcome) job.controller.abort(new StudioProtocolError('CANCELLED', 'Studio closed.')); listeners.clear(); } };
|
|
349
408
|
}
|
package/src/commands/cast.js
CHANGED
|
@@ -5,7 +5,9 @@ import { studioActionDeclaration } from "../studio-actions.js";
|
|
|
5
5
|
import { characterOf, fail, changedIds } from "./shared.js";
|
|
6
6
|
import { elementSetSchema, registerElementSet } from './elements.js';
|
|
7
7
|
import './elements/character.js';
|
|
8
|
-
import { createCharacterEntry } from '../scenes.js';
|
|
8
|
+
import { createCharacterEntry, CHARACTER_KIND_IDS, POSTURES, PROXY_FIGURE_MODEL, isProxyFigure } from '../scenes.js';
|
|
9
|
+
import { KIND_FIXED_REFUSAL } from '../character-kind.js';
|
|
10
|
+
import { track } from '../analytics.js';
|
|
9
11
|
import { DEFAULT_POSE } from '../poses.js';
|
|
10
12
|
import { createStableItemId, updateStableItem, removeStableItem } from '../stable-items.js';
|
|
11
13
|
import { movePromptClipFrames } from '../ardy/prompt-clips.js';
|
|
@@ -14,13 +16,18 @@ import { STUDIO_TOOL_SCHEMAS, StudioSchemas } from '../studio-agent-protocol.js'
|
|
|
14
16
|
const id = StudioSchemas.TargetGuard.properties.targetId;
|
|
15
17
|
const input = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
16
18
|
const record = { type: 'object', properties: {}, additionalProperties: true };
|
|
19
|
+
// A capsule figure is cast by model id; its posture is the one body choice it
|
|
20
|
+
// has. A patched posture outside POSTURES normalizes to "stand" (createCharacterEntry).
|
|
21
|
+
const newCharacter = { ...record, properties: { model: { type: 'string', enum: [...CHARACTER_KIND_IDS] }, posture: { type: 'string', enum: [...POSTURES] } }, required: [] };
|
|
22
|
+
const characterPatch = { ...record, properties: { posture: { type: 'string', description: `One of ${POSTURES.join(', ')}; anything else stands.` } }, required: [] };
|
|
17
23
|
const mutation = (id, label, schema, exposure = 'open') => ({ id, label, description: label, kind: 'mutation', undoDomain: 'cast', input: schema, exposure });
|
|
18
24
|
const blocks = elementSetSchema('character').properties.set.properties.layer.properties.promptClips;
|
|
19
25
|
const semantic = [
|
|
20
26
|
mutation('character.set', 'Set character fields', elementSetSchema('character')),
|
|
21
|
-
mutation('character.add', 'Add character', input({ character:
|
|
27
|
+
mutation('character.add', 'Add character', input({ character: newCharacter })),
|
|
22
28
|
mutation('character.remove', 'Remove character', input({ characterId: id })),
|
|
23
|
-
mutation('character.update', 'Update character', input({ characterId: id, patch:
|
|
29
|
+
mutation('character.update', 'Update character', input({ characterId: id, patch: characterPatch })),
|
|
30
|
+
studioActionDeclaration('character.move'),
|
|
24
31
|
mutation('character.setPose', 'Set character pose', input({ characterId: id, pose: { oneOf: [id, record, { type: 'null' }] }, clearMotion: { type: 'boolean' } }, ['characterId', 'pose'])),
|
|
25
32
|
mutation('character.setPromptBlocks', 'Set prompt blocks', input({ characterId: id, blocks })),
|
|
26
33
|
mutation('characters.arrange', 'Arrange characters', STUDIO_TOOL_SCHEMAS.arrange_characters),
|
|
@@ -41,7 +48,14 @@ export function register(registry, ports) {
|
|
|
41
48
|
const mounted = () => Boolean(ports.storeDomain?.('cast')) || 'The cast document owner is not mounted.';
|
|
42
49
|
const character = characterId => owner().read().find(row => row.id === characterId) ?? fail('STALE_TARGET', `Character ${characterId} is not in this scene.`);
|
|
43
50
|
const pose = value => typeof value === 'string' ? owner().poses().find(row => row.id === value) ?? fail('STALE_TARGET', `Pose ${value} is not in the library.`) : value;
|
|
44
|
-
const patch = (characterId, update) => { character(characterId); owner().
|
|
51
|
+
const patch = (characterId, update) => { character(characterId); owner().update(characterId, update); };
|
|
52
|
+
// A character groups under a scene object that stands in the world. An
|
|
53
|
+
// object a character carries is refused: it rides a bone, not a route.
|
|
54
|
+
const groupParent = value => {
|
|
55
|
+
if (value === null || value === undefined) return;
|
|
56
|
+
const object = ports.state().objects.find(row => row.id === value) ?? fail('STALE_TARGET', `Object ${value} is not in this scene.`);
|
|
57
|
+
if (object.attach) fail('INVALID_ARGUMENT', 'A character cannot be grouped under an object that a character carries.');
|
|
58
|
+
};
|
|
45
59
|
function promptEdit(command, args) {
|
|
46
60
|
const before = character(args.characterId), clips = before.layer.promptClips;
|
|
47
61
|
let next;
|
|
@@ -72,7 +86,13 @@ export function register(registry, ports) {
|
|
|
72
86
|
owner().write(rows => [...rows, row]);
|
|
73
87
|
},
|
|
74
88
|
'character.remove': ({ characterId }) => { character(characterId); if (owner().read().length <= 1) fail('INVALID_ARGUMENT', 'Cannot remove the final character.'); owner().write(rows => rows.filter(row => row.id !== characterId)); },
|
|
75
|
-
'character.update': ({ characterId, patch: value }) =>
|
|
89
|
+
'character.update': ({ characterId, patch: value }) => {
|
|
90
|
+
// Rig <-> capsule figure is a different kind of cast member; rig-to-rig model swaps stay.
|
|
91
|
+
if (Object.hasOwn(value, 'model') && (value.model === PROXY_FIGURE_MODEL) !== isProxyFigure(character(characterId))) fail('INVALID_ARGUMENT', KIND_FIXED_REFUSAL.en);
|
|
92
|
+
if (Object.hasOwn(value, 'parent')) groupParent(value.parent);
|
|
93
|
+
patch(characterId, value);
|
|
94
|
+
},
|
|
95
|
+
'character.move': ({ characterId, ...value }) => patch(characterId, value),
|
|
76
96
|
'character.setPose': ({ characterId, pose: value, clearMotion }) => { character(characterId); owner().applyPose(characterId, pose(value), clearMotion); },
|
|
77
97
|
'character.setPromptBlocks': ({ characterId, blocks }) => {
|
|
78
98
|
const entry = character(characterId); patch(characterId, { layer: { ...entry.layer, promptClips: blocks } });
|
|
@@ -96,6 +116,7 @@ export function register(registry, ports) {
|
|
|
96
116
|
registry.register({ ...entry, available: mounted, run(args) {
|
|
97
117
|
// A pose id selects a complete library entry, not a deep bone patch.
|
|
98
118
|
const ops = args.ops ?? [args];
|
|
119
|
+
for (const op of ops) if (Object.hasOwn(op.set, 'parent')) groupParent(op.set.parent);
|
|
99
120
|
const poses = ops.filter(op => Object.hasOwn(op.set, 'pose')).map(op => ({ id: op.id, value: pose(op.set.pose) }));
|
|
100
121
|
const fields = ops.map(({ id, set: { pose: _pose, ...set } }) => ({ id, set }));
|
|
101
122
|
const result = entry.run(args.ops ? { ops: fields } : fields[0]);
|
|
@@ -103,10 +124,13 @@ export function register(registry, ports) {
|
|
|
103
124
|
return result;
|
|
104
125
|
} });
|
|
105
126
|
} }, ports, semantic[0]);
|
|
106
|
-
for (const declaration of semantic.slice(1)) registry.register({ ...declaration, available: mounted, run(args) {
|
|
107
|
-
if (declaration.id === 'characters.arrange') { const plan = owner().arrange(args); return { affectedIds: plan.affectedIds, summary: 'Arranged characters.' }; }
|
|
127
|
+
for (const declaration of semantic.slice(1)) registry.register({ ...declaration, available: mounted, run(args, context) {
|
|
128
|
+
if (declaration.id === 'characters.arrange') { const plan = owner().arrange(args); return { affectedIds: plan.affectedIds, summary: 'Arranged characters.', checks: plan.checks, warnings: plan.warnings }; }
|
|
108
129
|
const before = owner().read();
|
|
109
130
|
if (declaration.id.endsWith('PromptBlock')) promptEdit(declaration.id, args); else methods[declaration.id](args);
|
|
131
|
+
// The Assets tile and the MCP live path report themselves; this is the agent and
|
|
132
|
+
// any other non-UI bus caller adding a capsule figure.
|
|
133
|
+
if (declaration.id === 'character.add' && context?.origin && context.origin !== 'ui' && isProxyFigure(owner().read().find(row => !before.some(prior => prior.id === row.id)))) track('cast:proxy_added', { surface: 'agent' });
|
|
110
134
|
const changed = changedIds(before, owner().read());
|
|
111
135
|
return { affectedIds: changed.length ? changed : [args.characterId ?? ports.state().activeSceneId], summary: declaration.label };
|
|
112
136
|
} });
|
|
@@ -14,7 +14,7 @@ const elements = STUDIO_ELEMENTS.filter(row => row.path.startsWith('object.')).m
|
|
|
14
14
|
return { ...element, ...(components[field] ? { documentPath: components[field] } : {}),
|
|
15
15
|
...(field === 'remove' ? { readback: item => ({ flag: item === undefined }) } : {}) };
|
|
16
16
|
});
|
|
17
|
-
|
|
17
|
+
function normalizeObjectSet(record) {
|
|
18
18
|
const item = normalizeSceneObject(record);
|
|
19
19
|
// The owning collection consumes this lifecycle intent before persistence.
|
|
20
20
|
return record.remove === true ? { ...item, remove: true } : item;
|
|
@@ -2,7 +2,7 @@ import { createShotAuthoringDocument } from '../../shot-authoring.js';
|
|
|
2
2
|
import { STUDIO_ELEMENTS } from '../../studio-elements.js';
|
|
3
3
|
import { registerElementKind } from '../elements.js';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
function normalizeShot(record) {
|
|
6
6
|
return createShotAuthoringDocument({ shots: [record], frameCount: Math.max(24, record.endFrame + 1) }).shots[0];
|
|
7
7
|
}
|
|
8
8
|
registerElementKind('shot', { collection: true, documentKey: 'shots',
|
package/src/commands/elements.js
CHANGED
|
@@ -58,7 +58,7 @@ export function readElement(document, path) {
|
|
|
58
58
|
? Object.fromEntries(Object.entries(element.documentPath).map(([component, stored]) => [component, atPath(document, stored)]))
|
|
59
59
|
: atPath(document, setPath(element));
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
function mergeElementSet(document = {}, patch) {
|
|
62
62
|
document = document ?? {};
|
|
63
63
|
const next = { ...document };
|
|
64
64
|
for (const [key, value] of Object.entries(patch)) next[key] = value && typeof value === 'object' && !Array.isArray(value)
|
package/src/commands/export.js
CHANGED
|
@@ -10,11 +10,11 @@ export function register(registry, ports) {
|
|
|
10
10
|
registry.register({ ...studioActionDeclaration("export.shotVideo"),
|
|
11
11
|
available: state => state.exporting ? "An export is already running; wait for it to finish."
|
|
12
12
|
: state.canExportVideo || "There is nothing to record yet: add a shot (shot.create), camera keys or a motion take first.",
|
|
13
|
-
run: async ({ shotId }, context) => {
|
|
13
|
+
run: async ({ shotId, scope }, context) => {
|
|
14
14
|
const shot = shotId ? shotOf(ports, shotId) : null;
|
|
15
|
-
const result = await ports.exportShotVideo({ shotId: shotId ?? null }, context);
|
|
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 ${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); the browser was asked to download it.` };
|
|
19
19
|
} });
|
|
20
20
|
}
|
package/src/commands/motion.js
CHANGED
|
@@ -70,7 +70,7 @@ const queued = ['motion.commitLineEdit', 'motion.regenerateTrail'].map(id => ({
|
|
|
70
70
|
const legacyIk = ['character.setIkKey', 'character.removeIkKey', 'character.clearIkKeys'].map(studioActionDeclaration);
|
|
71
71
|
const ik = legacyIk.map((entry, index) => ({ ...entry, id: ['ik.setKey', 'ik.removeKey', 'ik.clearKeys'][index] }));
|
|
72
72
|
|
|
73
|
-
const clear = { id: 'motion.clear', label: 'Clear motion', description: 'Clear the active take, its corrections and take-owned cast fields.',
|
|
73
|
+
const clear = { id: 'motion.clear', label: 'Clear motion', description: 'Clear the active take, its corrections, prompt blocks and take-owned cast fields.',
|
|
74
74
|
kind: 'mutation', undoDomain: 'motion', input: { type: 'object', properties: { characterId: { type: 'string' } }, required: ['characterId'], additionalProperties: false } };
|
|
75
75
|
const videoDraft = { id: 'motion.setVideoDraft', label: 'Set video motion draft', description: 'Set the uncommitted AI-video form without changing the project or its history.', kind: 'transient',
|
|
76
76
|
input: input({ instruction: { oneOf: [{ const: '' }, { type: 'string', maxLength: 3700 }] }, promptOverride: { oneOf: [{ const: '' }, { type: 'string', maxLength: 3700 }] }, duration: { oneOf: I2V_MOTION_DURATIONS.map(value => ({ const: value })) } }, []) };
|
|
@@ -194,7 +194,7 @@ export function register(registry, ports) {
|
|
|
194
194
|
if (ports.state().activeCharacterId !== characterId) fail('TARGET_NOT_READY', 'Select this character before clearing its take.');
|
|
195
195
|
ports.clearMotionNative();
|
|
196
196
|
}
|
|
197
|
-
return { affectedIds: [characterId], summary: 'Cleared motion.' };
|
|
197
|
+
return { affectedIds: [characterId], summary: 'Cleared motion and prompt blocks.' };
|
|
198
198
|
} });
|
|
199
199
|
registry.register({ ...studioActionDeclaration("motion.generateAllBlocks"), target: () => ports.state().activeCharacterId,
|
|
200
200
|
available: state => state.generating ? "A motion generation is already running."
|
package/src/commands/objects.js
CHANGED
|
@@ -3,7 +3,11 @@ import { studioActionDeclaration } from "../studio-actions.js";
|
|
|
3
3
|
import { characterOf, fail } from "./shared.js";
|
|
4
4
|
import { elementSetSchema, registerElementSet } from './elements.js';
|
|
5
5
|
import './elements/object.js';
|
|
6
|
-
import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, normalizeSceneObject } from '../scene-objects.js';
|
|
6
|
+
import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, normalizeSceneObject, groupUnderNewEmpty, objectPatchFields, createSculptObject, SCULPT_KIND } from '../scene-objects.js';
|
|
7
|
+
import { expandSculptParts, normalizeSculptRecipe, patchSculptRecipe } from '../sculpt-recipe.js';
|
|
8
|
+
import { rankingRow, scoreSculptAgainstReference } from '../sculpt-compare.js';
|
|
9
|
+
import { browserSculptImages, shrinkRgba } from './sculpt-images.js';
|
|
10
|
+
import { clampWarning } from '../receipt-findings.js';
|
|
7
11
|
import { STUDIO_TOOL_SCHEMAS, StudioSchemas } from '../studio-agent-protocol.js';
|
|
8
12
|
|
|
9
13
|
const id = StudioSchemas.TargetGuard.properties.targetId;
|
|
@@ -14,15 +18,24 @@ const placement = input({ x: number, y: number, z: number, rot: number }, []);
|
|
|
14
18
|
const setInput = elementSetSchema('object');
|
|
15
19
|
const scale = setInput.properties.set.properties.scale;
|
|
16
20
|
setInput.properties.set.properties.scale = { oneOf: [number, scale] };
|
|
21
|
+
const PATCH_HINTS = { scale: 'use scaleX, scaleY, scaleZ', rotY: 'use rot for yaw', yaw: 'use rot for yaw', rotation: 'use rot, rotX, rotZ', position: 'use x, y, z', parent: 'use object.group or object.set parent', parentId: 'use object.group or object.set parent' };
|
|
17
22
|
const mutation = (id, label, input) => ({ id, label, description: label, kind: 'mutation', undoDomain: 'objects', input });
|
|
18
23
|
const semantic = [
|
|
19
24
|
mutation('object.set', 'Set object fields', setInput),
|
|
20
|
-
mutation('object.add', 'Add object', input({ kind: { type: 'string' }, placement, name: { type: 'string' }, parent: id }, ['kind'])),
|
|
25
|
+
mutation('object.add', 'Add object', input({ kind: { type: 'string' }, placement, name: { type: 'string' }, parent: id, first: { type: 'boolean' } }, ['kind'])),
|
|
26
|
+
// A clay prop from a recipe of soft parts (docs/sculpt-from-image.md). With `id` it re-sculpts that
|
|
27
|
+
// sculpt in place (one undo); without, it creates one. The recipe is checked by sculpt-recipe.js.
|
|
28
|
+
{ ...mutation('object.sculpt', 'Sculpt a clay prop from a recipe', input({ recipe: { type: 'object', properties: {}, additionalProperties: true }, edits: { type: 'array', minItems: 1, maxItems: 48, items: { type: 'object', properties: {}, additionalProperties: true } }, id, name: { type: 'string', maxLength: 240 }, placement, parent: id }, [])),
|
|
29
|
+
description: 'Create a sculpt (or re-sculpt one by id) from a recipe, or patch a sculpt by id with edits [{ set: partId, ...fields } | { add: part } | { remove: partId }]. Recipe { version: 1, parts: [{ id, shape: blob|box|cylinder|torus|frame, size: [x,y,z] m, position, rotation (deg), color, parent, mirror, roundness, taper, border }] }. Floor is y=0, front +z, the subject\'s left +x; mirror reflects across x=0.' },
|
|
21
30
|
mutation('object.remove', 'Remove objects', input({ ids })),
|
|
22
31
|
mutation('object.rename', 'Rename object', input({ id, name: { type: 'string', maxLength: 240 } })),
|
|
23
32
|
mutation('object.group', 'Group objects', input({ parent: id, children: ids })),
|
|
24
33
|
mutation('object.ungroup', 'Ungroup objects', input({ children: ids })),
|
|
25
|
-
|
|
34
|
+
// The Outliner's "Group under new Empty": the Empty and the reparent are one write, so one undo.
|
|
35
|
+
{ ...mutation('object.groupUnderEmpty', 'Group object under a new Empty', input({ id })), exposure: 'ui-only' },
|
|
36
|
+
{ ...mutation('object.update', 'Update object', input({ id, patch: { type: 'object', properties: {}, additionalProperties: true } }, [])), agentHidden: true },
|
|
37
|
+
// agentHidden: object.update silently ignores unknown keys, so the agent's action index and inspect_studio leave it out
|
|
38
|
+
// and the prompt points at object.set / arrange_objects; MCP, the CLI and the UI keep it.
|
|
26
39
|
mutation('objects.arrange', 'Arrange objects', STUDIO_TOOL_SCHEMAS.arrange_objects),
|
|
27
40
|
];
|
|
28
41
|
const batch = { id: 'objects.batch', label: 'Batch objects', description: 'Apply legacy object operations in one retained entry.', kind: 'job', domain: 'objects',
|
|
@@ -37,8 +50,18 @@ const assetImport = { ...studioActionDeclaration('asset.import'), kind: 'job', d
|
|
|
37
50
|
input({ assetId: id, placement, placeAs: assetProperties.placeAs }, ['assetId', 'placeAs']),
|
|
38
51
|
input({ fileToken: id, placeAs: { type: 'string', enum: ['mesh', 'cutout'] } }, ['fileToken', 'placeAs']),
|
|
39
52
|
] } };
|
|
53
|
+
// Read-only: a job that never commits, so its receipt is `completed` with the score as output,
|
|
54
|
+
// authored false and no undo. The scoring is MCP compare_sculpt's (sculpt-compare.js).
|
|
55
|
+
const openObject = { type: 'object', properties: {}, additionalProperties: true };
|
|
56
|
+
const sculptCompare = { id: 'sculpt.compare', label: 'Compare a sculpt with its reference', kind: 'job', timeoutMs: 60000,
|
|
57
|
+
description: 'Score a sculpt\'s silhouette against a reference image without changing anything: IoU, width-to-height delta, missing area by height band (top, middle, bottom), parts that stick out, and a reference | overlay sheet (grey both, red missing, blue extra) as sheetDataUrl. reference is { assetId } of a project image or { dataUrl }. yaw is the reference camera azimuth in degrees (0 in front, positive toward the subject\'s own left); omitted tries -45..45 and keeps the best. candidates (up to 12 { label, edits }) are scored from the same view and ranked against the current sculpt; none is applied. Apply the winner with object.sculpt { id, edits }.',
|
|
58
|
+
input: input({ id, reference: { oneOf: [input({ assetId: id }), input({ dataUrl: { type: 'string', pattern: '^data:image/(png|jpeg|webp|gif);base64,', maxLength: 6000000 } })] },
|
|
59
|
+
yaw: { type: 'number', minimum: -180, maximum: 180 }, elevation: { type: 'number', minimum: -60, maximum: 60 },
|
|
60
|
+
candidates: { type: 'array', minItems: 1, maxItems: 12, items: input({ label: { type: 'string', maxLength: 60 }, edits: { type: 'array', minItems: 1, maxItems: 48, items: openObject } }) } }, ['id', 'reference']) };
|
|
61
|
+
// A receipt is at most 8 KiB, so the sheet is shrunk until the whole output fits this.
|
|
62
|
+
const SCULPT_COMPARE_OUTPUT_BYTES = 6500;
|
|
40
63
|
const matte = { id: 'object.matte', label: 'Apply object matte', description: 'Prepare derived assets, then publish one fenced object edit.', kind: 'job', domain: 'objects', exposure: 'ui-only', input: input({ objectId: id }) };
|
|
41
|
-
export const declarations = Object.freeze([...semantic, replace, batch, ...['object.attach', 'object.detach', 'object.duplicate'].map(studioActionDeclaration), matte, assetImport]);
|
|
64
|
+
export const declarations = Object.freeze([...semantic, replace, batch, ...['object.attach', 'object.detach', 'object.duplicate'].map(studioActionDeclaration), matte, sculptCompare, assetImport]);
|
|
42
65
|
|
|
43
66
|
export function register(registry, ports) {
|
|
44
67
|
const objectOf = objectId => ports.state().objects.find(object => object.id === objectId)
|
|
@@ -53,10 +76,43 @@ export function register(registry, ports) {
|
|
|
53
76
|
const object = createSceneObject(args.kind, before, args.placement);
|
|
54
77
|
if (!object) fail('INVALID_ARGUMENT', `Unknown object kind: ${args.kind}`);
|
|
55
78
|
const placed = updateSceneObject([object], object.id, { ...(args.placement ?? {}), ...(args.name === undefined ? {} : { name: args.name }) })[0];
|
|
56
|
-
|
|
79
|
+
// `first`: the Outliner lists Props in record order, so a UI-made object
|
|
80
|
+
// goes to the top where it is seen, not below a long expanded group.
|
|
81
|
+
const next = args.first ? [placed, ...before] : [...before, placed];
|
|
57
82
|
owned().write(args.parent === undefined ? next : setSceneObjectParent(next, placed.id, args.parent));
|
|
58
83
|
return result(before, 'Added object.');
|
|
59
84
|
},
|
|
85
|
+
'object.sculpt': args => {
|
|
86
|
+
const before = owned().read();
|
|
87
|
+
if ((args.recipe === undefined) === (args.edits === undefined)) fail('INVALID_ARGUMENT', 'Give exactly one of recipe (whole) or edits (part by part).');
|
|
88
|
+
if (args.edits !== undefined && args.id === undefined) fail('INVALID_ARGUMENT', 'edits patch an existing sculpt: give its id.');
|
|
89
|
+
let refused, changedParts = null;
|
|
90
|
+
if (args.edits !== undefined) {
|
|
91
|
+
const target = objectOf(args.id);
|
|
92
|
+
if (target.renderer !== SCULPT_KIND) fail('INVALID_ARGUMENT', `${args.id} is a ${target.renderer}, not a sculpt.`);
|
|
93
|
+
refused = patchSculptRecipe(target.recipe, args.edits);
|
|
94
|
+
changedParts = refused.changed;
|
|
95
|
+
} else refused = normalizeSculptRecipe(args.recipe);
|
|
96
|
+
if (!refused.ok) fail('INVALID_ARGUMENT', `Recipe refused at ${refused.error}`);
|
|
97
|
+
const patch = { ...(args.placement ?? {}), ...(args.name === undefined ? {} : { name: args.name }) };
|
|
98
|
+
let objectId = args.id;
|
|
99
|
+
if (objectId !== undefined) {
|
|
100
|
+
const target = objectOf(objectId);
|
|
101
|
+
if (target.renderer !== SCULPT_KIND) fail('INVALID_ARGUMENT', `${objectId} is a ${target.renderer}, not a sculpt; omit id to create a new sculpt.`);
|
|
102
|
+
owned().write(updateSceneObject(before, objectId, { ...patch, recipe: refused.recipe }));
|
|
103
|
+
} else {
|
|
104
|
+
if (args.parent !== undefined) objectOf(args.parent);
|
|
105
|
+
const made = createSculptObject({ recipe: refused.recipe, name: args.name }, before, args.placement);
|
|
106
|
+
objectId = made.object.id;
|
|
107
|
+
const next = [...before, updateSceneObject([made.object], objectId, patch)[0]];
|
|
108
|
+
owned().write(args.parent === undefined ? next : setSceneObjectParent(next, objectId, args.parent));
|
|
109
|
+
}
|
|
110
|
+
const sculpted = owned().read().find(row => row.id === objectId);
|
|
111
|
+
const parts = expandSculptParts(sculpted.recipe).length;
|
|
112
|
+
const size = `${sculpted.footprint.width} × ${sculpted.footprint.depth} m, ${sculpted.height} m tall`;
|
|
113
|
+
return { affectedIds: [objectId, ...result(before, '').affectedIds.filter(row => row !== objectId)],
|
|
114
|
+
summary: `${args.id === undefined ? 'Sculpted' : changedParts ? `Patched ${changedParts.join(', ')} on` : 'Re-sculpted'} ${sculpted.name} (${objectId}): ${parts} parts, ${size}.` };
|
|
115
|
+
},
|
|
60
116
|
'object.remove': ({ ids }) => {
|
|
61
117
|
const before = owned().read(); ids.forEach(objectOf);
|
|
62
118
|
owned().write(ids.reduce((rows, id) => removeSceneObject(rows, id), before));
|
|
@@ -67,10 +123,16 @@ export function register(registry, ports) {
|
|
|
67
123
|
owned().write(updateSceneObject(before, id, { name }));
|
|
68
124
|
return result(before, 'Renamed object.');
|
|
69
125
|
},
|
|
70
|
-
'object.update': ({ id, patch }) => {
|
|
71
|
-
objectOf(id)
|
|
72
|
-
|
|
73
|
-
|
|
126
|
+
'object.update': ({ id, patch }, context) => {
|
|
127
|
+
const target = objectOf(id), before = owned().read();
|
|
128
|
+
// Wire callers get an error for a key nothing reads; the UI's own patches stay lenient.
|
|
129
|
+
const unknown = Object.keys(patch ?? {}).filter(key => !objectPatchFields(target).includes(key));
|
|
130
|
+
if (unknown.length && context?.origin && context.origin !== 'ui') fail('INVALID_ARGUMENT', `object.update cannot set ${unknown.join(', ')}${unknown.some(key => PATCH_HINTS[key]) ? ` (${unknown.filter(key => PATCH_HINTS[key]).map(key => `${key}: ${PATCH_HINTS[key]}`).join('; ')})` : ''}. Supported fields: ${objectPatchFields(target).join(', ')}.`);
|
|
131
|
+
const next = updateSceneObject(before, id, patch);
|
|
132
|
+
owned().write(next);
|
|
133
|
+
const kept = next.find(row => row.id === id);
|
|
134
|
+
const warning = clampWarning(id, Object.fromEntries(['x', 'y', 'z', 'scaleX', 'scaleY', 'scaleZ'].filter(key => Object.hasOwn(patch ?? {}, key)).map(key => [key, Number(patch[key])])), kept);
|
|
135
|
+
return { ...result(before, 'Updated object.'), ...(warning ? { warnings: [warning] } : {}) };
|
|
74
136
|
},
|
|
75
137
|
'object.group': ({ parent, children }) => {
|
|
76
138
|
objectOf(parent); children.forEach(objectOf);
|
|
@@ -84,9 +146,17 @@ export function register(registry, ports) {
|
|
|
84
146
|
owned().write(children.reduce((rows, id) => setSceneObjectParent(rows, id, null), before));
|
|
85
147
|
return result(before, 'Ungrouped objects.');
|
|
86
148
|
},
|
|
149
|
+
'object.groupUnderEmpty': ({ id }) => {
|
|
150
|
+
const object = objectOf(id); const before = owned().read();
|
|
151
|
+
if (object.attach) fail('CAPABILITY_MISSING', 'Detach the object from its character before grouping it.');
|
|
152
|
+
const grouped = groupUnderNewEmpty(before, id);
|
|
153
|
+
if (!grouped) fail('INVALID_ARGUMENT', 'Cannot group this object.');
|
|
154
|
+
owned().write(grouped.objects);
|
|
155
|
+
return { affectedIds: [grouped.emptyId, id], summary: 'Grouped the object under a new Empty.' };
|
|
156
|
+
},
|
|
87
157
|
'objects.arrange': args => {
|
|
88
158
|
const plan = owned().arrange(args);
|
|
89
|
-
return { affectedIds: plan.affectedIds, summary: 'Arranged objects.' };
|
|
159
|
+
return { affectedIds: plan.affectedIds, summary: 'Arranged objects.', checks: plan.checks, warnings: plan.warnings };
|
|
90
160
|
},
|
|
91
161
|
};
|
|
92
162
|
// Scalar scale is a convenience at the command boundary; the registry still
|
|
@@ -157,6 +227,31 @@ export function register(registry, ports) {
|
|
|
157
227
|
await owned().applyMatte(objectId, context);
|
|
158
228
|
return { affectedIds: [objectId], summary: 'Applied the object matte.' };
|
|
159
229
|
} });
|
|
230
|
+
registry.register({ ...sculptCompare, available, run: async ({ id: objectId, reference, yaw, elevation = 0, candidates }, context) => {
|
|
231
|
+
const target = objectOf(objectId);
|
|
232
|
+
if (target.renderer !== SCULPT_KIND || !target.recipe) fail('INVALID_ARGUMENT', `${objectId} is a ${target.renderer}, not a sculpt.`);
|
|
233
|
+
const images = ports.sculptImages ?? browserSculptImages;
|
|
234
|
+
let image, scored;
|
|
235
|
+
try { image = await images.decode(reference); }
|
|
236
|
+
catch (error) { fail('INVALID_ARGUMENT', `Could not read the reference image: ${error?.message || error}`); }
|
|
237
|
+
context?.signal?.throwIfAborted();
|
|
238
|
+
try { scored = scoreSculptAgainstReference(target.recipe, image, { yaw, elevation, candidates }); }
|
|
239
|
+
catch (error) { fail('INVALID_ARGUMENT', error?.message || String(error)); }
|
|
240
|
+
const { result, ranked, refused } = scored;
|
|
241
|
+
const output = { id: objectId, ...result.report };
|
|
242
|
+
if (ranked) Object.assign(output, { ranking: [...ranked.map(row => rankingRow(row, result)), ...refused.map(({ label, error }) => ({ label, refused: error }))],
|
|
243
|
+
sheetLabel: ranked[0].label, note: `Nothing was applied. Apply the winner with object.sculpt { id: "${objectId}", edits }.` });
|
|
244
|
+
const sheet = (ranked ? ranked[0].result : result).sheet;
|
|
245
|
+
const room = SCULPT_COMPARE_OUTPUT_BYTES - JSON.stringify(output).length;
|
|
246
|
+
// The full-size sheet is always tried, however narrow; shrinking stops at 64 px.
|
|
247
|
+
for (let factor = 1; factor === 1 || sheet.width / factor >= 64; factor *= 2) {
|
|
248
|
+
const dataUrl = await images.encodePng(factor === 1 ? sheet : shrinkRgba(sheet, factor));
|
|
249
|
+
if (dataUrl.length + 40 <= room) { Object.assign(output, { sheetDataUrl: dataUrl, ...(factor > 1 ? { sheetScale: 1 / factor } : {}) }); break; }
|
|
250
|
+
}
|
|
251
|
+
if (!output.sheetDataUrl) output.sheetOmitted = 'The overlay sheet does not fit in a receipt at a readable size; read missingByBand and extraByPart.';
|
|
252
|
+
const lead = ranked ? `best ${ranked[0].label} at IoU ${ranked[0].result.report.iou} (current ${result.report.iou}), ${candidates.length} candidates scored, none applied` : `IoU ${result.report.iou}`;
|
|
253
|
+
return { affectedIds: [], output, summary: `Compared ${target.name || objectId} with the reference at yaw ${result.report.view.yaw}: ${lead}.` };
|
|
254
|
+
} });
|
|
160
255
|
registry.register({ ...assetImport, available: () => true,
|
|
161
256
|
run: async (args, context) => {
|
|
162
257
|
const { source, ...options } = args;
|