Movie Editor: input sources, editable role labels, better picker labels

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:<slotId>:<outName>" and "core:<id>:<idx>"
  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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-07 00:03:46 +03:00
co-authored by Claude Sonnet 4.6
parent 1d50e44edf
commit 8070585fa4
5 changed files with 134 additions and 24 deletions
+58 -10
View File
@@ -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:<slotId>:<outName>" → another slot's named output;
# "core:<coreId>:<outIdx>" → 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:<id> | node:<slotId>:<input>).
# 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:<slotId>:<outName>" | "core:<coreId>:<outIdx>" | "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:])
+9
View File
@@ -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."""
+61 -13
View File
@@ -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:<slotId>:<outName>", "core:<coreId>:<outIdx>", "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();
+2
View File
@@ -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;
+4 -1
View File
@@ -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):