feat: model family is chosen in Pipeline setup, with declared placeholders for H3

Setting the family in Models was the wrong first step — the family decides which nodes
and which model files everything after it looks for, so it is now the first question in
the Pipeline setup panel rather than something to find afterwards.

MiniMax H3 is not released: its nodes are in an unmerged ComfyUI PR and no weights exist.
Rather than hide the family until then, pipeline_deps.FAMILY_SETUP declares what it will
need — the slot nodes (with why each one is there) and the model files with their target
models/ folders — and the panel shows that as "not in your ComfyUI yet" / "to download
when published", with a link to the PR. Picking it configures the project now; it starts
working when ComfyUI ships the nodes and the weights land, with no further FunPack update.
The table is deliberately data, not code: shipping the real thing means flipping
"released" and filling in the hints, not editing the setup flow.

Dependency detection is family-aware throughout, so an H3 project is no longer told to
install packs for LTXVConditioning / Concat / LTXVAudioVAEDecode — nodes its graph never
emits. An unreleased family still counts as needs_setup, or the modal would never open
and a project that cannot generate would look ready. The install button and its lead text
only appear when there is something Manager can actually install; offering to fetch an
unreleased model would be a promise the button cannot keep.

Also reachable on demand: Models → Model family → Setup…, since the auto-prompt is
one-shot per browser and was previously unreachable once dismissed.

