Movie Editor: show + re-wire the built-in core pipeline

The Models modal only listed user loader slots; the fixed core (Studio, Sampler,
Conditioning, Concat/Separate, audio decode, VHS, SaveRefinement, KeyLoader,
primitives) was invisible, so internal wiring couldn't be inspected or changed.

Add builder.core_graph() describing each core node's inputs (current source +
candidate sources) and outputs (internal destinations), exposed at /api/core-graph.
New collapsible 'Built-in pipeline' panel in the Models modal renders it and — not
read-only — lets each core input be re-wired via a source picker. Overrides are
stored per-project as models.core_overrides and applied by build() (4b), beating the
default CORE_LINKS wiring and auto-wire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-07 13:02:18 +03:00
co-authored by Claude Opus 4.8
parent c0dff7893d
commit 023f42eb8a
5 changed files with 170 additions and 1 deletions
+92
View File
@@ -344,6 +344,21 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No
graph[dnode]["inputs"][dinput] = [sid, oidx]
report["wired"].append(f"{s.get('node_class')}.{out_name} -> {dnode}.{dinput}")
# 4b. core input overrides: redirect a built-in core node input to a chosen source
# ("core:<coreId>:<idx>" or "out:<slotId>:<outName>"). Overrides the default wiring.
for cid, ovs in ((models_config or {}).get("core_overrides") or {}).items():
if cid not in graph:
continue
for inp, source in (ovs or {}).items():
if not source:
continue
src = _resolve_source(source, slot_node_id, slot_def, object_info)
if src:
graph[cid]["inputs"][inp] = list(src)
report["wired"].append(f"{source} -> {cid}.{inp} (core override)")
else:
report["unsatisfied"].append(f"core override {cid}.{inp}: '{source}' could not be resolved.")
# 5. auto-wire remaining unbound typed inputs by unique producer.
producers = _producers(graph, slots, slot_node_id, slot_def, object_info)
_autowire(graph, slots, slot_node_id, slot_def, object_info, producers, report)
@@ -351,6 +366,83 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No
return graph, report
def core_graph(object_info: dict, models_config: dict | None = None) -> list[dict]:
"""Describe the built-in core pipeline for the editor: each node, its inputs (with the
current source + candidate sources so they can be re-wired) and outputs (with internal
destinations). Inputs default to the built-in wiring; `core_overrides` in models_config
can redirect any of them (applied by build())."""
object_info = object_info or {}
overrides = (models_config or {}).get("core_overrides") or {}
slots = (models_config or {}).get("slots", []) or []
rev: dict[str, list] = {}
for cid, links in CORE_LINKS.items():
for inp, (src, idx) in links.items():
rev.setdefault(src, []).append((idx, cid, inp))
def _outs(core_id):
return node_outputs(object_info.get(CORE.get(core_id)) or {})
def _out_name(core_id, idx):
outs = _outs(core_id)
return outs[idx]["name"] if 0 <= idx < len(outs) else f"out{idx}"
# typed producers (core outputs + slot outputs) for the source pickers
producers: dict[str, list] = {}
for cid2, cls2 in CORE.items():
for i, o in enumerate(node_outputs(object_info.get(cls2) or {})):
producers.setdefault(o["type"], []).append((f"core:{cid2}:{i}", f"{cid2} · {o['name']}"))
slot_label = {}
for s in slots:
sid = s.get("id"); lbl = s.get("label") or s.get("node_class") or sid
slot_label[sid] = lbl
for o in node_outputs(object_info.get(s.get("node_class")) or {}):
producers.setdefault(o["type"], []).append((f"out:{sid}:{o['name']}", f"{lbl} · {o['name']}"))
open_by_node: dict[str, dict] = {}
for (cid, inp, t, req) in OPEN_PORTS:
open_by_node.setdefault(cid, {})[inp] = (t, req)
def _input_type(cid, inp):
for ci in connection_inputs(object_info.get(CORE.get(cid)) or {}):
if ci["name"] == inp:
return ci["type"]
return None
def _options(t, builtin_label, self_cid):
opts = [{"value": "", "label": f"built-in: {builtin_label}"}]
for val, lbl in producers.get(t, []):
if val.startswith(f"core:{self_cid}:"):
continue
opts.append({"value": val, "label": lbl})
return opts
nodes = []
for cid, cls in CORE.items():
nd = object_info.get(cls)
ov = overrides.get(cid) or {}
inputs = []
for inp, (src, idx) in CORE_LINKS.get(cid, {}).items():
t = _input_type(cid, inp) or "*"
builtin = f"{src} · {_out_name(src, idx)}"
inputs.append({"name": inp, "type": t, "from": "internal", "detail": builtin,
"value": ov.get(inp, ""), "options": _options(t, builtin, cid)})
for inp, (t, req) in open_by_node.get(cid, {}).items():
builtin = f"a {t} from your loaders"
inputs.append({"name": inp, "type": t, "from": "loader", "required": req,
"detail": builtin + ("" if req else " (optional)"),
"value": ov.get(inp, ""), "options": _options(t, "(auto-wire from loaders)", cid)})
outputs = []
for i, o in enumerate(node_outputs(nd or {})):
dests = [f"{d} · {di}" for (oi, d, di) in rev.get(cid, []) if oi == i]
outputs.append({"name": o["name"], "type": o["type"], "to": dests})
nodes.append({"id": cid, "class": cls,
"display_name": (nd or {}).get("display_name", cls),
"installed": cls in object_info,
"inputs": inputs, "outputs": outputs})
return nodes
def _port_index(object_info: dict) -> dict[str, tuple[str, str]]:
"""Map pipeline-port id ('Class.input' / 'FunPackStudio.input') -> (core_id, input)."""
cls_to_core = {v: k for k, v in CORE.items()}
+1
View File
@@ -68,6 +68,7 @@
imageTargets: (pid) => j("GET", API("/image-targets" + (pid ? `?pid=${encodeURIComponent(pid)}` : ""))),
// Models config is per-project; pass the project id. Falls back to the global
// default route (used as the seed/template) when no project is given.
coreGraph: (pid) => j("GET", API("/core-graph" + (pid ? `?pid=${encodeURIComponent(pid)}` : ""))),
getModels: (pid) => j("GET", API(pid ? `/projects/${pid}/models` : "/models")),
saveModels: (pid, data) => j("PUT", API(pid ? `/projects/${pid}/models` : "/models"), data),
refreshModels: () => j("POST", API("/models/refresh")),
+65
View File
@@ -10,6 +10,8 @@
const candCache = {}; // role -> [candidate]
const specByClass = {}; // class -> full node spec (inputs/outputs/connection_inputs)
let config = { slots: [] }; // {slots:[{id,role,role_label,node_class,inputs:{},wires:{},input_sources:{}}]}
let coreNodes = []; // built-in pipeline description (from /core-graph)
let coreOpen = false; // built-in pipeline panel expanded?
let coreProducers = []; // [{id,type,label}]
let requirements = []; // [{id,type,label,required,role_hint,hint}]
let overlay = null;
@@ -670,11 +672,72 @@
return sec;
}
// ── built-in pipeline (core) — visible + re-wireable ──────────────────────────
function setCoreOverride(cid, inp, value) {
config.core_overrides = config.core_overrides || {};
config.core_overrides[cid] = config.core_overrides[cid] || {};
if (value) config.core_overrides[cid][inp] = value;
else delete config.core_overrides[cid][inp];
}
function coreCard(n) {
const card = el("div", "slot-card open" + (n.installed ? "" : " slot-bad"));
const head = el("div", "slot-head");
head.append(el("span", "slot-role", n.display_name));
head.append(el("span", "slot-node", n.class));
if (!n.installed) head.append(el("span", "slot-badge bad", "not installed"));
card.append(head);
if ((n.inputs || []).length) {
const box = el("div", "wire-box");
box.append(el("div", "wire-title", "Inputs ← source"));
n.inputs.forEach((inp) => {
const row = el("div", "wire-row");
row.append(el("span", "wire-out", `${inp.name} (${inp.type})`));
row.append(el("span", "wire-arrow", "←"));
const sel = el("select", "wire-select");
(inp.options || []).forEach((o) => { const op = el("option", null, o.label); op.value = o.value; if (o.value === (inp.value || "")) op.selected = true; sel.append(op); });
if (inp.value && !(inp.options || []).some((o) => o.value === inp.value)) { const op = el("option", null, inp.value + " (missing)"); op.value = inp.value; op.selected = true; sel.append(op); }
sel.onchange = async () => { setCoreOverride(n.id, inp.name, sel.value); await persist(); coreNodes = (await API.coreGraph(window.Store?.get().project?.id).catch(() => ({}))).nodes || coreNodes; render(); };
row.append(sel);
box.append(row);
});
card.append(box);
}
const outs = (n.outputs || []).filter((o) => (o.to || []).length);
if (outs.length) {
const box = el("div", "wire-box");
box.append(el("div", "wire-title", "Outputs → destinations"));
outs.forEach((o) => {
const row = el("div", "wire-row");
row.append(el("span", "wire-out", `${o.name} (${o.type})`));
row.append(el("span", "wire-arrow", "→"));
row.append(el("span", "wire-dest", o.to.join(", ")));
box.append(row);
});
card.append(box);
}
return card;
}
function coreSection() {
const sec = el("div", "links-section");
const head = el("div", "links-head");
const toggle = el("button", "btn ghost tiny", (coreOpen ? "▾ " : "▸ ") + "Built-in pipeline");
toggle.onclick = () => { coreOpen = !coreOpen; render(); };
head.append(toggle);
head.append(el("span", "lib-sub", `${coreNodes.length} fixed nodes`));
sec.append(head);
if (coreOpen) {
sec.append(el("div", "links-hint", "The fixed FunPack nodes and their wiring. Each input defaults to its built-in source — pick another to re-wire it."));
coreNodes.forEach((n) => sec.append(coreCard(n)));
}
return sec;
}
function body() {
const b = el("div", "models-body");
b.append(requirementsPanel());
b.append(composer());
b.append(linksSection());
b.append(coreSection());
const v = validation();
const list = el("div", "slot-list");
if (!config.slots.length) list.append(el("div", "empty-stage", "No models configured yet — use the form above to add loaders."));
@@ -700,6 +763,7 @@
Object.keys(specByClass).forEach((k) => delete specByClass[k]);
allNodes = null;
try { await API.refreshModels(); } catch (_) {}
try { coreNodes = (await API.coreGraph(window.Store?.get().project?.id)).nodes || []; } catch (_) {}
await prewarmSpecs();
render();
}
@@ -708,6 +772,7 @@
await ensureRoles();
try { const pp = await API.pipelinePorts(); ports = pp.ports || []; coreProducers = pp.core_producers || []; requirements = pp.requirements || []; } catch (_) { ports = []; coreProducers = []; requirements = []; }
try { config = await API.getModels(window.Store?.get().project?.id); } catch (_) { config = { slots: [] }; }
try { coreNodes = (await API.coreGraph(window.Store?.get().project?.id)).nodes || []; } catch (_) { coreNodes = []; }
await prewarmSpecs();
overlay = el("div", "modal-overlay");
+1
View File
@@ -493,6 +493,7 @@ label.field.linked input, label.field.linked select { opacity: .6; }
.wire-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.wire-out { font-family: var(--font-mono); font-size: 11px; color: var(--muted); flex: 0 0 auto; min-width: 130px; }
.wire-arrow { color: var(--teal); }
.wire-dest { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.wire-select { flex: 1; }
.wire-multi { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.wire-multi-row { display: flex; gap: 5px; align-items: center; }
+11 -1
View File
@@ -112,7 +112,7 @@ def _project_or_404(pid: str) -> Project:
def _project_models(p: Optional[Project]) -> dict:
"""The project's own pipeline config, or the global default when it has none yet."""
m = getattr(p, "models", None) or {}
if m.get("slots") or m.get("links"):
if m.get("slots") or m.get("links") or m.get("core_overrides"):
return m
return nodes.load_models()
@@ -624,6 +624,16 @@ if web is not None and PromptServer is not None:
out.append({"value": f"node:{slot['id']}:{ci['name']}", "label": label})
return web.json_response({"targets": out})
@routes.get(UI_PREFIX + "/api/core-graph")
async def _core_graph(req):
try:
oi = await bridge.object_info()
except Exception:
oi = {}
pid = req.query.get("pid")
models_cfg = _project_models(projects.get(pid)) if pid else nodes.load_models()
return web.json_response({"nodes": builder.core_graph(oi, models_cfg)})
@routes.get(UI_PREFIX + "/api/all-nodes")
async def _all_nodes(_req):
try: