cozyclay 1.9.0 → 2.0.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 +171 -0
- package/bin/agent/agent-routes.mjs +74 -70
- 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/cozyclay.mjs +26 -8
- package/bin/live/cli.mjs +22 -3
- package/bin/mcp-runtime.mjs +35 -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-C1sdVbg4.css +1 -0
- package/dist/assets/app-CA55tcI3.js +4876 -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-Yrt99wZx.js +17 -0
- package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
- package/dist/assets/workflow-DRdwoDBT.css +1 -0
- package/dist/assets/workflow-dNPGY9xz.js +179 -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 +6 -6
- package/dist/workflow/index.html +5 -5
- package/mcp/LIVE-PROTOCOL.md +1 -1
- 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 +224 -377
- package/package.json +2 -1
- package/src/App.jsx +1273 -9075
- 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/collision-blockers.js +9 -3
- package/src/ardy/fix-collisions.js +10 -66
- package/src/ardy/ground.js +4 -4
- package/src/ardy/ik-drag.js +101 -0
- package/src/ardy/ik-key-json.js +43 -0
- package/src/ardy/ik.js +122 -76
- 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 +1199 -0
- package/src/ardy/timeline.jsx +149 -41
- package/src/ardy/waypoints.js +2 -2
- package/src/asset-pane.css +462 -0
- package/src/asset-pane.jsx +461 -232
- 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 +817 -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-model.js +24 -16
- package/src/hierarchy-panel.css +386 -0
- package/src/hierarchy-panel.jsx +150 -21
- 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/otio.js +2 -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 +284 -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/scene-objects.js +37 -1
- package/src/scenes.js +5 -0
- 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 +41 -271
- package/src/studio-agent-context.js +39 -10
- package/src/studio-agent-motion.js +122 -373
- package/src/studio-agent-protocol.js +67 -31
- 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 +460 -1742
- package/src/theme.js +44 -0
- package/src/timeline-extent.js +16 -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 +82 -69
- 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/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -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/morphgs/assets/gen_truth.py +39 -0
- package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
- package/tools/morphgs/assets/playback-check.mjs +30 -0
- package/tools/morphgs/demo-gate.sh +26 -0
- package/tools/morphgs/fbx2morphgs.mjs +68 -0
- package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
- package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
- package/tools/morphgs/setup-on-cluster.sh +126 -0
- package/tools/qa/css-rule-usage.mjs +283 -0
- package/tools/qa/studio-control-count.mjs +70 -32
- package/tools/run-tests.mjs +134 -12
- 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-BWusbqgO.js +0 -4861
- package/dist/assets/app-qKDo4PBX.css +0 -1
- package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
- package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
- package/dist/assets/workflow-CVwzMhz2.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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Cast commands: a named character's root waypoints and IK key layer. They
|
|
2
|
+
// name their character explicitly, so they run the same way whichever
|
|
3
|
+
// character is active and whatever mode the editor is in.
|
|
4
|
+
import { studioActionDeclaration } from "../studio-actions.js";
|
|
5
|
+
import { characterOf, fail, changedIds } from "./shared.js";
|
|
6
|
+
import { elementSetSchema, registerElementSet } from './elements.js';
|
|
7
|
+
import './elements/character.js';
|
|
8
|
+
import { createCharacterEntry } from '../scenes.js';
|
|
9
|
+
import { DEFAULT_POSE } from '../poses.js';
|
|
10
|
+
import { createStableItemId, updateStableItem, removeStableItem } from '../stable-items.js';
|
|
11
|
+
import { movePromptClipFrames } from '../ardy/prompt-clips.js';
|
|
12
|
+
import { STUDIO_TOOL_SCHEMAS, StudioSchemas } from '../studio-agent-protocol.js';
|
|
13
|
+
|
|
14
|
+
const id = StudioSchemas.TargetGuard.properties.targetId;
|
|
15
|
+
const input = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
16
|
+
const record = { type: 'object', properties: {}, additionalProperties: true };
|
|
17
|
+
const mutation = (id, label, schema, exposure = 'open') => ({ id, label, description: label, kind: 'mutation', undoDomain: 'cast', input: schema, exposure });
|
|
18
|
+
const blocks = elementSetSchema('character').properties.set.properties.layer.properties.promptClips;
|
|
19
|
+
const semantic = [
|
|
20
|
+
mutation('character.set', 'Set character fields', elementSetSchema('character')),
|
|
21
|
+
mutation('character.add', 'Add character', input({ character: record })),
|
|
22
|
+
mutation('character.remove', 'Remove character', input({ characterId: id })),
|
|
23
|
+
mutation('character.update', 'Update character', input({ characterId: id, patch: record })),
|
|
24
|
+
mutation('character.setPose', 'Set character pose', input({ characterId: id, pose: { oneOf: [id, record, { type: 'null' }] }, clearMotion: { type: 'boolean' } }, ['characterId', 'pose'])),
|
|
25
|
+
mutation('character.setPromptBlocks', 'Set prompt blocks', input({ characterId: id, blocks })),
|
|
26
|
+
mutation('characters.arrange', 'Arrange characters', STUDIO_TOOL_SCHEMAS.arrange_characters),
|
|
27
|
+
mutation('cast.replace', 'Replace cast', input({ characters: { type: 'array', items: record, minItems: 1 } }), 'ui-only'),
|
|
28
|
+
mutation('cast.setCustomPoses', 'Set pose library', input({ poses: { type: 'array', items: record } }), 'ui-only'),
|
|
29
|
+
mutation('cast.savePose', 'Save pose', input({ pose: record, characterId: id, clearMotion: { type: 'boolean' } }, ['pose']), 'ui-only'),
|
|
30
|
+
mutation('cast.removePose', 'Remove saved pose', input({ id }), 'ui-only'),
|
|
31
|
+
mutation('cast.showExtras', 'Show extra subjects', input({ show: { type: 'boolean' } }), 'ui-only'),
|
|
32
|
+
mutation('cast.setLayer', 'Set character layer', input({ characterId: id, layer: record }), 'ui-only'),
|
|
33
|
+
...['add', 'move', 'resize', 'change', 'remove'].map(verb => mutation(`character.${verb}PromptBlock`, `${verb} prompt block`,
|
|
34
|
+
input({ characterId: id, id, frame: { type: 'number' }, edge: { type: 'string', enum: ['start', 'end'] }, text: { type: 'string', maxLength: 2000 } },
|
|
35
|
+
verb === 'add' ? ['characterId', 'frame'] : verb === 'remove' ? ['characterId', 'id'] : verb === 'change' ? ['characterId', 'id', 'text'] : verb === 'resize' ? ['characterId', 'id', 'edge', 'frame'] : ['characterId', 'id', 'frame']), 'ui-only')),
|
|
36
|
+
];
|
|
37
|
+
export const declarations = Object.freeze([...semantic, ...["character.addWaypoint", "character.moveWaypoint", "character.removeWaypoint", "character.clearWaypoints"].map(studioActionDeclaration)]);
|
|
38
|
+
|
|
39
|
+
export function register(registry, ports) {
|
|
40
|
+
const owner = () => ports.storeDomain('cast');
|
|
41
|
+
const mounted = () => Boolean(ports.storeDomain?.('cast')) || 'The cast document owner is not mounted.';
|
|
42
|
+
const character = characterId => owner().read().find(row => row.id === characterId) ?? fail('STALE_TARGET', `Character ${characterId} is not in this scene.`);
|
|
43
|
+
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().write(rows => rows.map(row => row.id === characterId ? { ...row, ...update } : row)); };
|
|
45
|
+
function promptEdit(command, args) {
|
|
46
|
+
const before = character(args.characterId), clips = before.layer.promptClips;
|
|
47
|
+
let next;
|
|
48
|
+
if (command === 'character.addPromptBlock') {
|
|
49
|
+
const snapped = Math.max(0, Math.round(args.frame / 48) * 48);
|
|
50
|
+
const blocked = clips.some(clip => snapped < clip.endFrame && snapped + 48 > clip.startFrame);
|
|
51
|
+
const startFrame = blocked ? Math.max(snapped, ...clips.map(clip => clip.endFrame)) : snapped;
|
|
52
|
+
next = [...clips, { id: createStableItemId('prompt-clip'), startFrame, endFrame: startFrame + 48, text: '' }];
|
|
53
|
+
} else if (command === 'character.removePromptBlock') next = removeStableItem(clips, args.id, 'promptClips');
|
|
54
|
+
else if (command === 'character.changePromptBlock') next = updateStableItem(clips, args.id, clip => ({ ...clip, text: args.text }), 'promptClips');
|
|
55
|
+
else if (command === 'character.movePromptBlock') next = movePromptClipFrames(clips, args.id, args.frame, 48);
|
|
56
|
+
else {
|
|
57
|
+
next = updateStableItem(clips, args.id, clip => {
|
|
58
|
+
const snapped = Math.max(0, Math.round(args.frame / 48) * 48);
|
|
59
|
+
return args.edge === 'start' ? { ...clip, startFrame: Math.min(Math.max(snapped, clip.endFrame - 120), clip.endFrame - 48) }
|
|
60
|
+
: { ...clip, endFrame: Math.min(Math.max(clip.startFrame + 48, snapped), clip.startFrame + 120) };
|
|
61
|
+
}, 'promptClips');
|
|
62
|
+
const resized = next.find(clip => clip.id === args.id);
|
|
63
|
+
if (next.some(clip => clip.id !== args.id && resized.startFrame < clip.endFrame && resized.endFrame > clip.startFrame)) return;
|
|
64
|
+
}
|
|
65
|
+
patch(args.characterId, { layer: { ...before.layer, promptClips: next } });
|
|
66
|
+
owner().extendTimeline(next.reduce((end, clip) => Math.max(end, clip.endFrame), 24));
|
|
67
|
+
}
|
|
68
|
+
const methods = {
|
|
69
|
+
'character.add': ({ character: value }) => {
|
|
70
|
+
const row = createCharacterEntry({ ...value, id: value.id ?? createStableItemId('character') }, owner().read().length);
|
|
71
|
+
if (owner().read().some(entry => entry.id === row.id)) fail('INVALID_ARGUMENT', 'Character id already exists.');
|
|
72
|
+
owner().write(rows => [...rows, row]);
|
|
73
|
+
},
|
|
74
|
+
'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 }) => patch(characterId, value),
|
|
76
|
+
'character.setPose': ({ characterId, pose: value, clearMotion }) => { character(characterId); owner().applyPose(characterId, pose(value), clearMotion); },
|
|
77
|
+
'character.setPromptBlocks': ({ characterId, blocks }) => {
|
|
78
|
+
const entry = character(characterId); patch(characterId, { layer: { ...entry.layer, promptClips: blocks } });
|
|
79
|
+
},
|
|
80
|
+
'cast.replace': ({ characters }) => owner().write(characters),
|
|
81
|
+
'cast.setCustomPoses': ({ poses }) => owner().writeState(before => ({ ...before, customPoses: poses })),
|
|
82
|
+
'cast.savePose': ({ pose, characterId, clearMotion }) => {
|
|
83
|
+
if (characterId) character(characterId);
|
|
84
|
+
owner().writeState(before => ({ ...before, customPoses: [...before.customPoses.filter(entry => entry.id !== pose.id), pose] }));
|
|
85
|
+
if (characterId) owner().applyPose(characterId, pose, clearMotion);
|
|
86
|
+
},
|
|
87
|
+
'cast.removePose': ({ id }) => owner().writeState(before => ({
|
|
88
|
+
characters: before.characters.map(entry => entry.pose?.id === id ? { ...entry, pose: DEFAULT_POSE } : entry),
|
|
89
|
+
customPoses: before.customPoses.filter(entry => entry.id !== id),
|
|
90
|
+
})),
|
|
91
|
+
|
|
92
|
+
'cast.showExtras': ({ show }) => owner().showExtras(show),
|
|
93
|
+
'cast.setLayer': ({ characterId, layer }) => patch(characterId, { layer: { ...character(characterId).layer, ...layer } }),
|
|
94
|
+
};
|
|
95
|
+
registerElementSet({ register(entry) {
|
|
96
|
+
registry.register({ ...entry, available: mounted, run(args) {
|
|
97
|
+
// A pose id selects a complete library entry, not a deep bone patch.
|
|
98
|
+
const ops = args.ops ?? [args];
|
|
99
|
+
const poses = ops.filter(op => Object.hasOwn(op.set, 'pose')).map(op => ({ id: op.id, value: pose(op.set.pose) }));
|
|
100
|
+
const fields = ops.map(({ id, set: { pose: _pose, ...set } }) => ({ id, set }));
|
|
101
|
+
const result = entry.run(args.ops ? { ops: fields } : fields[0]);
|
|
102
|
+
for (const selected of poses) owner().applyPose(selected.id, selected.value, false);
|
|
103
|
+
return result;
|
|
104
|
+
} });
|
|
105
|
+
} }, 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.' }; }
|
|
108
|
+
const before = owner().read();
|
|
109
|
+
if (declaration.id.endsWith('PromptBlock')) promptEdit(declaration.id, args); else methods[declaration.id](args);
|
|
110
|
+
const changed = changedIds(before, owner().read());
|
|
111
|
+
return { affectedIds: changed.length ? changed : [args.characterId ?? ports.state().activeSceneId], summary: declaration.label };
|
|
112
|
+
} });
|
|
113
|
+
if (ports.storeDomain?.('cast')) registry.registerToolAlias('arrange_characters', 'characters.arrange');
|
|
114
|
+
const castAction = (id, run) => registry.register({ ...studioActionDeclaration(id),
|
|
115
|
+
available: state => state.characters.length > 0 || "There are no characters in this scene; add one with arrange_characters.",
|
|
116
|
+
run: args => {
|
|
117
|
+
const character = characterOf(ports, args.characterId);
|
|
118
|
+
return { affectedIds: [character.id], summary: run(args, character.subject || character.id) };
|
|
119
|
+
} });
|
|
120
|
+
const pin = waypoint => `frame ${waypoint.frame} (x ${waypoint.x}, z ${waypoint.z})`;
|
|
121
|
+
const warned = warnings => warnings.length ? `; warning: ${warnings[0]}` : "";
|
|
122
|
+
castAction("character.addWaypoint", ({ characterId, position, frame }, name) => {
|
|
123
|
+
const { waypoint, index, warnings } = ports.addCharacterWaypoint(characterId, position, frame ?? null);
|
|
124
|
+
ports.setWaypointMode(true);
|
|
125
|
+
return `Added ${name}'s root waypoint ${index + 1} at ${pin(waypoint)}${warned(warnings)}.`;
|
|
126
|
+
});
|
|
127
|
+
castAction("character.moveWaypoint", ({ characterId, frame, position }, name) => {
|
|
128
|
+
const { waypoint, warnings } = ports.moveCharacterWaypoint(characterId, frame, position);
|
|
129
|
+
ports.setWaypointMode(true);
|
|
130
|
+
return `Moved ${name}'s root waypoint to ${pin(waypoint)}${warned(warnings)}.`;
|
|
131
|
+
});
|
|
132
|
+
castAction("character.removeWaypoint", ({ characterId, frame }, name) => {
|
|
133
|
+
ports.removeCharacterWaypoint(characterId, frame);
|
|
134
|
+
return `Removed ${name}'s root waypoint at frame ${frame}.`;
|
|
135
|
+
});
|
|
136
|
+
castAction("character.clearWaypoints", ({ characterId }, name) => {
|
|
137
|
+
const count = ports.clearCharacterWaypoints(characterId);
|
|
138
|
+
return count ? `Cleared ${name}'s root path (${count} waypoint${count === 1 ? "" : "s"}).` : `${name} has no root waypoints; nothing changed.`;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createCharacterEntry } from '../../scenes.js';
|
|
2
|
+
import { STUDIO_ELEMENTS } from '../../studio-elements.js';
|
|
3
|
+
import { registerElementKind } from '../elements.js';
|
|
4
|
+
|
|
5
|
+
const elements = STUDIO_ELEMENTS.filter(row => row.path.startsWith('character.')).map(row => {
|
|
6
|
+
const { min, max, gizmo, ...element } = row;
|
|
7
|
+
if (row.path === 'character.position') return { ...element, documentPath: { x: 'x', y: 'y', z: 'z' } };
|
|
8
|
+
if (row.path === 'character.promptBlocks') return { ...element, documentPath: 'layer.promptClips' };
|
|
9
|
+
if (row.path === 'character.pose') return { ...element, readback: item => ({ text: item?.pose?.id ?? null }) };
|
|
10
|
+
return element;
|
|
11
|
+
});
|
|
12
|
+
registerElementKind('character', { collection: true, documentKey: 'characters', elements, normalize: createCharacterEntry });
|
|
13
|
+
registerElementKind('poseLibrary', { collection: true, documentKey: 'customPoses', elements: [], normalize: value => value });
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { registerElementKind } from '../elements.js';
|
|
2
|
+
import { wrapAngle } from '../../scene-objects.js';
|
|
3
|
+
|
|
4
|
+
const elements = [
|
|
5
|
+
...['anchorX', 'anchorZ', 'rotationDeg'].map(field => ({ path: `motion.take.${field}`, type: 'number' })),
|
|
6
|
+
{ path: 'motion.take.prompt', type: 'string' },
|
|
7
|
+
{ path: 'motion.ikKeys', type: 'array', readOnly: true },
|
|
8
|
+
{ path: 'motion.takeRecipe', type: 'object', readOnly: true },
|
|
9
|
+
{ path: 'motion.takeVersions', type: 'array', readOnly: true },
|
|
10
|
+
];
|
|
11
|
+
registerElementKind('motion', { collection: true, elements, normalize: row => ({ ...row, take: row.take && { ...row.take,
|
|
12
|
+
anchorX: Math.max(-240, Math.min(240, row.take.anchorX ?? 0)), anchorZ: Math.max(-240, Math.min(240, row.take.anchorZ ?? 0)),
|
|
13
|
+
rotationDeg: wrapAngle(row.take.rotationDeg ?? 0),
|
|
14
|
+
} }) });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { normalizeSceneObject } from '../../scene-objects.js';
|
|
2
|
+
import { STUDIO_ELEMENTS } from '../../studio-elements.js';
|
|
3
|
+
import { registerElementKind } from '../elements.js';
|
|
4
|
+
|
|
5
|
+
const components = {
|
|
6
|
+
position: { x: 'x', y: 'y', z: 'z' },
|
|
7
|
+
rotation: { x: 'rotX', y: 'rot', z: 'rotZ' },
|
|
8
|
+
scale: { x: 'scaleX', y: 'scaleY', z: 'scaleZ' },
|
|
9
|
+
};
|
|
10
|
+
const elements = STUDIO_ELEMENTS.filter(row => row.path.startsWith('object.')).map(row => {
|
|
11
|
+
// Wire validation checks finite numbers; persistence owns the transform clamps.
|
|
12
|
+
const { min, max, ...element } = row;
|
|
13
|
+
const field = row.path.slice(7);
|
|
14
|
+
return { ...element, ...(components[field] ? { documentPath: components[field] } : {}),
|
|
15
|
+
...(field === 'remove' ? { readback: item => ({ flag: item === undefined }) } : {}) };
|
|
16
|
+
});
|
|
17
|
+
export function normalizeObjectSet(record) {
|
|
18
|
+
const item = normalizeSceneObject(record);
|
|
19
|
+
// The owning collection consumes this lifecycle intent before persistence.
|
|
20
|
+
return record.remove === true ? { ...item, remove: true } : item;
|
|
21
|
+
}
|
|
22
|
+
registerElementKind('object', { collection: true, documentKey: 'objects', elements, normalize: normalizeObjectSet });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { registerElementKind } from '../elements.js';
|
|
2
|
+
|
|
3
|
+
// Array position is the authoritative order. The owner refreshes this persisted
|
|
4
|
+
// field after every move, so generic set/read and the project file agree.
|
|
5
|
+
registerElementKind('scene', {
|
|
6
|
+
collection: true, documentKey: 'scenes',
|
|
7
|
+
elements: [
|
|
8
|
+
{ path: 'scene.name', type: 'string' },
|
|
9
|
+
{ path: 'scene.order', type: 'number' },
|
|
10
|
+
],
|
|
11
|
+
normalize: scene => ({ ...scene, name: scene.name.trim(), order: Math.max(0, Math.round(scene.order)) }),
|
|
12
|
+
});
|
|
13
|
+
registerElementKind('project', {
|
|
14
|
+
elements: [{ path: 'project.name', type: 'string' }],
|
|
15
|
+
normalize: project => project,
|
|
16
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createShotAuthoringDocument } from '../../shot-authoring.js';
|
|
2
|
+
import { STUDIO_ELEMENTS } from '../../studio-elements.js';
|
|
3
|
+
import { registerElementKind } from '../elements.js';
|
|
4
|
+
|
|
5
|
+
export function normalizeShot(record) {
|
|
6
|
+
return createShotAuthoringDocument({ shots: [record], frameCount: Math.max(24, record.endFrame + 1) }).shots[0];
|
|
7
|
+
}
|
|
8
|
+
registerElementKind('shot', { collection: true, documentKey: 'shots',
|
|
9
|
+
elements: STUDIO_ELEMENTS.filter(row => row.path.startsWith('shot.')), normalize: normalizeShot });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createSceneStage } from '../../scenes.js';
|
|
2
|
+
import { STUDIO_ELEMENTS } from '../../studio-elements.js';
|
|
3
|
+
import { registerElementKind } from '../elements.js';
|
|
4
|
+
|
|
5
|
+
const elements = STUDIO_ELEMENTS.filter(row => row.path.startsWith('stage.'));
|
|
6
|
+
export const STAGE_FIELDS = [...new Set(elements.map(element => (element.documentPath ?? element.path.slice(6)).split('.')[0]))];
|
|
7
|
+
export function normalizeStage(source) {
|
|
8
|
+
const normalized = createSceneStage(source);
|
|
9
|
+
return Object.fromEntries(STAGE_FIELDS.map(key => [key, normalized[key]]));
|
|
10
|
+
}
|
|
11
|
+
registerElementKind('stage', { elements, normalize: normalizeStage });
|