Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
137 lines
6.6 KiB
Python
137 lines
6.6 KiB
Python
"""The VAE, CLIP, LoRA and checkpoint loaders, every option they offer, against ComfyUI's real loading code.
|
|
|
|
A VAE is a real (random) SD-shaped one, 335 MB written once to a temp folder, because ComfyUI's VAE class is fixed to
|
|
that shape. The text encoder cannot be built small, so its slots, order, device and every family name are checked
|
|
through ComfyUI's own `CLIPType`, with the file reading replaced. LoRAs are built for the tiny real H3 in every
|
|
key dialect the matcher knows. Random weights prove the path runs; they say nothing about quality.
|
|
"""
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _needs_comfy(comfyui):
|
|
"""Imports comfy."""
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def tiny(comfyui, tmp_path_factory):
|
|
import comfy.model_patcher as mp
|
|
import comfy.supported_models as sm
|
|
torch.manual_seed(0)
|
|
config = sm.MiniMaxH3(dict(
|
|
image_model="minimax_h3", hidden_size=64, num_layers=4, token_refiner_num_layers=1, num_attention_heads=2,
|
|
attention_head_dim=32, ffn_hidden_size=64, text_dim=48, timestep_input_dim=32, time_embed_hidden_size=64,
|
|
time_embed_dim=32, rope_inv_freq_len=4))
|
|
config.set_inference_dtype(torch.float32, None)
|
|
base = config.get_model({}, device=torch.device("cpu"))
|
|
for p in base.diffusion_model.parameters():
|
|
torch.nn.init.normal_(p, std=0.05)
|
|
p.requires_grad_(False)
|
|
return mp.ModelPatcher(base, load_device=torch.device("cpu"), offload_device=torch.device("cpu"))
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def folders(comfyui, tiny, tmp_path_factory):
|
|
import folder_paths
|
|
import safetensors.torch as st
|
|
from comfy.ldm.models.autoencoder import AutoencoderKL
|
|
root = tmp_path_factory.mktemp("models")
|
|
for kind in ("vae", "loras", "checkpoints"):
|
|
(root / kind).mkdir()
|
|
folder_paths.add_model_folder_path(kind, str(root / kind))
|
|
ddconfig = dict(double_z=True, z_channels=4, resolution=256, in_channels=3, out_ch=3, ch=128, ch_mult=[1, 2, 4, 4],
|
|
num_res_blocks=2, attn_resolutions=[], dropout=0.0)
|
|
vae = AutoencoderKL(ddconfig=ddconfig, embed_dim=4)
|
|
st.save_file({k: v.contiguous() for k, v in vae.state_dict().items()}, str(root / "vae" / "tiny_vae.safetensors"))
|
|
unet = {k: v.contiguous() for k, v in tiny.model.diffusion_model.state_dict().items()}
|
|
st.save_file(unet, str(root / "checkpoints" / "unet_only.safetensors"))
|
|
return root
|
|
|
|
|
|
def test_the_vae_loader_decodes_in_every_dtype_it_offers(folders):
|
|
from modules.loaders.vae.nodes import DTYPES, FunUIVAELoader
|
|
for name in DTYPES:
|
|
vae, status = FunUIVAELoader.execute("tiny_vae.safetensors", name).result
|
|
assert name in status
|
|
assert vae.decode(torch.zeros(1, 4, 4, 4)).shape == (1, 32, 32, 3)
|
|
|
|
|
|
def test_the_checkpoint_loader_says_what_a_file_without_encoder_or_vae_lacks(folders):
|
|
from modules.loaders.checkpoint.nodes import FunUICheckpointLoader
|
|
model, clip, vae, status = FunUICheckpointLoader.execute("unet_only.safetensors").result
|
|
assert type(model.model).__name__ == "MiniMaxH3"
|
|
assert clip is None and "(no clip, vae)" in status
|
|
|
|
|
|
def _target(tiny):
|
|
for key, weight in tiny.model.diffusion_model.state_dict().items():
|
|
if key.endswith(".weight") and weight.dim() == 2 and min(weight.shape) >= 8 and "blocks" in key:
|
|
return key[:-len(".weight")], weight.shape
|
|
raise AssertionError("no linear layer found")
|
|
|
|
|
|
DIALECTS = {
|
|
"bare": ("", "lora_A.weight", "lora_B.weight"),
|
|
"diffusion_model": ("diffusion_model.", "lora_A.weight", "lora_B.weight"),
|
|
"transformer": ("transformer.", "lora_A.weight", "lora_B.weight"),
|
|
"peft wrapper": ("base_model.model.", "lora_A.weight", "lora_B.weight"),
|
|
"peft dit wrapper": ("base_model.model.dit.", "lora_A.weight", "lora_B.weight"),
|
|
"dit": ("dit.", "lora_A.weight", "lora_B.weight"),
|
|
"up and down": ("diffusion_model.", "lora_down.weight", "lora_up.weight"),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("dialect", DIALECTS)
|
|
def test_a_lora_in_every_known_key_dialect_lands_in_the_model(dialect, tiny, folders):
|
|
import safetensors.torch as st
|
|
from modules.loaders.lora.nodes import FunUILoraLoader
|
|
prefix, down, up = DIALECTS[dialect]
|
|
layer, shape = _target(tiny)
|
|
st.save_file({f"{prefix}{layer}.{down}": torch.randn(4, shape[1]), f"{prefix}{layer}.{up}": torch.randn(shape[0], 4)},
|
|
str(folders / "loras" / f"{dialect.replace(' ', '_')}.safetensors"))
|
|
patched, _, status = FunUILoraLoader.execute(tiny, f"{dialect.replace(' ', '_')}.safetensors", 0.7).result
|
|
assert patched is not tiny and patched.patches, status
|
|
assert "matched nothing" not in status
|
|
|
|
|
|
def test_a_lora_for_another_model_is_reported_not_silently_applied(tiny, folders):
|
|
import safetensors.torch as st
|
|
from modules.loaders.lora.nodes import FunUILoraLoader
|
|
st.save_file({"nothing.here.lora_A.weight": torch.randn(4, 8), "nothing.here.lora_B.weight": torch.randn(8, 4)},
|
|
str(folders / "loras" / "other.safetensors"))
|
|
same, _, status = FunUILoraLoader.execute(tiny, "other.safetensors", 1.0).result
|
|
assert same is tiny and "matched nothing" in status
|
|
|
|
|
|
def test_negative_and_large_strengths_apply(tiny, folders):
|
|
from modules.loaders.lora.nodes import FunUILoraLoader
|
|
for strength in (-1.0, 0.01, 5.0):
|
|
patched, *_ = FunUILoraLoader.execute(tiny, "diffusion_model.safetensors", strength).result
|
|
assert patched.patches
|
|
|
|
|
|
def test_every_clip_family_name_is_accepted_or_refused_in_plain_words(folders, monkeypatch):
|
|
import comfy.sd
|
|
from modules.loaders.clip import nodes
|
|
seen = []
|
|
|
|
def fake_load_clip(ckpt_paths, embedding_directory=None, clip_type=None, model_options={}):
|
|
seen.append((clip_type, len(ckpt_paths), model_options))
|
|
return object()
|
|
monkeypatch.setattr(comfy.sd, "load_clip", fake_load_clip)
|
|
monkeypatch.setattr(nodes.folder_paths, "get_full_path_or_raise", lambda kind, name: f"/x/{name}.safetensors")
|
|
monkeypatch.setattr(nodes.gguf_support, "has_gguf_magic", lambda p: False)
|
|
for family in nodes.clip_types():
|
|
out = nodes.FunUICLIPLoader.execute("a", family, "default", clip_name2="b", clip_name3="c", clip_name4="d")
|
|
assert f"type={family}" in out.result[1]
|
|
assert len(seen) == len(nodes.clip_types()) and all(n == 4 for _, n, _ in seen)
|
|
nodes.FunUICLIPLoader.execute("a", "ltxv", "cpu")
|
|
assert seen[-1][2]["load_device"] == torch.device("cpu")
|
|
with pytest.raises(RuntimeError, match="no encoder family"):
|
|
nodes.FunUICLIPLoader.execute("a", "not_a_family")
|
|
with pytest.raises(RuntimeError, match="could not|Could not"):
|
|
monkeypatch.setattr(comfy.sd, "load_clip", lambda **k: None)
|
|
nodes.FunUICLIPLoader.execute("a", "ltxv")
|