cozyclay 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +219 -38
- 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 +6 -5
- package/bin/cozyclay.mjs +12 -3
- package/bin/live/cli.mjs +2 -3
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +12 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-B41FmPYI.js +1 -0
- package/dist/assets/app-Do-Og5Pu.css +1 -0
- package/dist/assets/app-DwP-tl2x.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
- package/dist/assets/scene-asset-sync-BCWDJB1d.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-jqMhqL88.js} +1 -1
- package/dist/assets/workflow-Br-K7Srs.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/sitemap.xml +5 -5
- 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 +801 -259
- 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 +196 -3
- package/src/ardy/timeline.jsx +117 -29
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +1 -4
- 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 +43 -24
- 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 +16 -3
- 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 +89 -32
- 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/generation.js +1 -1
- 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 +52 -3
- 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 +62 -17
- 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 +58 -14
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/TopBar.jsx +17 -3
- package/src/shell/ViewportToolbar.jsx +18 -3
- 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/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 +186 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +56 -23
- package/src/studio-app-binding.js +52 -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 +111 -10
- package/src/theme.js +1 -1
- package/src/timeline-fps.js +13 -0
- 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 +110 -20
- 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/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 +96 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-C1sdVbg4.css +0 -1
- package/dist/assets/app-CA55tcI3.js +0 -4876
- package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
- package/dist/assets/workflow-dNPGY9xz.js +0 -179
package/src/camera-tutorial.jsx
CHANGED
|
@@ -11,7 +11,7 @@ import { ko } from "./locale.js";
|
|
|
11
11
|
// to them directly.
|
|
12
12
|
//
|
|
13
13
|
// cozyclay:nav fly / walk / dolly / orbit (src/controls.jsx)
|
|
14
|
-
//
|
|
14
|
+
// shotCount / railReady shot / rail (authored state)
|
|
15
15
|
// previewing prop play (lookThroughShot)
|
|
16
16
|
//
|
|
17
17
|
// Nothing here drives the studio. A step is done when the operator has done
|
|
@@ -69,16 +69,16 @@ export const CAMERA_TUTORIAL_STEPS = [
|
|
|
69
69
|
label: ko("Shot", "샷"),
|
|
70
70
|
// The control lives in another region than the card, so the card says
|
|
71
71
|
// which way to look before it says what to do.
|
|
72
|
-
where: ko("↓ Timeline,
|
|
72
|
+
where: ko("↓ Timeline, Camera Cuts", "↓ 타임라인 Camera Cuts"),
|
|
73
73
|
how: () => ko(
|
|
74
|
-
<>In the timeline's
|
|
75
|
-
<>타임라인의
|
|
74
|
+
<>In the timeline's Camera Cuts lane click <b>+ Add shot</b>. That is your cut.</>,
|
|
75
|
+
<>타임라인의 Camera Cuts 레인에서 <b>+ 샷 추가</b>를 누르세요. 그게 컷입니다.</>,
|
|
76
76
|
),
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
kind: "rail",
|
|
80
80
|
label: ko("Rail", "레일"),
|
|
81
|
-
where: ko("↓ Timeline,
|
|
81
|
+
where: ko("↓ Timeline, Camera Cuts", "↓ 타임라인 Camera Cuts"),
|
|
82
82
|
how: () => ko(
|
|
83
83
|
<>Select the shot, click <b>Draw rail</b> in the camera bar, and drag a line across the top view. That line is the dolly move.</>,
|
|
84
84
|
<>샷을 선택하고 카메라 바의 <b>레일 그리기</b>를 누른 뒤, 탑뷰에 선을 그으세요. 그 선이 돌리 이동입니다.</>,
|
|
@@ -87,10 +87,10 @@ export const CAMERA_TUTORIAL_STEPS = [
|
|
|
87
87
|
{
|
|
88
88
|
kind: "play",
|
|
89
89
|
label: ko("Play", "재생"),
|
|
90
|
-
where: ko("
|
|
90
|
+
where: ko("↘ Shot Camera monitor", "↘ Shot Camera 모니터"),
|
|
91
91
|
how: () => ko(
|
|
92
92
|
<>Click <b>Look through</b> in the Shot monitor to fly the shot camera; <b>▶</b> rides the rail, <kbd>Esc</kbd> returns to the free camera.</>,
|
|
93
|
-
|
|
93
|
+
<>Shot Camera 모니터의 <b>샷 시점</b>을 눌러 샷 카메라를 조종하세요. <b>▶</b>는 레일을 타고, <kbd>Esc</kbd>로 자유 카메라로 돌아옵니다.</>,
|
|
94
94
|
),
|
|
95
95
|
},
|
|
96
96
|
];
|
|
@@ -98,8 +98,6 @@ export const CAMERA_TUTORIAL_STEPS = [
|
|
|
98
98
|
const NAV_KINDS = new Set(["fly", "walk", "dolly", "orbit"]);
|
|
99
99
|
const SIGNAL_KINDS = new Set(["shot", "rail"]);
|
|
100
100
|
|
|
101
|
-
/* ------------------------------------------------------------- beacons --- */
|
|
102
|
-
|
|
103
101
|
// Where each of the last three steps actually happens (#211). The card names
|
|
104
102
|
// the gesture; these pin a numbered circle and a caption onto the control
|
|
105
103
|
// itself, so "click + Add shot" stops being a treasure hunt across two panes.
|
|
@@ -119,10 +117,13 @@ export const TUTORIAL_BEACONS = {
|
|
|
119
117
|
label: () => ko("1. Select the shot", "1. 샷을 선택"),
|
|
120
118
|
},
|
|
121
119
|
{ role: "draw-rail", selector: ".tl-rail-draw", label: () => ko("2. Draw rail, then drag across the top view", "2. 레일 그리기 후 탑뷰에 선 긋기") },
|
|
122
|
-
|
|
120
|
+
// The Top-View becomes an active drawing surface after the tool is armed.
|
|
121
|
+
{ role: "top-view", selector: ".vp-inset", requires: '[data-rail-draw="1"]', label: () => ko("Drag a line here", "여기에 선을 그으세요") },
|
|
123
122
|
],
|
|
124
123
|
play: [
|
|
125
|
-
|
|
124
|
+
// The glass shell keeps the same action in the bottom dock while the
|
|
125
|
+
// compact viewport card owns it in the classic shell.
|
|
126
|
+
{ role: "look-through", selector: ".dock-shot-look, .vp-look-through", label: () => ko("Click to fly the shot camera", "클릭해 샷 카메라를 조종") },
|
|
126
127
|
],
|
|
127
128
|
};
|
|
128
129
|
|
|
@@ -217,7 +218,7 @@ export function TutorialBeacon({ kind, step, role, selector, label, requires = n
|
|
|
217
218
|
if (pane) resize.observe(pane);
|
|
218
219
|
}
|
|
219
220
|
const mutations = new MutationObserver(schedule);
|
|
220
|
-
mutations.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
|
|
221
|
+
mutations.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "data-rail-draw"] });
|
|
221
222
|
return () => {
|
|
222
223
|
if (frame) cancelAnimationFrame(frame);
|
|
223
224
|
window.removeEventListener("resize", schedule);
|
|
@@ -246,8 +247,6 @@ export function TutorialBeacon({ kind, step, role, selector, label, requires = n
|
|
|
246
247
|
);
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
/* -------------------------------------------------------- gesture cues --- */
|
|
250
|
-
|
|
251
250
|
// Steps 1–4 have no control to point at: the gesture IS the interface. The cue
|
|
252
251
|
// draws the mouse and the keys the step wants, animated the way the hand is
|
|
253
252
|
// supposed to move, and sits centre-bottom of the viewport where the eye
|
|
@@ -327,11 +326,18 @@ export function GestureCue({ kind, walked, leaving = false, onLeft }) {
|
|
|
327
326
|
* Top-View inset, and never takes the pointer except on its own close button
|
|
328
327
|
* — every step is completed by working the studio underneath it.
|
|
329
328
|
*/
|
|
330
|
-
export function CameraTutorial({ previewing = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
|
|
329
|
+
export function CameraTutorial({ previsMode, previewing = false, shotCount = 0, railReady = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
|
|
331
330
|
const [done, setDone] = useState(() => new Set());
|
|
332
331
|
const [walked, setWalked] = useState(() => new Set());
|
|
333
332
|
const [cue, setCue] = useState(null);
|
|
334
333
|
const completionReported = useRef(false);
|
|
334
|
+
const effectiveDone = useMemo(() => {
|
|
335
|
+
const next = new Set(done);
|
|
336
|
+
if (shotCount > 0) next.add("shot");
|
|
337
|
+
if (railReady) next.add("rail");
|
|
338
|
+
if (previewing && next.has("rail")) next.add("play");
|
|
339
|
+
return next;
|
|
340
|
+
}, [done, previewing, railReady, shotCount]);
|
|
335
341
|
|
|
336
342
|
useEffect(() => {
|
|
337
343
|
const complete = (kind) => setDone((current) => (current.has(kind) ? current : new Set(current).add(kind)));
|
|
@@ -362,6 +368,16 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
|
|
|
362
368
|
};
|
|
363
369
|
}, []);
|
|
364
370
|
|
|
371
|
+
// Studio-native completion comes from the authored state. The old playground
|
|
372
|
+
// signal was never emitted by the Studio shell, so a real Shot or rail could
|
|
373
|
+
// leave the tutorial stuck on its previous step.
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
if (shotCount > 0) setDone((current) => (current.has("shot") ? current : new Set(current).add("shot")));
|
|
376
|
+
}, [shotCount]);
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (railReady) setDone((current) => (current.has("rail") ? current : new Set(current).add("rail")));
|
|
379
|
+
}, [railReady]);
|
|
380
|
+
|
|
365
381
|
// The player is the last step, and only once there is a rail to ride:
|
|
366
382
|
// look-through before the dolly exists shows a still frame, which teaches
|
|
367
383
|
// nothing about the move. Analytics v1 observes this existing look-through
|
|
@@ -369,12 +385,12 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
|
|
|
369
385
|
useEffect(() => {
|
|
370
386
|
if (!previewing) return;
|
|
371
387
|
setDone((current) => (!current.has("rail") || current.has("play") ? current : new Set(current).add("play")));
|
|
372
|
-
}, [previewing]);
|
|
388
|
+
}, [previewing, railReady]);
|
|
373
389
|
|
|
374
|
-
const current = useMemo(() => CAMERA_TUTORIAL_STEPS.find((step) => !
|
|
390
|
+
const current = useMemo(() => CAMERA_TUTORIAL_STEPS.find((step) => !effectiveDone.has(step.kind)) ?? null, [effectiveDone]);
|
|
375
391
|
const complete = current === null;
|
|
376
392
|
const currentKind = current?.kind ?? null;
|
|
377
|
-
const showHandoff = !!shotId && (handoff?.canShow(
|
|
393
|
+
const showHandoff = !!shotId && (handoff?.canShow(effectiveDone) ?? false);
|
|
378
394
|
useEffect(() => {
|
|
379
395
|
if (!complete || completionReported.current) return;
|
|
380
396
|
completionReported.current = true;
|
|
@@ -384,8 +400,8 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
|
|
|
384
400
|
// Observe committed progression, outside state updaters (which StrictMode
|
|
385
401
|
// may replay). The attempt belongs to App and survives effect cleanup/resume.
|
|
386
402
|
useEffect(() => {
|
|
387
|
-
analytics?.observe(
|
|
388
|
-
}, [analytics,
|
|
403
|
+
analytics?.observe(effectiveDone, currentKind);
|
|
404
|
+
}, [analytics, effectiveDone, currentKind]);
|
|
389
405
|
|
|
390
406
|
// The step the operator is on is the studio's business too: App puts it on
|
|
391
407
|
// the .app root as data-tutorial-step, and styles.css spotlights whichever
|
|
@@ -409,6 +425,9 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
|
|
|
409
425
|
});
|
|
410
426
|
}, [currentKind]);
|
|
411
427
|
|
|
428
|
+
// Storyboard projects never show the camera tutorial (its steps are shot/rail/play).
|
|
429
|
+
if (previsMode === "storyboard") return null;
|
|
430
|
+
|
|
412
431
|
return (
|
|
413
432
|
<>
|
|
414
433
|
<aside
|
|
@@ -425,10 +444,10 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
|
|
|
425
444
|
key={step.kind}
|
|
426
445
|
data-testid="camera-tutorial-step"
|
|
427
446
|
data-kind={step.kind}
|
|
428
|
-
data-done={
|
|
447
|
+
data-done={effectiveDone.has(step.kind) ? 1 : 0}
|
|
429
448
|
data-current={step === current ? 1 : 0}
|
|
430
449
|
>
|
|
431
|
-
<i aria-hidden="true">{
|
|
450
|
+
<i aria-hidden="true">{effectiveDone.has(step.kind) ? "✓" : index + 1}</i>
|
|
432
451
|
{step.label}
|
|
433
452
|
</li>
|
|
434
453
|
))}
|
|
@@ -490,8 +509,8 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
|
|
|
490
509
|
role={beacon.role}
|
|
491
510
|
selector={beacon.selector}
|
|
492
511
|
label={beacon.label}
|
|
493
|
-
requires={beacon.requires
|
|
494
|
-
absent={beacon.absent
|
|
512
|
+
requires={beacon.requires}
|
|
513
|
+
absent={beacon.absent}
|
|
495
514
|
/>
|
|
496
515
|
))}
|
|
497
516
|
</>
|
|
@@ -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."
|