From a2567151c71e67a52663513dbfbc464f5a2a107a Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:35:42 +0300 Subject: [PATCH] fix: Models shape editing: shown defaults are sent, same-kind slots told apart, unwired links recorded explicitly, swap says what roles it loses Co-Authored-By: Claude Sonnet 5.5 --- app/legacy/models.js | 36 ++++++++++++++++++++++++++++--- app/legacy/pipeline_state.js | 29 +++++++++++++++++++------ app/legacy/pipeline_state.test.js | 10 ++++++++- app/legacy/store.js | 10 +++++---- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/app/legacy/models.js b/app/legacy/models.js index cfa5b703..f60a0fa7 100644 --- a/app/legacy/models.js +++ b/app/legacy/models.js @@ -46,7 +46,10 @@ function slotDisplayLabel(slot) { const spec = nodesByClass[slot.node]; - return (spec && spec.title) || slot.node || slot.id; + const title = (spec && spec.title) || slot.node || slot.id; + // Two slots of one kind (the positive and negative prompt, two Primitives) are told apart by id. + const twin = (PS.slots() || []).some((o) => o.id !== slot.id && ((nodesByClass[o.node] && nodesByClass[o.node].title) || o.node) === title); + return twin ? `${title} (${slot.id})` : title; } function setInput(slotId, name, value) { @@ -60,10 +63,32 @@ // reason is shown here and nothing changes. let lastRefusal = []; + // A node that is new (added, swapped in) or whose input was just unwired has nothing SET on it, while + // its form shows the node's defaults: send what is shown, so what runs is what is seen. + async function fillShown(slotId, onlyInput) { + const slot = (PS.slots() || []).find((s) => s.id === slotId); + const spec = slot && nodesByClass[slot.node]; + if (!spec || !spec.widgets) return; + const values = {}; + spec.widgets.forEach((w) => { + if (onlyInput && w.name !== onlyInput) return; + if (slot.inputs && slot.inputs[w.name] !== undefined) return; + const v = w.default !== undefined ? w.default + : w.type === "COMBO" ? (w.choices || [])[0] : w.type === "BOOLEAN" ? false : undefined; + if (v !== undefined) values[w.name] = v; + }); + if (Object.keys(values).length) await PS.save({ inputs: { [slotId]: values } }); + } + async function structural(body) { const res = await PS.edit(body); lastRefusal = res.refused || []; await ensureNodesLoaded(); + if (!lastRefusal.length) { + if (body.action === "replace") await fillShown(body.slot); + else if (body.action === "unwire") await fillShown(body.slot, body.input); + else if (body.action === "add") { const s = (PS.slots() || []).slice(-1)[0]; if (s) await fillShown(s.id); } + } render(); return !lastRefusal.length; } @@ -84,6 +109,7 @@ group = name; PS.setGroup(slot.id, name).then(render); } else { + lastRefusal = []; PS.setGroup(slot.id, sel.value).then(render); } }; @@ -136,7 +162,10 @@ const swap = el("button", "btn ghost tiny", "Swap node…"); swap.title = "Use a different node here. It must still produce what the nodes after it read."; swap.onclick = () => pickNode(`Swap ${slotDisplayLabel(slot)} for…`, (cls) => { - if (!confirm(`Swap ${slotDisplayLabel(slot)} for ${cls}?\n\nEverything set on this node (files, values, connections into it) is cleared, and the new node starts empty.`)) return; + const roles = (slot.roles || []).map((r) => r.label || r.input).filter(Boolean); + const note = roles.length + ? `\n\nThe main window drives this node's ${roles.join(", ")}: those controls keep working only for inputs the new node also has, by the same name.` : ""; + if (!confirm(`Swap ${slotDisplayLabel(slot)} for ${cls}?\n\nEverything set on this node (files, values, connections into it) is cleared, and the new node starts empty.${note}`)) return; structural({ action: "replace", slot: slot.id, node: cls }); }); const rm = el("button", "btn ghost tiny danger", "Remove"); @@ -190,7 +219,8 @@ const row = controlForPlainWidget(slot, widget); // An input the app itself writes (the prompt box, the source picture, the length) is the app's: // wiring it elsewhere would make every Generate refuse. - if ((slot.roles || []).some((r) => r.input === widget.name)) return row; + // (Width, height and length are the exception: Generate skips them when they are wired.) + if ((slot.roles || []).some((r) => r.input === widget.name && r.at !== "project.video")) return row; // Another node can drive this value (one Primitive shared by several inputs is how a "link" is made). if (widget.type !== "COMBO" || (widget.choices || []).length) { const sources = sourcesFor(slot, widget.type); diff --git a/app/legacy/pipeline_state.js b/app/legacy/pipeline_state.js index 215472cc..0f8b284b 100644 --- a/app/legacy/pipeline_state.js +++ b/app/legacy/pipeline_state.js @@ -65,9 +65,10 @@ let queueable = false; let loading = false; let loadError = null; + let unwired = {}; // default links the person cut: {slotId: [input, ...]} let removed = new Set(); // default slots the person took out of this project's pipeline let adoptGate = null; // a promise while a project's pipeline is being put in - let deferredRemoved; + let deferredRemoved, deferredUnwired; let deferred = null; // a project's saved pipeline waiting for the first successful load let epoch = 0; // bumped whenever a project's pipeline replaces the live one let saving = false; @@ -131,7 +132,7 @@ } loading = false; loadPromise = null; - if (slots !== null && deferred) { const d = deferred, r = deferredRemoved; deferred = null; deferredRemoved = undefined; await adopt(d, r); } + if (slots !== null && deferred) { const d = deferred, r = deferredRemoved, u = deferredUnwired; deferred = null; deferredRemoved = undefined; deferredUnwired = undefined; await adopt(d, r, u); } })(); return loadPromise; } @@ -315,6 +316,14 @@ if (!refusedNow.length) { if (body.action === "remove" && offered.some((s) => s.id === body.slot)) removed.add(body.slot); // the person's own additions just go if (body.action === "add") (res.slots || []).forEach((s) => removed.delete(s.id)); + if (body.action === "unwire" && offered.some((s) => s.id === body.slot)) { + unwired[body.slot] = [...new Set([...(unwired[body.slot] || []), body.input])]; + } + if (body.action === "wire" && unwired[body.slot]) { + unwired[body.slot] = unwired[body.slot].filter((k) => k !== body.input); + if (!unwired[body.slot].length) delete unwired[body.slot]; + } + if (body.action === "remove") delete unwired[body.slot]; try { await refreshManifest(); } catch (_) { /* the next save retries it */ } _changed(); } @@ -354,17 +363,17 @@ // the saved copy predates, so an update's new settings are not lost to an old project. // Not announced as a change: it IS the project's own copy. // True when the project's pipeline is in (or it had none to put in); false when it could not be. - async function adopt(saved, removedIds) { + async function adopt(saved, removedIds, unwiredMap) { let open; const gate = new Promise((r) => { open = r; }); adoptGate = gate; // before anything awaits: an edit right behind this waits too - try { await ensureLoaded(); return await _adopt(saved, removedIds); } finally { if (adoptGate === gate) adoptGate = null; open(); } + try { await ensureLoaded(); return await _adopt(saved, removedIds, unwiredMap); } finally { if (adoptGate === gate) adoptGate = null; open(); } } - async function _adopt(saved, removedIds) { + async function _adopt(saved, removedIds, unwiredMap) { // Not loaded (ComfyUI unreachable): remember what the project holds and put it in the moment a // load succeeds, so the default is never what the next edit is built on. - if (slots === null) { if (Array.isArray(saved) && saved.length) { deferred = saved; deferredRemoved = removedIds; } return !(Array.isArray(saved) && saved.length); } + if (slots === null) { if (Array.isArray(saved) && saved.length) { deferred = saved; deferredRemoved = removedIds; deferredUnwired = unwiredMap; } return !(Array.isArray(saved) && saved.length); } if (!Array.isArray(saved) || !saved.length) return true; // A saved slot is only what the server would accept: a project file outlives the code that // wrote it (v4 files name the node differently), and one bad slot refuses every later edit. @@ -383,7 +392,8 @@ // A default LINK the saved copy lacks was unwired by the person; a default VALUE it lacks is // one an update added, and fills in. const inputs = { ...(def.inputs || {}) }; - Object.keys(inputs).forEach((k) => { if (Array.isArray(inputs[k]) && !(k in mine.inputs)) delete inputs[k]; }); + const cut = (unwiredMap || {})[def.id]; + Object.keys(inputs).forEach((k) => { if (Array.isArray(cut) && cut.includes(k) && !(k in mine.inputs)) delete inputs[k]; }); const out = { ...def, inputs: { ...inputs, ...mine.inputs } }; if (typeof mine.group === "string" && mine.group) out.group = mine.group; if (typeof mine.bypassed === "boolean") out.bypassed = mine.bypassed; @@ -392,6 +402,10 @@ // Replaces whatever pipeline was live: nothing queued for the old one may land on the new. const mine = ++epoch; removed = new Set(gone); + unwired = {}; + Object.entries(unwiredMap && typeof unwiredMap === "object" ? unwiredMap : {}).forEach(([id, list]) => { + if (Array.isArray(list)) unwired[id] = list.filter((x) => typeof x === "string"); + }); groupEdits = {}; pendingBody = null; pending = false; for (let waited = 0; saving && waited < 10000; waited += 20) await new Promise((r) => setTimeout(r, 20)); @@ -421,6 +435,7 @@ ensureLoaded, save, edit, setGroup, adopt, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, valuesAlreadyPlaced, currentValues, setModuleValue, modulesById: () => modulesById, removedIds: () => [...removed], + unwiredMap: () => JSON.parse(JSON.stringify(unwired)), slots: () => slots, incomplete: () => incomplete, refused: () => refused, diff --git a/app/legacy/pipeline_state.test.js b/app/legacy/pipeline_state.test.js index 5473b353..be15983f 100644 --- a/app/legacy/pipeline_state.test.js +++ b/app/legacy/pipeline_state.test.js @@ -158,8 +158,16 @@ test("a structural edit is sent on its own and its refusal is handed back", asyn test("a default link the person unwired stays unwired when the project is opened again", async () => { const PS = load([]); await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } }, - { id: "gen", node: "Gen", inputs: { steps: 8 } }]); // gen.model (a link) was unwired + { id: "gen", node: "Gen", inputs: { steps: 8 } }], [], { gen: ["model"] }); assert.strictEqual("model" in PS.slots().find((s) => s.id === "gen").inputs, false); + assert.deepStrictEqual(PS.unwiredMap(), { gen: ["model"] }); +}); + +test("a default link an older project never had (an update added it) is kept", async () => { + const PS = load([]); + await PS.adopt([{ id: "model", node: "Loader", inputs: { file: "x" } }, + { id: "gen", node: "Gen", inputs: { steps: 8 } }]); // nothing recorded as unwired + assert.strictEqual("model" in PS.slots().find((s) => s.id === "gen").inputs, true); }); test("a group moved while a save is in flight is still moved when it lands", async () => { diff --git a/app/legacy/store.js b/app/legacy/store.js index 6246fd69..aea8475c 100644 --- a/app/legacy/store.js +++ b/app/legacy/store.js @@ -4708,7 +4708,8 @@ const p = (async () => { try { // Bounded: a hung request must not hold every later project save behind it. - const ok = await Promise.race([window.PipelineState.adopt(saved, state.project && state.project.models && state.project.models.removed), new Promise((r) => setTimeout(() => r(false), 20000))]); + const ok = await Promise.race([window.PipelineState.adopt(saved, state.project && state.project.models && state.project.models.removed, + state.project && state.project.models && state.project.models.unwired), new Promise((r) => setTimeout(() => r(false), 20000))]); const live = window.PipelineState.slots(); // The project's pipeline is not in (ComfyUI unreachable, slow, or it refused the file): the // project's own copy stays exactly as it was and is not rewritten from whatever is live -- @@ -4716,10 +4717,11 @@ _modelsAdopted = !!(ok && live); const base = (state.project && state.project.models) || {}; state.models = { ...base, slots: _modelsAdopted ? JSON.parse(JSON.stringify(live)) : saved, - removed: _modelsAdopted ? window.PipelineState.removedIds() : (base.removed || []) }; + removed: _modelsAdopted ? window.PipelineState.removedIds() : (base.removed || []), + unwired: _modelsAdopted ? window.PipelineState.unwiredMap() : (base.unwired || {}) }; if (!_modelsAdopted && _modelsRetries++ < 3) setTimeout(loadModels, 5000); else if (_modelsAdopted) _modelsRetries = 0; - } catch (_) { state.models = { slots: saved }; } + } catch (_) { state.models = { ...((state.project && state.project.models) || {}), slots: saved }; _modelsAdopted = false; } })(); _modelsLoad = p; await p; @@ -5022,7 +5024,7 @@ window.PipelineState.subscribe((slots) => { if (!state.project || !_modelsAdopted) return; state.models = { ...(state.models || {}), slots: JSON.parse(JSON.stringify(slots || [])), - removed: window.PipelineState.removedIds() }; + removed: window.PipelineState.removedIds(), unwired: window.PipelineState.unwiredMap() }; // Soon, and past a Settings window's hold: a model pick must survive the reload that follows it. _localDirty = true; clearTimeout(_pipelineSaveTimer);