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.
Files changed (221) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +22 -56
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/session-store.mjs +2 -2
  10. package/bin/agent/studio-prompt.mjs +1 -1
  11. package/bin/agent/studio-task-routes.mjs +185 -0
  12. package/bin/agent/studio-tasks.mjs +156 -0
  13. package/bin/agent/studio-tools.mjs +3 -2
  14. package/bin/agent/studio-turn-transaction.mjs +76 -0
  15. package/bin/cozyclay.mjs +5 -7
  16. package/dist/app/index.html +7 -6
  17. package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
  18. package/dist/assets/app-B2BpfCl2.js +5088 -0
  19. package/dist/assets/app-BlwOpest.css +1 -0
  20. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  21. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  22. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  23. package/dist/assets/motion3-compile-B887n77J.js +1 -0
  24. package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
  25. package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
  26. package/dist/assets/task-board-BKaM7d-9.js +14 -0
  27. package/dist/assets/workflow-B8Dhzexj.js +179 -0
  28. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  29. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  30. package/dist/cozyclay-package.json +1 -1
  31. package/dist/greybox-to-video/index.html +2 -3
  32. package/dist/index.html +7 -57
  33. package/dist/llms.txt +2 -1
  34. package/dist/previs-software/index.html +2 -2
  35. package/dist/privacy/index.html +3 -7
  36. package/dist/seedance-camera-control/index.html +2 -2
  37. package/dist/sitemap.xml +5 -5
  38. package/dist/sw.js +5 -1
  39. package/dist/thumbs/blank-stage-dark.webp +0 -0
  40. package/dist/thumbs/blank-stage-light.webp +0 -0
  41. package/dist/workflow/index.html +5 -4
  42. package/mcp/mesh-file.mjs +3 -1
  43. package/mcp/server.mjs +3 -0
  44. package/package.json +3 -3
  45. package/src/App.jsx +50 -281
  46. package/src/analytics.js +3 -148
  47. package/src/app-stage.jsx +1 -1
  48. package/src/asset-pane.jsx +5 -10
  49. package/src/auto-flat.js +10 -0
  50. package/src/bus-task-scopes.js +69 -0
  51. package/src/bus-task-targets.js +48 -0
  52. package/src/bus-tasks.js +181 -0
  53. package/src/command-bus.js +15 -40
  54. package/src/command-history.js +36 -0
  55. package/src/commands/cast.js +10 -3
  56. package/src/commands/motion.js +2 -1
  57. package/src/commands/motion3-compile.js +51 -27
  58. package/src/commands/motion3.js +13 -7
  59. package/src/commands/project.js +1 -1
  60. package/src/commands/shared.js +9 -0
  61. package/src/commands/shot.js +16 -4
  62. package/src/controls.jsx +0 -11
  63. package/src/cuts.js +11 -0
  64. package/src/domains/cast.js +3 -2
  65. package/src/domains/motion.js +3 -10
  66. package/src/domains/playback.js +32 -0
  67. package/src/domains/scenes.js +3 -22
  68. package/src/domains/shots.js +5 -5
  69. package/src/dualview.jsx +11 -15
  70. package/src/hierarchy-panel.css +18 -4
  71. package/src/hierarchy-panel.jsx +12 -11
  72. package/src/kits/held.js +3 -2
  73. package/src/light-blockers.js +2 -2
  74. package/src/main.jsx +5 -0
  75. package/src/motion/ground.js +5 -14
  76. package/src/motion/playback-frame-store.js +79 -0
  77. package/src/motion/playback-probe.js +16 -0
  78. package/src/motion/playback-subject.js +7 -0
  79. package/src/motion/timeline.css +43 -5
  80. package/src/motion/timeline.jsx +4 -3
  81. package/src/motion3/body.js +37 -5
  82. package/src/motion3/carried.js +11 -3
  83. package/src/motion3/compile-job.js +1 -1
  84. package/src/motion3/compiler/anchors.js +33 -5
  85. package/src/motion3/compiler/compile.js +27 -4
  86. package/src/motion3/compiler/finger-close.js +137 -0
  87. package/src/motion3/compiler/grip.js +9 -3
  88. package/src/motion3/compiler/limbs.js +18 -3
  89. package/src/motion3/compiler/pose.js +6 -0
  90. package/src/motion3/compiler/socket-grip.js +34 -10
  91. package/src/motion3/compiler/validate-objects.js +17 -2
  92. package/src/motion3/compiler/validate-parts.js +2 -1
  93. package/src/motion3/compiler/vocab.js +4 -4
  94. package/src/motion3/describe.js +2 -2
  95. package/src/motion3/docs.js +1 -1
  96. package/src/motion3/fix/index.js +7 -3
  97. package/src/motion3/flight.js +38 -0
  98. package/src/motion3/free-motion.js +10 -7
  99. package/src/motion3/load.js +125 -0
  100. package/src/motion3/mass.js +23 -0
  101. package/src/motion3/object-keys.js +11 -6
  102. package/src/motion3/object-pose.js +11 -3
  103. package/src/motion3/pipeline.js +5 -0
  104. package/src/motion3/qa/explain.js +3 -1
  105. package/src/motion3/qa/grasp-checks.js +123 -0
  106. package/src/motion3/qa/hints-grasp.js +71 -0
  107. package/src/motion3/qa/index.js +3 -1
  108. package/src/motion3/qa/object-checks.js +7 -2
  109. package/src/motion3/qa/patch.js +2 -1
  110. package/src/motion3/qa/spec-checks.js +21 -1
  111. package/src/motion3/schema/fields.js +3 -3
  112. package/src/motion3/schema/topics.js +11 -10
  113. package/src/motion3/schema/types.js +5 -1
  114. package/src/motion3/seat.js +84 -0
  115. package/src/motion3/settle.js +11 -6
  116. package/src/motion3/shapes.js +70 -0
  117. package/src/motion3/skills/basic.js +61 -13
  118. package/src/motion3/skills/catch.js +71 -0
  119. package/src/motion3/skills/index.js +13 -4
  120. package/src/motion3/skills/info.js +14 -1
  121. package/src/motion3/skills/reach-human.js +139 -0
  122. package/src/motion3/skills/stance.js +92 -0
  123. package/src/motion3/skills/throw.js +72 -0
  124. package/src/motion3/sockets.js +21 -4
  125. package/src/motion3/views.js +28 -5
  126. package/src/object-catalog.jsx +0 -4
  127. package/src/object-gizmo.jsx +2 -1
  128. package/src/object-path.js +1 -1
  129. package/src/panels/ProjectPanel.jsx +42 -21
  130. package/src/panels/details.css +15 -4
  131. package/src/popover.js +37 -0
  132. package/src/posestudio.jsx +15 -10
  133. package/src/project-browser-art.jsx +19 -0
  134. package/src/project-browser.css +74 -121
  135. package/src/project-browser.jsx +36 -60
  136. package/src/props.jsx +4 -7
  137. package/src/retired-kinds.js +37 -0
  138. package/src/scene-objects.js +3 -7
  139. package/src/scenes.js +4 -5
  140. package/src/settings-menu.jsx +1 -20
  141. package/src/shell/BottomDock.jsx +9 -9
  142. package/src/shell/MenuBar.jsx +1 -9
  143. package/src/shell/PreferencesDialog.jsx +3 -4
  144. package/src/shell/ViewportToolbar.jsx +3 -3
  145. package/src/shell/add-camera-shot.js +11 -0
  146. package/src/shell/glass-regions.css +3 -2
  147. package/src/shell/glass.css +0 -5
  148. package/src/shell/lens-scrub-math.js +51 -0
  149. package/src/shell/lens-scrub.css +73 -0
  150. package/src/shell/lens-scrub.jsx +98 -0
  151. package/src/shell/mode.css +6 -5
  152. package/src/shell/preferences.css +1 -32
  153. package/src/shell/shell.css +18 -1
  154. package/src/shell/studio-shell-context.js +5 -0
  155. package/src/shot-frame.js +26 -0
  156. package/src/stage-canvas-rigs.jsx +7 -8
  157. package/src/stage-characters.jsx +26 -0
  158. package/src/stage-gizmo.js +43 -0
  159. package/src/stage-labels-ko.js +0 -6
  160. package/src/stage-startup-defaults.js +3 -9
  161. package/src/starter-scenes.js +5 -11
  162. package/src/studio-agent-commands.js +2 -2
  163. package/src/studio-agent-protocol.js +2 -7
  164. package/src/studio-app-binding.js +3 -3
  165. package/src/studio-elements.js +1 -1
  166. package/src/studio-error-codes.js +1 -1
  167. package/src/studio-motion-visual.js +4 -4
  168. package/src/styles/themes.css +13 -0
  169. package/src/styles.css +6 -674
  170. package/src/text/text-object.jsx +1 -1
  171. package/src/ui.jsx +8 -16
  172. package/src/use-embed-bridge.js +3 -6
  173. package/src/use-render-activity.js +3 -1
  174. package/src/view-ghosts.jsx +5 -3
  175. package/src/viewport-visible-rect.js +37 -0
  176. package/src/workflow/AgentPanel.jsx +75 -42
  177. package/src/workflow/AgentProgressCard.jsx +1 -1
  178. package/src/workflow/TaskBoard.jsx +102 -0
  179. package/src/workflow/TaskInline.jsx +114 -0
  180. package/src/workflow/agent-client.js +11 -25
  181. package/src/workflow/agent-panel.css +14 -1
  182. package/src/workflow/attachment-image.js +10 -3
  183. package/src/workflow/attachment-video.js +91 -0
  184. package/src/workflow/task-board.css +98 -0
  185. package/src/workflow/task-board.js +169 -0
  186. package/tools/check-eslint.mjs +2 -1
  187. package/tools/check-knip.mjs +2 -1
  188. package/tools/dev/pages/greybox-to-video.html +2 -3
  189. package/tools/dev/pages/previs-software.html +2 -2
  190. package/tools/dev/pages/privacy.html +3 -7
  191. package/tools/dev/pages/seedance-camera-control.html +2 -2
  192. package/tools/dev-full.mjs +11 -2
  193. package/tools/eslint-baseline.json +6 -6
  194. package/tools/knip-baseline.json +0 -11
  195. package/tools/loc-baseline.json +30 -31
  196. package/tools/metrics-snapshot.mjs +43 -5
  197. package/tools/render-start-thumbs.mjs +175 -0
  198. package/tools/run-tests.mjs +7 -14
  199. package/tools/sitemap.mjs +1 -1
  200. package/dist/assets/analytics-DbKG0NgD.js +0 -6
  201. package/dist/assets/app-34i-iUy1.js +0 -5088
  202. package/dist/assets/app-f-fkV5uP.css +0 -1
  203. package/dist/assets/compile-worker-DYpa2nvO.js +0 -5
  204. package/dist/assets/first-shot-handoff-CrGZ3n_O.js +0 -1
  205. package/dist/assets/landing-mq5bnqFE.js +0 -1
  206. package/dist/assets/scene-asset-sync-7J-w5oNh.js +0 -20
  207. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  208. package/dist/assets/workflow-dUhurYUc.js +0 -179
  209. package/dist/scenes/city-block.cclayproject +0 -1
  210. package/src/burn-in.js +0 -91
  211. package/src/camera-tutorial.jsx +0 -520
  212. package/src/facing-marks.js +0 -88
  213. package/src/first-shot-handoff.js +0 -44
  214. package/src/panels/motion.css +0 -197
  215. package/src/set-pieces.jsx +0 -183
  216. package/src/tutorial-analytics.js +0 -86
  217. package/src/tutorial-walk.js +0 -17
  218. package/src/use-camera-tutorial.js +0 -50
  219. package/src/use-semantic-state.js +0 -12
  220. package/tools/metrics/archive-traffic.mjs +0 -138
  221. package/tools/perf/ik-camera-drag.mjs +0 -138
