Movie Editor: linked inputs, node rename, links list

- Link inputs: enter link mode, click + on 2+ node inputs across nodes,
  Save link (named) → one shared control drives them all (e.g. width/height).
  Linked inputs render disabled with a 🔗 tag; edits route through the link.
- Links section in the Models modal lists each link: rename, shared-value
  control, member chips (unlink), expose-to-main-editor eye, unlink-all.
- Exposed links appear in the inspector's Exposed controls (Store.setModelLink
  writes through to all members).
- Rename any configured node (slot.label) via a ✎ in its header.
- Builder applies links: each member input set to the link's shared value.

Scene/models config gains links[]; +builder test. 21 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-06 20:52:20 +03:00
co-authored by Claude Opus 4.8
parent 12c9b607a3
commit cffaeeec13
6 changed files with 198 additions and 9 deletions
+10
View File
@@ -234,6 +234,16 @@ def build(object_info: dict, models_config: dict, params: dict) -> tuple[dict, d
if cls not in object_info:
report["unsatisfied"].append(f"Slot node '{cls}' is not installed in ComfyUI.")
# 3b. linked inputs: one shared value drives several node inputs (e.g. width/height).
for link in (models_config or {}).get("links") or []:
val = link.get("value")
if val is None:
continue
for m in link.get("members") or []:
sid = slot_node_id.get(m.get("slotId"))
if sid and sid in graph:
graph[sid]["inputs"][m.get("input")] = val
# 4. explicit wires (slot OUTPUT -> port:<id> | node:<slotId>:<input>).
port_to_core = _port_index(object_info)
for s in slots:
+10 -5
View File
@@ -136,13 +136,18 @@
}
function renderExposed(st) {
const items = [];
((st.models && st.models.slots) || []).forEach((slot) =>
(slot.exposed || []).forEach((d) => items.push([slot, d])));
if (!items.length) return;
const m = st.models || {};
const slotItems = [];
(m.slots || []).forEach((slot) => (slot.exposed || []).forEach((d) => slotItems.push([slot, d])));
const linkItems = (m.links || []).filter((l) => l.exposed);
if (!slotItems.length && !linkItems.length) return;
const wrap = el("div", "insp-block");
const tag = el("div", "insp-tag"); tag.textContent = "Exposed controls"; wrap.append(tag);
items.forEach(([slot, d]) => {
linkItems.forEach((l) => {
const ctrl = exposedControl({ kind: l.kind, choices: l.choices }, l.value, (v) => S.setModelLink(l.id, v));
wrap.append(field(`🔗 ${l.name} (${(l.members || []).length})`, ctrl));
});
slotItems.forEach(([slot, d]) => {
const ctrl = exposedControl(d, (slot.inputs || {})[d.name], (v) => S.setModelInput(slot.id, d.name, v));
wrap.append(field(`${slotLabel(slot)} · ${d.label || d.name}`, ctrl));
});
+125 -3
View File
@@ -12,8 +12,21 @@
let config = { slots: [] }; // {slots:[{id,role,node_class,inputs:{},wires:{outName:value}}]}
let overlay = null;
const expanded = new Set(); // slot ids currently expanded (collapsed by default)
let linkMode = false; // selecting inputs to bind into one shared control
let linkSel = []; // [{slotId, input, kind, choices, label}]
function roleLabel(key) { const r = roles.find((x) => x.key === key); return r ? r.label : (key === "custom" ? "Node" : key); }
function slotName(slot) { return slot.label || slot.node_class; }
function slotById(id) { return config.slots.find((s) => s.id === id); }
// ── linked inputs (one control drives several node inputs) ────────────────────
function ensureLinks() { if (!config.links) config.links = []; return config.links; }
function linkOf(slotId, input) { return (config.links || []).find((l) => (l.members || []).some((m) => m.slotId === slotId && m.input === input)); }
function linkSelHas(slotId, input) { return linkSel.some((s) => s.slotId === slotId && s.input === input); }
function applyLinkValue(link, value) {
link.value = value;
(link.members || []).forEach((m) => { const s = slotById(m.slotId); if (s) { s.inputs = s.inputs || {}; s.inputs[m.input] = value; } });
}
function uid() { return Math.random().toString(36).slice(2, 9); }
@@ -157,7 +170,14 @@
const head = el("div", "slot-head");
head.append(el("span", "slot-chev", isExp ? "▾" : "▸"));
head.append(el("span", "slot-role", role ? role.label : slot.role));
head.append(el("span", "slot-node", slot.node_class));
head.append(el("span", "slot-node", slotName(slot)));
const ren = el("button", "ic-btn", "✎"); ren.title = "Rename this node";
ren.onclick = async (e) => {
e.stopPropagation();
const n = prompt("Node label:", slot.label || slot.node_class);
if (n != null) { slot.label = n.trim() || undefined; await persist(); render(); }
};
head.append(ren);
const nExp = (slot.exposed || []).length;
if (nExp) head.append(el("span", "slot-badge exposed", `◉ ${nExp}`));
if (errs) head.append(el("span", "slot-badge bad", `${errs} error${errs > 1 ? "s" : ""}`));
@@ -178,9 +198,30 @@
if (cand && cand.inputs.length) {
const grid = el("div", "slot-fields");
cand.inputs.forEach((spec) => {
const f = widgetField(spec, slot.inputs[spec.name], async (v) => { slot.inputs[spec.name] = v; await persist(); });
const lk = linkOf(slot.id, spec.name);
const f = widgetField(spec, slot.inputs[spec.name], async (v) => {
slot.inputs[spec.name] = v;
const l2 = linkOf(slot.id, spec.name); if (l2) applyLinkValue(l2, v); // keep group in sync
await persist();
});
f.classList.add("with-eye");
f.append(eyeButton(slot, spec));
if (lk) {
f.classList.add("linked");
const ctrl = f.querySelector("input,select"); if (ctrl) ctrl.disabled = true;
f.append(el("span", "link-tag", "🔗 " + lk.name));
} else if (linkMode) {
const chk = el("button", "eye-btn link-pick" + (linkSelHas(slot.id, spec.name) ? " on" : ""), "+");
chk.type = "button"; chk.title = "Add to link selection";
chk.onclick = (e) => {
e.preventDefault(); e.stopPropagation();
if (linkSelHas(slot.id, spec.name)) linkSel = linkSel.filter((s) => !(s.slotId === slot.id && s.input === spec.name));
else linkSel.push({ slotId: slot.id, input: spec.name, kind: spec.kind, choices: spec.choices, label: `${slotName(slot)} · ${spec.name}` });
render();
};
f.append(chk);
} else {
f.append(eyeButton(slot, spec));
}
grid.append(f);
});
card.append(grid);
@@ -324,12 +365,93 @@
return bar;
}
// ── linked-inputs section ──────────────────────────────────────────────────────
function linkExposeBtn(link) {
const b = el("button", "eye-btn" + (link.exposed ? " on" : ""), "◉"); b.type = "button";
b.title = link.exposed ? "Hide from main editor window" : "Show in main editor window";
b.onclick = async () => { link.exposed = !link.exposed; await persist(); render(); };
return b;
}
function linkCard(link) {
const card = el("div", "link-card");
const head = el("div", "link-head");
head.append(el("span", "link-ico", "🔗"));
const name = el("input", "link-name"); name.value = link.name || "";
name.onchange = async () => { link.name = name.value.trim() || link.name; await persist(); render(); };
head.append(name);
head.append(linkExposeBtn(link));
const rm = el("button", "btn ghost tiny danger", "unlink all");
rm.onclick = async () => { config.links = (config.links || []).filter((l) => l.id !== link.id); await persist(); render(); };
head.append(rm);
card.append(head);
const spec = { name: "shared value", kind: link.kind, choices: link.choices, required: false };
const vf = widgetField(spec, link.value, async (v) => { applyLinkValue(link, v); await persist(); });
vf.classList.add("link-value");
card.append(vf);
const mem = el("div", "link-members");
(link.members || []).forEach((m) => {
const s = slotById(m.slotId);
const chip = el("span", "link-chip", `${s ? slotName(s) : "?"} · ${m.input}`);
const x = el("button", "chip-x", "✕"); x.title = "Remove from link";
x.onclick = async () => {
link.members = (link.members || []).filter((mm) => !(mm.slotId === m.slotId && mm.input === m.input));
if (link.members.length < 2) config.links = (config.links || []).filter((l) => l.id !== link.id);
await persist(); render();
};
chip.append(x); mem.append(chip);
});
card.append(mem);
return card;
}
function linksSection() {
const sec = el("div", "links-section");
const head = el("div", "links-head");
head.append(el("div", "composer-title", "Linked inputs"));
const right = el("div", "links-head-right");
if (!linkMode) {
const b = el("button", "btn ghost tiny", "🔗 Link inputs");
b.onclick = () => { linkMode = true; linkSel = []; config.slots.forEach((s) => expanded.add(s.id)); render(); };
right.append(b);
} else {
const save = el("button", "btn primary tiny", `Save link (${linkSel.length})`);
save.disabled = linkSel.length < 2;
save.onclick = async () => {
if (linkSel.length < 2) return;
const def = "size " + ((config.links || []).length + 1);
const nm = prompt("Link name:", def); if (nm == null) return;
const first = linkSel[0]; const s0 = slotById(first.slotId);
const val = s0 ? (s0.inputs || {})[first.input] : undefined;
const link = { id: uid(), name: nm.trim() || def, kind: first.kind,
choices: first.kind === "combo" ? (first.choices || []) : undefined, value: val,
members: linkSel.map((s) => ({ slotId: s.slotId, input: s.input })), exposed: false };
ensureLinks().push(link);
applyLinkValue(link, val);
linkMode = false; linkSel = []; await persist(); render();
};
const cancel = el("button", "btn ghost tiny", "Cancel");
cancel.onclick = () => { linkMode = false; linkSel = []; render(); };
right.append(save); right.append(cancel);
}
head.append(right);
sec.append(head);
if (linkMode) sec.append(el("div", "links-hint", "Click + next to any input below to add it to the link, then Save. (Pick 2+ inputs.)"));
(config.links || []).forEach((l) => sec.append(linkCard(l)));
if (!linkMode && !(config.links || []).length)
sec.append(el("div", "links-empty", "No links yet. Use “Link inputs” to drive several node inputs (e.g. width/height) from one control."));
return sec;
}
function body() {
const b = el("div", "models-body");
b.append(composer());
const v = validation();
const banner = summaryBanner(v);
if (banner) b.append(banner);
b.append(linksSection());
const list = el("div", "slot-list");
if (!config.slots.length) list.append(el("div", "empty-stage", "No models configured yet. Add Unet, VAEs, CLIP, and pipeline nodes above."));
else config.slots.forEach((s) => list.append(slotRow(s, v.perSlot[s.id])));
+15 -1
View File
@@ -202,6 +202,20 @@
catch (e) { console.error("saveModels failed", e); }
}
// Set a linked control's shared value (writes through to all member inputs) and persist.
async function setModelLink(linkId, value) {
const link = (state.models.links || []).find((l) => l.id === linkId);
if (!link) return;
link.value = value;
(link.members || []).forEach((m) => {
const s = (state.models.slots || []).find((x) => x.id === m.slotId);
if (s) { s.inputs = s.inputs || {}; s.inputs[m.input] = value; }
});
notify();
try { state.models = await API.saveModels(state.models); notify(); }
catch (e) { console.error("saveModels failed", e); }
}
// ── boot ─────────────────────────────────────────────────────────────────────
async function init() {
try { state.health = await API.health(); } catch (_) { state.health = { ok: false }; }
@@ -219,6 +233,6 @@
refreshProjectList, loadProject, newProject, deleteProject,
patchProject, patchScene, selectScene, addScene, removeScene, moveScene, scene,
resizeScene, splitScene, snapFrames,
refreshPreview, generate, loadModels, setModelInput,
refreshPreview, generate, loadModels, setModelInput, setModelLink,
};
})();
+22
View File
@@ -328,6 +328,28 @@ label.field.with-eye { position: relative; padding-right: 26px; }
cursor: pointer; font-size: 11px; }
.eye-btn:hover { color: var(--muted); border-color: var(--muted); }
.eye-btn.on { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 55%, var(--line)); }
.eye-btn.link-pick { color: var(--teal); }
.eye-btn.link-pick.on { color: var(--ink-0); background: var(--teal); border-color: var(--teal); }
label.field.linked input, label.field.linked select { opacity: .6; }
.link-tag { position: absolute; top: 0; right: 0; font-size: 9.5px; color: var(--teal); font-family: var(--font-mono); }
/* linked-inputs section */
.links-section { background: var(--ink-0); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin-bottom: 18px; }
.links-head { display: flex; align-items: center; gap: 10px; }
.links-head .composer-title { margin-bottom: 0; }
.links-head-right { margin-left: auto; display: flex; gap: 6px; }
.links-hint, .links-empty { font-size: 11.5px; color: var(--faint); margin-top: 8px; }
.link-card { margin-top: 10px; padding: 10px; border: 1px solid var(--line); border-left: 3px solid var(--teal); border-radius: 8px; background: var(--ink-1); }
.link-head { display: flex; align-items: center; gap: 8px; }
.link-ico { color: var(--teal); }
.link-name { flex: 1; font-weight: 600; }
.link-head .danger { margin-left: 4px; }
.link-value { margin: 8px 0 0; }
.link-members { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.link-chip { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: 10.5px;
color: var(--muted); background: var(--ink-0); border: 1px solid var(--line); border-radius: 5px; padding: 2px 4px 2px 7px; }
.chip-x { border: none; background: transparent; color: var(--faint); cursor: pointer; font-size: 10px; padding: 0 2px; }
.chip-x:hover { color: var(--danger); }
.wire-box { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--line); }
.wire-title { font-family: var(--font-mono); font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--teal); margin-bottom: 8px; }
+16
View File
@@ -115,6 +115,22 @@ def test_explicit_wires_and_autowire():
assert report["ambiguous"] == []
def test_linked_inputs_drive_multiple_node_values():
models = {
"slots": [
{"id": "a", "node_class": "ImgProc", "inputs": {"length": 50}, "wires": {}},
{"id": "b", "node_class": "ImgProc", "inputs": {"length": 50}, "wires": {}},
],
"links": [
{"id": "L1", "name": "size", "kind": "int", "value": 121,
"members": [{"slotId": "a", "input": "length"}, {"slotId": "b", "input": "length"}]},
],
}
graph, _ = builder.build(OI, models, PARAMS)
assert graph["slot_a"]["inputs"]["length"] == 121
assert graph["slot_b"]["inputs"]["length"] == 121
def test_ambiguous_type_is_reported_not_guessed():
models = {"slots": [
{"id": "v1", "node_class": "VaeLoader", "inputs": {}, "wires": {}},