cozyclay 3.0.2 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/app/index.html +4 -4
- package/dist/assets/analytics-BibyE5ar.js +6 -0
- package/dist/assets/{app-34i-iUy1.js → app-DhRJ-PBz.js} +40 -40
- package/dist/assets/compile-worker-DqiA4AaN.js +5 -0
- package/dist/assets/{first-shot-handoff-CrGZ3n_O.js → first-shot-handoff-Qx7SKl38.js} +1 -1
- package/dist/assets/{landing-mq5bnqFE.js → landing-DYg87MDY.js} +1 -1
- package/dist/assets/scene-asset-sync-DBYQBR0T.js +20 -0
- package/dist/assets/{workflow-dUhurYUc.js → workflow-BSA22Ofr.js} +1 -1
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +3 -3
- package/dist/workflow/index.html +3 -3
- package/package.json +1 -1
- package/src/bus-task-scopes.js +69 -0
- package/src/bus-task-targets.js +48 -0
- package/src/bus-tasks.js +175 -0
- package/src/command-bus.js +14 -39
- package/src/command-history.js +36 -0
- package/src/commands/cast.js +10 -3
- package/src/commands/motion.js +2 -1
- package/src/commands/motion3.js +10 -4
- package/src/commands/shared.js +9 -0
- package/src/motion3/compiler/anchors.js +33 -5
- package/src/motion3/compiler/compile.js +20 -3
- package/src/motion3/compiler/grip.js +9 -3
- package/src/motion3/compiler/socket-grip.js +34 -10
- package/src/motion3/docs.js +1 -1
- package/src/motion3/fix/index.js +5 -3
- package/src/motion3/pipeline.js +2 -0
- package/src/motion3/qa/spec-checks.js +12 -1
- package/src/motion3/schema/topics.js +2 -2
- package/src/motion3/schema/types.js +1 -0
- package/src/motion3/skills/basic.js +61 -13
- package/src/motion3/skills/index.js +4 -2
- package/src/motion3/skills/info.js +1 -1
- package/src/motion3/skills/reach-human.js +139 -0
- package/src/motion3/skills/stance.js +92 -0
- package/src/motion3/sockets.js +21 -4
- package/src/motion3/views.js +28 -5
- package/src/studio-app-binding.js +2 -2
- package/src/studio-error-codes.js +1 -1
- package/src/studio-motion-visual.js +4 -4
- package/tools/run-tests.mjs +1 -1
- package/dist/assets/analytics-DbKG0NgD.js +0 -6
- package/dist/assets/compile-worker-DYpa2nvO.js +0 -5
- package/dist/assets/scene-asset-sync-7J-w5oNh.js +0 -20
|
@@ -1 +1 @@
|
|
|
1
|
-
{"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.0.
|
|
1
|
+
{"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.0.3\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"b8a07fdd5d546c0314c6099680d3569a89a01e59bd3a442fb2360f1acec56330\"}","signature":"uZinWHO8tjMSZNdv8SjUSnLeHER+UvdGPW6ay++FCJiXOt8eIKjw0bYpN4HJpoNQG6/i9RBBeKMdrPBepn76Ag=="}
|
package/dist/index.html
CHANGED
|
@@ -301,9 +301,9 @@
|
|
|
301
301
|
.btn, a.card, .seg button, .tutorial-steps li { transition: none; }
|
|
302
302
|
}
|
|
303
303
|
</style>
|
|
304
|
-
<script type="module" crossorigin src="/assets/landing-
|
|
305
|
-
<link rel="modulepreload" crossorigin href="/assets/analytics-
|
|
306
|
-
<link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-
|
|
304
|
+
<script type="module" crossorigin src="/assets/landing-DYg87MDY.js"></script>
|
|
305
|
+
<link rel="modulepreload" crossorigin href="/assets/analytics-BibyE5ar.js">
|
|
306
|
+
<link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-Qx7SKl38.js">
|
|
307
307
|
</head>
|
|
308
308
|
<body>
|
|
309
309
|
<header class="site">
|
package/dist/workflow/index.html
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
<meta name="theme-color" content="#101116" />
|
|
9
9
|
<meta name="description" content="CozyClay visual workflow builder" />
|
|
10
10
|
<title>CozyClay Workflow</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/workflow-
|
|
11
|
+
<script type="module" crossorigin src="/assets/workflow-BSA22Ofr.js"></script>
|
|
12
12
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
|
13
|
-
<link rel="modulepreload" crossorigin href="/assets/analytics-
|
|
13
|
+
<link rel="modulepreload" crossorigin href="/assets/analytics-BibyE5ar.js">
|
|
14
14
|
<link rel="modulepreload" crossorigin href="/assets/fflate.module-KbGj5-H6.js">
|
|
15
|
-
<link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-
|
|
15
|
+
<link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-DBYQBR0T.js">
|
|
16
16
|
<link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-_S8NQl5B.css">
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/workflow-DRdwoDBT.css">
|
|
18
18
|
</head>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cozyclay",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"description": "Open source previs software in the browser: block a scene, pose characters, author camera moves and cuts, then take the same shots to an AI video model.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "node tools/dev-full.mjs --host 127.0.0.1 --port 5180",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The document targets a command writes, known before it runs, for tasks that edit one scene at once (parallel Studio
|
|
2
|
+
// tasks, P1). A key names one target: object:<id> (a group carries its subtree), character:<id> (its placement, its
|
|
3
|
+
// take, its IK), shot:<id> (its camera, rail and keys), or one of the singletons stage (light, environment, lens),
|
|
4
|
+
// timeline (frame count, shot order and ranges) and scene (documents, the cast library, a replace of a whole kind).
|
|
5
|
+
// timeline and scene are exclusive: no other task runs beside them. A command that only creates (object.add, a kit,
|
|
6
|
+
// character.add) needs no key: what it makes joins its task. A command this table does not know writes scene, so a
|
|
7
|
+
// new command is safe until it is named here.
|
|
8
|
+
const EXCLUSIVE_KEYS = new Set(['timeline', 'scene']);
|
|
9
|
+
const KEY = /^(?:(?:object|character|shot):[A-Za-z0-9][A-Za-z0-9._:-]*|stage|timeline|scene)$/;
|
|
10
|
+
export const isScopeKey = key => typeof key === 'string' && key.length <= 140 && KEY.test(key);
|
|
11
|
+
|
|
12
|
+
const SCENE = ['cast.replace', 'cast.setCustomPoses', 'cast.showExtras', 'cast.removePose', 'objects.replace', 'shot.replace', 'ai.prepareShot'];
|
|
13
|
+
const TIMELINE = ['shot.create', 'shot.createStill', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.reorder', 'shot.setRange', 'shot.setTimeline'];
|
|
14
|
+
const CREATE = ['object.add', 'object.createKit', 'asset.import', 'character.add'];
|
|
15
|
+
const CHARACTER = /^(character\.|characters?\b|cast\.(savePose|setLayer)$|ik\.|motion\.)/;
|
|
16
|
+
|
|
17
|
+
/** object:<id> and every object under it. */
|
|
18
|
+
function objectKeys(ids, state) {
|
|
19
|
+
const rows = state?.objects ?? [], out = new Set();
|
|
20
|
+
const visit = id => { if (!id || out.has(`object:${id}`)) return; out.add(`object:${id}`); for (const row of rows) if (row.parent === id) visit(row.id); };
|
|
21
|
+
for (const id of ids) visit(id);
|
|
22
|
+
return [...out];
|
|
23
|
+
}
|
|
24
|
+
const arrangeKeys = (ops, state) => ops.flatMap(op => op.op === 'create' ? [] : op.op === 'group' ? objectKeys([op.parentId, ...op.childIds], state)
|
|
25
|
+
: op.op === 'ungroup' ? objectKeys(op.childIds, state) : objectKeys([op.id], state));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The keys command `id` with validated `args` writes against state `state` (the bus's read), or [] when it writes
|
|
29
|
+
* none (a read, a view change, a pure creation). `entry` is its registry entry (kind, readOnly).
|
|
30
|
+
*/
|
|
31
|
+
export function commandKeys(id, args = {}, state = {}, entry = {}) {
|
|
32
|
+
if (entry.kind === 'document' || /^(scene|project)\./.test(id) || id === 'load_scenes' || SCENE.includes(id)) return ['scene'];
|
|
33
|
+
if (entry.readOnly || entry.kind === 'transient' || /^(view|timeline)\./.test(id) || ['motion.qa', 'motion.sample', 'motion.describe', 'motion.report', 'sculpt.compare', 'export.shotVideo'].includes(id)) return [];
|
|
34
|
+
if (CREATE.includes(id)) return [];
|
|
35
|
+
if (id === 'objects.batch') return [...new Set(args.ops.flatMap(op => commandKeys(op.name, op.args, state)))];
|
|
36
|
+
if (id === 'objects.arrange') return [...new Set(arrangeKeys(args.ops, state))];
|
|
37
|
+
if (id === 'characters.arrange') return args.ops.flatMap(op => op.op === 'create' ? [] : [`character:${op.characterId}`]);
|
|
38
|
+
if (id.startsWith('stage.') || id === 'shot.setLens') return ['stage'];
|
|
39
|
+
if (TIMELINE.includes(id)) return ['timeline'];
|
|
40
|
+
if (id.startsWith('shot.')) {
|
|
41
|
+
const shot = args.shotId ?? args.id ?? state.selectedShotId;
|
|
42
|
+
return shot ? [`shot:${shot}`] : ['timeline'];
|
|
43
|
+
}
|
|
44
|
+
if (id === 'object.group') return objectKeys([args.parent, ...args.children], state);
|
|
45
|
+
if (id === 'object.ungroup') return objectKeys(args.children, state);
|
|
46
|
+
if (id === 'object.remove') return objectKeys(args.ids, state);
|
|
47
|
+
if (id === 'object.attach' || id === 'motion.keyObject') return [`character:${args.characterId}`, ...objectKeys([args.objectId], state)];
|
|
48
|
+
if (id.startsWith('object.')) return objectKeys([args.id ?? args.objectId], state);
|
|
49
|
+
if (CHARACTER.test(id) && (args.characterId ?? args.id)) return [`character:${args.characterId ?? args.id}`];
|
|
50
|
+
return ['scene'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The key an id a receipt names resolves to in state, or null (the scene's own id, a removed target). */
|
|
54
|
+
export function keyOfId(id, state = {}) {
|
|
55
|
+
if ((state.objects ?? []).some(row => row.id === id)) return `object:${id}`;
|
|
56
|
+
if ((state.characters ?? []).some(row => row.id === id)) return `character:${id}`;
|
|
57
|
+
if ((state.shots ?? []).some(row => row.id === id)) return `shot:${id}`;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Why `keys` cannot join task `taskId` beside the other open tasks, or null. */
|
|
62
|
+
export function scopeConflict(taskId, keys, tasks) {
|
|
63
|
+
for (const other of tasks.values()) {
|
|
64
|
+
if (other.taskId === taskId) continue;
|
|
65
|
+
const held = keys.find(key => other.keys.has(key) || EXCLUSIVE_KEYS.has(key) || [...other.keys].some(k => EXCLUSIVE_KEYS.has(k)));
|
|
66
|
+
if (held) return `${held} is held by task ${other.taskId}${other.title ? ` (${other.title})` : ''}`;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// What a task's scope keys are in the editor's document stores, for the bus's tasks (bus-tasks.js): a snapshot of a
|
|
2
|
+
// key is its rows (an object's row; a character's cast row and motion layer; a shot's row) or, for stage and timeline,
|
|
3
|
+
// the whole slices; restoring puts every row back where it stood (or removes a row the task created) in one composed
|
|
4
|
+
// entry, so undo, history and saves see an ordinary edit. scene is not restorable: documents have no undo.
|
|
5
|
+
import { StudioProtocolError } from './studio-agent-protocol.js';
|
|
6
|
+
|
|
7
|
+
const ROWS = { object: [['objects', null]], character: [['cast', 'characters'], ['motion', null]], shot: [['shot', 'shots']] };
|
|
8
|
+
const WHOLE = { stage: ['stage'], timeline: ['shot', 'motion'] };
|
|
9
|
+
const split = key => { const at = key.indexOf(':'); return at < 0 ? [key] : [key.slice(0, at), key.slice(at + 1)]; };
|
|
10
|
+
|
|
11
|
+
function put(slice, part) {
|
|
12
|
+
if ('whole' in part) return part.whole;
|
|
13
|
+
const rows = (part.path ? slice[part.path] : slice).filter(row => row.id !== part.id);
|
|
14
|
+
if (part.row) rows.splice(Math.min(part.index, rows.length), 0, part.row);
|
|
15
|
+
return part.path ? { ...slice, [part.path]: rows } : rows;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** snapshotTarget(key) and restoreTargets([{ key, value }]) over the app's store domains (ports.storeDomain, ports.beginAction). */
|
|
19
|
+
export function taskTargetPorts(ports) {
|
|
20
|
+
const store = domain => ports.storeDomain?.(domain)?.documentStore;
|
|
21
|
+
const rowParts = (kind, id, read) => ROWS[kind].filter(([domain]) => store(domain)).map(([domain, path]) => {
|
|
22
|
+
const slice = store(domain).read(domain), rows = (path ? slice?.[path] : slice) ?? [];
|
|
23
|
+
const index = read ? rows.findIndex(row => row.id === id) : -1;
|
|
24
|
+
return { domain, path, id, index, row: index < 0 ? undefined : rows[index] };
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
snapshotTarget(key) {
|
|
28
|
+
const [kind, id] = split(key);
|
|
29
|
+
if (WHOLE[kind]) return WHOLE[kind].filter(store).map(domain => ({ domain, whole: store(domain).read(domain) }));
|
|
30
|
+
return ROWS[kind] ? rowParts(kind, id, true) : null;
|
|
31
|
+
},
|
|
32
|
+
restoreTargets(values) {
|
|
33
|
+
const parts = values.flatMap(({ key, value }) => {
|
|
34
|
+
if (value === null) throw new StudioProtocolError('CAPABILITY_MISSING', `${key} cannot be restored: a document change has no undo.`);
|
|
35
|
+
const [kind, id] = split(key);
|
|
36
|
+
return value ?? rowParts(kind, id, false);
|
|
37
|
+
});
|
|
38
|
+
const domains = [...new Set(parts.map(part => part.domain))];
|
|
39
|
+
if (!domains.length) return { historyEntryId: null };
|
|
40
|
+
const session = ports.beginAction(domains[0], null);
|
|
41
|
+
try {
|
|
42
|
+
for (const domain of domains.slice(1)) session.touch(domain);
|
|
43
|
+
session.run(() => { for (const part of parts) store(part.domain).write(part.domain, slice => put(slice, part)); });
|
|
44
|
+
return session.commit();
|
|
45
|
+
} catch (error) { session.cancel(); throw error; }
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/bus-tasks.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Tasks: several agents editing one scene at once (parallel Studio tasks, P1; plan in docs). A task holds a scope of
|
|
2
|
+
// keys (bus-task-scopes.js) instead of the whole-scene turn lock: its commands write only inside it, each command
|
|
3
|
+
// commits as its own short entry (the per-command write lock is the document's own composition), and admission reads
|
|
4
|
+
// the scope's target tokens, not the scene revision, so another task's edit elsewhere never makes it stale. Before a
|
|
5
|
+
// key's first write the task keeps its pre-image (ports.snapshotTarget); cancel and undo-task put the pre-images back
|
|
6
|
+
// as one new entry (ports.restoreTargets), which touches no other task's keys. A mark and rollback (a script's start)
|
|
7
|
+
// and undo_edit of the task's newest edit restore the same way. The user's own edits are never refused: the task's
|
|
8
|
+
// next command on a target the user changed answers STALE_TARGET once. Ctrl+Z stops at a running task's entry.
|
|
9
|
+
// A single agent turn (agent.turn.*) is untouched: tasks and a turn never run together.
|
|
10
|
+
import { StudioProtocolError, validateReceipt } from './studio-agent-protocol.js';
|
|
11
|
+
import { commandKeys, keyOfId, scopeConflict, isScopeKey } from './bus-task-scopes.js';
|
|
12
|
+
|
|
13
|
+
const fail = (code, message) => { throw new StudioProtocolError(code, message); };
|
|
14
|
+
const VERBS = ['begin', 'requestScope', 'mark', 'rollback', 'finish', 'cancel', 'undo'];
|
|
15
|
+
const FINISHED_KEPT = 32;
|
|
16
|
+
const listOf = keys => [...keys].slice(0, 6).join(', ') + (keys.length > 6 ? ` and ${keys.length - 6} more` : '');
|
|
17
|
+
|
|
18
|
+
export function createBusTasks({ ports, emit }) {
|
|
19
|
+
const open = new Map(), finished = new Map(), entryTask = new Map();
|
|
20
|
+
const tokenOf = key => {
|
|
21
|
+
const [kind, id] = key.split(/:(.*)/s);
|
|
22
|
+
if (id !== undefined) return ports.readTarget?.(id) ?? 'absent';
|
|
23
|
+
const state = ports.read();
|
|
24
|
+
return kind === 'scene' ? `r${state.revision}` : `${kind}:${state.domainRevisions?.[kind === 'timeline' ? 'shot' : kind] ?? 0}`;
|
|
25
|
+
};
|
|
26
|
+
const snapshot = key => ({ key, value: ports.snapshotTarget ? ports.snapshotTarget(key) : fail('CAPABILITY_MISSING', 'This editor cannot snapshot task targets.') });
|
|
27
|
+
// A scene key has no restorable value (documents have no undo): it is left as it is and said so.
|
|
28
|
+
function restore(task, values, action) {
|
|
29
|
+
task.skipped = values.filter(v => v.value === null).map(v => v.key);
|
|
30
|
+
values = values.filter(v => v.value !== null);
|
|
31
|
+
if (!values.length) return null;
|
|
32
|
+
if (!ports.restoreTargets) fail('CAPABILITY_MISSING', 'This editor cannot restore task targets.');
|
|
33
|
+
const { historyEntryId = null } = ports.restoreTargets(values) ?? {};
|
|
34
|
+
if (historyEntryId) entryTask.set(historyEntryId, task.taskId);
|
|
35
|
+
task.restored = [...new Set(values.map(({ key }) => key.includes(':') ? key.slice(key.indexOf(':') + 1) : ports.read().host.sceneId))];
|
|
36
|
+
for (const { key } of values) task.tokens.set(key, tokenOf(key));
|
|
37
|
+
emit?.({ type: `task.${action}`, taskId: task.taskId, keys: values.map(v => v.key) });
|
|
38
|
+
return historyEntryId;
|
|
39
|
+
}
|
|
40
|
+
function grant(task, keys) {
|
|
41
|
+
const bad = keys.find(key => !isScopeKey(key));
|
|
42
|
+
if (bad !== undefined) fail('INVALID_ARGUMENT', `"${bad}" is not a scope key: object:<id>, character:<id>, shot:<id>, stage, timeline or scene.`);
|
|
43
|
+
const conflict = scopeConflict(task.taskId, keys, open);
|
|
44
|
+
if (conflict) fail('SCOPE_BUSY', `${conflict}; wait for it, or ask for what is free.`);
|
|
45
|
+
for (const key of keys) if (!task.keys.has(key)) { task.keys.add(key); task.tokens.set(key, tokenOf(key)); }
|
|
46
|
+
}
|
|
47
|
+
function receipt(id, task, request, before, { historyEntryId = null, affectedIds = [], output, warnings = [] } = {}) {
|
|
48
|
+
const after = ports.read();
|
|
49
|
+
if (task.skipped?.length) warnings = [...warnings, { code: 'SCOPE_NOT_RESTORED', message: `Left as they are (a document change has no undo): ${listOf(task.skipped)}.` }];
|
|
50
|
+
task.skipped = null;
|
|
51
|
+
return validateReceipt({ ok: true, status: 'completed', kind: 'transaction', commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
|
|
52
|
+
action: id, txId: task.taskId, authored: Boolean(historyEntryId), revision: { before: before.revision, after: after.revision },
|
|
53
|
+
affectedIds: historyEntryId && !affectedIds.length ? task.restored : affectedIds, delta: [],
|
|
54
|
+
checks: { coverage: 'task-scope' }, warnings, undo: historyEntryId ? { historyEntryId, entries: 1, canUndoDirect: false } : null,
|
|
55
|
+
output: { scope: [...task.keys], ...output } });
|
|
56
|
+
}
|
|
57
|
+
const ownerOf = (request, taskId) => { if (request.origin !== 'agent' || request.taskId !== taskId) fail('CAPABILITY_MISSING', 'Only the owning task can control it.'); };
|
|
58
|
+
return {
|
|
59
|
+
get size() { return open.size; },
|
|
60
|
+
controls: (object, identifier) => Object.fromEntries(VERBS.map(verb => [`task.${verb}`,
|
|
61
|
+
verb === 'begin' || verb === 'requestScope'
|
|
62
|
+
? { ...object({ taskId: identifier, scope: { type: 'array', items: { type: 'string' }, maxItems: 64 } }), properties: { taskId: identifier, scope: { type: 'array', items: { type: 'string' }, maxItems: 64 }, reads: { type: 'array', items: { type: 'string' }, maxItems: 64 }, title: { type: 'string', maxLength: 120 } } }
|
|
63
|
+
: object({ taskId: identifier })])),
|
|
64
|
+
/** The open task a request names (null without one); a request naming a closed task is refused. */
|
|
65
|
+
of(request, id) {
|
|
66
|
+
if (!request.taskId) return null;
|
|
67
|
+
const task = open.get(request.taskId);
|
|
68
|
+
if (!task && !id.startsWith('task.')) fail('STALE_TARGET', `Task ${request.taskId} is not open.`);
|
|
69
|
+
return task ?? null;
|
|
70
|
+
},
|
|
71
|
+
/** task.* controls. `busy` says why no task may begin (an agent turn or a command transaction is open). */
|
|
72
|
+
control(id, args, request, before, busy) {
|
|
73
|
+
if (id === 'task.undo') {
|
|
74
|
+
const task = finished.get(args.taskId) ?? fail('UNDO_EXPIRED', `Task ${args.taskId} is not a finished task this editor still holds.`);
|
|
75
|
+
const moved = [...task.final].filter(([key, token]) => tokenOf(key) !== token).map(([key]) => key);
|
|
76
|
+
if (moved.length) fail('UNDO_CONFLICT', `Changed since the task finished: ${listOf(moved)}; undo those first or edit by hand.`);
|
|
77
|
+
const historyEntryId = restore(task, [...task.pre.values()], 'undone');
|
|
78
|
+
finished.delete(args.taskId);
|
|
79
|
+
return receipt(id, task, request, before, { historyEntryId, affectedIds: task.affectedIds });
|
|
80
|
+
}
|
|
81
|
+
ownerOf(request, args.taskId);
|
|
82
|
+
if (id === 'task.begin') {
|
|
83
|
+
if (busy) fail('TARGET_BUSY', busy);
|
|
84
|
+
if (open.has(args.taskId) || finished.has(args.taskId)) fail('INVALID_ARGUMENT', `Task ${args.taskId} already exists.`);
|
|
85
|
+
const task = { taskId: args.taskId, title: args.title ?? '', keys: new Set(), reads: new Set(args.reads ?? []), tokens: new Map(), pre: new Map(), edits: [], notices: [], affectedIds: [] };
|
|
86
|
+
grant(task, args.scope);
|
|
87
|
+
open.set(task.taskId, task);
|
|
88
|
+
emit?.({ type: 'task.begun', taskId: task.taskId, scope: [...task.keys] });
|
|
89
|
+
return receipt(id, task, request, before);
|
|
90
|
+
}
|
|
91
|
+
const task = open.get(args.taskId) ?? fail('STALE_TARGET', `Task ${args.taskId} is not open.`);
|
|
92
|
+
if (id === 'task.requestScope') { grant(task, args.scope); return receipt(id, task, request, before); }
|
|
93
|
+
if (id === 'task.mark') { task.mark = { values: [...task.keys].map(snapshot), keys: new Set(task.keys) }; return receipt(id, task, request, before); }
|
|
94
|
+
if (id === 'task.rollback') {
|
|
95
|
+
const mark = task.mark ?? fail('STALE_TARGET', 'The task has no mark to roll back to.');
|
|
96
|
+
task.mark = null;
|
|
97
|
+
const after = [...task.keys].filter(key => !mark.keys.has(key)).map(key => task.pre.get(key) ?? snapshot(key));
|
|
98
|
+
const historyEntryId = restore(task, [...mark.values, ...after], 'rolledBack');
|
|
99
|
+
task.edits = task.edits.filter(edit => edit.mark !== mark);
|
|
100
|
+
return receipt(id, task, request, before, { historyEntryId });
|
|
101
|
+
}
|
|
102
|
+
open.delete(task.taskId);
|
|
103
|
+
if (id === 'task.cancel') {
|
|
104
|
+
const historyEntryId = restore(task, [...task.pre.values()], 'cancelled');
|
|
105
|
+
return receipt(id, task, request, before, { historyEntryId, affectedIds: task.affectedIds });
|
|
106
|
+
}
|
|
107
|
+
task.final = new Map([...task.pre.keys()].map(key => [key, tokenOf(key)]));
|
|
108
|
+
finished.set(task.taskId, task);
|
|
109
|
+
while (finished.size > FINISHED_KEPT) finished.delete(finished.keys().next().value);
|
|
110
|
+
emit?.({ type: 'task.finished', taskId: task.taskId });
|
|
111
|
+
return receipt(id, task, request, before, { affectedIds: task.affectedIds, output: { edits: task.edits.length } });
|
|
112
|
+
},
|
|
113
|
+
/**
|
|
114
|
+
* Admits a command: a task writes only inside its scope, on targets nobody else changed since it last looked; a
|
|
115
|
+
* wire caller outside any task may not write a running task's keys; the UI always may. Answers the point a
|
|
116
|
+
* task's edit restores to (undo_edit of it), after keeping each key's first pre-image.
|
|
117
|
+
*/
|
|
118
|
+
admit(task, entry, args, request, before) {
|
|
119
|
+
if (!task && (request.origin === 'ui' || !open.size)) return null;
|
|
120
|
+
const keys = entry?.id ? commandKeys(entry.id, args, before, entry) : [];
|
|
121
|
+
if (!task) {
|
|
122
|
+
const held = keys.find(key => scopeConflict(null, [key], open));
|
|
123
|
+
if (held) fail('SCOPE_BUSY', `${scopeConflict(null, [held], open)}; this command waits for that task.`);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const outside = keys.filter(key => !task.keys.has(key));
|
|
127
|
+
if (outside.length) fail('SCOPE_DENIED', `Outside this task's scope: ${listOf(outside)}. Ask for it with task.requestScope, or leave it to the task that holds it.`);
|
|
128
|
+
const moved = keys.filter(key => task.tokens.has(key) && tokenOf(key) !== task.tokens.get(key));
|
|
129
|
+
if (moved.length) {
|
|
130
|
+
for (const key of moved) task.tokens.set(key, tokenOf(key));
|
|
131
|
+
fail('STALE_TARGET', `${listOf(moved)} changed outside this task (the user, or an undo) since it last looked; read it again, then retry.`);
|
|
132
|
+
}
|
|
133
|
+
for (const key of keys) if (!task.pre.has(key)) task.pre.set(key, snapshot(key));
|
|
134
|
+
return { task, keys, values: keys.map(snapshot), mark: task.mark };
|
|
135
|
+
},
|
|
136
|
+
/** Files a task's receipt: ids it created join its scope, it may be undone newest first, readers hear of it. */
|
|
137
|
+
record(point, recorded) {
|
|
138
|
+
if (!point || !recorded?.ok) return recorded;
|
|
139
|
+
const { task } = point, state = ports.read(), warnings = [];
|
|
140
|
+
for (const id of recorded.affectedIds ?? []) {
|
|
141
|
+
const key = keyOfId(id, state);
|
|
142
|
+
if (!key || task.keys.has(key)) continue;
|
|
143
|
+
if (scopeConflict(task.taskId, [key], open)) warnings.push({ code: 'SCOPE_DENIED', message: `${key} changed outside the task's scope.` });
|
|
144
|
+
else { task.keys.add(key); if (!point.keys.includes(key)) { task.pre.set(key, { key, value: undefined }); point.values.push({ key, value: undefined }); } }
|
|
145
|
+
}
|
|
146
|
+
for (const key of task.keys) task.tokens.set(key, tokenOf(key));
|
|
147
|
+
if (recorded.authored) {
|
|
148
|
+
task.edits.push({ receiptId: recorded.receiptId, values: point.values, keys: point.keys, mark: point.mark });
|
|
149
|
+
task.affectedIds = [...new Set([...task.affectedIds, ...recorded.affectedIds])];
|
|
150
|
+
if (recorded.undo?.historyEntryId) entryTask.set(recorded.undo.historyEntryId, task.taskId);
|
|
151
|
+
for (const other of open.values()) for (const key of point.keys) if (other !== task && other.reads.has(key)) {
|
|
152
|
+
other.notices.push({ code: 'DEPENDENCY_CHANGED', message: `${key}, which this task reads, was changed by task ${task.taskId}.` });
|
|
153
|
+
emit?.({ type: 'task.dependencyChanged', taskId: other.taskId, key, by: task.taskId });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const heard = task.notices.splice(0);
|
|
157
|
+
return heard.length || warnings.length ? { ...recorded, warnings: [...recorded.warnings, ...warnings, ...heard].slice(0, 12) } : recorded;
|
|
158
|
+
},
|
|
159
|
+
/** History traversal: undo_edit of a task's newest edit inside it; the UI's undo stops at a running task's entry. */
|
|
160
|
+
traversal(id, args, request, before, frontier) {
|
|
161
|
+
const task = request.taskId ? open.get(request.taskId) : null;
|
|
162
|
+
if (task && id === 'edit.undo') {
|
|
163
|
+
const at = task.edits.findIndex(edit => edit.receiptId === args.receiptId);
|
|
164
|
+
if (at < 0) fail('TARGET_BUSY', `Inside a task, undo_edit takes the receiptId of the task's own newest edit${task.edits.length ? ` (now ${task.edits.at(-1).receiptId})` : ''}.`);
|
|
165
|
+
if (at < task.edits.length - 1) fail('UNDO_CONFLICT', `Later edits of this task come after it; undo them first, newest first: ${task.edits.slice(at + 1).reverse().map(edit => edit.receiptId).join(', ')}.`);
|
|
166
|
+
const edit = task.edits.pop(), historyEntryId = restore(task, edit.values, 'editUndone');
|
|
167
|
+
return receipt(id, task, request, before, { historyEntryId, output: { undoneReceiptId: edit.receiptId } });
|
|
168
|
+
}
|
|
169
|
+
const owner = open.get(entryTask.get(frontier));
|
|
170
|
+
if (!task && owner) fail('TARGET_BUSY', `The last edit belongs to running task ${owner.taskId}${owner.title ? ` (${owner.title})` : ''}; cancel or undo it from that task.`);
|
|
171
|
+
return null;
|
|
172
|
+
},
|
|
173
|
+
dispose() { open.clear(); finished.clear(); entryTask.clear(); },
|
|
174
|
+
};
|
|
175
|
+
}
|
package/src/command-bus.js
CHANGED
|
@@ -1,40 +1,11 @@
|
|
|
1
1
|
// The Studio command runner. The registry owns declarations/implementations;
|
|
2
2
|
// the editor ports own native state/history, and the existing journal owns
|
|
3
3
|
// idempotency. Synchronous UI actions stay synchronous (including user gestures).
|
|
4
|
-
import { HISTORY_LIMIT } from './history.js';
|
|
5
4
|
import { StudioProtocolError, StudioSchemas, validateStudioSchema, validateReceipt, validateStudioIdentity, refusalCode } from './studio-agent-protocol.js';
|
|
6
5
|
import { agentTurnControls, markAgentTurn, turnEditPoint, recordTurnEdit, undoTurnEdit } from './agent-turn-mark.js';
|
|
6
|
+
import { createBusTasks } from './bus-tasks.js';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
// native store still owns every snapshot and transition; this tracks only the
|
|
10
|
-
// retained pre-image identities needed by receipts.
|
|
11
|
-
export function withCommandHistory(store) {
|
|
12
|
-
let past = [], future = [], group = null;
|
|
13
|
-
const adapter = Object.create(store);
|
|
14
|
-
const pushed = before => { if (before !== store.present()) { past.push(before); past = past.slice(-HISTORY_LIMIT); future = []; } };
|
|
15
|
-
adapter.hasHistoryState = state => state === store.present() || past.includes(state) || future.includes(state);
|
|
16
|
-
adapter.settle = (...args) => { const before = store.present(); store.settle(...args); pushed(before); };
|
|
17
|
-
adapter.applyAtomic = fn => {
|
|
18
|
-
if (group) { if (!group.running) fail('TARGET_BUSY', 'A command transaction owns the object history.'); return store.applyIn(group.token, fn); }
|
|
19
|
-
adapter.settle(); const before = store.present(); store.applyAtomic(fn); pushed(before);
|
|
20
|
-
};
|
|
21
|
-
adapter.begin = (...args) => { adapter.settle(); return store.begin(...args); };
|
|
22
|
-
adapter.end = (token, options) => { const before = store.present(), ended = store.end(token, options); if (options.commit) pushed(before); return ended; };
|
|
23
|
-
adapter.undo = () => { adapter.settle(); const before = store.present(), restored = store.undo(); if (restored !== null) { past.pop(); future.unshift(before); } return restored; };
|
|
24
|
-
adapter.redo = () => { const before = store.present(), restored = store.redo(); if (restored !== null) { past.push(before); future.shift(); } return restored; };
|
|
25
|
-
adapter.beginCommand = () => {
|
|
26
|
-
if (group) fail('TARGET_BUSY', 'An object command is already in progress.');
|
|
27
|
-
adapter.settle(); const before = store.present();
|
|
28
|
-
const current = { running: 0, token: store.begin('command-bus', () => { current.closed = true; group = null; }) }; group = current;
|
|
29
|
-
const check = () => { if (current.closed) fail('STALE_TARGET', 'Native object transaction was retired.'); };
|
|
30
|
-
return {
|
|
31
|
-
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; } },
|
|
32
|
-
commit() { check(); current.closed = true; group = null; store.end(current.token, { commit: true }); pushed(before); return before !== store.present(); },
|
|
33
|
-
cancel() { if (current.closed) return false; current.closed = true; group = null; return store.end(current.token, { commit: false }); },
|
|
34
|
-
};
|
|
35
|
-
};
|
|
36
|
-
return adapter;
|
|
37
|
-
}
|
|
8
|
+
export { withCommandHistory } from './command-history.js';
|
|
38
9
|
|
|
39
10
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
40
11
|
const fail = (code, message) => { throw new StudioProtocolError(code, message); };
|
|
@@ -47,6 +18,7 @@ export function createCommandBus({ registry, ports }) {
|
|
|
47
18
|
const pending = new Map(), transactions = new Map(), jobs = new Map(), listeners = new Set();
|
|
48
19
|
const confirmations = new Map(), historyReceipts = new Map();
|
|
49
20
|
let agentTurn = null;
|
|
21
|
+
const tasks = createBusTasks({ ports, emit: event => emit(event) });
|
|
50
22
|
function exposure(entry, args, request) {
|
|
51
23
|
if (request.origin === 'ui') return;
|
|
52
24
|
if (entry.exposure === 'ui-only') fail('CAPABILITY_MISSING', entry.uiOnlyHint ?? 'This command is available only from the Studio UI.');
|
|
@@ -63,7 +35,7 @@ export function createCommandBus({ registry, ports }) {
|
|
|
63
35
|
const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
|
|
64
36
|
const argsSchema = { type: 'object', properties: {}, required: [], additionalProperties: true };
|
|
65
37
|
const controls = {
|
|
66
|
-
...agentTurnControls(object, identifier),
|
|
38
|
+
...agentTurnControls(object, identifier), ...tasks.controls(object, identifier),
|
|
67
39
|
'run.begin': object({ id: identifier, args: argsSchema }),
|
|
68
40
|
'run.update': object({ txId: identifier, args: argsSchema }),
|
|
69
41
|
'run.commit': object({ txId: identifier }),
|
|
@@ -84,10 +56,11 @@ export function createCommandBus({ registry, ports }) {
|
|
|
84
56
|
tx.timer = (ports.setTimeout ?? setTimeout)(() => cancelTransaction(tx, true), ports.transactionIdleMs ?? 30_000);
|
|
85
57
|
}
|
|
86
58
|
function control(id, args, request, before) {
|
|
59
|
+
if (id.startsWith('task.')) return tasks.control(id, args, request, before, agentTurn ? 'Finish the agent turn first.' : transactions.size ? 'Finish the open command transaction first.' : null);
|
|
87
60
|
if (id.startsWith('agent.turn.')) {
|
|
88
61
|
if (request.origin !== 'agent' || request.turnId !== args.turnId) fail('CAPABILITY_MISSING', 'Only the owning agent turn can control this transaction.');
|
|
89
62
|
if (id === 'agent.turn.begin') {
|
|
90
|
-
if (agentTurn || transactions.size) fail('TARGET_BUSY', 'Finish the open transaction first.');
|
|
63
|
+
if (agentTurn || transactions.size || tasks.size) fail('TARGET_BUSY', 'Finish the open transaction first.');
|
|
91
64
|
agentTurn = { txId: args.turnId, before, affectedIds: new Set(), toasts: [], session: null };
|
|
92
65
|
return transactionReceipt(id, agentTurn, request, before);
|
|
93
66
|
}
|
|
@@ -105,7 +78,8 @@ export function createCommandBus({ registry, ports }) {
|
|
|
105
78
|
// Every Studio turn opens a turn transaction (#715), so "undo the last thing" arrives inside one. Until the
|
|
106
79
|
// turn has authored (no session yet) history traversal is safe: the undo lands as its own entry (#735).
|
|
107
80
|
// After that the turn owns the frontier: its own agent may undo its newest edit (agent-turn-mark.js).
|
|
108
|
-
const traversal = id === 'edit.undo' || id === 'edit.redo';
|
|
81
|
+
const traversal = id === 'edit.undo' || id === 'edit.redo', scoped = traversal && tasks.traversal(id, args, request, before, id === 'edit.undo' ? ports.history?.(false) : null);
|
|
82
|
+
if (scoped) return scoped;
|
|
109
83
|
if (agentTurn && !id.startsWith('job.') && !(traversal && !agentTurn.session)) { if (id === 'edit.undo' && request.turnId === agentTurn.txId) return validateReceipt(undoTurnEdit(agentTurn, args, request, before, () => ports.read())); fail('TARGET_BUSY', 'Finish the agent turn before traversing history or opening another transaction.'); }
|
|
110
84
|
if (traversal) {
|
|
111
85
|
const redo = id === 'edit.redo', historyEntryId = ports.history?.(redo);
|
|
@@ -242,7 +216,7 @@ export function createCommandBus({ registry, ports }) {
|
|
|
242
216
|
if (agentTurn && !same(agentTurn.before.host, before.host)) {
|
|
243
217
|
agentTurn.session?.cancel({ restore: false }); agentTurn = null;
|
|
244
218
|
}
|
|
245
|
-
const turn = request.origin === 'agent' && request.turnId === agentTurn?.txId ? agentTurn : null;
|
|
219
|
+
const turn = request.origin === 'agent' && request.turnId === agentTurn?.txId ? agentTurn : null, task = tasks.of(request, id);
|
|
246
220
|
const recordAction = (domain, invoke, targetId, nested = false) => {
|
|
247
221
|
if (!turn) return ports.recordAction(domain, invoke, targetId, nested);
|
|
248
222
|
// Enrol each owner in the existing composed session. Do not commit (or
|
|
@@ -293,13 +267,14 @@ export function createCommandBus({ registry, ports }) {
|
|
|
293
267
|
// can advance the revision while their wire request is in transit.
|
|
294
268
|
// Document identity and the job's own publication fence still apply.
|
|
295
269
|
// A readOnly action (declared data, e.g. motion.qa) only measures: no revision or gesture fence, the host check above still holds.
|
|
296
|
-
if (!entry?.readOnly && !['job.await', 'job.cancel', 'agent.turn.mark', 'agent.turn.rollback', 'agent.turn.finish', 'agent.turn.cancel'].includes(id) && request.expectedRevision !== before.revision) fail('STALE_SCENE', 'Authored state changed; obtain fresh intent.');
|
|
270
|
+
if (!task && !id.startsWith('task.') && !entry?.readOnly && !['job.await', 'job.cancel', 'agent.turn.mark', 'agent.turn.rollback', 'agent.turn.finish', 'agent.turn.cancel'].includes(id) && request.expectedRevision !== before.revision) fail('STALE_SCENE', 'Authored state changed; obtain fresh intent.');
|
|
297
271
|
if (!entry?.readOnly && before.busy && !turn && !transactions.has(validated.txId) && !id.startsWith('job.')) fail('TARGET_BUSY', 'Finish the current editor gesture first.');
|
|
298
272
|
}
|
|
299
273
|
if (controls[id]) return mapResult(control(id, validated, request, before), remember, rejected);
|
|
300
274
|
if (transactions.size) fail('TARGET_BUSY', 'Finish or cancel the open command transaction first.');
|
|
301
275
|
if (turn && entry.kind === 'document') fail('TARGET_BUSY', 'Finish the agent turn before changing documents.');
|
|
302
276
|
exposure(entry, validated, request);
|
|
277
|
+
const scoped = tasks.admit(task, entry, validated, request, before);
|
|
303
278
|
releaseToasts = ports.captureToasts?.(toast => toasts.push(typeof toast === 'string' ? { message: toast } : toast));
|
|
304
279
|
const domain = entry.domain ?? entry.undoDomain;
|
|
305
280
|
const targetId = entry.target?.(validated, before) ?? validated.characterId ?? validated.shotId ?? validated.objectId;
|
|
@@ -317,7 +292,7 @@ export function createCommandBus({ registry, ports }) {
|
|
|
317
292
|
const current = ports.read();
|
|
318
293
|
// Name the fence that tripped: the three share one code, and a bare "something changed" leaves the user and the agent guessing.
|
|
319
294
|
if (!same(current.host, before.host)) fail('STALE_TARGET', 'The open document or scene changed while the job was running.');
|
|
320
|
-
if (domain && current.domainRevisions?.[domain] !== domainRevision) fail('STALE_TARGET', `The ${domain} data changed while the job was running (revision ${domainRevision} to ${current.domainRevisions?.[domain]}); an edit landed between the request and its result.`);
|
|
295
|
+
if (domain && !task && current.domainRevisions?.[domain] !== domainRevision) fail('STALE_TARGET', `The ${domain} data changed while the job was running (revision ${domainRevision} to ${current.domainRevisions?.[domain]}); an edit landed between the request and its result.`);
|
|
321
296
|
if (targetId && ports.readTarget?.(targetId) !== token) fail('STALE_TARGET', `${targetId} changed while the job was running.`);
|
|
322
297
|
},
|
|
323
298
|
commit(apply) {
|
|
@@ -363,7 +338,7 @@ export function createCommandBus({ registry, ports }) {
|
|
|
363
338
|
releaseToasts?.(); releaseToasts = null;
|
|
364
339
|
const refused = output?.output === undefined ? toastRefusal() : null; // a job that answered was not refused by a toast that showed meanwhile
|
|
365
340
|
if (refused) throw refused;
|
|
366
|
-
return recordTurnEdit(turn, point, remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts, turn)));
|
|
341
|
+
return tasks.record(scoped, recordTurnEdit(turn, point, remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts, turn))));
|
|
367
342
|
});
|
|
368
343
|
const finished = value?.then ? value.then(finish) : finish(value);
|
|
369
344
|
const answer = finished?.then ? finished.catch(rejected) : finished;
|
|
@@ -405,5 +380,5 @@ export function createCommandBus({ registry, ports }) {
|
|
|
405
380
|
// Trusted UI adapter only: wire callers can consume, never mint a token.
|
|
406
381
|
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; },
|
|
407
382
|
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
408
|
-
dispose() { agentTurn?.session?.cancel(); agentTurn = null; for (const tx of transactions.values()) cancelTransaction(tx); for (const job of jobs.values()) if (!job.outcome) job.controller.abort(new StudioProtocolError('CANCELLED', 'Studio closed.')); listeners.clear(); } };
|
|
383
|
+
dispose() { agentTurn?.session?.cancel(); agentTurn = null; tasks.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(); } };
|
|
409
384
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The object history adapter the command bus uses (moved out of command-bus.js unchanged).
|
|
2
|
+
import { HISTORY_LIMIT } from './history.js';
|
|
3
|
+
import { StudioProtocolError } from './studio-agent-protocol.js';
|
|
4
|
+
|
|
5
|
+
const fail = (code, message) => { throw new StudioProtocolError(code, message); };
|
|
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
|
+
}
|
package/src/commands/cast.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// name their character explicitly, so they run the same way whichever
|
|
3
3
|
// character is active and whatever mode the editor is in.
|
|
4
4
|
import { studioActionDeclaration } from "../studio-actions.js";
|
|
5
|
-
import { characterOf, fail, changedIds } from "./shared.js";
|
|
5
|
+
import { characterOf, fail, changedIds, checkPoseBones } from "./shared.js";
|
|
6
6
|
import { elementSetSchema, registerElementSet } from './elements.js';
|
|
7
7
|
import './elements/character.js';
|
|
8
8
|
import { createCharacterEntry, CHARACTER_KIND_IDS, POSTURES, PROXY_FIGURE_MODEL, isProxyFigure } from '../scenes.js';
|
|
@@ -43,7 +43,13 @@ export function register(registry, ports) {
|
|
|
43
43
|
const owner = () => ports.storeDomain('cast');
|
|
44
44
|
const mounted = () => Boolean(ports.storeDomain?.('cast')) || 'The cast document owner is not mounted.';
|
|
45
45
|
const character = characterId => owner().read().find(row => row.id === characterId) ?? fail('STALE_TARGET', `Character ${characterId} is not in this scene.`);
|
|
46
|
-
|
|
46
|
+
// Every pose path names only POSE_BONES joints: a pose sent whole, one chosen from the library by id,
|
|
47
|
+
// and every library entry written (#773).
|
|
48
|
+
const pose = value => {
|
|
49
|
+
const resolved = typeof value === 'string' ? owner().poses().find(row => row.id === value) ?? fail('STALE_TARGET', `Pose ${value} is not in the library.`) : value;
|
|
50
|
+
checkPoseBones(resolved?.bones);
|
|
51
|
+
return resolved;
|
|
52
|
+
};
|
|
47
53
|
const patch = (characterId, update) => { character(characterId); owner().update(characterId, update); };
|
|
48
54
|
// A character groups under a scene object that stands in the world. An
|
|
49
55
|
// object a character carries is refused: it rides a bone, not a route.
|
|
@@ -69,8 +75,9 @@ export function register(registry, ports) {
|
|
|
69
75
|
'character.setPose': ({ characterId, pose: value, clearMotion }) => { character(characterId); owner().applyPose(characterId, pose(value), clearMotion); },
|
|
70
76
|
|
|
71
77
|
'cast.replace': ({ characters }) => owner().write(characters),
|
|
72
|
-
'cast.setCustomPoses': ({ poses }) => owner().writeState(before => ({ ...before, customPoses: poses })),
|
|
78
|
+
'cast.setCustomPoses': ({ poses }) => { for (const entry of poses) checkPoseBones(entry?.bones); owner().writeState(before => ({ ...before, customPoses: poses })); },
|
|
73
79
|
'cast.savePose': ({ pose, characterId, clearMotion }) => {
|
|
80
|
+
checkPoseBones(pose?.bones);
|
|
74
81
|
if (characterId) character(characterId);
|
|
75
82
|
owner().writeState(before => ({ ...before, customPoses: [...before.customPoses.filter(entry => entry.id !== pose.id), pose] }));
|
|
76
83
|
if (characterId) owner().applyPose(characterId, pose, clearMotion);
|
package/src/commands/motion.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Motion and IK commands share one per-character authored-intent owner.
|
|
2
2
|
import { STUDIO_IK_CHAIN_TRACKS, studioActionDeclaration } from "../studio-actions.js";
|
|
3
|
-
import { fail, characterOf } from "./shared.js";
|
|
3
|
+
import { fail, characterOf, checkPoseBones } from "./shared.js";
|
|
4
4
|
import { elementSetSchema, registerElementSet } from './elements.js';
|
|
5
5
|
import { registerTiming, timingDeclarations } from './motion-timing.js';
|
|
6
6
|
import './elements/motion.js';
|
|
@@ -41,6 +41,7 @@ export function register(registry, ports) {
|
|
|
41
41
|
for (const declaration of tools) registry.register({ ...declaration, available: mounted, run(args) {
|
|
42
42
|
characterOf(ports, args.characterId);
|
|
43
43
|
if (args.frame >= ports.state().frameCount) fail('INVALID_RANGE', 'IK frame is outside the timeline.');
|
|
44
|
+
checkPoseBones(args.pose.bones);
|
|
44
45
|
for (const angles of Object.values(args.pose.bones)) if (!Array.isArray(angles) || angles.length !== 3 || !angles.every(Number.isFinite)) fail('INVALID_ARGUMENT', 'Pose bones require three finite rotation angles.');
|
|
45
46
|
owner().keyPose(args.characterId, args.frame, args.pose);
|
|
46
47
|
return { affectedIds: [args.characterId], summary: declaration.label };
|
package/src/commands/motion3.js
CHANGED
|
@@ -48,7 +48,7 @@ const declarations = Object.freeze([
|
|
|
48
48
|
input: input({ topic: { type: "string", enum: DESCRIBE_TOPICS }, name: id,
|
|
49
49
|
topics: list({ oneOf: [{ type: "string", enum: DESCRIBE_TOPICS }, input({ topic: { type: "string", enum: DESCRIBE_TOPICS }, name: id }, ["topic"])] }, 8) }, []) },
|
|
50
50
|
{ id: "motion.report", label: "Read motion report", kind: "job", domain: "motion", timeoutMs: 10000, readOnly: true, description: HELP.report,
|
|
51
|
-
input: input({ specId, part: { type: "string", enum: ["groups", "timeline", "probes"], default: "groups" }, from: { type: "integer", minimum: 0, default: 0 } }, [
|
|
51
|
+
input: input({ specId, characterId: id, part: { type: "string", enum: ["groups", "timeline", "probes"], default: "groups" }, from: { type: "integer", minimum: 0, default: 0 } }, []) },
|
|
52
52
|
{ id: "motion.keyObject", label: "Key held object", kind: "job", domain: "motion", timeoutMs: 120000, description: HELP.keyObject,
|
|
53
53
|
input: input({ characterId: id, objectId: id, frame: { type: "integer", minimum: 0 }, pos: numbers(3), rot: numbers(4), aim: numbers(3), roll: { type: "number" },
|
|
54
54
|
angle: { type: "number" }, remove: { type: "boolean", default: false } }, ["characterId", "objectId", "frame"]) },
|
|
@@ -198,9 +198,15 @@ export function register(registry, ports) {
|
|
|
198
198
|
}
|
|
199
199
|
return { affectedIds: [], summary: `Described ${topic}${name ? ` ${name}` : ""}.`, output: asArgument(() => describe(topic, name)) };
|
|
200
200
|
} });
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
// a characterId reads the installed take's report (session 2ad2760a asked for it that way and was refused): the stored
|
|
202
|
+
// one when this session compiled that spec, else the take's QA run now
|
|
203
|
+
registry.register({ ...declarations[4], available: always, run({ specId: wanted, characterId, part = "groups", from = 0 }) {
|
|
204
|
+
const answer = (parts, id) => { const page = reportPage(parts, id, part, from); return { affectedIds: [], summary: `${page.items.length} ${part} from ${from}.`, output: page }; };
|
|
205
|
+
if (wanted) return answer(store.parts(wanted) ?? unknownSpec("specId", wanted, "compile it again"), wanted);
|
|
206
|
+
if (!characterId) fail("INVALID_ARGUMENT", "motion.report needs specId (from a motion.compile or motion.qa answer) or characterId (its installed take).");
|
|
207
|
+
const take = installed(characterId), id = specIdOf(take.spec), kept = store.parts(id);
|
|
208
|
+
if (kept) return answer(kept, id);
|
|
209
|
+
return after(compile(characterOf(ports, characterId), take.spec), r => { const out = report(r, {}); return answer(store.parts(out.specId), out.specId); });
|
|
204
210
|
} });
|
|
205
211
|
// motion.keyObject (C14): one key of a held object (or a hinge angle) in the installed take's spec, compiled
|
|
206
212
|
// and installed whatever QA says (a user edit), as one undo entry
|
package/src/commands/shared.js
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
// object: its members are read at call time, so a run always reaches the
|
|
3
3
|
// latest handlers and `ports.state()` the synchronously published document.
|
|
4
4
|
import { StudioProtocolError } from "../studio-agent-protocol.js";
|
|
5
|
+
import { POSE_BONES } from "../poses.js";
|
|
5
6
|
|
|
6
7
|
export const fail = (code, message) => { throw new StudioProtocolError(code, message); };
|
|
7
8
|
|
|
9
|
+
// A pose bone outside POSE_BONES is dropped by applyPose, so the pose falls back to the
|
|
10
|
+
// rest (T) pose for that joint. Refuse it and name the joints a pose can set (#773).
|
|
11
|
+
const POSE_BONE_IDS = POSE_BONES.map(entry => entry.id);
|
|
12
|
+
export function checkPoseBones(bones) {
|
|
13
|
+
const unknown = Object.keys(bones ?? {}).filter(name => !POSE_BONE_IDS.includes(name));
|
|
14
|
+
if (unknown.length) fail("INVALID_ARGUMENT", `Unknown pose bone ${unknown.join(", ")}. Use one of: ${POSE_BONE_IDS.join(", ")}.`);
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/** Ids of the rows added, changed or removed between two renders of a list. */
|
|
9
18
|
export const changedIds = (before, after) => [...new Set([
|
|
10
19
|
...after.filter(row => !before.includes(row)).map(row => row.id),
|