@@ -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
- // A batching/retention adapter over the existing object history owner. The
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
+ }
@@ -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
- const pose = value => typeof value === 'string' ? owner().poses().find(row => row.id === value) ?? fail('STALE_TARGET', `Pose ${value} is not in the library.`) : value;
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);
@@ -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: one module Web Worker (motion3/compile-worker.js), started
2
- // on the first compile and reused, jobs answered in arrival order, so a compile costs the worker's time
3
- // and never blocks the viewport (the main thread runs the compiler ~4x slower and freezes while it does).
4
- // Where there is no Worker (Node, tests) or the worker fails: the same job on the calling thread,
5
- // synchronously, so `registry.run("motion.compile", ...)` there still answers at once. The answer is the
6
- // same either way (compile-job.js runs the same code on the same plain inputs).
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 worker, dead = false, warning = null, next = 0;
30
- const pending = new Map();
31
- /** The worker failed (would not start, or crashed): every job from now on, and those in flight, run here. */
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
- for (const [id, { job, resolve, reject }] of pending) { pending.delete(id); try { resolve(local(job)); } catch (error) { reject(error); } }
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
- const started = () => {
40
- if (worker || dead) return worker;
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 = pending.get(id);
45
- if (!job) return;
46
- pending.delete(id);
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
- return worker;
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
- if (!started()) return local(job);
56
- return new Promise((resolve, reject) => {
57
- const id = ++next;
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; },
@@ -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 } }, ["specId"]) },
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
- registry.register({ ...declarations[4], available: always, run({ specId: wanted, part = "groups", from = 0 }) {
202
- const page = reportPage(store.parts(wanted) ?? unknownSpec("specId", wanted, "compile it again"), wanted, part, from);
203
- return { affectedIds: [], summary: `${page.items.length} ${part} from ${from}.`, output: page };
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
@@ -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", "tutorial", "launch"] }, name });
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]);
@@ -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),
@@ -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: 28800 } })),
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() : owner().write(current => addShotAtFrame(current, ports.state().frame, owner().state().frameCount, owner().capture())),
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 || `There is no free room for a new shot at the playhead (frame ${state.frame}); move it with operate_studio { frame } or shorten a shot.`,
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;
@@ -546,7 +546,7 @@ export function useCast(appContext) {
546
546
  return current.length;
547
547
  }
548
548
 
549
- /** ARDY-demo style authoring: each empty-floor press in the Shot view drops
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
- if (receipt.status === 'noop') appContext.notify(ko("Nothing to undo", "실행 취소할 작업이 없어요"));
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() {
@@ -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
- const count = Math.max(1, Math.floor(steps));
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;