cozyclay 3.1.0 → 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.
- package/CHANGELOG.md +65 -0
- package/bin/agent/agent-routes.mjs +12 -11
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +4 -1
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +2 -1
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-rebase.mjs +5 -4
- package/bin/agent/studio-task-routes.mjs +1 -1
- package/bin/agent/studio-tasks.mjs +116 -25
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
- package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
- package/dist/assets/app-C4QTcDcb.js +5144 -0
- package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
- package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
- package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
- package/dist/assets/task-board-BqReHzOi.js +15 -0
- package/dist/assets/workflow-C87p6E1E.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +5 -3
- package/mcp/tool-handlers.mjs +7 -7
- package/package.json +3 -1
- package/src/App.jsx +44 -66
- package/src/bus-task-scopes.js +59 -11
- package/src/bus-task-targets.js +35 -9
- package/src/bus-tasks.js +23 -10
- package/src/command-bus.js +7 -4
- package/src/commands/ai.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/commands/objects.js +1 -1
- package/src/export-workspace.js +244 -0
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion3/compile-job.js +15 -3
- package/src/motion3/compiler/channels.js +74 -24
- package/src/motion3/compiler/compile.js +2 -1
- package/src/motion3/compiler/idle.js +2 -0
- package/src/motion3/compiler/overlap.js +180 -0
- package/src/motion3/compiler/pose.js +6 -4
- package/src/motion3/compiler/validate-common.js +1 -0
- package/src/motion3/compiler/validate-parts.js +5 -1
- package/src/motion3/compiler/validate.js +30 -10
- package/src/motion3/correct.js +236 -0
- package/src/motion3/docs.js +8 -6
- package/src/motion3/face-lean.js +31 -0
- package/src/motion3/fix/index.js +5 -1
- package/src/motion3/fix/reach-body.js +172 -0
- package/src/motion3/hint-check.js +44 -0
- package/src/motion3/json-patch.js +120 -12
- package/src/motion3/lenient.js +46 -4
- package/src/motion3/object-keys.js +17 -5
- package/src/motion3/pipeline.js +46 -9
- package/src/motion3/qa/arm-checks.js +96 -0
- package/src/motion3/qa/asked.js +113 -0
- package/src/motion3/qa/goals.js +37 -8
- package/src/motion3/qa/hints-goal.js +6 -5
- package/src/motion3/qa/index.js +5 -2
- package/src/motion3/qa/key-checks.js +4 -3
- package/src/motion3/qa/motion-life.js +243 -0
- package/src/motion3/qa/spec-checks.js +15 -9
- package/src/motion3/qa/verdict.js +50 -0
- package/src/motion3/refs.js +38 -14
- package/src/motion3/repair-watch.js +132 -0
- package/src/motion3/repair.js +52 -19
- package/src/motion3/report.js +50 -9
- package/src/motion3/schema/topics.js +6 -4
- package/src/motion3/schema/types.js +1 -1
- package/src/motion3/skills/basic.js +15 -8
- package/src/motion3/skills/index.js +52 -18
- package/src/motion3/skills/info.js +1 -1
- package/src/motion3/skills/stance.js +6 -0
- package/src/motion3/skills/walk-fit.js +170 -0
- package/src/object-look.js +9 -5
- package/src/panels/PoseImagePanel.jsx +3 -2
- package/src/panels/SculptPanel.jsx +2 -2
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- package/src/result-modal.jsx +3 -3
- package/src/scene-objects.js +2 -2
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/shell/ExportFolderPreference.jsx +103 -0
- package/src/shell/PreferencesDialog.jsx +3 -166
- package/src/shell/StatusBar.jsx +1 -1
- package/src/shell/preference-controls.jsx +114 -0
- package/src/shell/preference-pages.jsx +72 -0
- package/src/shell/preferences.css +46 -0
- package/src/shot-prompt.js +29 -9
- package/src/studio-actions.js +2 -2
- package/src/studio-agent-motion.js +19 -9
- package/src/studio-agent-protocol.js +1 -1
- package/src/studio-app-binding.js +3 -2
- package/src/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +6 -6
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- package/src/workflow/agent-client.js +14 -11
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/src/workflow/keyframe-pack-request.js +3 -10
- package/src/workflow/task-board.css +1 -1
- package/src/workflow/task-board.js +7 -5
- package/tools/loc-baseline.json +1 -2
- package/tools/run-tests.mjs +8 -8
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-B887n77J.js +0 -1
- package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
- package/dist/assets/task-board-BKaM7d-9.js +0 -14
- package/dist/assets/workflow-B8Dhzexj.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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
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.
|
|
148
|
-
if (scopeConflict(task.taskId, [key], open)) warnings.push({ code: 'SCOPE_DENIED', message: `${key} changed outside the task's scope.` });
|
|
149
|
-
|
|
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));
|
package/src/command-bus.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/src/commands/ai.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/commands/elements.js
CHANGED
|
@@ -111,9 +111,10 @@ export function elementPatchArgs(kind, args) {
|
|
|
111
111
|
});
|
|
112
112
|
return collection ? ops.length === 1 ? ops[0] : { ops } : ops.reduce((patch, op) => mergeElementSet(patch, op.set), {});
|
|
113
113
|
}
|
|
114
|
-
// Unasked, an optional structured value (a glow, a light)
|
|
115
|
-
// the item has one, so every plain
|
|
116
|
-
|
|
114
|
+
// Unasked, an optional structured value (a glow, a light) or a `sparse` field (a
|
|
115
|
+
// surface preset) is listed only where the item has one, so every plain
|
|
116
|
+
// object's receipt stays as short as before.
|
|
117
|
+
const listed = (element, item, paths) => paths ? paths.includes(element.path) : (element.type !== 'object' && !element.sparse) || readElement(item, element.path) != null;
|
|
117
118
|
export function elementReadback(kind, item, paths, document) {
|
|
118
119
|
return patchElements(projectionKind(kind)).filter(element => listed(element, item, paths)).map(element => {
|
|
119
120
|
const path = element.path;
|
package/src/commands/export.js
CHANGED
|
@@ -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);
|
|
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
|
}
|
package/src/commands/motion3.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
162
|
-
const
|
|
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 }) {
|
package/src/commands/objects.js
CHANGED
|
@@ -28,7 +28,7 @@ const semantic = [
|
|
|
28
28
|
// A clay prop from a recipe of soft parts (docs/sculpt-from-image.md). With `id` it re-sculpts that
|
|
29
29
|
// sculpt in place (one undo); without, it creates one. The recipe is checked by sculpt-recipe.js.
|
|
30
30
|
{ ...mutation('object.sculpt', 'Sculpt a clay prop from a recipe', input({ recipe: { type: 'object', properties: {}, additionalProperties: true }, edits: { type: 'array', minItems: 1, maxItems: 48, items: { type: 'object', properties: {}, additionalProperties: true } }, id, name: { type: 'string', maxLength: 240 }, placement, parent: id }, [])),
|
|
31
|
-
description: 'Create a sculpt (or re-sculpt one by id) from a recipe, or patch a sculpt by id with edits [{ set: partId, ...fields } | { add: part } | { remove: partId }]. Recipe { version: 1, parts: [{ id, shape
|
|
31
|
+
description: 'Create a sculpt (or re-sculpt one by id) from a recipe, or patch a sculpt by id with edits [{ set: partId, ...fields } | { add: part } | { remove: partId }]. Recipe { version: 1, parts: [{ id, shape, size: [x,y,z] m, position, rotation (deg), color, parent, mirror, ...shape fields, material, finish, detail, gradient, op }] }. Shapes: blob {roundness, taper} soft forms; box {roundness}; cylinder {taper}; torus; frame {border} rims; lathe {profile [[r 0..1, y 0..1]...], smooth} turned objects (pot, vase, bottle, bowl, lampshade); tube {path [[x,y,z] each -0.5..0.5 of size], thickness m, taper, closed} ropes, vines, cables, handles, bike frames; extrude {outline [[x,y] -0.5..0.5], holes, roundness} flat cut shapes (sign, leaf, gable, star); loft {sections [{ y 0..1, points [[x,z]] } | { y, w, d, x?, z?, roundness? }], smooth} cross-sections that change (hull, car body, shoe, loaf); convex {points | seed, facetCount, facets} rocks, boulders, gems; scatter {item blade|tuft|flower|leaf|pebble|twig, count <= 4000, spread ground|shell, seed} thousands of small items as ONE part (grass, flower beds, gravel, leaf crowns). Any part: material clay|wood|straw|stone|brick|concrete|metal|glass|ceramic|plastic|rubber|fabric|paper|foliage (real texture and shine; color tints it; never fake glass with opacity or thatch with stripes) and finish matte|satin|gloss; detail {amount 0..1, scale, seed} lumpy surface (clouds, bushes, rocks, hay); gradient {color, axis, from, to} two-tone blend; op subtract|intersect turns the part into a cutter of its parent (windows, doors, arches; hollow a lathe with a smaller lathe of the same profile). Floor is y=0, front +z, the subject\'s left +x; mirror reflects across x=0.' },
|
|
32
32
|
mutation('object.remove', 'Remove objects', input({ ids })),
|
|
33
33
|
mutation('object.rename', 'Rename object', input({ id, name: { type: 'string', maxLength: 240 } })),
|
|
34
34
|
mutation('object.group', 'Group objects', input({ parent: id, children: ids })),
|
|
@@ -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
|
+
}
|
package/src/keyframe-pack.js
CHANGED
|
@@ -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
|
|
package/src/live-control.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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 =
|
|
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();
|