diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index dbd6405b..49d522f6 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -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::" or "out::"). 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()} diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 16d319bb..42d3a012 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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")), diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index 2f40b724..825293d0 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -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"); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 958d00d7..0b52389e 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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; } diff --git a/movie_editor/server.py b/movie_editor/server.py index 871eccd9..1ab7f906 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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: