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"