cozyclay 3.1.1 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/agent/agent-routes.mjs +4 -4
  3. package/bin/agent/main-agent.mjs +21 -7
  4. package/bin/agent/main-prompt.mjs +7 -6
  5. package/bin/agent/script-tool.mjs +2 -1
  6. package/bin/agent/studio-prompt.mjs +1 -1
  7. package/bin/agent/studio-rebase.mjs +5 -4
  8. package/bin/agent/studio-task-routes.mjs +1 -1
  9. package/bin/agent/studio-tasks.mjs +116 -25
  10. package/bin/agent/studio-tools.mjs +3 -2
  11. package/bin/cozyclay.mjs +4 -2
  12. package/bin/live-endpoint.mjs +2 -0
  13. package/dist/app/index.html +6 -6
  14. package/dist/assets/TaskBoard-GR8xZm5c.js +1 -0
  15. package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
  16. package/dist/assets/app-C4QTcDcb.js +5144 -0
  17. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  18. package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
  19. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  20. package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
  21. package/dist/assets/task-board-BqReHzOi.js +15 -0
  22. package/dist/assets/workflow-C87p6E1E.js +179 -0
  23. package/dist/cozyclay-package.json +1 -1
  24. package/dist/index.html +1 -1
  25. package/dist/sitemap.xml +1 -1
  26. package/dist/workflow/index.html +4 -4
  27. package/mcp/README.md +1 -1
  28. package/mcp/live-hub.mjs +5 -3
  29. package/mcp/tool-handlers.mjs +5 -5
  30. package/package.json +1 -1
  31. package/src/App.jsx +42 -64
  32. package/src/bus-task-scopes.js +59 -11
  33. package/src/bus-task-targets.js +35 -9
  34. package/src/bus-tasks.js +23 -10
  35. package/src/command-bus.js +7 -4
  36. package/src/commands/ai.js +1 -1
  37. package/src/commands/export.js +1 -1
  38. package/src/commands/motion3.js +27 -7
  39. package/src/export-workspace.js +244 -0
  40. package/src/keyframe-pack.js +5 -1
  41. package/src/live-control.js +14 -7
  42. package/src/motion3/compile-job.js +15 -3
  43. package/src/motion3/compiler/channels.js +74 -24
  44. package/src/motion3/compiler/compile.js +2 -1
  45. package/src/motion3/compiler/idle.js +2 -0
  46. package/src/motion3/compiler/overlap.js +180 -0
  47. package/src/motion3/compiler/pose.js +6 -4
  48. package/src/motion3/compiler/validate-common.js +1 -0
  49. package/src/motion3/compiler/validate-parts.js +5 -1
  50. package/src/motion3/compiler/validate.js +30 -10
  51. package/src/motion3/correct.js +236 -0
  52. package/src/motion3/docs.js +8 -6
  53. package/src/motion3/face-lean.js +31 -0
  54. package/src/motion3/fix/index.js +5 -1
  55. package/src/motion3/fix/reach-body.js +172 -0
  56. package/src/motion3/hint-check.js +44 -0
  57. package/src/motion3/json-patch.js +120 -12
  58. package/src/motion3/lenient.js +46 -4
  59. package/src/motion3/object-keys.js +17 -5
  60. package/src/motion3/pipeline.js +46 -9
  61. package/src/motion3/qa/arm-checks.js +96 -0
  62. package/src/motion3/qa/asked.js +113 -0
  63. package/src/motion3/qa/goals.js +37 -8
  64. package/src/motion3/qa/hints-goal.js +6 -5
  65. package/src/motion3/qa/index.js +5 -2
  66. package/src/motion3/qa/key-checks.js +4 -3
  67. package/src/motion3/qa/motion-life.js +243 -0
  68. package/src/motion3/qa/spec-checks.js +15 -9
  69. package/src/motion3/qa/verdict.js +50 -0
  70. package/src/motion3/refs.js +38 -14
  71. package/src/motion3/repair-watch.js +132 -0
  72. package/src/motion3/repair.js +52 -19
  73. package/src/motion3/report.js +50 -9
  74. package/src/motion3/schema/topics.js +6 -4
  75. package/src/motion3/schema/types.js +1 -1
  76. package/src/motion3/skills/basic.js +15 -8
  77. package/src/motion3/skills/index.js +52 -18
  78. package/src/motion3/skills/info.js +1 -1
  79. package/src/motion3/skills/stance.js +6 -0
  80. package/src/motion3/skills/walk-fit.js +170 -0
  81. package/src/panels/PoseImagePanel.jsx +3 -2
  82. package/src/panels/SculptPanel.jsx +2 -2
  83. package/src/result-modal.jsx +3 -3
  84. package/src/shell/ExportFolderPreference.jsx +103 -0
  85. package/src/shell/PreferencesDialog.jsx +3 -166
  86. package/src/shell/StatusBar.jsx +1 -1
  87. package/src/shell/preference-controls.jsx +114 -0
  88. package/src/shell/preference-pages.jsx +72 -0
  89. package/src/shell/preferences.css +46 -0
  90. package/src/shot-prompt.js +29 -9
  91. package/src/studio-actions.js +2 -2
  92. package/src/studio-agent-motion.js +19 -9
  93. package/src/studio-agent-protocol.js +1 -1
  94. package/src/studio-app-binding.js +3 -2
  95. package/src/workflow/AgentPanel.jsx +2 -2
  96. package/src/workflow/CozySceneNode.jsx +1 -1
  97. package/src/workflow/WorkflowBuilder.jsx +2 -2
  98. package/src/workflow/keyframe-pack-request.js +3 -10
  99. package/src/workflow/task-board.css +1 -1
  100. package/src/workflow/task-board.js +7 -5
  101. package/tools/loc-baseline.json +1 -2
  102. package/tools/run-tests.mjs +5 -5
  103. package/dist/assets/TaskBoard-ngdqiu7u.js +0 -1
  104. package/dist/assets/app-DmR7jBkz.js +0 -5144
  105. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  106. package/dist/assets/motion3-compile-BB3_1-EB.js +0 -1
  107. package/dist/assets/scene-asset-sync-CBx7XRti.css +0 -1
  108. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +0 -13
  109. package/dist/assets/task-board-ErQvfoaz.js +0 -14
  110. package/dist/assets/workflow-zQM-EKW1.js +0 -179
