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:
co-authored by
Claude Opus 4.8
parent
12c9b607a3
commit
cffaeeec13
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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])));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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": {}},
|
||||
|
||||
Reference in New Issue
Block a user