cozyclay 3.0.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/INSTALL_FOR_AGENTS.md +146 -0
- package/README.md +74 -192
- package/THIRD_PARTY_NOTICES.md +0 -21
- package/bin/agent/agent-routes.mjs +22 -56
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/agent/main-agent.mjs +70 -0
- package/bin/agent/main-prompt.mjs +17 -0
- package/bin/agent/session-store.mjs +2 -2
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-task-routes.mjs +185 -0
- package/bin/agent/studio-tasks.mjs +156 -0
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/studio-turn-transaction.mjs +76 -0
- package/bin/cozyclay.mjs +5 -7
- package/dist/app/index.html +7 -6
- package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
- package/dist/assets/app-B2BpfCl2.js +5088 -0
- package/dist/assets/app-BlwOpest.css +1 -0
- package/dist/assets/compile-worker-D0pKioa0.js +5 -0
- package/dist/assets/landing-y3EqDgSZ.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-B887n77J.js +1 -0
- package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
- package/dist/assets/task-board-BKaM7d-9.js +14 -0
- package/dist/assets/workflow-B8Dhzexj.js +179 -0
- package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
- package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +2 -3
- package/dist/index.html +7 -57
- package/dist/llms.txt +2 -1
- package/dist/previs-software/index.html +2 -2
- package/dist/privacy/index.html +3 -7
- package/dist/seedance-camera-control/index.html +2 -2
- package/dist/sitemap.xml +5 -5
- package/dist/sw.js +5 -1
- package/dist/thumbs/blank-stage-dark.webp +0 -0
- package/dist/thumbs/blank-stage-light.webp +0 -0
- package/dist/workflow/index.html +5 -4
- package/mcp/mesh-file.mjs +3 -1
- package/mcp/server.mjs +3 -0
- package/package.json +3 -3
- package/src/App.jsx +50 -281
- package/src/analytics.js +3 -148
- package/src/app-stage.jsx +1 -1
- package/src/asset-pane.jsx +5 -10
- package/src/auto-flat.js +10 -0
- package/src/bus-task-scopes.js +69 -0
- package/src/bus-task-targets.js +48 -0
- package/src/bus-tasks.js +181 -0
- package/src/command-bus.js +15 -40
- 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-compile.js +51 -27
- package/src/commands/motion3.js +13 -7
- package/src/commands/project.js +1 -1
- package/src/commands/shared.js +9 -0
- package/src/commands/shot.js +16 -4
- package/src/controls.jsx +0 -11
- package/src/cuts.js +11 -0
- package/src/domains/cast.js +3 -2
- package/src/domains/motion.js +3 -10
- package/src/domains/playback.js +32 -0
- package/src/domains/scenes.js +3 -22
- package/src/domains/shots.js +5 -5
- package/src/dualview.jsx +11 -15
- package/src/hierarchy-panel.css +18 -4
- package/src/hierarchy-panel.jsx +12 -11
- package/src/kits/held.js +3 -2
- package/src/light-blockers.js +2 -2
- package/src/main.jsx +5 -0
- package/src/motion/ground.js +5 -14
- package/src/motion/playback-frame-store.js +79 -0
- package/src/motion/playback-probe.js +16 -0
- package/src/motion/playback-subject.js +7 -0
- package/src/motion/timeline.css +43 -5
- package/src/motion/timeline.jsx +4 -3
- package/src/motion3/body.js +37 -5
- package/src/motion3/carried.js +11 -3
- package/src/motion3/compile-job.js +1 -1
- package/src/motion3/compiler/anchors.js +33 -5
- package/src/motion3/compiler/compile.js +27 -4
- package/src/motion3/compiler/finger-close.js +137 -0
- package/src/motion3/compiler/grip.js +9 -3
- package/src/motion3/compiler/limbs.js +18 -3
- package/src/motion3/compiler/pose.js +6 -0
- package/src/motion3/compiler/socket-grip.js +34 -10
- package/src/motion3/compiler/validate-objects.js +17 -2
- package/src/motion3/compiler/validate-parts.js +2 -1
- package/src/motion3/compiler/vocab.js +4 -4
- package/src/motion3/describe.js +2 -2
- package/src/motion3/docs.js +1 -1
- package/src/motion3/fix/index.js +7 -3
- package/src/motion3/flight.js +38 -0
- package/src/motion3/free-motion.js +10 -7
- package/src/motion3/load.js +125 -0
- package/src/motion3/mass.js +23 -0
- package/src/motion3/object-keys.js +11 -6
- package/src/motion3/object-pose.js +11 -3
- package/src/motion3/pipeline.js +5 -0
- package/src/motion3/qa/explain.js +3 -1
- package/src/motion3/qa/grasp-checks.js +123 -0
- package/src/motion3/qa/hints-grasp.js +71 -0
- package/src/motion3/qa/index.js +3 -1
- package/src/motion3/qa/object-checks.js +7 -2
- package/src/motion3/qa/patch.js +2 -1
- package/src/motion3/qa/spec-checks.js +21 -1
- package/src/motion3/schema/fields.js +3 -3
- package/src/motion3/schema/topics.js +11 -10
- package/src/motion3/schema/types.js +5 -1
- package/src/motion3/seat.js +84 -0
- package/src/motion3/settle.js +11 -6
- package/src/motion3/shapes.js +70 -0
- package/src/motion3/skills/basic.js +61 -13
- package/src/motion3/skills/catch.js +71 -0
- package/src/motion3/skills/index.js +13 -4
- package/src/motion3/skills/info.js +14 -1
- package/src/motion3/skills/reach-human.js +139 -0
- package/src/motion3/skills/stance.js +92 -0
- package/src/motion3/skills/throw.js +72 -0
- package/src/motion3/sockets.js +21 -4
- package/src/motion3/views.js +28 -5
- package/src/object-catalog.jsx +0 -4
- package/src/object-gizmo.jsx +2 -1
- package/src/object-path.js +1 -1
- package/src/panels/ProjectPanel.jsx +42 -21
- package/src/panels/details.css +15 -4
- package/src/popover.js +37 -0
- package/src/posestudio.jsx +15 -10
- package/src/project-browser-art.jsx +19 -0
- package/src/project-browser.css +74 -121
- package/src/project-browser.jsx +36 -60
- package/src/props.jsx +4 -7
- package/src/retired-kinds.js +37 -0
- package/src/scene-objects.js +3 -7
- package/src/scenes.js +4 -5
- package/src/settings-menu.jsx +1 -20
- package/src/shell/BottomDock.jsx +9 -9
- package/src/shell/MenuBar.jsx +1 -9
- package/src/shell/PreferencesDialog.jsx +3 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +11 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/glass.css +0 -5
- package/src/shell/lens-scrub-math.js +51 -0
- package/src/shell/lens-scrub.css +73 -0
- package/src/shell/lens-scrub.jsx +98 -0
- package/src/shell/mode.css +6 -5
- package/src/shell/preferences.css +1 -32
- package/src/shell/shell.css +18 -1
- package/src/shell/studio-shell-context.js +5 -0
- package/src/shot-frame.js +26 -0
- package/src/stage-canvas-rigs.jsx +7 -8
- package/src/stage-characters.jsx +26 -0
- package/src/stage-gizmo.js +43 -0
- package/src/stage-labels-ko.js +0 -6
- package/src/stage-startup-defaults.js +3 -9
- package/src/starter-scenes.js +5 -11
- package/src/studio-agent-commands.js +2 -2
- package/src/studio-agent-protocol.js +2 -7
- package/src/studio-app-binding.js +3 -3
- package/src/studio-elements.js +1 -1
- package/src/studio-error-codes.js +1 -1
- package/src/studio-motion-visual.js +4 -4
- package/src/styles/themes.css +13 -0
- package/src/styles.css +6 -674
- package/src/text/text-object.jsx +1 -1
- package/src/ui.jsx +8 -16
- package/src/use-embed-bridge.js +3 -6
- package/src/use-render-activity.js +3 -1
- package/src/view-ghosts.jsx +5 -3
- package/src/viewport-visible-rect.js +37 -0
- package/src/workflow/AgentPanel.jsx +75 -42
- package/src/workflow/AgentProgressCard.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +102 -0
- package/src/workflow/TaskInline.jsx +114 -0
- package/src/workflow/agent-client.js +11 -25
- package/src/workflow/agent-panel.css +14 -1
- package/src/workflow/attachment-image.js +10 -3
- package/src/workflow/attachment-video.js +91 -0
- package/src/workflow/task-board.css +98 -0
- package/src/workflow/task-board.js +169 -0
- package/tools/check-eslint.mjs +2 -1
- package/tools/check-knip.mjs +2 -1
- package/tools/dev/pages/greybox-to-video.html +2 -3
- package/tools/dev/pages/previs-software.html +2 -2
- package/tools/dev/pages/privacy.html +3 -7
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/dev-full.mjs +11 -2
- package/tools/eslint-baseline.json +6 -6
- package/tools/knip-baseline.json +0 -11
- package/tools/loc-baseline.json +30 -31
- package/tools/metrics-snapshot.mjs +43 -5
- package/tools/render-start-thumbs.mjs +175 -0
- package/tools/run-tests.mjs +7 -14
- package/tools/sitemap.mjs +1 -1
- package/dist/assets/analytics-DbKG0NgD.js +0 -6
- package/dist/assets/app-34i-iUy1.js +0 -5088
- package/dist/assets/app-f-fkV5uP.css +0 -1
- package/dist/assets/compile-worker-DYpa2nvO.js +0 -5
- package/dist/assets/first-shot-handoff-CrGZ3n_O.js +0 -1
- package/dist/assets/landing-mq5bnqFE.js +0 -1
- package/dist/assets/scene-asset-sync-7J-w5oNh.js +0 -20
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
- package/dist/assets/workflow-dUhurYUc.js +0 -179
- package/dist/scenes/city-block.cclayproject +0 -1
- package/src/burn-in.js +0 -91
- package/src/camera-tutorial.jsx +0 -520
- package/src/facing-marks.js +0 -88
- package/src/first-shot-handoff.js +0 -44
- package/src/panels/motion.css +0 -197
- package/src/set-pieces.jsx +0 -183
- package/src/tutorial-analytics.js +0 -86
- package/src/tutorial-walk.js +0 -17
- package/src/use-camera-tutorial.js +0 -50
- package/src/use-semantic-state.js +0 -12
- package/tools/metrics/archive-traffic.mjs +0 -138
- package/tools/perf/ik-camera-drag.mjs +0 -138
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;
|
|
@@ -311,13 +286,13 @@ export function createCommandBus({ registry, ports }) {
|
|
|
311
286
|
domainRevision = domain ? current.domainRevisions?.[domain] : null;
|
|
312
287
|
};
|
|
313
288
|
const nestedIds = new Set();
|
|
314
|
-
const context = { origin: request.origin, signal: controller.signal,
|
|
289
|
+
const context = { origin: request.origin, signal: controller.signal, taskId: request.taskId ?? null,
|
|
315
290
|
check() {
|
|
316
291
|
controller.signal.throwIfAborted();
|
|
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 };
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
// Where a motion compile runs. In the browser:
|
|
2
|
-
// on
|
|
3
|
-
// and never blocks the viewport (the main thread runs the compiler ~4x slower and freezes
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Where a motion compile runs. In the browser: a pool of module Web Workers (motion3/compile-worker.js), at most
|
|
2
|
+
// POOL of them, each started on first need and reused; a job takes an idle worker or waits in arrival order, so a
|
|
3
|
+
// compile costs a worker's time and never blocks the viewport (the main thread runs the compiler ~4x slower and freezes
|
|
4
|
+
// while it does). Two workers let two Studio tasks compile at once (parallel tasks, case 10) without a third
|
|
5
|
+
// starving the editor's cores; the rest wait, and the panel shows which task's compile is waiting (compileActivity).
|
|
6
|
+
// Where there is no Worker (Node, tests) or a worker fails: the same job on the calling thread, synchronously, so
|
|
7
|
+
// `registry.run("motion.compile", ...)` there still answers at once. The answer is the same either way (compile-job.js
|
|
8
|
+
// runs the same code on the same plain inputs), whichever worker runs it.
|
|
7
9
|
import { runCompileJob } from "../motion3/compile-job.js";
|
|
8
10
|
import { StudioProtocolError } from "../studio-agent-protocol.js";
|
|
9
11
|
|
|
12
|
+
const POOL = 2;
|
|
13
|
+
|
|
10
14
|
/** A job error (or a worker's serialized one) as the Studio's protocol error when it carries a code. */
|
|
11
15
|
function rethrow(error) {
|
|
12
16
|
if (error?.code && !(error instanceof StudioProtocolError)) throw new StudioProtocolError(error.code, error.message);
|
|
@@ -21,43 +25,63 @@ function browserWorker() {
|
|
|
21
25
|
return new Worker(new URL("../motion3/compile-worker.js", import.meta.url), { type: "module", name: "motion-compile" });
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
// What the editor's compiles are doing, for the panel: { running: [meta], waiting: [meta] } (meta: { taskId, characterId }).
|
|
29
|
+
const watchers = new Set();
|
|
30
|
+
let activity = { running: [], waiting: [] };
|
|
31
|
+
const publish = next => { activity = next; for (const watcher of watchers) watcher(activity); };
|
|
32
|
+
export const compileActivity = () => activity;
|
|
33
|
+
export function watchCompileActivity(watcher) { watchers.add(watcher); return () => watchers.delete(watcher); }
|
|
34
|
+
|
|
24
35
|
/**
|
|
25
|
-
* createCompileRunner({ spawn }) -> { run(job) -> answer | Promise<answer>, takeWarning() -> string | null }
|
|
36
|
+
* createCompileRunner({ spawn, size }) -> { run(job, meta?) -> answer | Promise<answer>, takeWarning() -> string | null }
|
|
26
37
|
* spawn() -> a Worker-like object (postMessage, onmessage, onerror, terminate) or null for the calling thread.
|
|
27
38
|
*/
|
|
28
|
-
export function createCompileRunner({ spawn = browserWorker } = {}) {
|
|
29
|
-
let
|
|
30
|
-
const
|
|
31
|
-
|
|
39
|
+
export function createCompileRunner({ spawn = browserWorker, size = POOL } = {}) {
|
|
40
|
+
let dead = false, warning = null, next = 0;
|
|
41
|
+
const slots = [], queue = [];
|
|
42
|
+
const report = () => publish({ running: slots.filter(slot => slot.job).map(slot => slot.job.meta), waiting: queue.map(item => item.meta) });
|
|
43
|
+
/** A worker failed (would not start, or crashed): every job from now on, and those in flight or waiting, run here. */
|
|
32
44
|
const fallBack = reason => {
|
|
33
45
|
dead = true;
|
|
34
|
-
worker?.terminate?.();
|
|
35
|
-
worker = null;
|
|
36
46
|
warning ??= `The motion compile worker stopped (${reason}); compiling on the main thread, which is slower and blocks the editor while it runs.`;
|
|
37
|
-
|
|
47
|
+
const orphans = [...slots.flatMap(slot => (slot.job ? [slot.job] : [])), ...queue.splice(0)];
|
|
48
|
+
for (const slot of slots.splice(0)) slot.worker?.terminate?.();
|
|
49
|
+
for (const { job, resolve, reject } of orphans) { try { resolve(local(job)); } catch (error) { reject(error); } }
|
|
50
|
+
report();
|
|
38
51
|
};
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
/** One more worker in the pool: its slot, or null when none can start (dead: every job runs here from now on). */
|
|
53
|
+
const grow = () => {
|
|
54
|
+
let worker;
|
|
41
55
|
try { worker = spawn(); } catch (error) { fallBack(error?.message ?? String(error)); return null; }
|
|
42
56
|
if (!worker) { dead = true; return null; }
|
|
57
|
+
const slot = { worker, job: null };
|
|
43
58
|
worker.onmessage = ({ data: { id, value, error } }) => {
|
|
44
|
-
const job =
|
|
45
|
-
if (!job) return;
|
|
46
|
-
|
|
59
|
+
const job = slot.job;
|
|
60
|
+
if (!job || job.id !== id) return;
|
|
61
|
+
slot.job = null;
|
|
47
62
|
if (error) { try { rethrow(error); } catch (thrown) { job.reject(thrown); } } else job.resolve(value);
|
|
63
|
+
pump();
|
|
48
64
|
};
|
|
49
65
|
worker.onerror = event => { event?.preventDefault?.(); fallBack(event?.message || "worker error"); };
|
|
50
66
|
worker.onmessageerror = () => fallBack("an answer could not be read");
|
|
51
|
-
|
|
67
|
+
slots.push(slot);
|
|
68
|
+
return slot;
|
|
69
|
+
};
|
|
70
|
+
const pump = () => {
|
|
71
|
+
while (!dead && queue.length) {
|
|
72
|
+
const slot = slots.find(candidate => !candidate.job) ?? (slots.length < size ? grow() : null);
|
|
73
|
+
if (!slot) break;
|
|
74
|
+
slot.job = queue.shift();
|
|
75
|
+
try { slot.worker.postMessage({ id: slot.job.id, job: slot.job.job }); } catch (error) { fallBack(error?.message ?? String(error)); return; }
|
|
76
|
+
}
|
|
77
|
+
if (dead) for (const item of queue.splice(0)) { try { item.resolve(local(item.job)); } catch (error) { item.reject(error); } }
|
|
78
|
+
report();
|
|
52
79
|
};
|
|
53
80
|
return {
|
|
54
|
-
run(job) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
pending.set(id, { job, resolve, reject });
|
|
59
|
-
try { worker.postMessage({ id, job }); } catch (error) { fallBack(error?.message ?? String(error)); }
|
|
60
|
-
});
|
|
81
|
+
run(job, meta = {}) {
|
|
82
|
+
// No worker can start here (Node, tests): answer on this thread at once, as before.
|
|
83
|
+
if (dead || (!slots.length && !grow())) return local(job);
|
|
84
|
+
return new Promise((resolve, reject) => { queue.push({ id: ++next, job, meta, resolve, reject }); pump(); });
|
|
61
85
|
},
|
|
62
86
|
/** The fallback note, once (a receipt warning), or null. */
|
|
63
87
|
takeWarning() { const note = warning; if (note) warning = ""; return note || null; },
|
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"]) },
|
|
@@ -105,10 +105,10 @@ export function register(registry, ports) {
|
|
|
105
105
|
* spec on the character's rig and the current scene (compile-job.js) -> { stored (the spec a take keeps and its specId
|
|
106
106
|
* names), qa, ..., take when bake is given }, at once or as a promise. opts: probes, checks, lenient, repair.
|
|
107
107
|
*/
|
|
108
|
-
const compile = (character, spec, opts = {}, bake) => {
|
|
108
|
+
const compile = (character, spec, opts = {}, bake, taskId = null) => {
|
|
109
109
|
rigOf(character);
|
|
110
110
|
const placed = bake && { position: [character.x ?? 0, character.y ?? 0, character.z ?? 0], yaw: character.rot ?? 0, ...bake };
|
|
111
|
-
return runner.run({ model: character.model, scene: sceneFor(character), spec, ...opts, ...(placed ? { bake: placed } : {}) });
|
|
111
|
+
return runner.run({ model: character.model, scene: sceneFor(character), spec, ...opts, ...(placed ? { bake: placed } : {}) }, { taskId, characterId: character.id });
|
|
112
112
|
};
|
|
113
113
|
/** The whole output, kept in the store (motion.report, delta) and cut to the receipt budget (report.js). */
|
|
114
114
|
const report = (r, head, extra = {}) => {
|
|
@@ -145,7 +145,7 @@ export function register(registry, ports) {
|
|
|
145
145
|
const installedSpec = () => { const take = owner().motionFor(characterId); return take?.spec && specIdOf(take.spec) === base ? structuredClone(take.spec) : null; };
|
|
146
146
|
const patched = [], source = spec ?? asArgument(() => applyPatch(store.get(base) ?? installedSpec() ?? unknownSpec("base", base), patch ?? [], patched));
|
|
147
147
|
// the take is always baked: the user watches every draft, installed or not
|
|
148
|
-
return after(compile(character, source, { probes, repair }, {}), r => {
|
|
148
|
+
return after(compile(character, source, { probes, repair }, {}, context?.taskId), r => {
|
|
149
149
|
const { qa, take } = r, notes = [...patched, ...(r.notes ?? [])];
|
|
150
150
|
// ifClean: install only a clean final result (qa.ok includes the goals; after repair when asked)
|
|
151
151
|
const wanted = install === "ifClean" ? qa.ok : install;
|
|
@@ -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/project.js
CHANGED
|
@@ -12,7 +12,7 @@ const saveAs = { ...save, id: "project.saveAs", label: "Save project as" };
|
|
|
12
12
|
const exportAsAnimation = studioActionDeclaration("project.exportAsAnimation");
|
|
13
13
|
const opening = document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token });
|
|
14
14
|
const fresh = document("project.new", "Start a new project", { name, previsMode: { type: "string", enum: [...PREVIS_MODES] } });
|
|
15
|
-
const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "
|
|
15
|
+
const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "launch"] }, name });
|
|
16
16
|
const restore = { ...document("project.restore", "Restore the remembered project", { handleToken: token }), exposure: "ui-only" };
|
|
17
17
|
const browse = { id: "project.browse", label: "Browse projects", description: "Show the project browser.", kind: "transient", exposure: "ui-only", input: input() };
|
|
18
18
|
export const declarations = Object.freeze([rename, save, saveAs, exportAsAnimation, opening, fresh, starter, restore, browse]);
|
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),
|
package/src/commands/shot.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// One semantic entry point for timeline, camera controls and agent framing.
|
|
2
2
|
import { createCameraBlock, updateCameraBlock, removeCameraRail } from '../camera-block.js';
|
|
3
|
-
import { addShotAtFrame, createShot, cutAtFrame, duplicateShot, removeShot, reorderShot, resizeShot, renameShot, moveCameraKey, removeCameraKey } from '../cuts.js';
|
|
3
|
+
import { addShotAtFrame, appendShotGrowingTake, createShot, cutAtFrame, duplicateShot, removeShot, reorderShot, resizeShot, renameShot, moveCameraKey, removeCameraKey } from '../cuts.js';
|
|
4
4
|
import { reflowStillShots, stillFrameCount, STILL_FRAME_COUNT_MAX, STILL_HOLD_DEFAULT } from '../shot-authoring.js';
|
|
5
5
|
import { ko } from '../locale.js';
|
|
6
6
|
import { track } from '../analytics.js';
|
|
@@ -17,6 +17,7 @@ import './elements/shot.js';
|
|
|
17
17
|
import { changedIds, fail, shotLabel } from './shared.js';
|
|
18
18
|
|
|
19
19
|
const id = StudioSchemas.TargetGuard.properties.targetId;
|
|
20
|
+
const TAKE_FRAME_MAX = 28800;
|
|
20
21
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
21
22
|
const number = { type: 'number' };
|
|
22
23
|
const frame = { type: 'integer', minimum: 0 };
|
|
@@ -30,7 +31,7 @@ const extra = [
|
|
|
30
31
|
mutation('shot.moveKey', 'Move camera key', input({ shotId: id, keyId: id, frame })),
|
|
31
32
|
mutation('shot.removeKey', 'Remove camera key', input({ shotId: id, keyId: id })),
|
|
32
33
|
mutation('shot.clearKeys', 'Clear camera keys', input({ shotId: id })),
|
|
33
|
-
mutation('shot.setTimeline', 'Set shot timeline', input({ frameCount: { type: 'integer', minimum: 24, maximum:
|
|
34
|
+
mutation('shot.setTimeline', 'Set shot timeline', input({ frameCount: { type: 'integer', minimum: 24, maximum: TAKE_FRAME_MAX } })),
|
|
34
35
|
mutation('shot.setLens', 'Set camera lens', input({ fovDeg: { ...number, minimum: 14, maximum: 90 } })),
|
|
35
36
|
mutation('shot.frame', 'Frame the shot', { ...input({ ...STUDIO_TOOL_SCHEMAS.frame_shot.properties, preset: { type: 'string' } }, []), oneOf: [STUDIO_TOOL_SCHEMAS.frame_shot, input({ preset: { type: 'string' } })] },
|
|
36
37
|
'Frame a shot on one character (the frame_shot tool): framing.intent { size, level, view, side, focalMm } or framing.exact { position, lookAt, focalMm }. Without keyAtFrame a shot that holds one framing is reframed whole: a held shot. keyAtFrame adds a camera key there; every key eases in and out, so the camera slows to a stop at each one: key a pan or zoom from one spot with two keys (its start and end), lay a moving camera as a rail (shot.setCameraRail) and cut to a new shot for a new view. On a rail shot it puts the shot back on keys. Answers checks { derivedSize, screenFraction, clipped, behindCamera, occluded, cameraSide } for the subject at that frame.'),
|
|
@@ -84,6 +85,17 @@ export function register(registry, ports) {
|
|
|
84
85
|
const created = written.find(shot => shot.id === still.id), { selection, view } = ports.readView();
|
|
85
86
|
ports.publishView({ selection, view: { ...view, frame: created.startFrame }, shotId: created.id });
|
|
86
87
|
}
|
|
88
|
+
// A clip shot goes in the free time at (or after) the playhead; with none left the take grows at its end,
|
|
89
|
+
// in the same write so one undo takes both back, and the playhead lands in the new shot.
|
|
90
|
+
function createClip() {
|
|
91
|
+
const current = owner().read(), { frameCount } = owner().state(), framing = owner().capture();
|
|
92
|
+
const placed = addShotAtFrame(current, ports.state().frame, frameCount, framing);
|
|
93
|
+
if (placed !== current) return owner().write(placed);
|
|
94
|
+
const grown = appendShotGrowingTake(current, frameCount, framing, TAKE_FRAME_MAX) ?? fail('INVALID_ARGUMENT', `The take is at its longest (${TAKE_FRAME_MAX} frames); shorten or remove a shot first.`);
|
|
95
|
+
owner().writeState(before => ({ ...before, ...grown }));
|
|
96
|
+
const added = grown.shots.find(shot => !current.some(row => row.id === shot.id)), { selection, view } = ports.readView();
|
|
97
|
+
if (added) ports.publishView({ selection, view: { ...view, frame: added.startFrame }, shotId: added.id });
|
|
98
|
+
}
|
|
87
99
|
function duplicate(shotId) {
|
|
88
100
|
const source = shotOf(shotId), copied = { kind: source.kind, caption: source.caption, cast: structuredClone(source.cast), stylizedAssetId: source.stylizedAssetId };
|
|
89
101
|
if (source.kind !== 'still') {
|
|
@@ -130,7 +142,7 @@ export function register(registry, ports) {
|
|
|
130
142
|
// aimHeight moves a cast aim off the chest to a world height (a whole body, a face).
|
|
131
143
|
const aimOf = (aim, aimHeight) => (aim?.ids && aimHeight !== undefined ? { ids: aim.ids, height: aimHeight } : aim);
|
|
132
144
|
const methods = {
|
|
133
|
-
'shot.create': () => storyboard() ? createStill() :
|
|
145
|
+
'shot.create': () => storyboard() ? createStill() : createClip(),
|
|
134
146
|
'shot.createStill': args => createStill(args),
|
|
135
147
|
'shot.setCaption': ({ shotId, caption }) => patchShot(shotId, shot => ({ ...shot, caption })),
|
|
136
148
|
'shot.setCastOverride': ({ shotId, characterId, override }) => owner().setCastOverride(shotId, characterId, override),
|
|
@@ -188,7 +200,7 @@ export function register(registry, ports) {
|
|
|
188
200
|
const hasShots = state => state.shots.length > 0 || 'There are no shots yet; add one with shot.create.';
|
|
189
201
|
const availability = {
|
|
190
202
|
'shot.createStill': () => true,
|
|
191
|
-
'shot.create': state => storyboard() || addShotAtFrame(state.shots, state.frame, state.frameCount, null) !== state.shots ||
|
|
203
|
+
'shot.create': state => storyboard() || addShotAtFrame(state.shots, state.frame, state.frameCount, null) !== state.shots || appendShotGrowingTake(state.shots, state.frameCount, null, TAKE_FRAME_MAX) !== null || `The take is at its longest (${TAKE_FRAME_MAX} frames); shorten or remove a shot first.`,
|
|
192
204
|
'shot.split': state => state.shots.some(shot => state.frame > shot.startFrame && state.frame <= shot.endFrame) || `The playhead (frame ${state.frame}) is not inside a shot after its first frame; move it with operate_studio { frame }.`,
|
|
193
205
|
'shot.clearCameraRail': state => state.shots.some(shot => createCameraBlock(shot.camera).cameraRail) || 'No shot has a camera rail; lay one with shot.setCameraRail.',
|
|
194
206
|
};
|
package/src/controls.jsx
CHANGED
|
@@ -53,14 +53,6 @@ export function aimAt(position, target) {
|
|
|
53
53
|
* `getPivot()` is optional and returns a world point to orbit; without one,
|
|
54
54
|
* Alt-drag orbits a point straight ahead of the lens.
|
|
55
55
|
*/
|
|
56
|
-
/** Coarse navigation signal for onboarding surfaces (the landing-page
|
|
57
|
-
* tutorial listens through the playground bridge). Fires once per gesture
|
|
58
|
-
* start, not per frame. */
|
|
59
|
-
function announceNav(kind, key = null) {
|
|
60
|
-
if (typeof window === "undefined") return;
|
|
61
|
-
window.dispatchEvent(new CustomEvent("cozyclay:nav", { detail: { kind, key } }));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
56
|
export function FlyControls({ enabled, cameraLocked = false, camRef, look, getPivot, onFlyStateChange, onCameraChange }) {
|
|
65
57
|
const { gl, invalidate } = useThree();
|
|
66
58
|
const keys = useRef(new Set());
|
|
@@ -136,7 +128,6 @@ export function FlyControls({ enabled, cameraLocked = false, camRef, look, getPi
|
|
|
136
128
|
if (gesture.current?.kind !== "fly") return;
|
|
137
129
|
const key = KEY_BY_CODE[e.code];
|
|
138
130
|
if (key) {
|
|
139
|
-
if (!keys.current.has(key) && key !== "shift") announceNav("walk", key);
|
|
140
131
|
keys.current.add(key);
|
|
141
132
|
e.preventDefault();
|
|
142
133
|
}
|
|
@@ -220,7 +211,6 @@ export function FlyControls({ enabled, cameraLocked = false, camRef, look, getPi
|
|
|
220
211
|
lockAttempts = 0;
|
|
221
212
|
if (e.pointerType !== "touch" && e.pointerType !== "pen") requestNavLock();
|
|
222
213
|
if (kind === "fly") flyStateRef.current?.(true);
|
|
223
|
-
announceNav(kind);
|
|
224
214
|
scheduleInvalidate();
|
|
225
215
|
};
|
|
226
216
|
|
|
@@ -309,7 +299,6 @@ export function FlyControls({ enabled, cameraLocked = false, camRef, look, getPi
|
|
|
309
299
|
return;
|
|
310
300
|
}
|
|
311
301
|
cam.position.addScaledVector(forwardFrom(look.current.yaw, look.current.pitch), -e.deltaY * DOLLY_STEP);
|
|
312
|
-
announceNav("dolly");
|
|
313
302
|
cameraChangeRef.current?.();
|
|
314
303
|
};
|
|
315
304
|
|
package/src/cuts.js
CHANGED
|
@@ -127,6 +127,17 @@ export function addShotAtFrame(shots, frame, frameCount, currentFraming, duratio
|
|
|
127
127
|
return [...list, createShot(`Shot ${list.length + 1}`, target, endFrame, keys)].sort((a, b) => a.startFrame - b.startFrame);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** Add Shot when the take has no free time left: the take grows by one default
|
|
131
|
+
* shot at its end and the new shot fills that, so the button never silently does
|
|
132
|
+
* nothing. Existing shots are untouched. Null past maxFrameCount. */
|
|
133
|
+
export function appendShotGrowingTake(shots, frameCount, currentFraming, maxFrameCount) {
|
|
134
|
+
const list = Array.isArray(shots) ? shots : [];
|
|
135
|
+
const startFrame = Math.max(frameNumber(frameCount, 1), ...list.map((shot) => shot.endFrame + 1));
|
|
136
|
+
const grownCount = startFrame + DEFAULT_SHOT_FRAMES;
|
|
137
|
+
if (grownCount > maxFrameCount) return null;
|
|
138
|
+
return { shots: addShotAtFrame(list, startFrame, grownCount, currentFraming), frameCount: grownCount };
|
|
139
|
+
}
|
|
140
|
+
|
|
130
141
|
export function resizeShot(shots, shotId, edge, rawFrame, frameCount) {
|
|
131
142
|
const shot = shotById(shots, shotId);
|
|
132
143
|
if (edge !== "start" && edge !== "end") return shots;
|
package/src/domains/cast.js
CHANGED
|
@@ -546,7 +546,7 @@ export function useCast(appContext) {
|
|
|
546
546
|
return current.length;
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
-
/**
|
|
549
|
+
/** Click-to-place authoring: each empty-floor press in the Shot view drops
|
|
550
550
|
the next waypoint where it was clicked; the frame gap comes from walking
|
|
551
551
|
distance. The bird's-eye board selects and drags existing waypoints. */
|
|
552
552
|
function addFloorWaypoint(point) {
|
|
@@ -715,7 +715,8 @@ export function useCast(appContext) {
|
|
|
715
715
|
}
|
|
716
716
|
function undoScene() {
|
|
717
717
|
const receipt = appContext.bus.run('edit.undo');
|
|
718
|
-
|
|
718
|
+
// A refusal says why: the last edit belongs to a running task (parallel tasks), which its own card undoes.
|
|
719
|
+
if (receipt.status === 'noop' || receipt.ok === false) appContext.notify(receipt.ok === false ? ko(receipt.message ?? "Undo was refused", `실행 취소 불가: ${receipt.message ?? ""}`) : ko("Nothing to undo", "실행 취소할 작업이 없어요"));
|
|
719
720
|
return receipt;
|
|
720
721
|
}
|
|
721
722
|
function redoScene() {
|
package/src/domains/motion.js
CHANGED
|
@@ -29,8 +29,8 @@ import {
|
|
|
29
29
|
} from "../motion/ik.js";
|
|
30
30
|
import * as THREE from "three";
|
|
31
31
|
import { StudioProtocolError } from "../studio-agent-protocol.js";
|
|
32
|
-
|
|
33
32
|
import { applyTakeFrame } from "../motion3/playback.js";
|
|
33
|
+
import { advancePlayback, installPlaybackApplier } from "./playback.js";
|
|
34
34
|
import { isKo, ko } from "../locale.js";
|
|
35
35
|
import { isProxyFigure } from "../scenes.js";
|
|
36
36
|
import { refuseRigOnly } from "../character-kind.js";
|
|
@@ -625,15 +625,7 @@ export function useMotion(appContext) {
|
|
|
625
625
|
|
|
626
626
|
|
|
627
627
|
function advanceFrame(steps = 1) {
|
|
628
|
-
|
|
629
|
-
const previewEnd = appContext.shared.cameraPreviewEndRef.current;
|
|
630
|
-
if (previewEnd != null && appContext.shared.tlFrameRef.current + count >= previewEnd) {
|
|
631
|
-
appContext.shared.cameraPreviewEndRef.current = null;
|
|
632
|
-
appContext.shared.setTlFrame(previewEnd);
|
|
633
|
-
appContext.shared.setTlPlaying(false);
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
appContext.shared.setTlFrame((f) => (f + count) % appContext.shared.frameCountRef.current);
|
|
628
|
+
advancePlayback(appContext.shared, steps);
|
|
637
629
|
}
|
|
638
630
|
|
|
639
631
|
function stepFrame(delta) {
|
|
@@ -677,6 +669,7 @@ export function useMotion(appContext) {
|
|
|
677
669
|
appContext.shared.poseMemberAtFrame(appContext.shared.rigs[entry.id], entry.sessionMotion, appContext.shared.ikStatesRef.current.get(entry.id), frame, IK_CORRECTION_BLEND_FRAMES);
|
|
678
670
|
}
|
|
679
671
|
};
|
|
672
|
+
installPlaybackApplier(appContext, motion, poseOtherCastMembers, ikEvaluate, IK_CORRECTION_BLEND_FRAMES);
|
|
680
673
|
|
|
681
674
|
function toggleIkMode() {
|
|
682
675
|
const next = !ikMode;
|