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:
co-authored by
Claude Opus 4.8
parent
a593ae7be7
commit
93febcf3b3
@@ -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
|
||||
|
||||
@@ -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_})
|
||||
|
||||
@@ -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
|
||||
@@ -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}`)),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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(() => {})); } };
|
||||
})();
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user