package/src/bus-tasks.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // next command on a target the user changed answers STALE_TARGET once. Ctrl+Z stops at a running task's entry.
9
9
  // A single agent turn (agent.turn.*) is untouched: tasks and a turn never run together.
10
10
  import { StudioProtocolError, validateReceipt } from './studio-agent-protocol.js';
11
- import { commandKeys, keyOfId, scopeConflict, isScopeKey } from './bus-task-scopes.js';
11
+ import { commandKeys, keyOfId, scopeConflict, isScopeKey, layerOf, expandScope, keysOverlap } from './bus-task-scopes.js';
12
12
 
13
13
  const fail = (code, message) => { throw new StudioProtocolError(code, message); };
14
14
  const VERBS = ['begin', 'requestScope', 'mark', 'rollback', 'finish', 'cancel', 'undo'];
@@ -17,7 +17,11 @@ const listOf = keys => [...keys].slice(0, 6).join(', ') + (keys.length > 6 ? ` a
17
17
 
18
18
  export function createBusTasks({ ports, emit }) {
19
19
  const open = new Map(), finished = new Map(), entryTask = new Map();
20
+ // A character layer has its own token (its fields only), so another task's edit of her other layers never makes it
21
+ // stale; an editor without layer reads falls back to the whole character's.
20
22
  const tokenOf = key => {
23
+ const of = layerOf(key);
24
+ if (of?.layer) return ports.readLayer?.(key) ?? ports.readTarget?.(of.id) ?? 'absent';
21
25
  const [kind, id] = key.split(/:(.*)/s);
22
26
  if (id !== undefined) return ports.readTarget?.(id) ?? 'absent';
23
27
  const state = ports.read();
@@ -32,14 +36,16 @@ export function createBusTasks({ ports, emit }) {
32
36
  if (!ports.restoreTargets) fail('CAPABILITY_MISSING', 'This editor cannot restore task targets.');
33
37
  const { historyEntryId = null } = ports.restoreTargets(values) ?? {};
34
38
  if (historyEntryId) entryTask.set(historyEntryId, task.taskId);
35
- task.restored = [...new Set(values.map(({ key }) => key.includes(':') ? key.slice(key.indexOf(':') + 1) : ports.read().host.sceneId))];
39
+ task.restored = [...new Set(values.map(({ key }) => layerOf(key)?.id ?? (key.includes(':') ? key.slice(key.indexOf(':') + 1) : ports.read().host.sceneId)))];
36
40
  for (const { key } of values) task.tokens.set(key, tokenOf(key));
37
41
  emit?.({ type: `task.${action}`, taskId: task.taskId, keys: values.map(v => v.key) });
38
42
  return historyEntryId;
39
43
  }
40
- function grant(task, keys) {
41
- const bad = keys.find(key => !isScopeKey(key));
42
- if (bad !== undefined) fail('INVALID_ARGUMENT', `"${bad}" is not a scope key: object:<id>, character:<id>, shot:<id>, stage, timeline or scene.`);
44
+ // A plain character:<id> is held as its four layers: the commands name layers, and each layer keeps its own token.
45
+ function grant(task, scope) {
46
+ const bad = scope.find(key => !isScopeKey(key));
47
+ if (bad !== undefined) fail('INVALID_ARGUMENT', `"${bad}" is not a scope key: object:<id>, character:<id> (or one layer: character:<id>:placement, :take, :ik, :props), shot:<id>, stage, timeline or scene.`);
48
+ const keys = expandScope(scope);
43
49
  const conflict = scopeConflict(task.taskId, keys, open);
44
50
  if (conflict) fail('SCOPE_BUSY', `${conflict}; wait for it, or ask for what is free.`);
45
51
  for (const key of keys) if (!task.keys.has(key)) { task.keys.add(key); task.tokens.set(key, tokenOf(key)); }
@@ -121,7 +127,7 @@ export function createBusTasks({ ports, emit }) {
121
127
  if (!task && !open.size) return null;
122
128
  const keys = entry?.id ? commandKeys(entry.id, args, before, entry) : [];
123
129
  // The user's own edit is never refused; a running task whose scope it touches hears of it (the panel's card).
124
- if (!task && request.origin === 'ui') { for (const other of open.values()) { const mine = keys.filter(key => other.keys.has(key)); if (mine.length) emit?.({ type: 'task.userEdit', taskId: other.taskId, keys: mine }); } return null; }
130
+ if (!task && request.origin === 'ui') { for (const other of open.values()) { const mine = keys.filter(key => [...other.keys].some(held => keysOverlap(key, held))); if (mine.length) emit?.({ type: 'task.userEdit', taskId: other.taskId, keys: mine }); } return null; }
125
131
  if (!task) {
126
132
  const held = keys.find(key => scopeConflict(null, [key], open));
127
133
  if (held) fail('SCOPE_BUSY', `${scopeConflict(null, [held], open)}; this command waits for that task.`);
@@ -135,18 +141,25 @@ export function createBusTasks({ ports, emit }) {
135
141
  fail('STALE_TARGET', `${listOf(moved)} changed outside this task (the user, or an undo) since it last looked; read it again, then retry.`);
136
142
  }
137
143
  for (const key of keys) if (!task.pre.has(key)) task.pre.set(key, snapshot(key));
138
- return { task, keys, values: keys.map(snapshot), mark: task.mark };
144
+ return { task, keys, values: keys.map(snapshot), mark: task.mark, fence: new Map(keys.map(key => [key, tokenOf(key)])) };
139
145
  },
146
+ /**
147
+ * A task's job fences on its own keys, not the whole target: another task placing the character while this one
148
+ * compiles her take is not a change under the compile. moved(point) names what changed since admit or rebase(point).
149
+ */
150
+ moved: point => [...point.fence].filter(([key, token]) => tokenOf(key) !== token).map(([key]) => key),
151
+ rebase(point) { for (const key of point.fence.keys()) point.fence.set(key, tokenOf(key)); },
140
152
  /** Files a task's receipt: ids it created join its scope, it may be undone newest first, readers hear of it. */
141
153
  record(point, recorded) {
142
154
  if (!point || !recorded?.ok) return recorded;
143
155
  const { task } = point, state = ports.read(), warnings = [];
144
156
  let grew = false;
157
+ // A character the task holds one layer of is its own: the command was admitted on that layer.
145
158
  for (const id of recorded.affectedIds ?? []) {
146
159
  const key = keyOfId(id, state);
147
- if (!key || task.keys.has(key)) continue;
148
- if (scopeConflict(task.taskId, [key], open)) warnings.push({ code: 'SCOPE_DENIED', message: `${key} changed outside the task's scope.` });
149
- else { task.keys.add(key); grew = true; if (!point.keys.includes(key)) { task.pre.set(key, { key, value: undefined }); point.values.push({ key, value: undefined }); } }
160
+ if (!key || [...task.keys].some(held => keysOverlap(key, held))) continue;
161
+ if (scopeConflict(task.taskId, [key], open)) { warnings.push({ code: 'SCOPE_DENIED', message: `${key} changed outside the task's scope.` }); continue; }
162
+ for (const made of expandScope([key])) { task.keys.add(made); grew = true; if (!point.keys.includes(made)) { task.pre.set(made, { key: made, value: undefined }); point.values.push({ key: made, value: undefined }); } }
150
163
  }
