Movie Editor: model-agnostic loader/node config via Models menu

The fixed pipeline only varies in its loaders + a few pipeline nodes (they depend
on installed models). New 'Models' menu configures them via ComfyUI /object_info,
no hardcoded model family:

- backend nodes.py: role registry (Unet, LoRA, Video/Audio VAE, CLIP, CLIP Vision,
  Input Image Processing, Empty Latent); filters object_info candidates by output
  type (source roles exclude patchers); extracts user widgets (combos w/ installed
  files, int/float/string/bool), skips graph-connection inputs; global config store
- bridge.object_info() (cached; refresh = ComfyUI 'R'); server routes for
  node-roles / node-candidates / models get|put / refresh
- frontend models.js: Models menu (Settings + Refresh model list) + modal
  'Add Model/Node' composer (type -> node -> exposed inputs) + configured slot list
- tests: +5 (filtering, widget extraction, store) = 13 passing

Note: drops the runtime-template concept per user — the editor will own the fixed
graph and inject these loaders. Graph builder is the next step (needs the montage
workflow JSON to encode exactly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-06 14:07:30 +03:00
co-authored by Claude Opus 4.8
parent a593ae7be7
commit 93febcf3b3
10 changed files with 495 additions and 1 deletions
+19
View File
@@ -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
+16
View File
@@ -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_})
+144
View File
@@ -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
+7
View File
@@ -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}`)),
+1
View File
@@ -65,6 +65,7 @@
<script src="api.js"></script>
<script src="dom.js"></script>
<script src="store.js"></script>
<script src="models.js"></script>
<script src="menubar.js"></script>
<script src="mediabrowser.js"></script>
<script src="player.js"></script>
+4
View File
@@ -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") },
+180
View File
@@ -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(() => {})); } };
})();
+27
View File
@@ -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; }
+33 -1
View File
@@ -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):
+64
View File
@@ -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"