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:
co-authored by
Claude Sonnet 4.6
parent
1d50e44edf
commit
8070585fa4
@@ -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:])
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user