151
164
  if (grew) emit?.({ type: 'task.scope', taskId: task.taskId, scope: [...task.keys] });
152
165
  for (const key of task.keys) task.tokens.set(key, tokenOf(key));
@@ -173,12 +173,13 @@ export function createCommandBus({ registry, ports }) {
173
173
  affectedIds: [...tx.affectedIds], delta: [], checks: { coverage: 'wire-transaction' }, warnings: toastWarnings(tx.toasts),
174
174
  undo: tx.historyEntryId ? { historyEntryId: tx.historyEntryId, entries: 1, canUndoDirect: true } : null });
175
175
  }
176
+ // a refusal may list every problem of the request at once (a motion spec: up to 12 lines), so it keeps 2000 characters
176
177
  function refusal(request, before, error, mutated) {
177
178
  const changed = mutated ?? (ports.read().revision !== before.revision);
178
179
  return validateReceipt({ ok: false, commandId: request.commandId, host: before.host,
179
180
  code: refusalCode(error), phase: changed ? 'commit' : 'admission', affectedIds: [], expectedTargets: [], currentTargets: [],
180
181
  mutated: changed, preserved: { authoredState: changed ? 'changed' : 'unchanged' }, recovery: { action: 'inspect', retryAllowed: false },
181
- message: [...String(error.message || error)].slice(0, 500).join('') });
182
+ message: [...String(error.message || error)].slice(0, 2000).join('') });
182
183
  }
183
184
  /** Findings first, toasts after; past the schema's twelve, say how many were left out. */
