diff --git a/movie_editor/README.md b/movie_editor/README.md
index 85785764..fa5722ca 100644
--- a/movie_editor/README.md
+++ b/movie_editor/README.md
@@ -83,6 +83,25 @@ source (Empty/Image), transition, and exclude flag; deselect (Generate ▸ Proje
to edit the anchor and global seed/frames/fps. Panel borders are draggable. The media bin
drag-drop and Render/Export are stubbed for later phases.
+## Models (loaders & pipeline nodes)
+
+The generation path is fixed (loaders → Studio → Chain Sampler → decode → save); only the
+**loaders and a couple of pipeline nodes vary per machine** because they depend on what's
+installed. The **Models** menu configures them model-agnostically:
+
+- **Refresh model list** — re-scan ComfyUI (`/object_info`), same as pressing `R` in ComfyUI,
+ so loader dropdowns show current files.
+- **Settings…** — *Add Model / Node*: pick a **type** (Unet, LoRA, Video VAE, Audio VAE, CLIP,
+ CLIP Vision, Input Image Processing, Empty Latent), then a **node** (only nodes that produce
+ that type are offered). The chosen node's inputs are rendered inline — combos populated with
+ your installed files — so it stays fully controlled. This "wires" the node into the pipeline.
+
+Discovery uses `/object_info` output types; source roles exclude patchers (e.g. a LoRA loader
+won't show under Unet). Config is stored globally at `~/.funpack_movie/models.json`.
+
+> The graph builder that injects these configured loaders into the fixed path is the next step
+> and needs the actual montage workflow JSON to encode exactly (the loader UI above works now).
+
## How the timeline maps to Studio
Studio splits one prompt into scenes by transition triggers; `segments[0]` (text before the
diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py
index 71d2b9ae..e1816be7 100644
--- a/movie_editor/backend/bridge.py
+++ b/movie_editor/backend/bridge.py
@@ -83,6 +83,22 @@ async def is_running(prompt_id: str) -> bool:
return False
+_object_info_cache = {"data": None}
+
+
+async def object_info(refresh: bool = False) -> dict:
+ """Full ComfyUI node registry (class -> input/output spec). Cached; combo lists
+ (installed files) refresh when `refresh=True` — same effect as pressing R in ComfyUI."""
+ if _object_info_cache["data"] is not None and not refresh:
+ return _object_info_cache["data"]
+ async with await _session() as s:
+ async with s.get(_url("/object_info")) as r:
+ r.raise_for_status()
+ data = await r.json()
+ _object_info_cache["data"] = data
+ return data
+
+
async def fetch_view(filename: str, subfolder: str = "", type_: str = "output") -> tuple[bytes, str]:
from urllib.parse import urlencode
q = urlencode({"filename": filename, "subfolder": subfolder, "type": type_})
diff --git a/movie_editor/backend/nodes.py b/movie_editor/backend/nodes.py
new file mode 100644
index 00000000..6e995d66
--- /dev/null
+++ b/movie_editor/backend/nodes.py
@@ -0,0 +1,144 @@
+"""Pluggable node slots for the fixed FunPack path.
+
+The generation graph is fixed; only certain slots vary per machine because they
+depend on installed models / chosen nodes. The user picks a ComfyUI node for each
+slot and the editor exposes that node's widget inputs (rendered from /object_info).
+
+This module: defines the slot roles, filters /object_info to candidate nodes per
+role, extracts the user-facing widget inputs, and persists the chosen config.
+"""
+from __future__ import annotations
+
+import json
+
+from . import config
+
+# Types that are graph CONNECTIONS (wired automatically), not user widgets.
+LINK_TYPES = {
+ "MODEL", "CLIP", "VAE", "CLIP_VISION", "CONDITIONING", "LATENT", "IMAGE",
+ "MASK", "CONTROL_NET", "STYLE_MODEL", "GLIGEN", "AUDIO", "SAMPLER", "SIGMAS",
+ "GUIDER", "NOISE", "UPSCALE_MODEL", "PHOTOMAKER", "WEBCAM",
+}
+WIDGET_PRIMITIVES = {"INT", "FLOAT", "STRING", "BOOLEAN"}
+
+# role -> {label, category, want_output(s), [want_input]}.
+# A node qualifies for a role if it OUTPUTS the wanted type (and, for patchers like
+# LoRA / image processors, also INPUTS the relevant type).
+ROLES: dict[str, dict] = {
+ "unet": {"label": "Unet / Diffusion Model", "category": "Loaders", "output": "MODEL"},
+ "lora": {"label": "LoRA", "category": "Loaders", "output": "MODEL", "input": "MODEL"},
+ "video_vae": {"label": "Video VAE", "category": "Loaders", "output": "VAE"},
+ "audio_vae": {"label": "Audio VAE", "category": "Loaders", "output": "VAE"},
+ "clip": {"label": "CLIP / Text Encoder", "category": "Loaders", "output": "CLIP"},
+ "clip_vision": {"label": "CLIP Vision", "category": "Loaders", "output": "CLIP_VISION"},
+ "image_processing": {"label": "Input Image Processing", "category": "Pipeline", "output": "IMAGE", "input": "IMAGE"},
+ "empty_latent": {"label": "Empty Latent Generator", "category": "Pipeline", "output": "LATENT"},
+}
+
+
+def _outputs(node_def: dict) -> list[str]:
+ out = node_def.get("output") or []
+ # output entries can be a string or a list-of-options; we only care about strings here
+ return [o for o in out if isinstance(o, str)]
+
+
+def _all_input_types(node_def: dict) -> list[str]:
+ types = []
+ inp = node_def.get("input") or {}
+ for group in ("required", "optional"):
+ for spec in (inp.get(group) or {}).values():
+ t = spec[0] if isinstance(spec, list) and spec else None
+ if isinstance(t, str):
+ types.append(t)
+ return types
+
+
+def _matches_role(node_def: dict, role: dict) -> bool:
+ outs = _outputs(node_def)
+ ins = _all_input_types(node_def)
+ if role.get("output"):
+ if role["output"] not in outs:
+ return False
+ # Pure-source roles (no declared input) must NOT consume their own output type —
+ # that signals a patcher/transformer (LoRA, sampler), not a loader/generator.
+ if not role.get("input") and role["output"] in ins:
+ return False
+ if role.get("input") and role["input"] not in ins:
+ return False
+ return True
+
+
+def widget_inputs(node_def: dict) -> list[dict]:
+ """User-facing widgets for a node: combos (with options) and primitive fields.
+ Skips graph-connection inputs (MODEL/CLIP/IMAGE/...)."""
+ out = []
+ inp = node_def.get("input") or {}
+ for group in ("required", "optional"):
+ for name, spec in (inp.get(group) or {}).items():
+ if not isinstance(spec, list) or not spec:
+ continue
+ t = spec[0]
+ opts = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
+ field = {"name": name, "required": group == "required", "options": opts}
+ if isinstance(t, list): # combo: t is the list of choices (e.g. files)
+ field["kind"] = "combo"
+ field["choices"] = t
+ field["default"] = opts.get("default", t[0] if t else None)
+ elif t in WIDGET_PRIMITIVES:
+ field["kind"] = t.lower()
+ field["default"] = opts.get("default")
+ else:
+ continue # connection input — wired by the graph builder, not the user
+ out.append(field)
+ return out
+
+
+def candidates(object_info: dict, role_key: str) -> list[dict]:
+ """Candidate node classes for a role, each with their widget inputs."""
+ role = ROLES.get(role_key)
+ if not role:
+ return []
+ result = []
+ for cls, node_def in object_info.items():
+ if not isinstance(node_def, dict):
+ continue
+ if not _matches_role(node_def, role):
+ continue
+ result.append({
+ "class": cls,
+ "display_name": node_def.get("display_name", cls),
+ "category": node_def.get("category", ""),
+ "inputs": widget_inputs(node_def),
+ })
+ result.sort(key=lambda c: c["display_name"].lower())
+ return result
+
+
+def roles_payload() -> list[dict]:
+ return [{"key": k, **v} for k, v in ROLES.items()]
+
+
+# ── persistence (global engine config) ────────────────────────────────────────
+
+def _models_path():
+ return config.DATA_DIR / "models.json"
+
+
+def load_models() -> dict:
+ config.ensure_dirs()
+ p = _models_path()
+ if not p.exists():
+ return {"slots": []}
+ try:
+ return json.loads(p.read_text())
+ except (json.JSONDecodeError, OSError):
+ return {"slots": []}
+
+
+def save_models(data: dict) -> dict:
+ config.ensure_dirs()
+ if not isinstance(data, dict):
+ data = {"slots": []}
+ data.setdefault("slots", [])
+ _models_path().write_text(json.dumps(data, indent=2))
+ return data
diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js
index 315d4df3..2bcc7841 100644
--- a/movie_editor/frontend/api.js
+++ b/movie_editor/frontend/api.js
@@ -36,6 +36,13 @@
// libraries
transitions: () => j("GET", API("/library/transitions")),
+ // models / node slots
+ nodeRoles: () => j("GET", API("/node-roles")),
+ nodeCandidates: (role, refresh) => j("GET", API(`/node-candidates/${role}${refresh ? "?refresh=true" : ""}`)),
+ getModels: () => j("GET", API("/models")),
+ saveModels: (data) => j("PUT", API("/models"), data),
+ refreshModels: () => j("POST", API("/models/refresh")),
+
// generate
generate: (id, onlyScene) => j("POST", API(`/projects/${id}/generate`), { only_scene: onlyScene || null }),
status: (id, promptId) => j("GET", API(`/projects/${id}/status/${promptId}`)),
diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html
index 4bd54e35..479c0da9 100644
--- a/movie_editor/frontend/index.html
+++ b/movie_editor/frontend/index.html
@@ -65,6 +65,7 @@
+
diff --git a/movie_editor/frontend/menubar.js b/movie_editor/frontend/menubar.js
index 0f632f2a..305807b0 100644
--- a/movie_editor/frontend/menubar.js
+++ b/movie_editor/frontend/menubar.js
@@ -46,6 +46,10 @@
{ label: "Project Settings", disabled: !hasProject(), action: () => S.selectScene(null) },
{ label: "Render / Export…", soon: true, disabled: true },
],
+ Models: [
+ { label: "Settings…", action: () => window.ModelsModal.open() },
+ { label: "Refresh model list", hint: "R", action: async () => { try { await window.MovieEditorAPI.refreshModels(); } catch (_) {} } },
+ ],
FunPack: [
{ menulabel: "Libraries (in ComfyUI Studio)" },
{ label: "Open ComfyUI", hint: "↗", action: () => window.open("/", "_blank") },
diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js
new file mode 100644
index 00000000..36f0e221
--- /dev/null
+++ b/movie_editor/frontend/models.js
@@ -0,0 +1,180 @@
+// "Models" settings modal: configure pluggable node slots for the fixed pipeline.
+// Pick a Model Type -> a Loader Node -> the editor exposes that node's inputs.
+(function () {
+ const { el, clear } = window.dom;
+ const API = window.MovieEditorAPI;
+
+ let roles = []; // [{key,label,category}]
+ const candCache = {}; // role -> [candidate]
+ let config = { slots: [] }; // {slots:[{id,role,node_class,inputs:{}}]}
+ let overlay = null;
+
+ function uid() { return Math.random().toString(36).slice(2, 9); }
+
+ async function ensureRoles() { if (!roles.length) roles = (await API.nodeRoles()).roles || []; }
+ async function candidates(role, refresh) {
+ if (!candCache[role] || refresh) candCache[role] = (await API.nodeCandidates(role, refresh)).candidates || [];
+ return candCache[role];
+ }
+ function candidate(role, cls) { return (candCache[role] || []).find((c) => c.class === cls); }
+
+ async function persist() { try { config = await API.saveModels(config); } catch (e) { console.error(e); } }
+
+ // ── widget field rendering from object_info spec ─────────────────────────────
+ function widgetField(spec, value, onChange) {
+ const wrap = el("label", "field");
+ wrap.append(el("span", null, spec.name + (spec.required ? "" : " ·opt")));
+ let ctrl;
+ if (spec.kind === "combo") {
+ ctrl = el("select");
+ (spec.choices || []).forEach((c) => { const o = el("option", null, String(c)); o.value = c; if (c === value) o.selected = true; ctrl.append(o); });
+ if (!spec.choices || !spec.choices.length) { ctrl.append(el("option", null, "(none installed)")); ctrl.disabled = true; }
+ ctrl.onchange = () => onChange(ctrl.value);
+ } else if (spec.kind === "boolean") {
+ ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!value; ctrl.style.width = "auto";
+ ctrl.onchange = () => onChange(ctrl.checked);
+ } else if (spec.kind === "int" || spec.kind === "float") {
+ ctrl = el("input"); ctrl.type = "number"; if (spec.kind === "float") ctrl.step = "any";
+ ctrl.value = value != null ? value : (spec.default != null ? spec.default : "");
+ ctrl.oninput = () => onChange(spec.kind === "int" ? parseInt(ctrl.value || "0", 10) : parseFloat(ctrl.value || "0"));
+ } else {
+ ctrl = el("input"); ctrl.type = "text"; ctrl.value = value != null ? value : (spec.default || "");
+ ctrl.oninput = () => onChange(ctrl.value);
+ }
+ wrap.append(ctrl);
+ return wrap;
+ }
+
+ function defaultsFor(cand) {
+ const o = {};
+ (cand.inputs || []).forEach((s) => { if (s.default != null) o[s.name] = s.default; else if (s.kind === "combo" && s.choices?.length) o[s.name] = s.choices[0]; });
+ return o;
+ }
+
+ // ── slot row (configured) ────────────────────────────────────────────────────
+ function slotRow(slot) {
+ const role = roles.find((r) => r.key === slot.role);
+ const card = el("div", "slot-card");
+ const head = el("div", "slot-head");
+ head.append(el("span", "slot-role", role ? role.label : slot.role));
+ head.append(el("span", "slot-node", slot.node_class));
+ const rm = el("button", "btn ghost tiny danger", "remove");
+ rm.onclick = async () => { config.slots = config.slots.filter((s) => s.id !== slot.id); await persist(); render(); };
+ head.append(rm);
+ card.append(head);
+
+ const cand = candidate(slot.role, slot.node_class);
+ if (cand && cand.inputs.length) {
+ const grid = el("div", "slot-fields");
+ cand.inputs.forEach((spec) => {
+ grid.append(widgetField(spec, slot.inputs[spec.name], async (v) => { slot.inputs[spec.name] = v; await persist(); }));
+ });
+ card.append(grid);
+ } else {
+ card.append(el("div", "pj-meta", "No configurable inputs."));
+ }
+ return card;
+ }
+
+ // ── add-model composer ───────────────────────────────────────────────────────
+ function composer() {
+ const box = el("div", "composer");
+ box.append(el("div", "composer-title", "Add Model / Node"));
+
+ const row = el("div", "composer-row");
+ const typeSel = el("select");
+ const ph = el("option", null, "Model Type…"); ph.value = ""; typeSel.append(ph);
+ const cats = {};
+ roles.forEach((r) => { (cats[r.category] = cats[r.category] || []).push(r); });
+ Object.keys(cats).forEach((cat) => {
+ const og = document.createElement("optgroup"); og.label = cat;
+ cats[cat].forEach((r) => { const o = el("option", null, r.label); o.value = r.key; og.append(o); });
+ typeSel.append(og);
+ });
+
+ const nodeSel = el("select"); nodeSel.disabled = true; nodeSel.append(new Option("Loader Node…", ""));
+ const addBtn = el("button", "btn primary", "Add"); addBtn.disabled = true;
+ const preview = el("div", "composer-preview");
+
+ let curRole = "", curCand = null;
+
+ typeSel.onchange = async () => {
+ curRole = typeSel.value; curCand = null; addBtn.disabled = true; clear(preview);
+ nodeSel.innerHTML = ""; nodeSel.append(new Option("Loading…", "")); nodeSel.disabled = true;
+ if (!curRole) { nodeSel.innerHTML = ""; nodeSel.append(new Option("Loader Node…", "")); return; }
+ let list = [];
+ try { list = await candidates(curRole); } catch (e) { nodeSel.innerHTML = ""; nodeSel.append(new Option("ComfyUI offline", "")); return; }
+ nodeSel.innerHTML = ""; nodeSel.append(new Option(list.length ? "Loader Node…" : "No matching nodes", ""));
+ list.forEach((c) => nodeSel.append(new Option(c.display_name + " · " + c.class, c.class)));
+ nodeSel.disabled = !list.length;
+ };
+ nodeSel.onchange = () => {
+ curCand = candidate(curRole, nodeSel.value); addBtn.disabled = !curCand; clear(preview);
+ if (curCand) {
+ const d = defaultsFor(curCand);
+ (curCand.inputs || []).forEach((spec) => preview.append(widgetField(spec, d[spec.name], (v) => { d[spec.name] = v; })));
+ preview._draft = d;
+ }
+ };
+ addBtn.onclick = async () => {
+ if (!curCand) return;
+ config.slots.push({ id: uid(), role: curRole, node_class: curCand.class, inputs: preview._draft || defaultsFor(curCand) });
+ await persist();
+ typeSel.value = ""; nodeSel.innerHTML = ""; nodeSel.append(new Option("Loader Node…", "")); nodeSel.disabled = true;
+ addBtn.disabled = true; clear(preview); render();
+ };
+
+ row.append(typeSel); row.append(nodeSel); row.append(addBtn);
+ box.append(row); box.append(preview);
+ return box;
+ }
+
+ function body() {
+ const b = el("div", "models-body");
+ b.append(composer());
+ const list = el("div", "slot-list");
+ if (!config.slots.length) list.append(el("div", "empty-stage", "No models configured yet. Add Unet, VAEs, CLIP, and pipeline nodes above."));
+ else config.slots.forEach((s) => list.append(slotRow(s)));
+ b.append(list);
+ return b;
+ }
+
+ function render() {
+ if (!overlay) return;
+ const content = overlay.querySelector(".modal-content");
+ clear(content);
+ content.append(body());
+ }
+
+ async function refreshList() {
+ Object.keys(candCache).forEach((k) => delete candCache[k]);
+ try { await API.refreshModels(); } catch (_) {}
+ // re-pull candidates for roles already configured so combos show fresh files
+ for (const role of new Set(config.slots.map((s) => s.role))) { try { await candidates(role, true); } catch (_) {} }
+ render();
+ }
+
+ async function open() {
+ await ensureRoles();
+ try { config = await API.getModels(); } catch (_) { config = { slots: [] }; }
+ // pre-warm candidate cache for configured roles so their fields render
+ for (const role of new Set(config.slots.map((s) => s.role))) { try { await candidates(role); } catch (_) {} }
+
+ overlay = el("div", "modal-overlay");
+ const modal = el("div", "modal");
+ const head = el("div", "modal-head");
+ head.append(el("div", "modal-title", "Models & Pipeline Nodes"));
+ const refresh = el("button", "btn ghost", "↻ Refresh model list"); refresh.onclick = refreshList;
+ const close = el("button", "btn ghost", "✕"); close.onclick = () => { overlay.remove(); overlay = null; };
+ const heRight = el("div", "modal-head-right"); heRight.append(refresh); heRight.append(close);
+ head.append(heRight);
+ modal.append(head);
+ modal.append(el("div", "modal-content"));
+ overlay.append(modal);
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) { overlay.remove(); overlay = null; } });
+ document.body.append(overlay);
+ render();
+ }
+
+ window.ModelsModal = { open, refresh: async () => { await ensureRoles().catch(() => {}); await (window.MovieEditorAPI.refreshModels().catch(() => {})); } };
+})();
diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css
index 1e35739f..ea4182c3 100644
--- a/movie_editor/frontend/styles.css
+++ b/movie_editor/frontend/styles.css
@@ -239,6 +239,33 @@ input[type=number] { font-family: var(--font-mono); }
.empty-stage { color: var(--faint); padding: 30px; text-align: center; width: 100%; }
+/* ───────────────────────── MODALS (Models settings) ───────────────────────── */
+.modal-overlay { position: fixed; inset: 0; z-index: 500; background: rgba(6,5,4,.66); backdrop-filter: blur(3px); display: flex; align-items: flex-start; justify-content: center; padding: 7vh 16px; animation: fade .15s ease; }
+@keyframes fade { from { opacity: 0; } }
+.modal { width: min(760px, 96vw); max-height: 82vh; display: flex; flex-direction: column; background: #16140f; border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); overflow: hidden; animation: pop .16s ease; }
+.modal-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); background: linear-gradient(#1b1813, #161410); }
+.modal-title { font-family: var(--font-display); font-weight: 700; font-size: 17px; }
+.modal-head-right { margin-left: auto; display: flex; gap: 8px; }
+.modal-content { padding: 16px; overflow: auto; }
+
+.composer { background: var(--ink-0); border: 1px solid var(--line); border-radius: 10px; padding: 14px; margin-bottom: 18px; }
+.composer-title { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--amber); margin-bottom: 10px; }
+.composer-row { display: flex; gap: 10px; align-items: center; }
+.composer-row select { flex: 1; }
+.composer-row .btn { flex: 0 0 auto; }
+.composer-preview { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; }
+.composer-preview:empty { display: none; }
+
+.slot-list { display: flex; flex-direction: column; gap: 12px; }
+.slot-card { background: var(--ink-1); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
+.slot-card { border-left: 3px solid var(--amber); }
+.slot-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
+.slot-role { font-weight: 600; }
+.slot-node { font-family: var(--font-mono); font-size: 11px; color: var(--muted); background: var(--ink-0); padding: 2px 7px; border-radius: 5px; border: 1px solid var(--line); }
+.slot-head .btn { margin-left: auto; }
+.slot-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 14px; }
+.slot-fields label.field, .composer-preview label.field { margin-bottom: 0; }
+
/* staggered zone reveal on load */
.zone { animation: rise .5s both; }
.media { animation-delay: .02s; }
diff --git a/movie_editor/server.py b/movie_editor/server.py
index f65c9a52..8e394081 100644
--- a/movie_editor/server.py
+++ b/movie_editor/server.py
@@ -19,7 +19,7 @@ except Exception: # pragma: no cover - only available inside ComfyUI
web = None
PromptServer = None
-from .backend import bridge, config, projects, workflow
+from .backend import bridge, config, nodes, projects, workflow
from .backend.timeline import Project, build_combined_prompt
UI_PREFIX = "/funpack/movie"
@@ -207,6 +207,38 @@ if web is not None and PromptServer is not None:
raise web.HTTPBadGateway(reason=f"could not fetch result: {e}")
return web.Response(body=data, content_type=ctype.split(";")[0])
+ # --- API: models / pluggable node slots ---
+ @routes.get(UI_PREFIX + "/api/node-roles")
+ async def _node_roles(_req):
+ return web.json_response({"roles": nodes.roles_payload()})
+
+ @routes.get(UI_PREFIX + "/api/node-candidates/{role}")
+ async def _node_candidates(req):
+ role = req.match_info["role"]
+ refresh = req.query.get("refresh") == "true"
+ try:
+ oi = await bridge.object_info(refresh=refresh)
+ except Exception as e: # noqa: BLE001
+ raise web.HTTPBadGateway(reason=f"object_info unavailable: {e}")
+ return web.json_response({"role": role, "candidates": nodes.candidates(oi, role)})
+
+ @routes.post(UI_PREFIX + "/api/models/refresh")
+ async def _models_refresh(_req):
+ try:
+ await bridge.object_info(refresh=True)
+ except Exception as e: # noqa: BLE001
+ raise web.HTTPBadGateway(reason=f"refresh failed: {e}")
+ return web.json_response({"ok": True})
+
+ @routes.get(UI_PREFIX + "/api/models")
+ async def _models_get(_req):
+ return web.json_response(nodes.load_models())
+
+ @routes.put(UI_PREFIX + "/api/models")
+ async def _models_put(req):
+ body = await req.json()
+ return web.json_response(nodes.save_models(body))
+
# --- UI: static frontend (must be registered AFTER api routes) ---
@routes.get(UI_PREFIX)
async def _root_redirect(_req):
diff --git a/movie_editor/tests/test_nodes.py b/movie_editor/tests/test_nodes.py
new file mode 100644
index 00000000..8c8a2942
--- /dev/null
+++ b/movie_editor/tests/test_nodes.py
@@ -0,0 +1,64 @@
+"""Tests for the pluggable node-slot discovery (object_info filtering)."""
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+
+from movie_editor.backend import nodes # noqa: E402
+
+OI = {
+ "CheckpointLoaderSimple": {"input": {"required": {"ckpt_name": [["a.safetensors", "b.ckpt"]]}},
+ "output": ["MODEL", "CLIP", "VAE"], "display_name": "Load Checkpoint"},
+ "VAELoader": {"input": {"required": {"vae_name": [["ltxv-vae.safetensors"]]}},
+ "output": ["VAE"], "display_name": "Load VAE"},
+ "LoraLoader": {"input": {"required": {"model": ["MODEL"], "clip": ["CLIP"],
+ "lora_name": [["l.safetensors"]],
+ "strength_model": ["FLOAT", {"default": 1.0}]}},
+ "output": ["MODEL", "CLIP"], "display_name": "Load LoRA"},
+ "EmptyLTXVLatentVideo": {"input": {"required": {"width": ["INT", {"default": 768}],
+ "length": ["INT", {"default": 97}]}},
+ "output": ["LATENT"], "display_name": "Empty LTXV Latent"},
+ "KSampler": {"input": {"required": {"model": ["MODEL"], "latent_image": ["LATENT"]}},
+ "output": ["LATENT"], "display_name": "KSampler"},
+ "LTXVPreprocess": {"input": {"required": {"image": ["IMAGE"], "crf": ["INT", {"default": 35}]}},
+ "output": ["IMAGE"], "display_name": "LTXV Preprocess"},
+}
+
+
+def names(role):
+ return [c["class"] for c in nodes.candidates(OI, role)]
+
+
+def test_source_roles_exclude_patchers():
+ assert names("unet") == ["CheckpointLoaderSimple"] # LoraLoader (consumes MODEL) excluded
+ assert names("empty_latent") == ["EmptyLTXVLatentVideo"] # KSampler (consumes LATENT) excluded
+ assert names("clip") == ["CheckpointLoaderSimple"]
+
+
+def test_vae_role_matches_all_vae_sources():
+ assert set(names("video_vae")) == {"CheckpointLoaderSimple", "VAELoader"}
+ assert set(names("audio_vae")) == {"CheckpointLoaderSimple", "VAELoader"}
+
+
+def test_patcher_and_processor_roles():
+ assert names("lora") == ["LoraLoader"] # MODEL in + MODEL out
+ assert names("image_processing") == ["LTXVPreprocess"] # IMAGE in + IMAGE out
+
+
+def test_widget_inputs_skip_connections_and_expose_combos():
+ lora = next(c for c in nodes.candidates(OI, "lora") if c["class"] == "LoraLoader")
+ kinds = {w["name"]: w["kind"] for w in lora["inputs"]}
+ assert "model" not in kinds and "clip" not in kinds # connections skipped
+ assert kinds["lora_name"] == "combo"
+ assert kinds["strength_model"] == "float"
+ ckpt = next(c for c in nodes.candidates(OI, "unet") if c["class"] == "CheckpointLoaderSimple")
+ assert ckpt["inputs"][0]["choices"] == ["a.safetensors", "b.ckpt"]
+
+
+def test_models_store_roundtrip(tmp_path, monkeypatch):
+ from movie_editor.backend import config
+ monkeypatch.setattr(config, "DATA_DIR", tmp_path)
+ assert nodes.load_models() == {"slots": []}
+ nodes.save_models({"slots": [{"id": "x", "role": "unet", "node_class": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "a.safetensors"}}]})
+ assert nodes.load_models()["slots"][0]["node_class"] == "CheckpointLoaderSimple"