Verified live: picking MiniMax H3 in the panel switches the project, lists the H3 node and
all four model files with their folders, links the PR, and switches back cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-03 01:44:56 +03:00
co-authored by Claude Opus 5
parent 990b29b8c9
commit 349cce2be8
6 changed files with 314 additions and 25 deletions
+129 -14
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from typing import Any, Callable, Optional
from . import config
from .builder import CORE
from .builder import CORE, family_core
MANAGER_FOLDER = "ComfyUI-Manager"
MANAGER_GIT_URL = "https://github.com/ComfyUI-Manager/ComfyUI-Manager"
@@ -51,6 +51,107 @@ PIPELINE_PACKS: list[dict[str, Any]] = [
_PACK_BY_ID = {p["id"]: p for p in PIPELINE_PACKS}
_ALL_MAPPED = frozenset().union(*(p["classes"] for p in PIPELINE_PACKS))
# ── per-family setup: what a project needs before it can generate ─────────────
# Declarative on purpose. MiniMax H3 is not released yet — its nodes are in an unmerged
# ComfyUI PR and no weights exist — so the entries below are PLACEHOLDERS: they describe
# what to look for and say plainly that it is not out. When it ships, the only thing that
# changes here is the data (flip `released`, fill in the real filename hints); the setup
# flow, the panel and the tests already work against it.
#
# `nodes` are slot node classes (not core graph nodes — those are covered by PIPELINE_PACKS).
# `models` are files the user has to download; `folder` is the ComfyUI models/ subdirectory.
FAMILY_SETUP: dict[str, dict[str, Any]] = {
"ltxav": {
"label": "LTX-2 / LTXAV",
"released": True,
"summary": "Lightricks LTX-2 / LTXAV. Gemma3 text encoder, separate video and audio latents.",
"nodes": [],
"models": [
{"role": "unet", "label": "LTX-2 diffusion model", "folder": "diffusion_models"},
{"role": "clip", "label": "Gemma3 text encoder", "folder": "text_encoders"},
{"role": "video_vae", "label": "LTX-2 video VAE", "folder": "vae"},
{"role": "audio_vae", "label": "LTX-2 audio VAE", "folder": "vae"},
],
},
"minimax_h3": {
"label": "MiniMax H3 (Hailuo)",
"released": False,
"summary": "MiniMax H3. Qwen3-VL text encoder, one joint AV latent, 24 fps, "
"native reference conditioning (ref2va).",
"not_released_note":
"MiniMax H3 is not available yet. Its nodes are in an unmerged ComfyUI pull "
"request and no weights have been published. You can select it now to set the "
"project up — the pipeline is already wired for it, and it will start working "
"as soon as ComfyUI ships the nodes and the weights appear in your models "
"folders. Nothing here needs another FunPack update.",
"source_url": "https://github.com/Comfy-Org/ComfyUI/pull/15224",
"source_title": "ComfyUI PR #15224 — MiniMax H3 support",
"nodes": [
{"class": "EmptyMiniMaxH3LatentAV", "label": "Empty MiniMax H3 AV Latent",
"role": "empty_latent",
"why": "Makes the joint video + audio latent that feeds the Chain Sampler."},
{"class": "MiniMaxH3SigmaShift", "label": "MiniMax H3 Sigma Shift",
"role": None, "optional": True,
"why": "Optional — sets the video/audio flow shifts (defaults 12.0 / 3.0)."},
],
"models": [
{"role": "unet", "label": "MiniMax H3 diffusion model", "folder": "diffusion_models",
"hint": "single-stream packed AV DiT"},
{"role": "clip", "label": "Qwen3-VL-32B text encoder (50 layers)", "folder": "text_encoders",
"hint": "H3 consumes the unnormalized hidden state after layer 50"},
{"role": "video_vae", "label": "MiniMax H3 video VAE", "folder": "vae",
"hint": "24-channel latent, 16x spatial"},
{"role": "audio_vae", "label": "MiniMax H3 audio VAE", "folder": "vae",
"hint": "32 kHz stereo; also encodes audio references for ref2va"},
],
},
}
DEFAULT_FAMILY = "ltxav"
def family_setup(family: str | None) -> dict:
key = str(family or DEFAULT_FAMILY).strip().lower()
return FAMILY_SETUP.get(key) or FAMILY_SETUP[DEFAULT_FAMILY]
def families_payload() -> list[dict]:
"""The setup picker's options, in display order."""
return [
{"key": key, "label": spec["label"], "released": bool(spec.get("released")),
"summary": spec.get("summary", ""),
"note": spec.get("not_released_note"),
"source_url": spec.get("source_url"), "source_title": spec.get("source_title")}
for key, spec in FAMILY_SETUP.items()
]
def family_readiness(object_info: dict | None, family: str) -> dict:
"""What this family still needs — nodes not installed, models not downloaded.
Model files cannot be checked from object_info alone (a loader lists them as combo
choices only once the node exists), so those are reported as EXPECTED rather than
missing: the honest statement is "here is what you will need", not a false negative.
"""
oi = object_info or {}
spec = family_setup(family)
nodes = []
for n in spec.get("nodes", []):
nodes.append({**n, "installed": n["class"] in oi})
return {
"family": str(family or DEFAULT_FAMILY).lower(),
"label": spec["label"],
"released": bool(spec.get("released")),
"summary": spec.get("summary", ""),
"note": spec.get("not_released_note"),
"source_url": spec.get("source_url"),
"source_title": spec.get("source_title"),
"nodes": nodes,
"missing_nodes": [n for n in nodes if not n["installed"] and not n.get("optional")],
"models": spec.get("models", []),
}
_install_jobs: dict[str, dict] = {}
@@ -75,17 +176,24 @@ def manager_dir_on_disk() -> bool:
return d.is_dir() and ((d / ".git").exists() or (d / "__init__.py").exists())
def required_core_classes() -> list[str]:
return sorted(set(CORE.values()))
def required_core_classes(family: str | None = None) -> list[str]:
return sorted(set(_core_for(family).values()))
def missing_core_classes(object_info: dict | None) -> list[str]:
def _core_for(family: str | None) -> dict:
"""The fixed-core node classes for `family` (H3 drops three of LTXAV's)."""
if not family:
return CORE
return family_core(str(family).strip().lower())[0]
def missing_core_classes(object_info: dict | None, family: str | None = None) -> list[str]:
oi = object_info or {}
return sorted(cls for cls in CORE.values() if cls not in oi)
return sorted(cls for cls in _core_for(family).values() if cls not in oi)
def missing_packs(object_info: dict | None) -> list[dict]:
missing = set(missing_core_classes(object_info))
def missing_packs(object_info: dict | None, family: str | None = None) -> list[dict]:
missing = set(missing_core_classes(object_info, family))
out: list[dict] = []
for pack in PIPELINE_PACKS:
hit = pack["classes"] & missing
@@ -99,8 +207,8 @@ def missing_packs(object_info: dict | None) -> list[dict]:
return out
def unmapped_missing_classes(object_info: dict | None) -> list[str]:
missing = set(missing_core_classes(object_info))
def unmapped_missing_classes(object_info: dict | None, family: str | None = None) -> list[str]:
missing = set(missing_core_classes(object_info, family))
return sorted(missing - _ALL_MAPPED)
@@ -109,18 +217,25 @@ def status_payload(
*,
manager_available: bool,
manager_on_disk: bool | None = None,
family: str | None = None,
) -> dict:
packs = missing_packs(object_info)
unmapped = unmapped_missing_classes(object_info)
packs = missing_packs(object_info, family)
unmapped = unmapped_missing_classes(object_info, family)
on_disk = manager_dir_on_disk() if manager_on_disk is None else manager_on_disk
needs_setup = bool(packs)
needs_manager = needs_setup and not manager_available
readiness = family_readiness(object_info, family or DEFAULT_FAMILY)
# A family whose nodes are not out yet is a setup problem too — the modal has to open
# and say so, rather than reporting a clean pipeline that cannot actually generate.
needs_setup = bool(packs) or bool(readiness["missing_nodes"])
needs_manager = bool(packs) and not manager_available
return {
"family": readiness["family"],
"families": families_payload(),
"readiness": readiness,
"manager_available": manager_available,
"manager_on_disk": on_disk,
"needs_manager_install": needs_manager and not on_disk,
"needs_manager_restart": needs_manager and on_disk,
"missing_classes": missing_core_classes(object_info),
"missing_classes": missing_core_classes(object_info, family),
"missing_packs": packs,
"unmapped_classes": unmapped,
"needs_install": needs_setup,
+5
View File
@@ -1231,6 +1231,11 @@
};
row.append(b);
});
const setup = el("button", "btn ghost tiny", "Setup…");
setup.title = "What this model family needs — nodes and model files, including anything "
+ "not released yet.";
setup.onclick = () => window.PipelineSetup?.open();
row.append(setup);
sec.append(row);
return sec;
}
+99 -11
View File
@@ -178,6 +178,73 @@
}
}
// The family decides which nodes and which model files the project needs, so it is the
// FIRST question — asking it after an install would have installed the wrong thing.
async function chooseFamily(key) {
if (!S.get().project) return;
const models = JSON.parse(JSON.stringify(S.get().models || { slots: [] }));
models.model_family = key;
await API.saveModels(S.get().project.id, models);
await S.loadModels();
let deps;
try { deps = await API.pipelineDeps(); } catch (_) { closeModal(); return; }
if (!deps?.needs_setup) { closeModal(); return; }
openModal(deps);
}
function familyStep(deps) {
const box = el("div", "pipe-setup-family");
box.append(el("div", "pipe-setup-manual-title", "Which model is this project for?"));
(deps.families || []).forEach((f) => {
const active = f.key === deps.family;
const b = el("button", "btn ghost pipe-setup-family-btn" + (active ? " active" : ""));
b.type = "button";
const title = el("div", "pipe-setup-family-title", f.label);
if (!f.released) title.append(el("span", "pipe-setup-sub", "not released yet"));
b.append(title);
b.append(el("div", "pipe-setup-sub", f.summary || ""));
b.onclick = () => chooseFamily(f.key);
box.append(b);
});
return box;
}
// What the chosen family still needs. For a family that is not out yet this is the
// whole point of the panel: say what is missing and why, instead of offering an
// install button that cannot possibly succeed.
function readinessBlock(r) {
const box = el("div", "pipe-setup-readiness");
if (r.note) box.append(el("div", "pipe-setup-hint", r.note));
if (r.source_url) {
const a = el("a", "pipe-setup-link", r.source_title || r.source_url);
a.href = r.source_url; a.target = "_blank"; a.rel = "noopener";
box.append(a);
}
const missing = r.missing_nodes || [];
if (missing.length) {
box.append(el("div", "pipe-setup-manual-title", "Nodes not in your ComfyUI yet:"));
const ul = el("ul", "pipe-setup-list");
missing.forEach((n) => {
const li = el("li", null, n.label);
li.append(el("span", "pipe-setup-sub", n.class + (n.why ? " — " + n.why : "")));
ul.append(li);
});
box.append(ul);
}
if ((r.models || []).length) {
box.append(el("div", "pipe-setup-manual-title",
r.released ? "Model files this pipeline uses:" : "Model files to download when they are published:"));
const ul = el("ul", "pipe-setup-list");
r.models.forEach((m) => {
const li = el("li", null, m.label);
li.append(el("span", "pipe-setup-sub", "models/" + m.folder + (m.hint ? " — " + m.hint : "")));
ul.append(li);
});
box.append(ul);
}
return box;
}
function openModal(deps) {
closeModal();
overlay = el("div", "modal-overlay pipe-setup-overlay");
@@ -187,24 +254,34 @@
modal.append(head);
const content = el("div", "modal-content");
if (deps.needs_manager_install) {
content.append(familyStep(deps));
const r = deps.readiness;
const unreleased = r && !r.released;
if (unreleased || (r && (r.missing_nodes || []).length)) {
content.append(readinessBlock(r));
}
// Only talk about installing when there is something Manager can actually install.
// A family that is not released yet has nothing to fetch, and offering to fetch it
// would be a promise the button cannot keep.
const hasPacks = (deps.missing_packs || []).length > 0;
if (hasPacks && deps.needs_manager_install) {
content.append(el("p", "pipe-setup-lead",
"Nodes required for the built-in pipeline are missing. Install ComfyUI-Manager first, then we can fetch the rest."));
} else if (deps.needs_manager_restart) {
} else if (hasPacks && deps.needs_manager_restart) {
content.append(el("p", "pipe-setup-lead",
"ComfyUI-Manager is on disk but not running yet. Restart ComfyUI to load it, then install the missing node packs."));
} else {
} else if (hasPacks) {
content.append(el("p", "pipe-setup-lead",
"Nodes required for the built-in pipeline are missing. Do you want to install them?"));
"Node packs required for the built-in pipeline are missing. Do you want to install them?"));
}
if ((deps.missing_packs || []).length) {
if (hasPacks) {
content.append(packList(deps));
}
if (deps.needs_manager_install) {
if (hasPacks && deps.needs_manager_install) {
content.append(el("div", "pipe-setup-hint",
"ComfyUI-Manager handles downloads and updates for custom node packs."));
content.append(manualBlock(deps));
} else if (!deps.manager_available) {
} else if (hasPacks && !deps.manager_available) {
content.append(manualBlock(deps));
}
if ((deps.unmapped_classes || []).length) {
@@ -214,17 +291,19 @@
modal.append(content);
const foot = el("div", "pipe-setup-foot");
if (deps.needs_manager_install) {
if (!hasPacks) {
// nothing installable — the only useful action left is to close and come back
} else if (deps.needs_manager_install) {
const mgrBtn = el("button", "btn primary", "Install ComfyUI-Manager");
mgrBtn.type = "button";
mgrBtn.onclick = () => startManagerInstall();
foot.append(mgrBtn);
} else if (deps.needs_manager_restart) {
} else if (hasPacks && deps.needs_manager_restart) {
const restartBtn = el("button", "btn primary", "Restart ComfyUI");
restartBtn.type = "button";
restartBtn.onclick = () => restartComfyOnly();
foot.append(restartBtn);
} else if (deps.manager_available) {
} else if (hasPacks && deps.manager_available) {
const installBtn = el("button", "btn primary", "Install missing nodes");
installBtn.type = "button";
installBtn.onclick = () => startInstall(deps);
@@ -267,5 +346,14 @@
openModal(deps);
}
window.PipelineSetup = { maybePrompt, close: closeModal };
// Opened on demand from Models → Model family, so the setup panel is reachable again
// after it has been dismissed once (the auto-prompt is one-shot per browser).
async function open() {
if (!S.get().project) return;
let deps;
try { deps = await API.pipelineDeps(); } catch (_) { return; }
openModal(deps);
}
window.PipelineSetup = { maybePrompt, open, close: closeModal };
})();
+14
View File
@@ -1335,6 +1335,20 @@ label.field.linked input, label.field.linked select { opacity: .6; }
.pipe-setup-manual-title { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.pipe-setup-foot { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 16px 16px; border-top: 1px solid var(--line); }
.pipe-setup-hint { margin: 0 0 10px; font-size: 12px; color: var(--muted); line-height: 1.45; }
/* Model family picker — the first question in setup, since it decides which nodes and
which model files everything after it looks for. */
.pipe-setup-family { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
.pipe-setup-family-btn { display: block; width: 100%; text-align: left; padding: 10px 12px;
border-radius: 8px; line-height: 1.4; }
.pipe-setup-family-btn.active { border-color: var(--accent); background: var(--accent-soft, rgba(255,170,60,.10)); }
.pipe-setup-family-title { font-size: 13px; color: var(--text); display: flex; align-items: baseline;
gap: 8px; flex-wrap: wrap; }
.pipe-setup-family-title .pipe-setup-sub { display: inline; margin-top: 0; font-family: inherit;
text-transform: uppercase; letter-spacing: .04em; font-size: 10px; }
.pipe-setup-family-btn .pipe-setup-sub { font-family: inherit; font-size: 11px; color: var(--muted); }
.pipe-setup-readiness { margin: 0 0 14px; padding: 10px 12px; border-radius: 8px;
border: 1px solid var(--line); background: var(--panel-2, rgba(255,255,255,.02)); }
.pipe-setup-link { display: inline-block; margin-bottom: 10px; font-size: 12px; }
.restart-card .restart-cancel { margin-top: 4px; }
/* restart overlay */
+1
View File
@@ -2208,6 +2208,7 @@ if web is not None and PromptServer is not None:
oi,
manager_available=mgr,
manager_on_disk=pipeline_deps.manager_dir_on_disk(),
family=pipeline_wiring.family_of(nodes.load_models()),
))
@routes.post(UI_PREFIX + "/api/pipeline-deps/install")
+66
View File
@@ -80,3 +80,69 @@ def test_custom_nodes_dir_uses_folder_paths(tmp_path, monkeypatch):
fp = type("FP", (), {"get_folder_paths": staticmethod(lambda _: [str(cn)])})()
monkeypatch.setitem(__import__("sys").modules, "folder_paths", fp)
assert pipeline_deps.custom_nodes_dir() == cn
# ── per-family setup placeholders ─────────────────────────────────────────────
# MiniMax H3 is not released — no merged nodes, no published weights. The setup flow has
# to say that plainly rather than either hiding the family or offering an install that
# cannot succeed. These pin the shape the placeholders must keep, so shipping the real
# thing is a data edit (flip `released`, fill the hints) and not a code change.
def test_both_families_are_offered_and_h3_is_marked_unreleased():
from movie_editor.backend import pipeline_deps as pd
fams = {f["key"]: f for f in pd.families_payload()}
assert set(fams) == {"ltxav", "minimax_h3"}
assert fams["ltxav"]["released"] is True
assert fams["minimax_h3"]["released"] is False
# a user picking it must be told why nothing generates yet, and where it comes from
assert "not available yet" in fams["minimax_h3"]["note"]
assert "15224" in fams["minimax_h3"]["source_url"]
assert fams["ltxav"]["note"] is None
def test_h3_readiness_lists_the_nodes_and_model_files_it_is_waiting_on():
from movie_editor.backend import pipeline_deps as pd
r = pd.family_readiness({}, "minimax_h3")
assert r["released"] is False
# the AV latent node is required; the sigma-shift node is optional and must not block
missing = {n["class"] for n in r["missing_nodes"]}
assert missing == {"EmptyMiniMaxH3LatentAV"}
assert {m["role"] for m in r["models"]} == {"unet", "clip", "video_vae", "audio_vae"}
assert all(m["folder"] for m in r["models"])
# once ComfyUI ships the node, it stops being listed as missing — no code change
r2 = pd.family_readiness({"EmptyMiniMaxH3LatentAV": {}}, "minimax_h3")
assert r2["missing_nodes"] == []
def test_an_unreleased_family_still_counts_as_needing_setup():
"""Otherwise the modal never opens and the project looks ready when it cannot generate."""
from movie_editor.backend import pipeline_deps as pd
full_oi = {cls: {} for cls in pd.required_core_classes("minimax_h3")}
s = pd.status_payload(full_oi, manager_available=True, family="minimax_h3")
assert s["missing_packs"] == [] # nothing for Manager to install
assert s["needs_setup"] is True # ... but setup is not finished
assert s["readiness"]["released"] is False
assert s["family"] == "minimax_h3"
# with the H3 node present too, setup is genuinely done
s2 = pd.status_payload({**full_oi, "EmptyMiniMaxH3LatentAV": {}},
manager_available=True, family="minimax_h3")
assert s2["needs_setup"] is False
def test_h3_does_not_ask_for_the_ltx_only_core_packs():
"""H3's core drops LTXVConditioning / Concat / LTXVAudioVAEDecode, so a user on H3
must not be told to install a pack for nodes their graph never emits."""
from movie_editor.backend import pipeline_deps as pd
oi = {cls: {} for cls in pd.required_core_classes("minimax_h3")}
assert pd.missing_core_classes(oi, "minimax_h3") == []
# the same object_info is NOT enough for LTXAV
ltx_missing = pd.missing_core_classes(oi, "ltxav")
assert "LTXVConditioning" in ltx_missing and "LTXVConcatAVLatent" in ltx_missing
def test_family_lookup_falls_back_instead_of_raising():
from movie_editor.backend import pipeline_deps as pd
assert pd.family_setup("hailuo-9000")["label"] == pd.family_setup("ltxav")["label"]
assert pd.family_setup(None)["label"] == pd.family_setup("ltxav")["label"]