184
185
  const receiptWarnings = (findings = [], toasts = [], omitted = 0, limit = 12) => {
@@ -283,17 +284,19 @@ export function createCommandBus({ registry, ports }) {
283
284
  const rebase = () => {
284
285
  const current = ports.read();
285
286
  token = targetId ? ports.readTarget?.(targetId) : null;
286
- domainRevision = domain ? current.domainRevisions?.[domain] : null;
287
+ domainRevision = domain ? current.domainRevisions?.[domain] : null; if (scoped?.keys.length) tasks.rebase(scoped);
287
288
  };
288
289
  const nestedIds = new Set();
289
- const context = { origin: request.origin, signal: controller.signal, taskId: request.taskId ?? null,
290
+ // callId: the agent's tool call (a script's nested commands share it), for motion.compile's stop rule (repair-watch.js)
291
+ const context = { origin: request.origin, signal: controller.signal, taskId: request.taskId ?? null, turnId: request.turnId ?? null, callId: typeof request.callId === 'string' ? request.callId : null,
290
292
  check() {
291
293
  controller.signal.throwIfAborted();
292
294
  const current = ports.read();
293
295
  // Name the fence that tripped: the three share one code, and a bare "something changed" leaves the user and the agent guessing.
294
296
  if (!same(current.host, before.host)) fail('STALE_TARGET', 'The open document or scene changed while the job was running.');
295
297
  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.`);
296
- if (targetId && ports.readTarget?.(targetId) !== token) fail('STALE_TARGET', `${targetId} changed while the job was running.`);
298
+ // Inside a task the fence is the task's own keys (bus-tasks.js moved): another task's layer of the target is not a change under it.
299
+ const moved = scoped?.keys.length ? tasks.moved(scoped) : targetId && ports.readTarget?.(targetId) !== token ? [targetId] : []; if (moved.length) fail('STALE_TARGET', `${moved.join(', ')} changed while the job was running.`);
297
300
  },
298
301
  commit(apply) {
299
302
  context.check();
@@ -26,6 +26,6 @@ export function register(registry, ports) {
26
26
  const shot = result.shot && { id: result.shot.id, name: result.shot.name, range: { startFrame: result.shot.startFrame, endFrameExclusive: result.shot.endFrame + 1 } };
27
27
  const referenceFrames = (result.frame ? 1 : 0) + (result.frameB ? 1 : 0);
28
28
  return { affectedIds: [], output: { prompt: result.prompt, mode: result.mode, modelLabel: result.modelLabel ?? null, shot, aspectRatio: result.aspectRatio, cameraMode: result.camera?.mode ?? null, referenceFrames },
29
- summary: `Prepared the ${result.mode} prompt${result.modelLabel ? ` for ${result.modelLabel}` : ""} for ${shot ? `${shot.name} [${shot.range.startFrame}, ${shot.range.endFrameExclusive})` : "the current camera"}; the Studio's result panel shows it with ${referenceFrames} reference frame${referenceFrames === 1 ? "" : "s"} for the user to copy and download.` };
29
+ summary: `Prepared the ${result.mode} prompt${result.modelLabel ? ` for ${result.modelLabel}` : ""} for ${shot ? `${shot.name} [${shot.range.startFrame}, ${shot.range.endFrameExclusive})` : "the current camera"}; the Studio's result panel shows it with ${referenceFrames} reference frame${referenceFrames === 1 ? "" : "s"} for the user to copy and export.` };
30
30
  } });
31
31
  }
@@ -15,6 +15,6 @@ export function register(registry, ports) {
15
15
  const result = await ports.exportShotVideo({ shotId: shotId ?? null, ...(scope ? { scope } : {}) }, context);
16
16
  if (!result?.fileName) fail("TARGET_NOT_READY", "The video export did not finish; the editor's export panel shows why and offers Retry.");
17
17
  return { affectedIds: shot ? [shot.id] : [], output: { fileName: result.fileName, frameCount: result.frameCount },
18
- summary: `Recorded ${scope === "timeline" ? "the whole timeline" : shot ? shotLabel(shot) : "the shot"} to ${result.fileName} (${result.frameCount} frames); the browser was asked to download it.` };
18
+ summary: `Recorded ${scope === "timeline" ? "the whole timeline" : shot ? shotLabel(shot) : "the shot"} to ${result.fileName} (${result.frameCount} frames); it was handed to the configured export folder or the browser download.` };
19
19
  } });
20
20
  }
@@ -22,6 +22,7 @@ import { qaSay } from "../qa-label.js";
22
22
  import { carryEdit } from "../motion3/edit-carry.js";
23
23
  import { normalizeCompileArgs } from "../motion3/lenient.js";
24
24
  import { createCompileRunner } from "./motion3-compile.js";
25
+ import { createRepairWatch } from "../motion3/repair-watch.js";
25
26
  import { peopleOf } from "../motion3/people.js";
26
27
 
27
28
  const TIMELINE_FPS = 24;
@@ -84,6 +85,8 @@ function asArgument(run) {
84
85
 
85
86
  export function register(registry, ports) {
86
87
  const store = createSpecStore();
88
+ // the stop rule: output.stuck when more compiles stopped paying (repair-watch.js)
89
+ const watch = createRepairWatch();
87
90
  const owner = () => ports.storeDomain("motion");
88
91
  const mounted = () => Boolean(ports.storeDomain?.("motion")) || "The motion owner is not mounted.";
89
92
  const always = () => true;
@@ -105,10 +108,21 @@ export function register(registry, ports) {
105
108
  * spec on the character's rig and the current scene (compile-job.js) -> { stored (the spec a take keeps and its specId
106
109
  * names), qa, ..., take when bake is given }, at once or as a promise. opts: probes, checks, lenient, repair.
107
110
  */
108
- const compile = (character, spec, opts = {}, bake, taskId = null) => {
111
+ const compile = (character, spec, opts = {}, bake, taskId = null, scene = sceneFor(character)) => {
109
112
  rigOf(character);
110
113
  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 } : {}) }, { taskId, characterId: character.id });
114
+ return runner.run({ model: character.model, scene, spec, ...opts, ...(placed ? { bake: placed } : {}) }, { taskId, characterId: character.id });
115
+ };
116
+ // where a take was baked: the body's place and a hash of the scene's bodies (a take kept for the stop rule fits only
117
+ // there): 0.14 ms for 860 bodies, where their JSON took 0.9 ms and 295 KB a key
118
+ const placeKey = (character, scene) => {
119
+ let h = 2166136261;
120
+ const mix = v => { h = Math.imul(h ^ Math.round(v * 1e4), 16777619); };
121
+ for (const [id, o] of Object.entries(scene.objects ?? {})) {
122
+ for (let i = 0; i < id.length; i++) mix(id.charCodeAt(i) / 1e4);
123
+ for (const list of [o.pos, o.half, ...o.R]) for (let i = 0; i < list.length; i++) mix(list[i]);
124
+ }
125
+ return `${character.model} ${[character.x ?? 0, character.y ?? 0, character.z ?? 0, character.rot ?? 0].join(" ")} ${(h >>> 0).toString(36)}`;
112
126
  };
