cozyclay 1.10.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +193 -0
- package/bin/agent/agent-routes.mjs +82 -72
- package/bin/agent/agent-runner.mjs +10 -3
- package/bin/agent/motion-runtime.mjs +32 -260
- package/bin/agent/providers.mjs +46 -3
- package/bin/agent/session-store.mjs +41 -5
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-tools.mjs +48 -11
- package/bin/codex-auth.mjs +2 -1
- package/bin/cozyclay.mjs +38 -11
- package/bin/live/cli.mjs +22 -3
- package/bin/mcp-runtime.mjs +45 -20
- package/bin/telemetry-state.mjs +5 -2
- package/bin/update-check.mjs +15 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-MVZOEW8O.js +1 -0
- package/dist/assets/app-BxBLC6GT.js +4876 -0
- package/dist/assets/app-CJdxvnrl.css +1 -0
- package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
- package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
- package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
- package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
- package/dist/assets/shot-prompt-hN0j_O-p.js +17 -0
- package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
- package/dist/assets/workflow-B5XZwVfe.js +179 -0
- package/dist/assets/workflow-DRdwoDBT.css +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
- package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
- package/dist/fonts/README.md +13 -9
- package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
- package/dist/index.html +7 -7
- package/dist/privacy/index.html +13 -8
- package/dist/sitemap.xml +2 -2
- package/dist/workflow/index.html +5 -5
- package/mcp/live-hub.mjs +35 -93
- package/mcp/mesh-file.mjs +48 -0
- package/mcp/server.mjs +6 -77
- package/mcp/tool-handlers.mjs +219 -376
- package/package.json +2 -1
- package/src/App.jsx +1275 -9086
- package/src/analytics.js +393 -9
- package/src/app-context.js +211 -0
- package/src/app-stage.jsx +25 -24
- package/src/ardy/auto-fix-panel.css +108 -0
- package/src/ardy/fix-collisions.js +10 -66
- package/src/ardy/ik-drag.js +101 -0
- package/src/ardy/ik-key-json.js +43 -0
- package/src/ardy/ik.js +97 -67
- package/src/ardy/physics-panel.css +2 -0
- package/src/ardy/physics-panel.jsx +17 -5
- package/src/ardy/platform-fit-panel.css +64 -0
- package/src/ardy/platform-fit-panel.jsx +40 -0
- package/src/ardy/platform-fit.js +326 -0
- package/src/ardy/playback.js +166 -20
- package/src/ardy/range-pin.js +299 -0
- package/src/ardy/timeline-coordinates.js +26 -0
- package/src/ardy/timeline.css +1214 -0
- package/src/ardy/timeline.jsx +152 -41
- package/src/ardy/waypoints.js +3 -3
- package/src/asset-pane.css +462 -0
- package/src/asset-pane.jsx +461 -232
- package/src/camera-tutorial.jsx +38 -18
- package/src/command-bus.js +349 -0
- package/src/commands/ai.js +31 -0
- package/src/commands/cast.js +140 -0
- package/src/commands/elements/character.js +13 -0
- package/src/commands/elements/motion.js +14 -0
- package/src/commands/elements/object.js +22 -0
- package/src/commands/elements/scene.js +16 -0
- package/src/commands/elements/shot.js +9 -0
- package/src/commands/elements/stage.js +11 -0
- package/src/commands/elements.js +155 -0
- package/src/commands/export.js +20 -0
- package/src/commands/index.js +40 -0
- package/src/commands/motion.js +231 -0
- package/src/commands/objects.js +174 -0
- package/src/commands/project.js +53 -0
- package/src/commands/scene.js +81 -0
- package/src/commands/shared.js +19 -0
- package/src/commands/shot.js +103 -0
- package/src/commands/stage.js +17 -0
- package/src/commands/view.js +42 -0
- package/src/document-store.js +144 -0
- package/src/domains/cast.js +1007 -0
- package/src/domains/motion.js +3219 -0
- package/src/domains/objects.js +932 -0
- package/src/domains/scenes.js +823 -0
- package/src/domains/shots.js +404 -0
- package/src/domains/stage.js +93 -0
- package/src/dualview.jsx +113 -57
- package/src/facing-marks.js +3 -0
- package/src/first-success-guide.jsx +14 -14
- package/src/grid-view.js +18 -5
- package/src/hierarchy-panel.css +386 -0
- package/src/hierarchy-panel.jsx +88 -19
- package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
- package/src/i2v-motion-studio.jsx +180 -0
- package/src/ik-camera.js +3 -0
- package/src/main.jsx +6 -0
- package/src/motion/generation.js +105 -0
- package/src/motion-readiness-ui.jsx +4 -4
- package/src/motion-readiness.js +11 -0
- package/src/motion-trail.js +366 -9
- package/src/object-gizmo.jsx +7 -1
- package/src/panels/CameraPanel.jsx +60 -0
- package/src/panels/CharacterTransformPanel.jsx +48 -0
- package/src/panels/EnvironmentPanel.jsx +40 -0
- package/src/panels/Foldout.jsx +32 -0
- package/src/panels/LightPanel.jsx +24 -0
- package/src/panels/ObjectTransformPanel.jsx +554 -0
- package/src/panels/PosePanel.jsx +97 -0
- package/src/panels/ProjectPanel.jsx +33 -0
- package/src/panels/PromptBlocksPanel.jsx +442 -0
- package/src/panels/PropsPanel.jsx +70 -0
- package/src/panels/ReferenceImageField.jsx +91 -0
- package/src/panels/RigControlPanel.jsx +78 -0
- package/src/panels/RigPanel.jsx +37 -0
- package/src/panels/SubjectBox.jsx +47 -0
- package/src/panels/SubjectsPanel.jsx +39 -0
- package/src/panels/VideoCapturePanel.jsx +180 -0
- package/src/panels/details.css +1171 -0
- package/src/panels/motion.css +197 -0
- package/src/panels/pose.css +39 -0
- package/src/planview.jsx +60 -31
- package/src/posestudio.jsx +59 -4
- package/src/project-browser.css +1073 -0
- package/src/project-browser.jsx +288 -123
- package/src/range-pin-object-transform.js +19 -0
- package/src/range-pin-panel.css +518 -0
- package/src/range-pin-panel.jsx +344 -0
- package/src/result-modal.jsx +5 -5
- package/src/room.jsx +79 -51
- package/src/scenes.js +9 -8
- package/src/semantic-edit.js +2 -0
- package/src/settings-menu.jsx +39 -105
- package/src/shell/BottomDock.jsx +450 -0
- package/src/shell/DetailsSlot.jsx +513 -0
- package/src/shell/LibrarySlot.jsx +86 -0
- package/src/shell/MenuBar.jsx +601 -0
- package/src/shell/OutlinerSlot.jsx +50 -0
- package/src/shell/PreferencesDialog.jsx +538 -0
- package/src/shell/PreferencesSlot.jsx +40 -0
- package/src/shell/StatusBar.jsx +125 -0
- package/src/shell/StudioShell.jsx +92 -0
- package/src/shell/TopBar.jsx +129 -0
- package/src/shell/ViewportToolbar.jsx +656 -0
- package/src/shell/agent-glass.css +296 -0
- package/src/shell/dock.css +93 -0
- package/src/shell/glass-regions.css +985 -0
- package/src/shell/glass.css +687 -0
- package/src/shell/log-store.js +52 -0
- package/src/shell/mode.css +9 -0
- package/src/shell/preferences.css +651 -0
- package/src/shell/shell.css +282 -0
- package/src/shell/studio-shell-context.js +19 -0
- package/src/shell/topbar.css +435 -0
- package/src/shell/viewport.css +451 -0
- package/src/store/authored-intent.js +22 -0
- package/src/store/runtime-adapters.js +92 -0
- package/src/store/scene-stage.js +21 -0
- package/src/store/use-document-store.js +14 -0
- package/src/studio-actions.js +210 -0
- package/src/studio-agent-commands.js +38 -268
- package/src/studio-agent-context.js +38 -9
- package/src/studio-agent-motion.js +122 -378
- package/src/studio-agent-protocol.js +66 -30
- package/src/studio-app-binding.js +379 -0
- package/src/studio-contact-sheet.js +73 -0
- package/src/studio-elements.js +58 -51
- package/src/styles/themes.css +200 -0
- package/src/styles/tokens.css +69 -0
- package/src/styles.css +456 -1748
- package/src/theme.js +44 -0
- package/src/timeline-extent.js +16 -0
- package/src/timeline-fps.js +13 -0
- package/src/trail-key-conflicts.js +39 -0
- package/src/trail-pick.js +59 -0
- package/src/ui.jsx +8 -8
- package/src/use-case-question.jsx +66 -0
- package/src/workflow/AgentPanel.jsx +86 -71
- package/src/workflow/agent-client.js +12 -2
- package/src/workflow/agent-panel.css +34 -32
- package/src/workflow/cozy-scene-node.css +2 -0
- package/src/workflow/workflow.css +2 -0
- package/tools/ardy/bridge.mjs +164 -146
- package/tools/ardy/visual-qa.mjs +5 -5
- package/tools/bench/EXP3.md +61 -0
- package/tools/bench/cclay_bench_extract_incam.py +125 -0
- package/tools/bench/cclay_bench_extract_obs.py +204 -0
- package/tools/bench/cclay_bench_runner.py +42 -0
- package/tools/bench/cube-contact.mjs +162 -0
- package/tools/bench/exp3.mjs +152 -0
- package/tools/bench/extract-bench-lib.mjs +216 -0
- package/tools/bench/extract-bench.mjs +302 -0
- package/tools/bench/fal-generate.mjs +88 -0
- package/tools/bench/fit/README.md +189 -0
- package/tools/bench/fit/camera.mjs +59 -0
- package/tools/bench/fit/contact.mjs +419 -0
- package/tools/bench/fit/footlock.mjs +234 -0
- package/tools/bench/fit/motion.mjs +85 -0
- package/tools/bench/fit/pin.mjs +27 -0
- package/tools/bench/fit/remote.mjs +105 -0
- package/tools/bench/fit-bench.mjs +126 -0
- package/tools/bench/fit-sanity.mjs +45 -0
- package/tools/bench/metrics.mjs +313 -0
- package/tools/bench/obs/depth.mjs +258 -0
- package/tools/bench/obs/extrinsics.mjs +175 -0
- package/tools/bench/obs/fit_mannequin_betas.py +340 -0
- package/tools/bench/obs/ground.mjs +294 -0
- package/tools/bench/obs/heading.mjs +151 -0
- package/tools/bench/obs/ladder.mjs +555 -0
- package/tools/bench/obs/mannequin-betas.json +124 -0
- package/tools/bench/obs/remote.mjs +83 -0
- package/tools/bench/obs/rest_joints.py +52 -0
- package/tools/bench/obs/ybot-targets.mjs +49 -0
- package/tools/bench/obs-bench.mjs +520 -0
- package/tools/bench/score.mjs +343 -0
- package/tools/bench/summarize.mjs +86 -0
- package/tools/dev/pages/privacy.html +13 -8
- package/tools/gt-render/browser.mjs +159 -0
- package/tools/gt-render/camera-math.mjs +276 -0
- package/tools/gt-render/page.mjs +267 -0
- package/tools/gt-render/render.mjs +514 -0
- package/tools/gt-render/scene-box.mjs +31 -0
- package/tools/gt-render/take-transform.mjs +87 -0
- package/tools/qa/css-rule-usage.mjs +283 -0
- package/tools/qa/studio-control-count.mjs +70 -32
- package/tools/run-tests.mjs +60 -3
- package/tools/track/DESIGN.md +513 -0
- package/tools/track/backfill-provenance.mjs +116 -0
- package/tools/track/budget.mjs +8 -0
- package/tools/track/check-rig.mjs +209 -0
- package/tools/track/diagnostics.schema.json +60 -0
- package/tools/track/export-rig.mjs +256 -0
- package/tools/track/fallback.mjs +7 -0
- package/tools/track/fk-parity-fixture.mjs +161 -0
- package/tools/track/gate.mjs +349 -0
- package/tools/track/masks.mjs +318 -0
- package/tools/track/metrics.mjs +201 -0
- package/tools/track/publish-obs.mjs +108 -0
- package/tools/track/py/check_env.py +61 -0
- package/tools/track/py/eval_lr.py +229 -0
- package/tools/track/py/lr_viterbi.py +294 -0
- package/tools/track/py/masks.py +405 -0
- package/tools/track/py/objective.py +382 -0
- package/tools/track/py/rig.py +508 -0
- package/tools/track/py/scene.py +326 -0
- package/tools/track/py/test_joint_indices.py +123 -0
- package/tools/track/py/test_lr_viterbi.py +195 -0
- package/tools/track/py/test_masks.py +229 -0
- package/tools/track/py/test_rig.py +288 -0
- package/tools/track/py/test_scene.py +354 -0
- package/tools/track/py/test_track.py +328 -0
- package/tools/track/py/track.py +411 -0
- package/tools/track/remote.mjs +140 -0
- package/tools/track/rig-dump.mjs +227 -0
- package/tools/track/run-box-tests.mjs +14 -0
- package/tools/track/run-box.mjs +168 -0
- package/tools/track/setup-box.sh +59 -0
- package/tools/track/study-2d.mjs +728 -0
- package/dist/assets/analytics-B1hnH66c.js +0 -1
- package/dist/assets/app-Cu1cexZR.css +0 -1
- package/dist/assets/app-Dxdh8kds.js +0 -4861
- package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
- package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
- package/dist/assets/workflow-CJd-PDtV.js +0 -179
- package/dist/assets/workflow-DsudxKHF.css +0 -1
- package/src/fal-motion-studio.jsx +0 -180
- package/src/scene-history.js +0 -129
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
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
|
];
|
|
@@ -119,10 +119,13 @@ export const TUTORIAL_BEACONS = {
|
|
|
119
119
|
label: () => ko("1. Select the shot", "1. 샷을 선택"),
|
|
120
120
|
},
|
|
121
121
|
{ role: "draw-rail", selector: ".tl-rail-draw", label: () => ko("2. Draw rail, then drag across the top view", "2. 레일 그리기 후 탑뷰에 선 긋기") },
|
|
122
|
-
|
|
122
|
+
// The Top-View becomes an active drawing surface after the tool is armed.
|
|
123
|
+
{ role: "top-view", selector: ".vp-inset", requires: '[data-rail-draw="1"]', label: () => ko("Drag a line here", "여기에 선을 그으세요") },
|
|
123
124
|
],
|
|
124
125
|
play: [
|
|
125
|
-
|
|
126
|
+
// The glass shell keeps the same action in the bottom dock while the
|
|
127
|
+
// compact viewport card owns it in the classic shell.
|
|
128
|
+
{ role: "look-through", selector: ".dock-shot-look, .vp-look-through", label: () => ko("Click to fly the shot camera", "클릭해 샷 카메라를 조종") },
|
|
126
129
|
],
|
|
127
130
|
};
|
|
128
131
|
|
|
@@ -217,7 +220,7 @@ export function TutorialBeacon({ kind, step, role, selector, label, requires = n
|
|
|
217
220
|
if (pane) resize.observe(pane);
|
|
218
221
|
}
|
|
219
222
|
const mutations = new MutationObserver(schedule);
|
|
220
|
-
mutations.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
|
|
223
|
+
mutations.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "data-rail-draw"] });
|
|
221
224
|
return () => {
|
|
222
225
|
if (frame) cancelAnimationFrame(frame);
|
|
223
226
|
window.removeEventListener("resize", schedule);
|
|
@@ -327,11 +330,18 @@ export function GestureCue({ kind, walked, leaving = false, onLeft }) {
|
|
|
327
330
|
* Top-View inset, and never takes the pointer except on its own close button
|
|
328
331
|
* — every step is completed by working the studio underneath it.
|
|
329
332
|
*/
|
|
330
|
-
export function CameraTutorial({ previewing = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
|
|
333
|
+
export function CameraTutorial({ previewing = false, shotCount = 0, railReady = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
|
|
331
334
|
const [done, setDone] = useState(() => new Set());
|
|
332
335
|
const [walked, setWalked] = useState(() => new Set());
|
|
333
336
|
const [cue, setCue] = useState(null);
|
|
334
337
|
const completionReported = useRef(false);
|
|
338
|
+
const effectiveDone = useMemo(() => {
|
|
339
|
+
const next = new Set(done);
|
|
340
|
+
if (shotCount > 0) next.add("shot");
|
|
341
|
+
if (railReady) next.add("rail");
|
|
342
|
+
if (previewing && next.has("rail")) next.add("play");
|
|
343
|
+
return next;
|
|
344
|
+
}, [done, previewing, railReady, shotCount]);
|
|
335
345
|
|
|
336
346
|
useEffect(() => {
|
|
337
347
|
const complete = (kind) => setDone((current) => (current.has(kind) ? current : new Set(current).add(kind)));
|
|
@@ -362,6 +372,16 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
|
|
|
362
372
|
};
|
|
363
373
|
}, []);
|
|
364
374
|
|
|
375
|
+
// Studio-native completion comes from the authored state. The old playground
|
|
376
|
+
// signal was never emitted by the Studio shell, so a real Shot or rail could
|
|
377
|
+
// leave the tutorial stuck on its previous step.
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
if (shotCount > 0) setDone((current) => (current.has("shot") ? current : new Set(current).add("shot")));
|
|
380
|
+
}, [shotCount]);
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (railReady) setDone((current) => (current.has("rail") ? current : new Set(current).add("rail")));
|
|
383
|
+
}, [railReady]);
|
|
384
|
+
|
|
365
385
|
// The player is the last step, and only once there is a rail to ride:
|
|
366
386
|
// look-through before the dolly exists shows a still frame, which teaches
|
|
367
387
|
// nothing about the move. Analytics v1 observes this existing look-through
|
|
@@ -369,12 +389,12 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
|
|
|
369
389
|
useEffect(() => {
|
|
370
390
|
if (!previewing) return;
|
|
371
391
|
setDone((current) => (!current.has("rail") || current.has("play") ? current : new Set(current).add("play")));
|
|
372
|
-
}, [previewing]);
|
|
392
|
+
}, [previewing, railReady]);
|
|
373
393
|
|
|
374
|
-
const current = useMemo(() => CAMERA_TUTORIAL_STEPS.find((step) => !
|
|
394
|
+
const current = useMemo(() => CAMERA_TUTORIAL_STEPS.find((step) => !effectiveDone.has(step.kind)) ?? null, [effectiveDone]);
|
|
375
395
|
const complete = current === null;
|
|
376
396
|
const currentKind = current?.kind ?? null;
|
|
377
|
-
const showHandoff = !!shotId && (handoff?.canShow(
|
|
397
|
+
const showHandoff = !!shotId && (handoff?.canShow(effectiveDone) ?? false);
|
|
378
398
|
useEffect(() => {
|
|
379
399
|
if (!complete || completionReported.current) return;
|
|
380
400
|
completionReported.current = true;
|
|
@@ -384,8 +404,8 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
|
|
|
384
404
|
// Observe committed progression, outside state updaters (which StrictMode
|
|
385
405
|
// may replay). The attempt belongs to App and survives effect cleanup/resume.
|
|
386
406
|
useEffect(() => {
|
|
387
|
-
analytics?.observe(
|
|
388
|
-
}, [analytics,
|
|
407
|
+
analytics?.observe(effectiveDone, currentKind);
|
|
408
|
+
}, [analytics, effectiveDone, currentKind]);
|
|
389
409
|
|
|
390
410
|
// The step the operator is on is the studio's business too: App puts it on
|
|
391
411
|
// the .app root as data-tutorial-step, and styles.css spotlights whichever
|
|
@@ -425,10 +445,10 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
|
|
|
425
445
|
key={step.kind}
|
|
426
446
|
data-testid="camera-tutorial-step"
|
|
427
447
|
data-kind={step.kind}
|
|
428
|
-
data-done={
|
|
448
|
+
data-done={effectiveDone.has(step.kind) ? 1 : 0}
|
|
429
449
|
data-current={step === current ? 1 : 0}
|
|
430
450
|
>
|
|
431
|
-
<i aria-hidden="true">{
|
|
451
|
+
<i aria-hidden="true">{effectiveDone.has(step.kind) ? "✓" : index + 1}</i>
|
|
432
452
|
{step.label}
|
|
433
453
|
</li>
|
|
434
454
|
))}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// The Studio command runner. The registry owns declarations/implementations;
|
|
2
|
+
// the editor ports own native state/history, and the existing journal owns
|
|
3
|
+
// idempotency. Synchronous UI actions stay synchronous (including user gestures).
|
|
4
|
+
import { HISTORY_LIMIT } from './history.js';
|
|
5
|
+
import { StudioProtocolError, StudioSchemas, validateStudioSchema, validateReceipt, validateStudioIdentity } from './studio-agent-protocol.js';
|
|
6
|
+
|
|
7
|
+
// A batching/retention adapter over the existing object history owner. The
|
|
8
|
+
// native store still owns every snapshot and transition; this tracks only the
|
|
9
|
+
// retained pre-image identities needed by receipts.
|
|
10
|
+
export function withCommandHistory(store) {
|
|
11
|
+
let past = [], future = [], group = null;
|
|
12
|
+
const adapter = Object.create(store);
|
|
13
|
+
const pushed = before => { if (before !== store.present()) { past.push(before); past = past.slice(-HISTORY_LIMIT); future = []; } };
|
|
14
|
+
adapter.hasHistoryState = state => state === store.present() || past.includes(state) || future.includes(state);
|
|
15
|
+
adapter.settle = (...args) => { const before = store.present(); store.settle(...args); pushed(before); };
|
|
16
|
+
adapter.applyAtomic = fn => {
|
|
17
|
+
if (group) { if (!group.running) fail('TARGET_BUSY', 'A command transaction owns the object history.'); return store.applyIn(group.token, fn); }
|
|
18
|
+
adapter.settle(); const before = store.present(); store.applyAtomic(fn); pushed(before);
|
|
19
|
+
};
|
|
20
|
+
adapter.begin = (...args) => { adapter.settle(); return store.begin(...args); };
|
|
21
|
+
adapter.end = (token, options) => { const before = store.present(), ended = store.end(token, options); if (options.commit) pushed(before); return ended; };
|
|
22
|
+
adapter.undo = () => { adapter.settle(); const before = store.present(), restored = store.undo(); if (restored !== null) { past.pop(); future.unshift(before); } return restored; };
|
|
23
|
+
adapter.redo = () => { const before = store.present(), restored = store.redo(); if (restored !== null) { past.push(before); future.shift(); } return restored; };
|
|
24
|
+
adapter.beginCommand = () => {
|
|
25
|
+
if (group) fail('TARGET_BUSY', 'An object command is already in progress.');
|
|
26
|
+
adapter.settle(); const before = store.present();
|
|
27
|
+
const current = { running: 0, token: store.begin('command-bus', () => { current.closed = true; group = null; }) }; group = current;
|
|
28
|
+
const check = () => { if (current.closed) fail('STALE_TARGET', 'Native object transaction was retired.'); };
|
|
29
|
+
return {
|
|
30
|
+
run(fn) { check(); current.running++; try { const result = fn(); if (result?.then) return result.finally(() => { current.running--; }); current.running--; return result; } catch (error) { current.running--; throw error; } },
|
|
31
|
+
commit() { check(); current.closed = true; group = null; store.end(current.token, { commit: true }); pushed(before); return before !== store.present(); },
|
|
32
|
+
cancel() { if (current.closed) return false; current.closed = true; group = null; return store.end(current.token, { commit: false }); },
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
return adapter;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
39
|
+
const fail = (code, message) => { throw new StudioProtocolError(code, message); };
|
|
40
|
+
const mapResult = (value, success, failure) => value?.then ? value.then(success, failure) : success(value);
|
|
41
|
+
const toastWarnings = toasts => toasts.slice(-12).map(toast => ({ code: 'STUDIO_TOAST', message: [...toast.message].slice(0, 120).join('') }));
|
|
42
|
+
let activeRunDepth = 0;
|
|
43
|
+
export const isBusRunActive = () => activeRunDepth > 0;
|
|
44
|
+
|
|
45
|
+
export function createCommandBus({ registry, ports }) {
|
|
46
|
+
const pending = new Map(), transactions = new Map(), jobs = new Map(), listeners = new Set();
|
|
47
|
+
const confirmations = new Map(), historyReceipts = new Map();
|
|
48
|
+
function exposure(entry, args, request) {
|
|
49
|
+
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.id === 'load_scenes') {
|
|
52
|
+
const ids = scenes => [...new Set(scenes.map(scene => scene.id))].sort();
|
|
53
|
+
if (same(ids(args.document.scenes), ids(registry.state().scenes))) return;
|
|
54
|
+
} else if (entry.exposure !== 'confirm' || entry.requiresConfirmation?.(registry.state(), args) === false) return;
|
|
55
|
+
const token = confirmations.get(request.confirmationToken);
|
|
56
|
+
if (!token || token.id !== entry.id || !same(token.args, args) || !same(token.host, ports.read().host) || token.expires < (ports.now ?? Date.now)()) fail('CONFIRMATION_REQUIRED', entry.confirmationReason ?? 'This command needs user confirmation.');
|
|
57
|
+
confirmations.delete(request.confirmationToken);
|
|
58
|
+
}
|
|
59
|
+
const emit = event => { for (const listener of listeners) listener(event); ports.emit?.(event); };
|
|
60
|
+
const identifier = StudioSchemas.TargetGuard.properties.targetId;
|
|
61
|
+
const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
|
|
62
|
+
const argsSchema = { type: 'object', properties: {}, required: [], additionalProperties: true };
|
|
63
|
+
const controls = {
|
|
64
|
+
'run.begin': object({ id: identifier, args: argsSchema }),
|
|
65
|
+
'run.update': object({ txId: identifier, args: argsSchema }),
|
|
66
|
+
'run.commit': object({ txId: identifier }),
|
|
67
|
+
'run.cancel': object({ txId: identifier }),
|
|
68
|
+
'job.await': { ...object({ jobId: identifier }), properties: { jobId: identifier, timeoutMs: { type: 'integer', minimum: 1, maximum: 300_000, default: 30_000 } } },
|
|
69
|
+
'job.cancel': object({ jobId: identifier }),
|
|
70
|
+
'edit.undo': { ...object({ receiptId: identifier }), required: [] },
|
|
71
|
+
'edit.redo': { ...object({ receiptId: identifier }), required: [] },
|
|
72
|
+
};
|
|
73
|
+
const clear = timer => (ports.clearTimeout ?? clearTimeout)(timer);
|
|
74
|
+
function cancelTransaction(tx, expired = false, reason = new StudioProtocolError('CANCELLED', 'Transaction was cancelled.')) {
|
|
75
|
+
transactions.delete(tx.txId); clear(tx.timer); if (tx.workTimer !== undefined) clear(tx.workTimer); tx.controller.abort(reason);
|
|
76
|
+
tx.session.cancel({ restore: same(tx.before.host, ports.read().host) });
|
|
77
|
+
emit({ type: 'transaction.cancelled', txId: tx.txId, expired });
|
|
78
|
+
}
|
|
79
|
+
function renew(tx) {
|
|
80
|
+
if (tx.timer !== undefined) clear(tx.timer);
|
|
81
|
+
tx.timer = (ports.setTimeout ?? setTimeout)(() => cancelTransaction(tx, true), ports.transactionIdleMs ?? 30_000);
|
|
82
|
+
}
|
|
83
|
+
function control(id, args, request, before) {
|
|
84
|
+
if (id === 'edit.undo' || id === 'edit.redo') {
|
|
85
|
+
const redo = id === 'edit.redo', historyEntryId = ports.history?.(redo);
|
|
86
|
+
const previous = args.receiptId ? ports.receipt(args.receiptId) : historyReceipts.get(historyEntryId)
|
|
87
|
+
?? (historyEntryId ? { receiptId: historyEntryId, host: before.host, affectedIds: [before.host.sceneId],
|
|
88
|
+
undo: { historyEntryId, entries: 1, canUndoDirect: true } } : null);
|
|
89
|
+
if (!args.receiptId && !previous) return validateReceipt({ ok: true, status: 'noop', action: id,
|
|
90
|
+
commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host, authored: false, mutated: false,
|
|
91
|
+
revision: { before: before.revision, after: before.revision }, affectedIds: [], delta: [],
|
|
92
|
+
checks: { coverage: 'native-history-restoration' }, undo: null, warnings: [] });
|
|
93
|
+
if (!previous?.undo || !ports.isRetained(previous)) fail('UNDO_EXPIRED', 'The receipt no longer has a retained history entry.');
|
|
94
|
+
if (!same(previous.host, before.host)) fail('STALE_SCENE', 'The receipt belongs to another document.');
|
|
95
|
+
if (redo ? historyEntryId !== previous.undo.historyEntryId : !ports.canUndo(previous)) fail('UNDO_CONFLICT', 'Another edit owns the history boundary.');
|
|
96
|
+
(redo ? ports.redo : ports.undo)();
|
|
97
|
+
const after = ports.read(), ids = previous.affectedIds;
|
|
98
|
+
const restoredTargets = ids.map(targetId => ({ ...before.host, targetId, token: ports.readTarget?.(targetId) ?? `removed-${crypto.randomUUID()}` }));
|
|
99
|
+
return validateReceipt({ ok: true, status: redo ? 'applied' : 'undone', commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
|
|
100
|
+
authored: true, revision: { before: before.revision, after: after.revision }, affectedIds: ids,
|
|
101
|
+
delta: ids.slice(0, 8).map(targetId => ({ id: targetId, after: { token: restoredTargets.find(t => t.targetId === targetId).token } })),
|
|
102
|
+
checks: { coverage: 'native-history-restoration' }, undo: { ...previous.undo, canUndoDirect: redo }, warnings: [],
|
|
103
|
+
...(redo ? { action: id, mutated: true } : { undoneReceiptId: previous.receiptId, restoredTargets }),
|
|
104
|
+
...(ids.length > 8 ? { detailCursor: request.commandId } : {}) });
|
|
105
|
+
}
|
|
106
|
+
if (id.startsWith('job.')) {
|
|
107
|
+
const job = jobs.get(args.jobId);
|
|
108
|
+
if (!job || !same(job.host, before.host)) fail('STALE_TARGET', 'Job is not in this document.');
|
|
109
|
+
if (id === 'job.cancel' && !job.outcome) job.controller.abort(new StudioProtocolError('CANCELLED', 'Job was cancelled.'));
|
|
110
|
+
let timer;
|
|
111
|
+
const wait = id === 'job.await' && !job.outcome ? Promise.race([job.completion, new Promise((_, reject) => {
|
|
112
|
+
timer = (ports.setTimeout ?? setTimeout)(() => reject(new StudioProtocolError('TIMEOUT', 'Job is still running.')), args.timeoutMs);
|
|
113
|
+
})]) : job.completion;
|
|
114
|
+
return wait.then(value => validateReceipt({ ...value, commandId: request.commandId, ...(value.ok ? { receiptId: crypto.randomUUID() } : {}), jobId: job.id })).finally(() => { if (timer !== undefined) clear(timer); });
|
|
115
|
+
}
|
|
116
|
+
let tx;
|
|
117
|
+
if (id === 'run.begin') {
|
|
118
|
+
if (transactions.size) fail('TARGET_BUSY', 'Finish or cancel the open command transaction first.');
|
|
119
|
+
const prepared = registry.prepare(args.id, args.args);
|
|
120
|
+
exposure(prepared.entry, prepared.args, request);
|
|
121
|
+
if (prepared.entry.kind !== 'mutation') fail('INVALID_ARGUMENT', 'Only mutations can open a transaction.');
|
|
122
|
+
tx = { txId: crypto.randomUUID(), entry: prepared.entry, before, origin: request.origin, targetId: prepared.args.characterId ?? null, controller: new AbortController(),
|
|
123
|
+
session: ports.beginAction(prepared.entry.undoDomain, prepared.args.characterId ?? null), affectedIds: new Set(), toasts: [] };
|
|
124
|
+
transactions.set(tx.txId, tx); renew(tx);
|
|
125
|
+
} else {
|
|
126
|
+
tx = transactions.get(args.txId);
|
|
127
|
+
if (!tx || !same(tx.before.host, before.host)) fail('STALE_TARGET', 'Transaction is no longer open in this document.');
|
|
128
|
+
if (request.origin !== tx.origin) fail('TARGET_BUSY', 'This transaction belongs to another origin.');
|
|
129
|
+
if (tx.updating && id !== 'run.cancel') fail('TARGET_BUSY', 'A transaction update is still running.');
|
|
130
|
+
if (id === 'run.update') {
|
|
131
|
+
const prepared = registry.prepare(tx.entry.id, args.args);
|
|
132
|
+
if (tx.entry.undoDomain === 'motion' && prepared.args.characterId !== tx.targetId) fail('INVALID_ARGUMENT', 'A motion transaction keeps its original target.');
|
|
133
|
+
const shown = [], release = ports.captureToasts?.(toast => shown.push(typeof toast === 'string' ? { message: toast } : toast));
|
|
134
|
+
const context = { origin: request.origin, signal: tx.controller.signal, check() {
|
|
135
|
+
tx.controller.signal.throwIfAborted();
|
|
136
|
+
if (!transactions.has(tx.txId) || !same(tx.before.host, ports.read().host)) fail('STALE_TARGET', 'Transaction is no longer current.');
|
|
137
|
+
} };
|
|
138
|
+
const finish = result => {
|
|
139
|
+
release?.(); clear(tx.workTimer); tx.updating = false; context.check();
|
|
140
|
+
if (shown.length && ports.read().revision === before.revision) fail('TARGET_NOT_READY', shown.at(-1).message);
|
|
141
|
+
for (const target of result.affectedIds) tx.affectedIds.add(target);
|
|
142
|
+
tx.toasts.push(...shown);
|
|
143
|
+
renew(tx); return transactionReceipt(id, tx, request, before);
|
|
144
|
+
};
|
|
145
|
+
const rejected = error => {
|
|
146
|
+
release?.(); clear(tx.workTimer); tx.updating = false;
|
|
147
|
+
if (!(error instanceof StudioProtocolError) && shown.length && ports.read().revision === before.revision) error = new StudioProtocolError('TARGET_NOT_READY', shown.at(-1).message);
|
|
148
|
+
if (transactions.has(tx.txId)) cancelTransaction(tx);
|
|
149
|
+
throw error;
|
|
150
|
+
};
|
|
151
|
+
try {
|
|
152
|
+
tx.updating = true; clear(tx.timer);
|
|
153
|
+
tx.workTimer = (ports.setTimeout ?? setTimeout)(() => cancelTransaction(tx, true, new StudioProtocolError('TIMEOUT', `${tx.entry.id} exceeded its deadline.`)), tx.entry.timeoutMs ?? 30_000);
|
|
154
|
+
const updated = tx.session.run(() => registry.invoke(tx.entry, prepared.args, context));
|
|
155
|
+
if (!updated?.then) return finish(updated);
|
|
156
|
+
const cancelled = new Promise((_, reject) => tx.controller.signal.addEventListener('abort', () => reject(tx.controller.signal.reason), { once: true }));
|
|
157
|
+
return Promise.race([updated, cancelled]).then(finish, rejected);
|
|
158
|
+
} catch (error) { return rejected(error); }
|
|
159
|
+
}
|
|
160
|
+
if (id === 'run.cancel') cancelTransaction(tx);
|
|
161
|
+
if (id === 'run.commit') {
|
|
162
|
+
transactions.delete(tx.txId); clear(tx.timer);
|
|
163
|
+
try { tx.historyEntryId = tx.session.commit().historyEntryId; }
|
|
164
|
+
catch (error) { tx.session.cancel({ restore: false }); throw error; }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return transactionReceipt(id, tx, request, id === 'run.commit' ? tx.before : before);
|
|
168
|
+
}
|
|
169
|
+
function transactionReceipt(id, tx, request, before) {
|
|
170
|
+
const after = ports.read();
|
|
171
|
+
return validateReceipt({ ok: true, status: 'completed', kind: 'transaction', commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
|
|
172
|
+
action: id, txId: tx.txId, authored: Boolean(tx.historyEntryId), revision: { before: before.revision, after: after.revision },
|
|
173
|
+
affectedIds: [...tx.affectedIds], delta: [], checks: { coverage: 'wire-transaction' }, warnings: toastWarnings(tx.toasts),
|
|
174
|
+
undo: tx.historyEntryId ? { historyEntryId: tx.historyEntryId, entries: 1, canUndoDirect: true } : null });
|
|
175
|
+
}
|
|
176
|
+
function refusal(request, before, error, mutated) {
|
|
177
|
+
const changed = mutated ?? (ports.read().revision !== before.revision);
|
|
178
|
+
return validateReceipt({ ok: false, commandId: request.commandId, host: before.host,
|
|
179
|
+
code: error.code ?? 'INVALID_ARGUMENT', phase: changed ? 'commit' : 'admission', affectedIds: [], expectedTargets: [], currentTargets: [],
|
|
180
|
+
mutated: changed, preserved: { authoredState: changed ? 'changed' : 'unchanged' }, recovery: { action: 'inspect', retryAllowed: false },
|
|
181
|
+
message: [...String(error.message || error)].slice(0, 500).join('') });
|
|
182
|
+
}
|
|
183
|
+
function receipt(entry, request, before, result, historyEntryId, toasts = []) {
|
|
184
|
+
const after = ports.read(), changed = after.revision !== before.revision;
|
|
185
|
+
if (entry.kind === 'mutation' && changed && !historyEntryId) fail('UNCERTAIN_APPLY', `${entry.id} changed the scene without one undoable entry.`);
|
|
186
|
+
const completed = entry.kind === 'job' || entry.kind === 'document' || (entry.kind === 'mutation' && changed && after.revision > before.revision + 1);
|
|
187
|
+
const ids = completed || changed ? result.affectedIds : entry.kind === 'transient' ? [before.host.sceneId] : [];
|
|
188
|
+
return validateReceipt({ ok: true, commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
|
|
189
|
+
action: entry.id, summary: result.summary, status: completed ? 'completed' : entry.kind === 'transient' ? 'transient' : changed ? 'applied' : 'noop',
|
|
190
|
+
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
|
+
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
|
+
checks: { coverage: `studio-action:${entry.id}` }, warnings: toastWarnings(toasts),
|
|
194
|
+
undo: historyEntryId ? { historyEntryId, entries: 1, canUndoDirect: true } : null, ...(ids.length > 8 ? { detailCursor: request.commandId } : {}) });
|
|
195
|
+
}
|
|
196
|
+
function executeRun(id, args = {}, options = {}) {
|
|
197
|
+
const request = { origin: 'ui', commandId: crypto.randomUUID(), ...options };
|
|
198
|
+
if (request.origin === 'ui' && (id === 'edit.undo' || id === 'edit.redo')) ports.finishHistoryGesture?.();
|
|
199
|
+
const before = ports.read(), journal = ports.journal();
|
|
200
|
+
let begun = false, releaseToasts, timer, foregroundTimer, job, applied = false, committedHistoryId;
|
|
201
|
+
const controller = new AbortController();
|
|
202
|
+
const clearTimer = () => {
|
|
203
|
+
if (timer !== undefined) clear(timer);
|
|
204
|
+
if (foregroundTimer !== undefined) clear(foregroundTimer);
|
|
205
|
+
};
|
|
206
|
+
const toasts = [];
|
|
207
|
+
const toastRefusal = () => toasts.length && ports.read().revision === before.revision ? new StudioProtocolError('TARGET_NOT_READY', toasts.at(-1).message) : null;
|
|
208
|
+
const remember = value => {
|
|
209
|
+
if (job?.background) {
|
|
210
|
+
const commandId = job.completionCommandId ??= crypto.randomUUID();
|
|
211
|
+
journal.begin(commandId);
|
|
212
|
+
value = validateReceipt({ ...value, commandId, jobId: job.id });
|
|
213
|
+
}
|
|
214
|
+
const recorded = journal.record(value);
|
|
215
|
+
if (recorded.undo?.canUndoDirect && !id.startsWith('edit.')) historyReceipts.set(recorded.undo.historyEntryId, recorded);
|
|
216
|
+
for (const [key, receipt] of historyReceipts) if (ports.isRetained?.(receipt) === false) historyReceipts.delete(key);
|
|
217
|
+
ports.remember?.(recorded); return recorded;
|
|
218
|
+
};
|
|
219
|
+
const rejected = error => {
|
|
220
|
+
clearTimer();
|
|
221
|
+
releaseToasts?.(); releaseToasts = null;
|
|
222
|
+
if (!(error instanceof StudioProtocolError)) error = toastRefusal() ?? error;
|
|
223
|
+
if (request.origin === 'ui' && error.uiMessage) ports.showRefusal?.(error.uiMessage);
|
|
224
|
+
const value = refusal(request, before, error, job ? applied : undefined);
|
|
225
|
+
const recorded = begun ? remember(value) : value;
|
|
226
|
+
if (job && !job.completion) { job.outcome = recorded; job.completion = Promise.resolve(recorded); emit({ type: 'job.completed', jobId: job.id, receipt: recorded }); }
|
|
227
|
+
return recorded;
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
if (request.origin !== 'ui' && !same(validateStudioIdentity(request.host), before.host)) fail('STALE_SCENE', 'The live document changed.');
|
|
231
|
+
const signature = JSON.stringify({ id, args, ...request });
|
|
232
|
+
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
|
+
begun = true;
|
|
234
|
+
const prepared = controls[id] ? { args: validateStudioSchema(controls[id], args) } : registry.prepare(id, args);
|
|
235
|
+
const { entry, args: validated } = prepared;
|
|
236
|
+
if (request.origin !== 'ui') {
|
|
237
|
+
// Job controls observe/cancel an admitted identity; completion itself
|
|
238
|
+
// can advance the revision while their wire request is in transit.
|
|
239
|
+
// 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.');
|
|
242
|
+
}
|
|
243
|
+
if (controls[id]) return mapResult(control(id, validated, request, before), remember, rejected);
|
|
244
|
+
if (transactions.size) fail('TARGET_BUSY', 'Finish or cancel the open command transaction first.');
|
|
245
|
+
exposure(entry, validated, request);
|
|
246
|
+
releaseToasts = ports.captureToasts?.(toast => toasts.push(typeof toast === 'string' ? { message: toast } : toast));
|
|
247
|
+
const domain = entry.domain ?? entry.undoDomain;
|
|
248
|
+
const targetId = entry.target?.(validated, before) ?? validated.characterId ?? validated.shotId ?? validated.objectId;
|
|
249
|
+
let token = targetId ? ports.readTarget?.(targetId) : null;
|
|
250
|
+
let domainRevision = domain ? before.domainRevisions?.[domain] : null;
|
|
251
|
+
const rebase = () => {
|
|
252
|
+
const current = ports.read();
|
|
253
|
+
token = targetId ? ports.readTarget?.(targetId) : null;
|
|
254
|
+
domainRevision = domain ? current.domainRevisions?.[domain] : null;
|
|
255
|
+
};
|
|
256
|
+
const nestedIds = new Set();
|
|
257
|
+
const context = { origin: request.origin, signal: controller.signal,
|
|
258
|
+
check() {
|
|
259
|
+
controller.signal.throwIfAborted();
|
|
260
|
+
const current = ports.read();
|
|
261
|
+
if (!same(current.host, before.host) || (domain && current.domainRevisions?.[domain] !== domainRevision) || (targetId && ports.readTarget?.(targetId) !== token)) fail('STALE_TARGET', 'The target or its authored domain changed while the job was running.');
|
|
262
|
+
},
|
|
263
|
+
commit(apply) {
|
|
264
|
+
context.check();
|
|
265
|
+
if (!domain) fail('INVALID_ARGUMENT', 'A committing job must declare its domain.');
|
|
266
|
+
const recorded = ports.recordAction(domain, apply, targetId ?? null);
|
|
267
|
+
if (recorded?.then) fail('INVALID_ARGUMENT', 'Job publication must be synchronous; prepare before commit.');
|
|
268
|
+
committedHistoryId = recorded.historyEntryId; applied ||= Boolean(committedHistoryId);
|
|
269
|
+
if (job) rebase();
|
|
270
|
+
return recorded.result;
|
|
271
|
+
},
|
|
272
|
+
run(nestedId, nestedArgs = {}) {
|
|
273
|
+
if (job) context.check(); else controller.signal.throwIfAborted();
|
|
274
|
+
const nested = registry.prepare(nestedId, nestedArgs); exposure(nested.entry, nested.args, request);
|
|
275
|
+
const invoke = () => registry.invoke(nested.entry, nested.args, context);
|
|
276
|
+
const value = nested.entry.kind === 'mutation' ? ports.recordAction(nested.entry.undoDomain, invoke, nested.args.characterId ?? null, true) : { result: invoke() };
|
|
277
|
+
// Only the synchronous owned step may advance the fence. Never absorb
|
|
278
|
+
// an external edit while an asynchronous nested step is suspended;
|
|
279
|
+
// its later publication must itself use context.run/commit.
|
|
280
|
+
if (job) rebase();
|
|
281
|
+
return mapResult(value, recorded => mapResult(recorded.result, result => {
|
|
282
|
+
if (job) { context.check(); rebase(); applied ||= Boolean(recorded.historyEntryId); }
|
|
283
|
+
for (const target of result.affectedIds) nestedIds.add(target);
|
|
284
|
+
return result;
|
|
285
|
+
}));
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
if (entry.kind === 'job') { job = { id: crypto.randomUUID(), host: before.host, controller, background: false }; jobs.set(job.id, job); }
|
|
289
|
+
timer = (ports.setTimeout ?? setTimeout)(() => {
|
|
290
|
+
const error = new StudioProtocolError('TIMEOUT', `${entry.id} exceeded its deadline.`);
|
|
291
|
+
controller.abort(error);
|
|
292
|
+
}, job && entry.background === true ? 300_000 : entry.timeoutMs ?? 30_000);
|
|
293
|
+
// The race observes expiry even if a backend ignores cancellation.
|
|
294
|
+
const invoke = () => {
|
|
295
|
+
const value = registry.invoke(entry, validated, context);
|
|
296
|
+
if (!value?.then) return value;
|
|
297
|
+
const deadline = new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true }));
|
|
298
|
+
return Promise.race([value, deadline]);
|
|
299
|
+
};
|
|
300
|
+
const value = entry.kind === 'mutation' ? ports.recordAction(entry.undoDomain, invoke, validated.characterId ?? null) : { result: invoke(), historyEntryId: null };
|
|
301
|
+
const finish = ({ result, historyEntryId }) => mapResult(result, output => {
|
|
302
|
+
clearTimer();
|
|
303
|
+
releaseToasts?.(); releaseToasts = null;
|
|
304
|
+
const refused = toastRefusal();
|
|
305
|
+
if (refused) throw refused;
|
|
306
|
+
return remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts));
|
|
307
|
+
});
|
|
308
|
+
const finished = value?.then ? value.then(finish) : finish(value);
|
|
309
|
+
const answer = finished?.then ? finished.catch(rejected) : finished;
|
|
310
|
+
if (job) {
|
|
311
|
+
job.completion = Promise.resolve(answer).then(outcome => { job.outcome = outcome; emit({ type: 'job.completed', jobId: job.id, receipt: outcome }); return outcome; });
|
|
312
|
+
const detach = () => {
|
|
313
|
+
job.background = true;
|
|
314
|
+
const current = ports.read();
|
|
315
|
+
return journal.record(validateReceipt({ ok: true, commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
|
|
316
|
+
action: entry.id, status: 'started', kind: 'job', jobId: job.id, authored: false, revision: { before: current.revision, after: current.revision },
|
|
317
|
+
affectedIds: [], delta: [], checks: { coverage: `studio-action:${entry.id}` }, warnings: [], undo: null }));
|
|
318
|
+
};
|
|
319
|
+
if (answer?.then && request.wait === false) return detach();
|
|
320
|
+
if (answer?.then && entry.background === true) {
|
|
321
|
+
const foreground = Promise.race([job.completion, new Promise(resolve => {
|
|
322
|
+
foregroundTimer = (ports.setTimeout ?? setTimeout)(() => resolve(detach()), entry.timeoutMs ?? 30_000);
|
|
323
|
+
})]).finally(() => pending.delete(request.commandId));
|
|
324
|
+
pending.set(request.commandId, foreground);
|
|
325
|
+
return foreground;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (answer?.then) { const settled = (job?.completion ?? answer).finally(() => pending.delete(request.commandId)); pending.set(request.commandId, settled); return settled; }
|
|
329
|
+
return answer;
|
|
330
|
+
} catch (error) { return rejected(error); }
|
|
331
|
+
}
|
|
332
|
+
function run(...args) {
|
|
333
|
+
activeRunDepth++;
|
|
334
|
+
try {
|
|
335
|
+
const value = executeRun(...args);
|
|
336
|
+
if (value?.then) return value.finally(() => { activeRunDepth--; });
|
|
337
|
+
activeRunDepth--;
|
|
338
|
+
return value;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
activeRunDepth--;
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { run,
|
|
345
|
+
// Trusted UI adapter only: wire callers can consume, never mint a token.
|
|
346
|
+
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
|
+
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(); } };
|
|
349
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// AI commands: the Send-to-AI package, the editor's own generate(), which
|
|
2
|
+
// reads the mode and image model from the render. A changed choice is set
|
|
3
|
+
// first and generate() runs once React has rendered it; a commit already under
|
|
4
|
+
// way can predate it, so the wait repeats until the render shows the choice.
|
|
5
|
+
import { studioActionDeclaration } from "../studio-actions.js";
|
|
6
|
+
import { fail } from "./shared.js";
|
|
7
|
+
|
|
8
|
+
export const declarations = Object.freeze(["ai.prepareShot"].map(studioActionDeclaration));
|
|
9
|
+
|
|
10
|
+
export function register(registry, ports) {
|
|
11
|
+
registry.register({ ...studioActionDeclaration("ai.prepareShot"), available: () => true,
|
|
12
|
+
run: async ({ mode, model }, context) => {
|
|
13
|
+
const current = ports.state().aiShot, wanted = { mode: mode ?? current.mode, imageModel: model ?? current.imageModel };
|
|
14
|
+
if (model && wanted.mode !== "image") fail("INVALID_ARGUMENT", `model picks an image model, but this would be a ${wanted.mode} prompt; omit model or pass mode "image".`);
|
|
15
|
+
const rendered = () => { const { aiShot } = ports.state(); return aiShot.mode === wanted.mode && aiShot.imageModel === wanted.imageModel; };
|
|
16
|
+
if (!rendered()) {
|
|
17
|
+
ports.setAiShotMode(wanted.mode);
|
|
18
|
+
ports.setAiImageModel(wanted.imageModel);
|
|
19
|
+
for (let commits = 0; !rendered(); commits++) {
|
|
20
|
+
if (commits === 3) fail("TARGET_NOT_READY", "The Studio did not render the new mode and model; run the action again.");
|
|
21
|
+
await ports.afterRender();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
context?.check();
|
|
25
|
+
const result = ports.generate();
|
|
26
|
+
const shot = result.shot && { id: result.shot.id, name: result.shot.name, range: { startFrame: result.shot.startFrame, endFrameExclusive: result.shot.endFrame + 1 } };
|
|
27
|
+
const referenceFrames = (result.frame ? 1 : 0) + (result.frameB ? 1 : 0);
|
|
28
|
+
return { affectedIds: [], output: { prompt: result.prompt, mode: result.mode, modelLabel: result.modelLabel ?? null, shot, aspectRatio: result.aspectRatio, cameraMode: result.camera?.mode ?? null, referenceFrames },
|
|
29
|
+
summary: `Prepared the ${result.mode} prompt${result.modelLabel ? ` for ${result.modelLabel}` : ""} for ${shot ? `${shot.name} [${shot.range.startFrame}, ${shot.range.endFrameExclusive})` : "the current camera"}; the Studio's result panel shows it with ${referenceFrames} reference frame${referenceFrames === 1 ? "" : "s"} for the user to copy and download.` };
|
|
30
|
+
} });
|
|
31
|
+
}
|