From cffaeeec133e65148ee6a8754a5fdd03f0067836 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:52:20 +0300 Subject: [PATCH] Movie Editor: linked inputs, node rename, links list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- movie_editor/backend/builder.py | 10 +++ movie_editor/frontend/inspector.js | 15 ++-- movie_editor/frontend/models.js | 128 ++++++++++++++++++++++++++++- movie_editor/frontend/store.js | 16 +++- movie_editor/frontend/styles.css | 22 +++++ movie_editor/tests/test_builder.py | 16 ++++ 6 files changed, 198 insertions(+), 9 deletions(-) diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index a1f9fec7..a0476043 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -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: | node::). port_to_core = _port_index(object_info) for s in slots: diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 8113335c..9c7ce5e6 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -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)); }); diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index 8ae47867..18a49514 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -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]))); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 5741b81c..777e845a 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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, }; })(); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index a2d4cda7..69c7eb17 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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; } diff --git a/movie_editor/tests/test_builder.py b/movie_editor/tests/test_builder.py index df064898..16f210d6 100644 --- a/movie_editor/tests/test_builder.py +++ b/movie_editor/tests/test_builder.py @@ -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": {}},