113
127
  /** The whole output, kept in the store (motion.report, delta) and cut to the receipt budget (report.js). */
114
128
  const report = (r, head, extra = {}) => {
@@ -144,8 +158,9 @@ export function register(registry, ports) {
144
158
  // a base the store forgot (a reload) may still be the installed take's spec: a document read names it by specId
145
159
  const installedSpec = () => { const take = owner().motionFor(characterId); return take?.spec && specIdOf(take.spec) === base ? structuredClone(take.spec) : null; };
146
160
  const patched = [], source = spec ?? asArgument(() => applyPatch(store.get(base) ?? installedSpec() ?? unknownSpec("base", base), patch ?? [], patched));
161
+ const scene = sceneFor(character), at = placeKey(character, scene);
147
162
  // the take is always baked: the user watches every draft, installed or not
148
- return after(compile(character, source, { probes, repair }, {}, context?.taskId), r => {
163
+ return after(compile(character, source, { probes, repair }, {}, context?.taskId, scene), r => {
149
164
  const { qa, take } = r, notes = [...patched, ...(r.notes ?? [])];
150
165
  // ifClean: install only a clean final result (qa.ok includes the goals; after repair when asked)
151
166
  const wanted = install === "ifClean" ? qa.ok : install;
@@ -158,11 +173,16 @@ export function register(registry, ports) {
158
173
  if (installs) timing = put(character, take, context);
159
174
  }
160
175
  const delta = base === undefined ? {} : { delta: deltaOf(store.parts(base)?.groups ?? [], qa.groups, base) };
161
- const how = kept ? "; already installed, unchanged" : install === "ifClean" ? (installs ? "; installed" : "; not installed") : "";
162
- const output = report(r, { frames: r.frameCount, fps: TIMELINE_FPS, installed: wanted, ...adjustedOf(notes) }, { ...delta, ...(r.repair ? { repair: r.repair } : {}), ...(timing ? { timing } : {}) });
176
+ // the stop rule (repair-watch.js): a note, and the best take so far put on when the note would leave the take empty or worse
177
+ const own = specIdOf(r.stored), carried = owner().motionFor(characterId)?.spec, current = carried ? specIdOf(carried) : null;
178
+ const { say: stuck, install: best } = watch.note({ characterId, turnId: context?.turnId, callId: context?.callId, specId: own, base, qa, at, take, installed: Boolean(installs || kept), current });
179
+ if (best) timing = put(character, best.take, context) ?? timing;
180
+ const self = best?.specId === own, how = kept ? "; already installed, unchanged" : self ? "; installed as the best take so far"
181
+ : best ? `; not installed; the best take so far, ${best.specId}, installed` : install === "ifClean" ? (installs ? "; installed" : "; not installed") : "";
182
+ const output = report(r, { frames: r.frameCount, fps: TIMELINE_FPS, installed: Boolean(wanted) || self, ...(stuck ? { stuck } : {}), ...adjustedOf(notes) }, { ...delta, ...(r.repair ? { repair: r.repair } : {}), ...(timing ? { timing } : {}) });
163
183
  // agent-progress.js: viewport ghost, timeline beats, agent panel
164
- publishDraft(characterId, { take, installed: installs || kept, specId: output.specId, label: r.label, qa, timeline: r.timeline });
165
- return { affectedIds: installs ? [characterId] : [], summary: `Compiled ${r.frameCount} frames; QA ${qaSay(qa)}${how}${adjustedSay(notes)}.`, output, ...workerWarning() };
184
+ publishDraft(characterId, { take, installed: installs || kept || self, specId: output.specId, label: r.label, qa, timeline: r.timeline });
185
+ return { affectedIds: installs || best ? [characterId] : [], summary: `Compiled ${r.frameCount} frames; QA ${qaSay(qa)}${how}${adjustedSay(notes)}.`, output, ...workerWarning() };
166
186
  });
167
187
  } });
168
188
  registry.register({ ...declarations[1], available: mounted, run({ characterId, checks }) {
@@ -0,0 +1,244 @@
1
+ // Optional export folder. When the browser grants a directory, download-type
2
+ // exports are written there; every failure path falls back to the browser's
3
+ // normal download so an export is never lost.
4
+
5
+ const DB_NAME = "cozyclay-export-workspace";
6
+ const DB_VERSION = 1;
7
+ const STORE = "handles";
8
+ const FOLDER_KEY = "folder";
9
+ const MAX_NAME = 180;
10
+ let writeQueue = Promise.resolve();
11
+
12
+ function splitExtension(name) {
13
+ const dot = name.lastIndexOf(".");
14
+ return dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
15
+ }
16
+
17
+ /** A file name that cannot leave the chosen directory or break the file system. */
18
+ function safeNameCharacter(character) {
19
+ const code = character.codePointAt(0);
20
+ if (code < 0x20 || code === 0x7f || '<>:"/\\|?*'.includes(character)) return "-";
21
+ return character;
22
+ }
23
+
24
+ export function sanitizeExportName(name) {
25
+ const leaf = String(name ?? "").split(/[\\/]/).pop() ?? "";
26
+ const cleaned = [...leaf]
27
+ .map(safeNameCharacter)
28
+ .join("")
29
+ .replace(/^[.\s]+/, "")
30
+ .replace(/[.\s]+$/, "")
31
+ .trim();
32
+ if (!cleaned) return "cozyclay-export";
33
+ if (cleaned.length <= MAX_NAME) return cleaned;
34
+ const [base, extension] = splitExtension(cleaned);
35
+ return `${base.slice(0, Math.max(1, MAX_NAME - extension.length))}${extension}`;
36
+ }
37
+
38
+ /** Browser download naming: shot.zip, shot (1).zip, shot (2).zip, ... */
39
+ export function collisionName(name, index) {
40
+ const [base, extension] = splitExtension(String(name));
41
+ return `${base} (${index})${extension}`;
42
+ }
43
+
44
+ async function fileHandleForWrite(directory, name) {
45
+ const safe = sanitizeExportName(name);
46
+ for (let index = 0; index < 10_000; index += 1) {
47
+ const candidate = index === 0 ? safe : collisionName(safe, index);
48
+ try {
49
+ await directory.getFileHandle(candidate);
50
+ } catch (error) {
51
+ if (error?.name === "NotFoundError") {
52
+ return { handle: await directory.getFileHandle(candidate, { create: true }), name: candidate };
53
+ }
54
+ // A folder with the export's name is a collision, not a reason to fail.
55
+ if (error?.name === "TypeMismatchError") continue;
56
+ throw error;
57
+ }
58
+ }
59
+ throw new Error("No free export file name.");
60
+ }
61
+
62
+ async function writeBlobNow(directory, blob, name) {
63
+ const target = await fileHandleForWrite(directory, name);
64
+ let writable = null;
65
+ try {
66
+ writable = await target.handle.createWritable();
67
+ await writable.write(blob);
68
+ await writable.close();
69
+ return { name: target.name, bytes: blob.size };
70
+ } catch (error) {
71
+ try { await writable?.abort(); } catch { /* the stream may already be closed */ }
72
+ try { await directory.removeEntry(target.name); } catch { /* best-effort cleanup */ }
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ /** Write one export without overwriting an existing file. Writes are serialized
78
+ * so two overlapping exports cannot both claim the same free name. */
79
+ export function writeBlobToDirectory(directory, blob, name) {
80
+ const run = () => writeBlobNow(directory, blob, name);
81
+ const result = writeQueue.then(run, run);
82
+ writeQueue = result.then(() => undefined, () => undefined);
83
+ return result;
84
+ }
85
+
86
+ function clickDownload(source, name) {
87
+ if (typeof document === "undefined") throw new Error("A browser download is unavailable here.");
88
+ if (typeof source === "string" && !/^(?:blob:|data:|https?:\/\/|\/)/i.test(source)) {
89
+ throw new Error("Unsupported export source.");
90
+ }
91
+ const anchor = document.createElement("a");
92
+ let url = null;
93
+ if (typeof source === "string") {
94
+ anchor.href = source;
95
+ } else {
96
+ url = URL.createObjectURL(source);
97
+ anchor.href = url;
98
+ }
99
+ anchor.download = sanitizeExportName(name);
100
+ document.body.appendChild(anchor);
101
+ anchor.click();
102
+ anchor.remove();
103
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 10_000);
104
+ }
105
+
106
+ async function toBlob(source) {
107
+ if (source instanceof Blob) return source;
108
+ if (typeof source === "string") {
109
+ const response = await fetch(source);
110
+ if (!response.ok) throw new Error(`Export source could not be read: ${response.status}`);
111
+ return response.blob();
112
+ }
113
+ throw new TypeError("An export must be a Blob or a URL.");
114
+ }
115
+
116
+ function openDb() {
117
+ return new Promise((resolve, reject) => {
118
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
119
+ request.onupgradeneeded = () => request.result.createObjectStore(STORE);
120
+ request.onsuccess = () => resolve(request.result);
121
+ request.onerror = () => reject(request.error);
122
+ request.onblocked = () => reject(new Error("Export folder storage is blocked."));
123
+ });
124
+ }
125
+
126
+ async function storedHandle() {
127
+ try {
128
+ const db = await openDb();
129
+ try {
130
+ return await new Promise((resolve, reject) => {
131
+ const request = db.transaction(STORE, "readonly").objectStore(STORE).get(FOLDER_KEY);
132
+ request.onsuccess = () => resolve(request.result ?? null);
133
+ request.onerror = () => reject(request.error);
134
+ });
135
+ } finally {
136
+ db.close();
137
+ }
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ async function storeHandle(handle) {
144
+ const db = await openDb();
145
+ try {
146
+ await new Promise((resolve, reject) => {
147
+ const transaction = db.transaction(STORE, "readwrite");
148
+ transaction.objectStore(STORE).put(handle, FOLDER_KEY);
149
+ transaction.oncomplete = () => resolve();
150
+ transaction.onerror = () => reject(transaction.error);
151
+ });
152
+ } finally {
153
+ db.close();
154
+ }
155
+ }
156
+
157
+ async function permissionOf(handle) {
158
+ if (!handle) return "denied";
159
+ if (typeof handle.queryPermission !== "function") return "granted";
160
+ try {
161
+ return await handle.queryPermission({ mode: "readwrite" });
162
+ } catch {
163
+ return "denied";
164
+ }
165
+ }
166
+
167
+ export function canChooseExportWorkspace() {
168
+ return typeof window !== "undefined" && typeof window.showDirectoryPicker === "function";
169
+ }
170
+
171
+ /** Ask for the folder; the picker grants read/write for this session. */
172
+ export async function chooseExportWorkspace() {
173
+ if (!canChooseExportWorkspace()) throw new Error("This browser cannot choose an export folder.");
174
+ const handle = await window.showDirectoryPicker({ id: "cozyclay-export-folder", mode: "readwrite" });
175
+ await storeHandle(handle);
176
+ return handle;
177
+ }
178
+
179
+ /** The remembered folder and its current permission, if any. */
180
+ export async function loadExportWorkspace() {
181
+ const handle = await storedHandle();
182
+ if (!handle) return null;
183
+ return { handle, permission: await permissionOf(handle) };
184
+ }
185
+
186
+ export async function requestExportWorkspacePermission(handle) {
187
+ if (await permissionOf(handle) === "granted") return "granted";
188
+ if (typeof handle?.requestPermission !== "function") return "granted";
189
+ try {
190
+ return await handle.requestPermission({ mode: "readwrite" });
191
+ } catch {
192
+ return "denied";
193
+ }
194
+ }
195
+
196
+ export async function clearExportWorkspace() {
197
+ let db = null;
198
+ try {
199
+ db = await openDb();
200
+ await new Promise((resolve, reject) => {
201
+ const transaction = db.transaction(STORE, "readwrite");
202
+ transaction.objectStore(STORE).delete(FOLDER_KEY);
203
+ transaction.oncomplete = () => resolve();
204
+ transaction.onerror = () => reject(transaction.error);
205
+ });
206
+ return true;
207
+ } catch {
208
+ return false;
209
+ } finally {
210
+ db?.close();
211
+ }
212
+ }
213
+
214
+ /** Save an export to the chosen folder, or hand it to the browser on any miss. */
215
+ export async function saveExport(source, name, options = {}) {
216
+ const injectedDirectory = Object.prototype.hasOwnProperty.call(options, "directory");
217
+ const download = options.download ?? clickDownload;
218
+ if (!injectedDirectory && !canChooseExportWorkspace()) {
219
+ download(source, name);
220
+ return { method: "download", name: sanitizeExportName(name), reason: "unsupported" };
221
+ }
222
+ let directory = options.directory ?? null;
223
+ let permission = injectedDirectory ? (options.permission ?? "granted") : "denied";
224
+ if (!injectedDirectory) {
225
+ const workspace = await loadExportWorkspace();
226
+ directory = workspace?.handle ?? null;
227
+ permission = workspace?.permission ?? "denied";
228
+ }
229
+ if (!directory) {
230
+ download(source, name);
231
+ return { method: "download", name: sanitizeExportName(name), reason: "no-workspace" };
232
+ }
233
+ if (permission !== "granted") {
234
+ download(source, name);
235
+ return { method: "download", name: sanitizeExportName(name), reason: "workspace-permission" };
236
+ }
237
+ try {
238
+ const written = await writeBlobToDirectory(directory, await toBlob(source), name);
239
+ return { method: "workspace", ...written, folderName: directory.name ?? "Export folder" };
240
+ } catch (error) {
241
+ download(source, name);
242
+ return { method: "download", name: sanitizeExportName(name), reason: "workspace-write-failed", error };
243
+ }
244
+ }
@@ -55,10 +55,14 @@ function readmeText({ shot, fps, hasLast, hasClip, ext, hasStylized }) {
55
55
  lines.push(
56
56
  "- camera.json: camera reference. The recorded camera move, fps, and",
57
57
  " frame range to reproduce the framing over time.",
58
- "- blocking-depth.mp4: shot-wide normalised depth video; lighter pixels are closer.",
59
58
  "- prompt.txt: the text prompt describing the intended action.",
60
59
  );
61
60
  if (hasStylized) lines.push("- stylized.png: the stylized picture of this panel; first.png is its greybox.");
61
+ lines.push(
62
+ "",
63
+ "Not in this pack: the shot-wide depth video (blocking-depth.mp4) is a",
64
+ "separate export - use Depth (mp4) in the export menu.",
65
+ );
62
66
  return lines.join("\n") + "\n";
63
67
  }
64
68
 
@@ -11,8 +11,11 @@ const MOTION_TELEMETRY_EVENTS = new Set([
11
11
  const LIVE_TELEMETRY_EVENTS = new Set([...MOTION_TELEMETRY_EVENTS, ...EXECUTION_TELEMETRY_EVENTS.filter((event) => event.startsWith("mcp:"))]);
12
12
 
13
13
  const LIVE_CONTROL_PORT = import.meta.env?.VITE_COZYCLAY_LIVE_PORT ?? "5184";
14
- export const liveControlUrl = (port = LIVE_CONTROL_PORT) => `ws://127.0.0.1:${port}/live`;
15
- const LIVE_CONTROL_URL = liveControlUrl();
14
+ // The npm launcher serves a prebuilt bundle, so the build-time port above is
15
+ // fixed; it injects the hub port it actually started as __COZYCLAY_LIVE_PORT__
16
+ // (#862). A valid runtime value wins over the build-time default.
17
+ export const resolveLiveControlPort = (runtime = globalThis.__COZYCLAY_LIVE_PORT__) => (/^[1-9]\d{0,4}$/.test(String(runtime)) && Number(runtime) < 65536 ? String(runtime) : LIVE_CONTROL_PORT);
18
+ export const liveControlUrl = (port = resolveLiveControlPort()) => `ws://127.0.0.1:${port}/live`;
16
19
  // Reconnect schedule: 1 s doubling to a 15 s cap with +-25 % jitter, so a hub
17
20
  // that comes back does not take every editor's retry in the same millisecond.
18
21
  const LIVE_RECONNECT_BASE_MS = 1_000;
@@ -147,10 +150,8 @@ export async function dispatchLiveFrame(data, handlers = {}) {
147
150
  }
148
151
  }
149
152
 
150
- /**
151
- * Open the editor's one-way client connection. Failures are intentionally
152
- * silent: a studio remains a fully local editor when the MCP server is absent.
153
- */
153
+ // Open the editor's one-way client connection. Failures are intentionally
154
+ // silent: a studio remains a fully local editor when the MCP server is absent.
154
155
  export function createLiveControl({
155
156
  handlers = {},
156
157
  onWorkspace = () => {},
@@ -164,7 +165,7 @@ export function createLiveControl({
164
165
  // Optional identity for the hub's live_status listing (scene/project names).
165
166
  meta = null,
166
167
  WebSocketImpl = globalThis.WebSocket,
167
- url = LIVE_CONTROL_URL,
168
+ url = liveControlUrl(),
168
169
  timers = defaultTimers,
169
170
  random = Math.random,
170
171
  // Aliased on purpose: an unqualified call to a binding named `listen` reads
@@ -377,6 +378,12 @@ export function createLiveControl({
377
378
  setHandlers(nextHandlers) {
378
379
  currentHandlers = nextHandlers && typeof nextHandlers === "object" ? nextHandlers : {};
379
380
  },
381
+ // Opening another project renames the tab; the next hello carries the new
382
+ // label and an open socket tells the hub now, so status stays truthful.
383
+ setMeta(nextMeta) {
384
+ meta = nextMeta && typeof nextMeta === "object" ? nextMeta : null;
385
+ if (meta) send({ type: "meta", meta });
386
+ },
380
387
  close() {
381
388
  stopped = true;
382
389
  clearRetry();
@@ -3,11 +3,13 @@
3
3
  // name, the motion scene (scene.js motionScene of the editor's objects: oriented boxes; shapes.js reads a sphere or a cylinder round from them), the spec as sent, and
4
4
  // what to do with it (probes, checks, lenient, repair, bake). The answer is plain data and typed arrays
5
5
  // only (the compiler's out holds closures), so it crosses a worker boundary: QA, observations, the stored
6
- // spec, and the baked take when asked.
6
+ // spec, and the baked take when asked. The hint patches QA offers are compiled first (hint-check.js).
7
7
  // A refusal is an Error with a code (INVALID_ARGUMENT, MOTION_SPEC_INVALID turned into it), which the
8
8
  // client turns back into the Studio's protocol error.
9
9
  import { compileSpec, liftRequest } from "./pipeline.js";
10
+ import { prepareSpec } from "./correct.js";
10
11
  import { repairSpec } from "./repair.js";
12
+ import { checkHints } from "./hint-check.js";
11
13
  import { bakeTake } from "./take.js";
12
14
  import { skeletonFor } from "./rigs/index.js";
13
15
  import { keptPeople } from "./people.js";
@@ -49,7 +51,17 @@ export function runCompileJob({ model, scene, spec: asked, probes: sentProbes, c
49
51
  const r = asArgument(() => compileSpec(sent, { sk, scene }, { ...opts, probes }));
50
52
  return { ...r, stored: r.spec === sent ? spec : r.spec };
51
53
  };
52
- const t0 = performance.now(), fixed = repair ? repairSpec(asked, repair, once) : { spec: asked, result: once(asked) };
54
+ // repair patches address the stored spec, so a repair starts from it corrected (correct.js), and says what was
55
+ const corrected = [], start = repair && !lenient ? prepareSpec(asked, { sk, scene }, corrected) : asked;
56
+ // every compile's QA by its spec, so a hint patch repair already tried is not compiled again (hint-check.js)
57
+ const seen = new Map(), measured = s => { const r = once(s); seen.set(JSON.stringify(s), r.qa); return r; };
58
+ const t0 = performance.now(), fixed = repair ? repairSpec(start, repair, measured) : { spec: asked, result: measured(asked) };
59
+ // a hint's patch is offered only when its compile helped (hint-check.js), and only by a compile that repairs: repair has
60
+ // compiled most of the patches already (seen), where a plain compile paid two more compiles for them (bench replay: first
61
+ // compile 1.6 -> 5.2 s median) and the agent applied an offered patch as sent once in the 60 bench runs (374 direct
62
+ // compiles, 319 with repair; a plain compile is mostly a variant in a sweep); a plain compile keeps the hint's words only
63
+ if (repair) checkHints(fixed.spec, fixed.result.qa, s => seen.get(JSON.stringify(s)) ?? measured(s).qa);
64
+ else for (const g of fixed.result.qa?.groups ?? []) delete g.patch;
53
65
  // ms.repair: the whole repair's wall time, so a sweep that repairs every variant shows what it costs
54
66
  const r = fixed.result, label = fixed.spec?.label ?? null, ms = fixed.repair ? { ...r.ms, repair: Math.round(performance.now() - t0) } : r.ms;
55
67
  const take = bake ? bakeTake(sk, r.out, { position: bake.position, yaw: bake.yaw }, { spec: r.stored, prompt: ("prompt" in bake ? bake.prompt : label) ?? "" }) : undefined;
@@ -57,7 +69,7 @@ export function runCompileJob({ model, scene, spec: asked, probes: sentProbes, c
57
69
  const people = take && keptPeople(scene, [r.expanded, r.stored]);
58
70
  if (people) take.people = people;
59
71
  return { stored: r.stored, qa: r.qa, observations: r.observations, used: r.used, fixed: r.fixed, search: r.search, timeline: r.timeline,
60
- ...(r.probes ? { probes: r.probes } : {}), ...(r.notes ? { notes: r.notes } : {}), ms, frameCount: r.out.frameCount, label,
72
+ ...(r.probes ? { probes: r.probes } : {}), ...(r.notes || corrected.length ? { notes: [...corrected, ...(r.notes ?? [])] } : {}), ms, frameCount: r.out.frameCount, label,
61
73
  ...(fixed.repair ? { repair: fixed.repair } : {}), ...(take ? { take } : {}) };
62
74
  }
63
75