From 8070585fa4e169d7a650ee875e0908e520f9ce4c Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 7 Jun 2026 00:03:46 +0300 Subject: [PATCH] Movie Editor: input sources, editable role labels, better picker labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input sources (nodes modal, expanded slot): - New "Input sources" section per slot — choose where each connection input comes from: (auto-wire), Timeline (scene image, IMAGE only), core primitives (Project frames/fps), or another slot's named output. - builder.py step 3b: applies input_sources before auto-wire; "timeline" creates media_load node and wires it; "out::" and "core::" resolve to specific graph edges. - nodes.py: core_producers() lists the fixed core primitives as source options. - server.py: pipeline-ports response includes core_producers array. Role label editing: - slot.role_label — user-editable name shown in the slot header role position. - The rename button (✎) now edits role_label (applies to all slots, not just custom). - Blank resets to the automatic role name from the roles registry. Better picker labels: - slotFullLabel(slot) = "RoleLabel · NodeName" — used in destinations() and sources() so every option clearly identifies which slot it belongs to. - roleLabel handles null/undefined role keys gracefully (shows "?"). Co-Authored-By: Claude Sonnet 4.6 --- movie_editor/backend/builder.py | 68 ++++++++++++++++++++++++----- movie_editor/backend/nodes.py | 9 ++++ movie_editor/frontend/models.js | 74 ++++++++++++++++++++++++++------ movie_editor/frontend/styles.css | 2 + movie_editor/server.py | 5 ++- 5 files changed, 134 insertions(+), 24 deletions(-) diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index 4e3063d0..bdec49aa 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -247,7 +247,33 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No msg = f"Slot node '{cls}' is not installed in ComfyUI." report["unsatisfied"].append(msg); report["blocking"].append(msg) - # 3b. linked inputs: one shared value drives several node inputs (e.g. width/height). + # 3b. explicit input sources: user-chosen source for a slot's connection input. + # Runs before auto-wire so these pre-empt the uniqueness heuristic. + # source values: "" / "auto" → skip; "timeline" → scene image (media_load); + # "out::" → another slot's named output; + # "core::" → a core primitive output. + for s in slots: + sid = slot_node_id[s["id"]] + nd_s = slot_def[s["id"]] + for ci_name, source in (s.get("input_sources") or {}).items(): + if not source or source == "auto": + continue + if source == "timeline": + if media and media.get("filename"): + graph.setdefault("media_load", { + "class_type": "LoadImage", "inputs": {"image": media["filename"]}}) + graph[sid]["inputs"][ci_name] = ["media_load", 0] + report["wired"].append(f"timeline image -> {sid}.{ci_name}") + continue + src = _resolve_source(source, slot_node_id, slot_def, object_info) + if src: + graph[sid]["inputs"][ci_name] = list(src) + report["wired"].append(f"{source} -> {sid}.{ci_name}") + else: + report["unsatisfied"].append( + f"{s.get('node_class')}.{ci_name}: input source '{source}' could not be resolved.") + + # 3d. linked inputs: one shared value drives several node inputs (e.g. width/height). # An "editor"-sourced link pulls its value from a project setting (params) instead. for link in (models_config or {}).get("links") or []: if link.get("source") == "editor": @@ -263,15 +289,20 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No port_to_core = _port_index(object_info) - # 3c. media: inject a LoadImage for the chosen asset, wired to the chosen input. - if media and media.get("filename") and media.get("target"): - graph["media_load"] = {"class_type": "LoadImage", "inputs": {"image": media["filename"]}} - dst = _resolve_target(media["target"], port_to_core, slot_node_id) - if dst and dst[0] in graph: - graph[dst[0]]["inputs"][dst[1]] = ["media_load", 0] - report["wired"].append(f"media -> {dst[0]}.{dst[1]}") - else: - report["unsatisfied"].append(f"media target '{media['target']}' could not be resolved.") + # 3e. media: inject a LoadImage for the chosen asset, wired to the chosen input(s). + # scene.source.target provides a legacy explicit target; "timeline" input_sources + # (step 3b) may have already created media_load — this step adds any extra targets. + if media and media.get("filename"): + if media.get("target"): + graph.setdefault("media_load", { + "class_type": "LoadImage", "inputs": {"image": media["filename"]}}) + dst = _resolve_target(media["target"], port_to_core, slot_node_id) + if dst and dst[0] in graph: + graph[dst[0]]["inputs"][dst[1]] = ["media_load", 0] + report["wired"].append(f"media -> {dst[0]}.{dst[1]}") + else: + report["unsatisfied"].append( + f"media target '{media['target']}' could not be resolved.") # 4. explicit wires (slot OUTPUT -> port: | node::). # target may be a string (legacy single) or a list of strings (multi-wire). @@ -313,6 +344,23 @@ def _port_index(object_info: dict) -> dict[str, tuple[str, str]]: return idx +def _resolve_source(source: str, slot_node_id: dict, slot_def: dict, object_info: dict) -> Optional[tuple[str, int]]: + """Resolve an input-source id to (node_id, output_index). + Formats: "out::" | "core::" | "timeline" handled by caller. + """ + if source.startswith("out:"): + _, sid, out_name = source.split(":", 2) + nid = slot_node_id.get(sid) + if not nid: + return None + nd = slot_def.get(sid) + return (nid, _output_index(nd, out_name)) + if source.startswith("core:"): + _, cid, oidx = source.split(":", 2) + return (cid, int(oidx)) + return None + + def _resolve_target(target: str, port_to_core, slot_node_id) -> Optional[tuple[str, str]]: if target.startswith("port:"): return port_to_core.get(target[5:]) diff --git a/movie_editor/backend/nodes.py b/movie_editor/backend/nodes.py index 4a1f1628..09e605a5 100644 --- a/movie_editor/backend/nodes.py +++ b/movie_editor/backend/nodes.py @@ -232,6 +232,15 @@ def ports_from_object_info(object_info: dict, cls: str, label: str) -> list[dict return ports +def core_producers() -> list[dict]: + """Typed outputs from the fixed core that slot nodes can source from.""" + return [ + {"id": "core:frames:0", "type": "INT", "label": "Project frames (primitive)"}, + {"id": "core:fps:0", "type": "FLOAT", "label": "Project FPS (primitive)"}, + {"id": "core:f2i:0", "type": "INT", "label": "Project FPS as int (primitive)"}, + ] + + def pipeline_ports(object_info: dict | None = None) -> list[dict]: """The fixed path's connection points loaders/nodes wire into. FunPack nodes derive from their INPUT_TYPES; LTXV core nodes derive from object_info. [] if nothing is loaded.""" diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index aa50c74c..30ad3fbf 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -9,14 +9,21 @@ let allNodes = null; // [{class,display_name,category}] for "any node" picker const candCache = {}; // role -> [candidate] const specByClass = {}; // class -> full node spec (inputs/outputs/connection_inputs) - let config = { slots: [] }; // {slots:[{id,role,node_class,inputs:{},wires:{outName:value}}]} + let config = { slots: [] }; // {slots:[{id,role,role_label,node_class,inputs:{},wires:{},input_sources:{}}]} + let coreProducers = []; // [{id,type,label}] 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 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; } + // Full display name for use in pickers: "RoleLabel · NodeName" + function slotFullLabel(slot) { + const rl = slot.role_label || roleLabel(slot.role); + const nn = slotName(slot); + return rl && rl !== nn ? `${rl} · ${nn}` : nn; + } function slotById(id) { return config.slots.find((s) => s.id === id); } // ── linked inputs (one control drives several node inputs) ──────────────────── @@ -111,21 +118,21 @@ if (!w.required) return; const v = slot.inputs[w.name]; if (w.kind === "combo" && (!w.choices || !w.choices.length)) { - issues.push({ level: "error", msg: `“${w.name}” has no installed options to pick from.` }); + issues.push({ level: "error", msg: `"${w.name}" has no installed options to pick from.` }); } else if (v == null || v === "") { - issues.push({ level: "error", msg: `Required field “${w.name}” is empty.` }); + issues.push({ level: "error", msg: `Required field "${w.name}" is empty.` }); } }); const outs = spec.outputs || []; const wires = slot.wires || {}; if (outs.length && !outs.some((o) => wireTargets(wires[o.name]).some(Boolean))) { - issues.push({ level: “warn”, msg: “No outputs wired — this node feeds nothing.” }); + issues.push({ level: "warn", msg: "No outputs wired — this node feeds nothing." }); } const dests = (o) => destinations(slot, o.type); outs.forEach((o) => { wireTargets(wires[o.name]).filter(Boolean).forEach((t) => { if (!dests(o).some((d) => d.value === t)) - issues.push({ level: “error”, msg: `”${o.name}” is wired to a destination that no longer exists.` }); + issues.push({ level: "error", msg: `"${o.name}" is wired to a destination that no longer exists.` }); }); }); return issues; @@ -170,13 +177,17 @@ const head = el("div", "slot-head"); head.append(el("span", "slot-chev", isExp ? "▾" : "▸")); - head.append(el("span", "slot-role", role ? role.label : slot.role)); + const roleText = slot.role_label || (role ? role.label : slot.role) || "?"; + const roleSpan = el("span", "slot-role", roleText); + roleSpan.title = "Click ✎ to rename this label"; + head.append(roleSpan); head.append(el("span", "slot-node", slotName(slot))); - const ren = el("button", "ic-btn", "✎"); ren.title = "Rename this node"; + const ren = el("button", "ic-btn", "✎"); ren.title = "Rename role label"; 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(); } + const cur = slot.role_label || (role ? role.label : slot.role) || ""; + const n = prompt("Role label (shown in pickers and headers):", cur); + if (n != null) { slot.role_label = n.trim() || undefined; await persist(); render(); } }; head.append(ren); const nExp = (slot.exposed || []).length; @@ -242,6 +253,28 @@ }); card.append(wbox); } + + // Input sources: explicitly choose where each connection input comes from. + if (cand && (cand.connection_inputs || []).length) { + slot.input_sources = slot.input_sources || {}; + const sbox = el("div", "wire-box"); + sbox.append(el("div", "wire-title", "Input sources")); + cand.connection_inputs.forEach((ci) => { + const row = el("div", "wire-row"); + row.append(el("span", "wire-out", `${ci.name} (${ci.type})`)); + row.append(el("span", "wire-arrow", "←")); + const srcs = sources(slot, ci.type); + const sel = el("select", "wire-select"); + const cur = slot.input_sources[ci.name] || ""; + srcs.forEach((s) => { const o = el("option", null, s.label); o.value = s.value; if (s.value === cur) o.selected = true; sel.append(o); }); + if (cur && !srcs.some((s) => s.value === cur)) { const o = el("option", null, cur + " (missing)"); o.value = cur; o.selected = true; sel.append(o); } + sel.onchange = async () => { slot.input_sources[ci.name] = sel.value; await persist(); }; + row.append(sel); + sbox.append(row); + }); + card.append(sbox); + } + return card; } @@ -251,7 +284,22 @@ config.slots.filter((s) => s.id !== slot.id).forEach((s2) => { const c2 = specFor(s2); (c2?.connection_inputs || []).filter((ci) => ci.type === type).forEach((ci) => - out.push({ value: `node:${s2.id}:${ci.name}`, label: `${roleLabel(s2.role)} · ${ci.name}` })); + out.push({ value: `node:${s2.id}:${ci.name}`, label: `${slotFullLabel(s2)} · ${ci.name}` })); + }); + return out; + } + + // Available sources for a slot connection input of a given type. + // Source IDs: "" = auto, "out::", "core::", "timeline" (IMAGE only). + function sources(slot, type) { + const out = [{ value: "", label: "(auto-wire)" }]; + if (type === "IMAGE") out.push({ value: "timeline", label: "Timeline (scene image)" }); + coreProducers.filter((p) => p.type === type).forEach((p) => + out.push({ value: p.id, label: p.label })); + config.slots.filter((s) => s.id !== slot.id).forEach((s2) => { + const c2 = specFor(s2); + (c2?.outputs || []).filter((o) => o.type === type).forEach((o) => + out.push({ value: `out:${s2.id}:${o.name}`, label: `${slotFullLabel(s2)} → ${o.name}` })); }); return out; } @@ -492,7 +540,7 @@ 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.")); + 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; } @@ -533,7 +581,7 @@ async function open() { await ensureRoles(); - try { ports = (await API.pipelinePorts()).ports || []; } catch (_) { ports = []; } + try { const pp = await API.pipelinePorts(); ports = pp.ports || []; coreProducers = pp.core_producers || []; } catch (_) { ports = []; coreProducers = []; } try { config = await API.getModels(); } catch (_) { config = { slots: [] }; } await prewarmSpecs(); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 7ab5c71f..7e3772ed 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -424,6 +424,8 @@ label.field.linked input, label.field.linked select { opacity: .6; } .wire-multi-row .wire-select { flex: 1; } .wire-rm { padding: 1px 5px; color: var(--red, #e06c75); } .wire-add { align-self: flex-start; } +/* Input-source arrow points left (← instead of →) */ +.wire-row .wire-arrow.src { color: var(--amber); } /* restart overlay */ .restart-overlay { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; diff --git a/movie_editor/server.py b/movie_editor/server.py index e3f5f138..51b35793 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -445,7 +445,10 @@ if web is not None and PromptServer is not None: oi = await bridge.object_info() except Exception: oi = None - return web.json_response({"ports": nodes.pipeline_ports(oi)}) + return web.json_response({ + "ports": nodes.pipeline_ports(oi), + "core_producers": nodes.core_producers(), + }) @routes.get(UI_PREFIX + "/api/image-targets") async def _image_targets(_req):