feat(workflow): support UNet-only Flux.1 D checkpoints via DualCLIPLoader
Adds a `flux_unet` model family for Flux.1 D checkpoints that ship only the UNet weights (no baked-in CLIP/T5/VAE) and require external encoder loading. Affected checkpoints: lust_v10, cyberrealisticFlux_v25, getphatFLUXReality_v11Softcore, moodyDesireMix_v20PRO, fcFluxPonyPerfectBase. These live in `models/diffusion_models/` (or `models/unet/`) on the ComfyUI host and are loaded via UNETLoader instead of CheckpointLoaderSimple. Detection: - New `FLUX_UNET_ONLY_PATTERNS` constant in config.py lists known UNet-only filename substrings. - `detect_model_family()` returns `flux_unet` when any pattern matches, taking precedence over base_model field (same architecture-override pattern used for FluxPony hybrids). Workflow: - New flux_unet workflow in comfyui.py uses UNETLoader + DualCLIPLoader (clip_l.safetensors + t5xxl_fp16.safetensors, type=flux) + VAELoader (ae.safetensors) wired into the same Flux KSampler graph as the monolithic flux family. - Family defaults inherit from flux (sampler/scheduler/steps/cfg) with external_clip flag set. Smoke-tested end-to-end on madcat with getphatFLUXReality_v11Softcore producing valid output. Flux.2 Klein checkpoints (lust_v10, moodyDesireMix) still fail because they require a different text encoder (qwen_3_8b) — see follow-up commit. 13 new tests; 240 -> 253 total.
This commit is contained in:
@@ -320,6 +320,73 @@ class TestModelFamilyDetection:
|
||||
assert detect_model_family("model.safetensors", "Flux.1 D") == "flux"
|
||||
assert detect_model_family("model.safetensors", "Flux.1 S schnell") == "flux_schnell"
|
||||
|
||||
def test_detect_flux_unet_lust(self) -> None:
|
||||
"""lust_*.safetensors → flux_unet (no 'flux' in name, custom pattern)."""
|
||||
from tensors.config import detect_model_family
|
||||
|
||||
assert detect_model_family("lust_v10.safetensors") == "flux_unet"
|
||||
assert detect_model_family("LUST_v10.safetensors") == "flux_unet"
|
||||
|
||||
def test_detect_flux_unet_cyberrealistic(self) -> None:
|
||||
"""cyberrealisticFlux_*.safetensors → flux_unet (intercepts generic 'flux' match)."""
|
||||
from tensors.config import detect_model_family
|
||||
|
||||
assert detect_model_family("cyberrealisticFlux_v25.safetensors") == "flux_unet"
|
||||
|
||||
def test_detect_flux_unet_getphat(self) -> None:
|
||||
"""getphatFLUXReality_*.safetensors → flux_unet."""
|
||||
from tensors.config import detect_model_family
|
||||
|
||||
assert detect_model_family("getphatFLUXReality_v11Softcore.safetensors") == "flux_unet"
|
||||
|
||||
def test_detect_flux_unet_moody(self) -> None:
|
||||
"""moodyDesireMix_*.safetensors → flux_unet (no 'flux' in name)."""
|
||||
from tensors.config import detect_model_family
|
||||
|
||||
assert detect_model_family("moodyDesireMix_v20PRO.safetensors") == "flux_unet"
|
||||
|
||||
def test_detect_flux_unet_fcfluxpony(self) -> None:
|
||||
"""fcFluxPony*.safetensors → flux_unet (intercepts flux + fluxpony)."""
|
||||
from tensors.config import detect_model_family
|
||||
|
||||
assert (
|
||||
detect_model_family("fcFluxPonyPerfectBase_fcFluxPerfectBase.safetensors")
|
||||
== "flux_unet"
|
||||
)
|
||||
|
||||
def test_detect_flux_unet_overrides_base_model(self) -> None:
|
||||
"""Filename UNet-only pattern wins over a (likely wrong) CivitAI base_model tag."""
|
||||
from tensors.config import detect_model_family
|
||||
|
||||
# Even if CivitAI claims "SDXL 1.0", the filename pattern wins.
|
||||
assert detect_model_family("lust_v10.safetensors", "SDXL 1.0") == "flux_unet"
|
||||
assert (
|
||||
detect_model_family("cyberrealisticFlux_v25.safetensors", "Pony") == "flux_unet"
|
||||
)
|
||||
|
||||
def test_flux_unet_family_defaults_has_external_clip(self) -> None:
|
||||
"""flux_unet preset advertises external_clip + clip filenames."""
|
||||
from tensors.config import MODEL_FAMILY_DEFAULTS
|
||||
|
||||
defaults = MODEL_FAMILY_DEFAULTS["flux_unet"]
|
||||
assert defaults["external_clip"] is True
|
||||
assert defaults["clip_l"] == "clip_l.safetensors"
|
||||
assert defaults["clip_t5"] == "t5xxl_fp16.safetensors"
|
||||
# Sanity: same sampling profile as flux
|
||||
assert defaults["cfg"] == 1.0
|
||||
assert defaults["guidance"] == 3.5
|
||||
assert defaults["vae"] == "ae.safetensors"
|
||||
|
||||
def test_get_model_generation_defaults_flux_unet(self) -> None:
|
||||
"""flux_unet model resolves to the flux_unet preset with external_clip set."""
|
||||
from tensors.config import get_model_generation_defaults
|
||||
|
||||
defaults = get_model_generation_defaults("lust_v10.safetensors")
|
||||
assert defaults["family"] == "flux_unet"
|
||||
assert defaults["external_clip"] is True
|
||||
assert defaults["sampler"] == "euler"
|
||||
assert defaults["scheduler"] == "simple"
|
||||
|
||||
def test_detect_sdxl_variants(self) -> None:
|
||||
"""Test detecting SDXL family variants."""
|
||||
from tensors.config import detect_model_family
|
||||
@@ -533,6 +600,104 @@ class TestFluxWorkflowBuilder:
|
||||
assert "120" not in wf
|
||||
|
||||
|
||||
class TestFluxUnetWorkflowBuilder:
|
||||
"""Tests for the UNet-only Flux workflow (split CLIP/T5/VAE loaders)."""
|
||||
|
||||
def test_build_workflow_flux_unet_uses_dual_clip_loader(self) -> None:
|
||||
"""flux_unet checkpoints emit UNETLoader + DualCLIPLoader + VAELoader and NO CheckpointLoaderSimple."""
|
||||
from tensors.comfyui import _build_workflow
|
||||
|
||||
wf = _build_workflow(prompt="a cat", model="lust_v10.safetensors")
|
||||
|
||||
# Three split loaders at the canonical IDs
|
||||
assert wf["100"]["class_type"] == "UNETLoader"
|
||||
assert wf["101"]["class_type"] == "DualCLIPLoader"
|
||||
assert wf["102"]["class_type"] == "VAELoader"
|
||||
|
||||
# The combined checkpoint loader must NOT appear anywhere.
|
||||
for node in wf.values():
|
||||
assert node["class_type"] != "CheckpointLoaderSimple"
|
||||
|
||||
# UNet filename plumbed through
|
||||
assert wf["100"]["inputs"]["unet_name"] == "lust_v10.safetensors"
|
||||
|
||||
# DualCLIPLoader configured for flux with both encoders
|
||||
clip_inputs = wf["101"]["inputs"]
|
||||
assert clip_inputs["clip_name1"] == "clip_l.safetensors"
|
||||
assert clip_inputs["clip_name2"] == "t5xxl_fp16.safetensors"
|
||||
assert clip_inputs["type"] == "flux"
|
||||
|
||||
# VAE defaults to ae.safetensors
|
||||
assert wf["102"]["inputs"]["vae_name"] == "ae.safetensors"
|
||||
|
||||
# Downstream wiring: CLIPTextEncode reads from DualCLIPLoader, VAEDecode from VAELoader
|
||||
assert wf["130"]["inputs"]["clip"] == ["101", 0]
|
||||
assert wf["131"]["inputs"]["clip"] == ["101", 0]
|
||||
assert wf["170"]["inputs"]["vae"] == ["102", 0]
|
||||
# ModelSamplingFlux still reads MODEL from node 100 (now UNETLoader)
|
||||
assert wf["120"]["inputs"]["model"] == ["100", 0]
|
||||
|
||||
def test_flux_unet_inherits_flux_sampling_profile(self) -> None:
|
||||
"""flux_unet locks KSampler.cfg to 1.0 and exposes the FluxGuidance dial."""
|
||||
from tensors.comfyui import _build_workflow
|
||||
|
||||
wf = _build_workflow(
|
||||
prompt="a cat", model="lust_v10.safetensors", cfg=7.5
|
||||
)
|
||||
assert wf["160"]["inputs"]["cfg"] == 1.0
|
||||
# The caller's cfg=7.5 should re-route to FluxGuidance (same precedence as plain flux)
|
||||
assert wf["140"]["inputs"]["guidance"] == 7.5
|
||||
|
||||
def test_flux_unet_lora_injection_wires_split_loaders(self) -> None:
|
||||
"""LoRA on flux_unet injects node 110 reading MODEL from UNETLoader and CLIP from DualCLIPLoader."""
|
||||
from tensors.comfyui import _build_workflow
|
||||
|
||||
wf = _build_workflow(
|
||||
prompt="a cat",
|
||||
model="lust_v10.safetensors",
|
||||
lora_name="my_style.safetensors",
|
||||
lora_strength=0.6,
|
||||
)
|
||||
assert wf["110"]["class_type"] == "LoraLoader"
|
||||
assert wf["110"]["inputs"]["lora_name"] == "my_style.safetensors"
|
||||
assert wf["110"]["inputs"]["strength_model"] == 0.6
|
||||
# LoRA reads model from UNETLoader, clip from DualCLIPLoader
|
||||
assert wf["110"]["inputs"]["model"] == ["100", 0]
|
||||
assert wf["110"]["inputs"]["clip"] == ["101", 0]
|
||||
# Downstream consumers now read from the LoRA outputs
|
||||
assert wf["120"]["inputs"]["model"] == ["110", 0]
|
||||
assert wf["130"]["inputs"]["clip"] == ["110", 1]
|
||||
assert wf["131"]["inputs"]["clip"] == ["110", 1]
|
||||
|
||||
def test_flux_unet_external_vae_overrides_default(self) -> None:
|
||||
"""Caller-provided VAE replaces ae.safetensors on the VAELoader (no new node)."""
|
||||
from tensors.comfyui import _build_workflow
|
||||
|
||||
wf = _build_workflow(
|
||||
prompt="a cat",
|
||||
model="lust_v10.safetensors",
|
||||
vae="other_vae.safetensors",
|
||||
)
|
||||
assert wf["102"]["inputs"]["vae_name"] == "other_vae.safetensors"
|
||||
# And VAEDecode still wires through node 102 — no shadow node 171.
|
||||
assert wf["170"]["inputs"]["vae"] == ["102", 0]
|
||||
assert "171" not in wf
|
||||
|
||||
def test_flux_unet_via_explicit_family_override(self) -> None:
|
||||
"""A non-pattern filename still gets the UNet workflow when -F flux_unet is forced.
|
||||
|
||||
We can't pass --family directly to _build_workflow (it auto-detects from
|
||||
the filename), but a checkpoint matching a UNet-only pattern proves the
|
||||
family→workflow dispatch end-to-end.
|
||||
"""
|
||||
from tensors.comfyui import _build_workflow
|
||||
|
||||
# moodyDesireMix has no "flux" in name but must route to the UNet workflow.
|
||||
wf = _build_workflow(prompt="a cat", model="moodyDesireMix_v20PRO.safetensors")
|
||||
assert wf["100"]["class_type"] == "UNETLoader"
|
||||
assert wf["101"]["class_type"] == "DualCLIPLoader"
|
||||
|
||||
|
||||
class TestDisplayFormatters:
|
||||
"""Tests for display formatting functions."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user