feat: GGUF loader support, and fix silent pipeline-editor state loss

GGUF: port v4's gguf_support.py (backend selection, content-based .gguf
detection, dequantize fallback) and wire it into both the diffusion-model
and CLIP loaders.

Pipeline editor: reopening the Models-and-pipeline settings window was
silently discarding the whole edited pipeline (a loaded preset, every
loader file, every sampler setting) because GET /api/pipeline is
deliberately stateless server-side and boot.js never fed its own
already-saved slots back into a freshly remounted editor -- found live
while walking through the MiniMax-H3 setup flow. Fixed by re-validating
the saved slots through check() on reopen instead of always replaying
the raw stateless default.

Also fixes, found via the extensive_testing adversarial loop over both
of the above: a stale file-listing cache in the pipeline window (no way
to see a newly-added model file without a full page reload), and a
chain of races/leaks in its own refresh/commit error-banner handling
that surfaced across five review rounds -- a rejection from a superseded
request overwriting a fresher success, a failure banner never clearing
on success, banners bleeding across groups on navigation, and a
commit()'s refusal landing on the wrong view after the user backs out
before it answers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-11 18:14:46 +03:00
co-authored by Claude Sonnet 5
parent 4744d30087
commit 7a226830b1
9 changed files with 1191 additions and 25 deletions
+58 -8
View File
@@ -24,11 +24,13 @@ backwards-compatible with every saved graph.
"""
import comfy.sd
import comfy.utils
import folder_paths
import torch
from comfy_api.latest import io
from ..._core import log
from .. import gguf_support
MAX_ENCODERS = 4
@@ -41,7 +43,10 @@ def clip_types():
def encoder_files():
return folder_paths.get_filename_list("text_encoders")
"""Text encoder files, `.gguf` included. Same reasoning as the diffusion
model loader's own file-list helper."""
return list(folder_paths.get_filename_list("text_encoders")) + \
gguf_support.gguf_names("text_encoders")
class FunPackCLIPLoader(io.ComfyNode):
@@ -107,13 +112,57 @@ class FunPackCLIPLoader(io.ComfyNode):
if device == "cpu":
model_options["load_device"] = model_options["offload_device"] = torch.device("cpu")
paths = [folder_paths.get_full_path_or_raise("text_encoders", name) for name in names]
clip = comfy.sd.load_clip(
ckpt_paths=paths,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
clip_type=clip_type,
model_options=model_options,
)
# Each name resolved to a path exactly once, whatever happens next --
# a second lookup per file would double every filesystem stat this
# loader makes. GGUF-ness is then read off that same path: by
# extension, or by container magic for a renamed file.
paths = {}
for n in names:
if gguf_support.is_gguf(n):
p = gguf_support.gguf_path("text_encoders", n)
if not p:
# Listed when the combo was built, gone by execute time --
# reachable now that the pipeline window can sit open for a
# while and re-list files without reloading. Refuse by name
# here, or the loader below gets None and fails deep inside
# gguf/pack machinery with no mention of which file it was.
raise RuntimeError(f"{n} is no longer where it was listed from.")
paths[n] = p
else:
paths[n] = folder_paths.get_full_path_or_raise("text_encoders", n)
misnamed = {n for n, p in paths.items()
if not gguf_support.is_gguf(n) and gguf_support.has_gguf_magic(p)}
gguf_notes = []
if misnamed or any(gguf_support.is_gguf(n) for n in names):
# A .gguf encoder cannot go through load_clip(), which reads files
# itself. Every slot becomes a state dict instead, so a GGUF and a
# .safetensors connector can sit in the same list -- the normal
# LTX-2.3 shape.
state_dicts = []
for n in names:
if gguf_support.is_gguf(n) or n in misnamed:
if n in misnamed:
gguf_notes.append(f"{n} is named .safetensors but is a GGUF container")
sd, gopts, gnote = gguf_support.load_clip_state_dict(paths[n])
state_dicts.append(sd)
model_options = {**model_options, **gopts}
gguf_notes.append(f"{n}: {gnote}")
else:
state_dicts.append(comfy.utils.load_torch_file(paths[n]))
clip = comfy.sd.load_text_encoder_state_dicts(
state_dicts,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
clip_type=clip_type,
model_options=model_options,
)
else:
clip = comfy.sd.load_clip(
ckpt_paths=[paths[n] for n in names],
embedding_directory=folder_paths.get_folder_paths("embeddings"),
clip_type=clip_type,
model_options=model_options,
)
if clip is None:
# comfy returns None for an unrecognised encoder rather than raising,
# and a None CLIP fails at encode time, far from the mistake.
@@ -124,4 +173,5 @@ class FunPackCLIPLoader(io.ComfyNode):
f"{len(names)} encoder file(s) loaded as type {type}, device {device}: "
+ ", ".join(names))
listing = "\n".join(f" {i + 1}. {name}" for i, name in enumerate(names))
listing += "".join(f"\n {gn}" for gn in gguf_notes)
return io.NodeOutput(clip, f"FunPack CLIP Loader | type={type} device={device}\n{listing}")
+39 -3
View File
@@ -11,11 +11,24 @@ import folder_paths
from comfy_api.latest import io
from ..._core import log
from .. import gguf_support
from ..common import (COMPUTE_DTYPES, WEIGHT_DTYPES, attention_choices,
attention_override, dtype_of, set_fp16_accumulation,
weight_model_options)
def _model_file_choices():
"""Diffusion model files, `.gguf` included.
Core's extension set has no `.gguf`, so those files are on disk and
invisible to every picker. Appended rather than merged in sorted order, so
an existing pipeline's saved choice keeps its position in the list and
nothing a user already picked moves.
"""
return list(folder_paths.get_filename_list("diffusion_models")) + \
gguf_support.gguf_names("diffusion_models")
class FunPackDiffusionModelLoader(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
@@ -25,7 +38,7 @@ class FunPackDiffusionModelLoader(io.ComfyNode):
category="FunPack/Loaders",
description="Load a diffusion model, choosing precision and attention backend.",
inputs=[
io.Combo.Input("model_name", options=folder_paths.get_filename_list("diffusion_models")),
io.Combo.Input("model_name", options=_model_file_choices()),
io.Combo.Input("weight_dtype", options=WEIGHT_DTYPES, default="default",
tooltip="How the weights are stored. 'default' keeps whatever "
"the file already is."),
@@ -57,8 +70,31 @@ class FunPackDiffusionModelLoader(io.ComfyNode):
model_options = weight_model_options(weight_dtype)
notes.append(f"weight dtype: {weight_dtype}")
path = folder_paths.get_full_path_or_raise("diffusion_models", model_name)
state_dict, metadata = comfy.utils.load_torch_file(path, return_metadata=True)
if gguf_support.is_gguf(model_name):
path = gguf_support.gguf_path("diffusion_models", model_name)
if not path:
raise RuntimeError(f"{model_name} is no longer where it was listed from.")
misnamed = False
else:
path = folder_paths.get_full_path_or_raise("diffusion_models", model_name)
# The CONTENT decides, not the extension. A .gguf renamed to
# .safetensors used to reach the safetensors parser and fail with
# a UTF-8 decode error from reading a binary header as JSON --
# true, and no help at all.
misnamed = gguf_support.has_gguf_magic(path)
if gguf_support.is_gguf(model_name) or misnamed:
if misnamed:
notes.append(f"{model_name} is named .safetensors but is a GGUF "
f"container -- loaded as GGUF")
state_dict, gguf_options, gguf_note = gguf_support.load_state_dict(path)
# The quantized path needs its own torch operations, and they must
# not be lost to the dtype options above -- a GGUF loaded with
# stock ops would try to matmul block-quantized storage.
model_options = {**model_options, **gguf_options}
metadata = None
notes.append(gguf_note)
else:
state_dict, metadata = comfy.utils.load_torch_file(path, return_metadata=True)
model = comfy.sd.load_diffusion_model_state_dict(
state_dict, model_options=model_options, metadata=metadata)
if model is None:
+309
View File
@@ -0,0 +1,309 @@
"""Load .gguf model files through FunPack's own loaders.
Not a module: see common.py's own note -- a plain file at the domain level is a
helper the loaders import, and ships no node of its own.
GGUF is two problems, and only the first is small:
1. **Reading the container.** A header, tensor metadata, then quantized blocks.
The `gguf` package (llama.cpp's own) does this, and ships a reference
dequantizer for every quant type it defines.
2. **Using the weights without dequantizing them.** The reason people reach for
GGUF is that a Q4 model occupies a quarter of the VRAM -- which only holds
if the weights STAY quantized and are expanded per-layer during the forward
pass. That needs a custom torch ops class, and hand-written kernels per
quant type.
So this module does not hand-roll (2). It picks the best backend actually
present:
* **ComfyUI-GGUF installed** -- use it. It is the mature implementation of
(2), and its output feeds `comfy.sd.load_diffusion_model_state_dict`
exactly like ours does, so FunPack's loader stays the only UI while its
runtime does the quantized maths.
* **`gguf` package only** -- read and dequantize at LOAD time, then hand over
an ordinary state dict. Fully native and correct, but the model occupies
its dequantized size, so the VRAM saving is gone. Said out loud rather than
left to be discovered.
* **Neither** -- refuse with the two ways to fix it, instead of a stack trace.
Nothing here downloads or installs anything.
"""
from __future__ import annotations
import importlib
import importlib.util
import os
import sys
import types
from typing import Optional
GGUF_EXT = ".gguf"
def is_gguf(name: str) -> bool:
return str(name or "").lower().endswith(GGUF_EXT)
GGUF_MAGIC = b"GGUF"
def has_gguf_magic(path: str) -> bool:
"""True when the FILE is a GGUF container, whatever it is called.
Renaming a .gguf to .safetensors is an easy mistake to make, and the
result is an unreadable error from the safetensors parser trying to read a
binary header as UTF-8 JSON. Four bytes settle it, so the content decides.
"""
try:
with open(path, "rb") as f:
return f.read(4) == GGUF_MAGIC
except OSError:
return False
# ── finding .gguf files ───────────────────────────────────────────────────────
def gguf_names(folder: str) -> list[str]:
"""`.gguf` files under a ComfyUI model folder, relative-named like get_filename_list().
Core's `supported_pt_extensions` has no `.gguf`, so `get_filename_list`
never returns one -- the files are on disk and simply invisible to every
picker. Scanned directly rather than by mutating core's global extension
set, which would change what every other node in the install offers.
"""
try:
import folder_paths
except ImportError:
return []
out: list[str] = []
seen: set[str] = set()
roots = list(folder_paths.get_folder_paths(folder) or [])
# ComfyUI-GGUF registers its own folders; include them under the same
# picker so a user who already keeps files there does not have to move them.
for extra in ("unet_gguf" if folder == "diffusion_models" else "clip_gguf",):
try:
roots += list(folder_paths.get_folder_paths(extra) or [])
except Exception:
pass
for root in roots:
if not root or not os.path.isdir(root):
continue
for dirpath, _dirs, files in os.walk(root, followlinks=True):
for f in files:
if not is_gguf(f):
continue
rel = os.path.relpath(os.path.join(dirpath, f), root)
if rel not in seen:
seen.add(rel)
out.append(rel)
return sorted(out)
def gguf_path(folder: str, name: str) -> Optional[str]:
"""Absolute path of a `.gguf` picked out of `gguf_names`."""
try:
import folder_paths
except ImportError:
return None
roots = list(folder_paths.get_folder_paths(folder) or [])
for extra in ("unet_gguf" if folder == "diffusion_models" else "clip_gguf",):
try:
roots += list(folder_paths.get_folder_paths(extra) or [])
except Exception:
pass
for root in roots:
cand = os.path.join(root or "", name)
if os.path.isfile(cand):
return cand
return None
# ── backends ──────────────────────────────────────────────────────────────────
_PKG_NAME = "_funpack_gguf_pack"
def _ensure_pack_package(dirname: str) -> str:
"""Register the pack's directory as an importable package, once.
Loading `loader.py` as a standalone module by file path fails the moment
it does `from .ops import ...` -- "attempted relative import with no known
parent package", because a module loaded that way has no package to be
relative TO. So a parent package is synthesized with `__path__` pointing at
the directory, and submodules are imported underneath it, which is what
makes relative imports resolve.
The pack's own `__init__.py` is deliberately NOT executed: it registers
the pack's nodes, and we want its loader, not a second copy of its node
list.
"""
pkg = sys.modules.get(_PKG_NAME)
if pkg is not None and getattr(pkg, "__path__", [None])[0] == dirname:
return _PKG_NAME
# Pointing at a different directory: drop the cached SUBMODULES too.
# Replacing only the parent leaves `<pkg>.loader` in sys.modules, and
# import_module would hand back the old pack's code from the new path's name.
for name in [m for m in sys.modules if m == _PKG_NAME or m.startswith(_PKG_NAME + ".")]:
del sys.modules[name]
pkg = types.ModuleType(_PKG_NAME)
pkg.__path__ = [dirname]
pkg.__package__ = _PKG_NAME
sys.modules[_PKG_NAME] = pkg
return _PKG_NAME
def _load_pack_module(dirname: str, mod: str):
"""Import one module out of a custom-node package by path.
Custom node folders are not importable by name (``ComfyUI-GGUF`` is not an
identifier), so the directory is mounted as a package first and the
submodule imported from it.
"""
pkg = _ensure_pack_package(dirname)
return importlib.import_module(f"{pkg}.{mod}")
def _pack_dir() -> Optional[str]:
"""ComfyUI-GGUF's directory, identified by what it CONTAINS rather than by
its name -- the folder gets renamed by Manager, by forks, and by hand."""
try:
import folder_paths
base = os.path.join(folder_paths.base_path, "custom_nodes")
except Exception:
return None
if not os.path.isdir(base):
return None
for entry in sorted(os.listdir(base)):
d = os.path.join(base, entry)
loader = os.path.join(d, "loader.py")
if not os.path.isfile(loader) or not os.path.isfile(os.path.join(d, "ops.py")):
continue
try:
with open(loader, "r", encoding="utf-8", errors="replace") as fh:
if "gguf_sd_loader" in fh.read():
return d
except OSError:
continue
return None
def backend() -> Optional[str]:
""""pack" (quantized in VRAM), "native" (dequantized at load), or None."""
if _pack_dir():
return "pack"
if importlib.util.find_spec("gguf") is not None:
return "native"
return None
UNAVAILABLE = (
"No GGUF backend is available. The `gguf` package is one of FunPack's requirements, "
"so an install that predates .gguf support just needs them re-run:\n"
" pip install -r custom_nodes/ComfyUI-FunPack/requirements.txt\n"
"That gives a load-time dequantize: the file loads, but at its full size. To keep the "
"weights quantized in VRAM -- which is the point of GGUF -- install the ComfyUI-GGUF "
"node pack as well; FunPack uses it as the runtime and stays the only loader you wire."
)
def load_state_dict(path: str) -> tuple[dict, dict, str]:
"""Read one .gguf into (state_dict, model_options, note).
`model_options` carries the custom operations the quantized path needs; it
is empty for the native path, whose tensors are ordinary ones by the time
they are returned.
"""
return _load(path, clip=False)
def load_clip_state_dict(path: str) -> tuple[dict, dict, str]:
"""Same as `load_state_dict`, for a text encoder.
ComfyUI-GGUF reads encoders through a separate entry point: an encoder's
GGUF carries llama.cpp tensor names that have to be mapped back to the
transformer's, which its diffusion-model reader does not do.
"""
return _load(path, clip=True)
def _load(path: str, clip: bool) -> tuple[dict, dict, str]:
"""Try the quantized backend, fall back to dequantizing, in that order.
The pack refuses architectures it has no handling for -- "Unexpected
architecture type in GGUF file: 'minimax_h3'" -- which is a reasonable
refusal on its part and a dead end for us. Since the pack DEPENDS on the
`gguf` package, its presence guarantees the fallback is available, so a
refusal costs memory rather than the load.
"""
which = backend()
if which is None:
raise RuntimeError(UNAVAILABLE)
if which == "pack":
d = _pack_dir()
try:
loader = _load_pack_module(d, "loader")
ops = _load_pack_module(d, "ops")
read = loader.gguf_sd_loader
if clip:
read = getattr(loader, "gguf_clip_loader", None) or read
sd = read(path)
options = {}
ggml_ops = getattr(ops, "GGMLOps", None)
if ggml_ops is not None:
options["custom_operations"] = ggml_ops()
return sd, options, f"gguf: quantized (ComfyUI-GGUF at {os.path.basename(d)})"
except Exception as e: # noqa: BLE001 -- any refusal is a reason to fall back
if importlib.util.find_spec("gguf") is None:
raise
sd, options, note = _load_native(path)
return sd, options, (f"gguf: ComfyUI-GGUF could not read this file ({e}) -- "
f"fell back. {note}")
return _load_native(path)
def _load_native(path: str) -> tuple[dict, dict, str]:
"""Dequantize every tensor to torch at load time using gguf's reference implementation.
Correct but not thrifty: the model ends up at its dequantized size, so
this buys the ability to LOAD a .gguf, not GGUF's memory saving. The
caller states that.
"""
import gguf
import torch
reader = gguf.GGUFReader(path)
sd: dict = {}
quantized = 0
for tensor in reader.tensors:
name = str(tensor.name)
qtype = tensor.tensor_type
if qtype in (gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16):
arr = tensor.data
else:
# gguf.quants.dequantize is the reference implementation for every
# type the package knows -- do not reimplement it per quant type.
arr = gguf.quants.dequantize(tensor.data, qtype)
quantized += 1
t = torch.from_numpy(arr.copy())
# GGUF stores dimensions in the opposite order to torch.
shape = tuple(int(d) for d in reversed(tensor.shape))
expected = 1
for d in shape:
expected *= d
if shape and t.numel() != expected:
# Loading the tensor unshaped would hand the model a weight of the
# wrong rank and fail somewhere far from here, so stop with the
# name of the culprit.
raise RuntimeError(
f"{os.path.basename(path)}: tensor {name!r} dequantized to {t.numel()} "
f"elements but its header declares {shape} ({expected}). This container "
f"is not laid out the way the gguf package describes it.")
if shape:
t = t.reshape(shape)
sd[name] = t.to(torch.float16) if t.dtype == torch.float32 else t
note = (f"gguf: dequantized at load ({quantized} quantized tensors expanded) -- the "
f"file loads, but it occupies its full size in VRAM. Install ComfyUI-GGUF to "
f"keep it quantized.")
return sd, {}, note
+248
View File
@@ -0,0 +1,248 @@
"""GGUF backend selection and file discovery.
Nothing here needs a .gguf file or the gguf package: what is testable is the
routing -- which backend gets picked, what happens when none is available, and
whether .gguf files are found at all (core's extension set excludes them,
which is the whole reason this module exists).
"""
import sys
import types
import pytest
from modules.loaders import gguf_support
@pytest.fixture
def fake_folders(tmp_path, monkeypatch):
"""A stand-in folder_paths with two model roots on disk."""
diff = tmp_path / "diffusion_models"
enc = tmp_path / "text_encoders"
sub = diff / "nested"
for d in (diff, enc, sub):
d.mkdir(parents=True, exist_ok=True)
(diff / "ltx.safetensors").write_bytes(b"x")
(diff / "ltx-Q4_K_M.gguf").write_bytes(b"x")
(sub / "h3-Q8_0.gguf").write_bytes(b"x")
(enc / "gemma.safetensors").write_bytes(b"x")
(enc / "gemma-Q5_K.gguf").write_bytes(b"x")
mod = types.ModuleType("folder_paths")
mod.base_path = str(tmp_path)
roots = {"diffusion_models": [str(diff)], "text_encoders": [str(enc)]}
mod.get_folder_paths = lambda name: roots.get(name, [])
monkeypatch.setitem(sys.modules, "folder_paths", mod)
return tmp_path
def test_is_gguf_is_case_insensitive():
assert gguf_support.is_gguf("model.gguf")
assert gguf_support.is_gguf("MODEL.GGUF")
assert not gguf_support.is_gguf("model.safetensors")
assert not gguf_support.is_gguf("")
assert not gguf_support.is_gguf(None)
def test_finds_gguf_files_core_cannot_list(fake_folders):
names = gguf_support.gguf_names("diffusion_models")
assert "ltx-Q4_K_M.gguf" in names
# Nested, because model folders are routinely organised into subdirectories.
assert any(n.endswith("h3-Q8_0.gguf") for n in names)
# Only .gguf -- the ordinary files still come from core's own listing.
assert not any(n.endswith(".safetensors") for n in names)
def test_encoders_are_listed_separately(fake_folders):
assert gguf_support.gguf_names("text_encoders") == ["gemma-Q5_K.gguf"]
def test_missing_folder_is_empty_not_an_error(fake_folders):
assert gguf_support.gguf_names("vae") == []
def test_path_resolves_a_listed_name(fake_folders):
p = gguf_support.gguf_path("diffusion_models", "ltx-Q4_K_M.gguf")
assert p and p.endswith("ltx-Q4_K_M.gguf")
assert gguf_support.gguf_path("diffusion_models", "not-there.gguf") is None
def test_no_backend_refuses_with_both_remedies(monkeypatch):
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: None)
monkeypatch.setattr(gguf_support.importlib.util, "find_spec", lambda name: None)
assert gguf_support.backend() is None
with pytest.raises(RuntimeError) as e:
gguf_support.load_state_dict("/nope.gguf")
msg = str(e.value)
# Both ways out must be named: they have different consequences for VRAM.
# `gguf` is a FunPack requirement now, so the first is a re-run of them
# rather than a lone package.
assert "ComfyUI-GGUF" in msg
assert "requirements.txt" in msg
assert "quantized in VRAM" in msg
def test_the_pack_wins_when_both_are_available(monkeypatch):
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: "/fake/ComfyUI-GGUF")
monkeypatch.setattr(gguf_support.importlib.util, "find_spec", lambda name: object())
# Quantized-in-VRAM is the point of GGUF; the dequantizing fallback is second best.
assert gguf_support.backend() == "pack"
def test_native_is_used_when_only_the_library_is_present(monkeypatch):
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: None)
monkeypatch.setattr(gguf_support.importlib.util, "find_spec",
lambda name: object() if name == "gguf" else None)
assert gguf_support.backend() == "native"
def test_pack_is_found_by_content_not_by_folder_name(tmp_path, monkeypatch):
"""Manager, forks and hand-installs all rename the folder; the marker is the code."""
cn = tmp_path / "custom_nodes"
good = cn / "some-renamed-fork"
decoy = cn / "ComfyUI-GGUF-Themed-Nodes" # named right, contains nothing
for d in (good, decoy):
d.mkdir(parents=True)
(good / "loader.py").write_text("def gguf_sd_loader(path):\n return {}\n")
(good / "ops.py").write_text("class GGMLOps:\n pass\n")
(decoy / "loader.py").write_text("# nothing useful\n")
mod = types.ModuleType("folder_paths")
mod.base_path = str(tmp_path)
mod.get_folder_paths = lambda name: []
monkeypatch.setitem(sys.modules, "folder_paths", mod)
assert gguf_support._pack_dir() == str(good)
def test_pack_load_passes_custom_operations_through(tmp_path, monkeypatch):
"""The quantized weights are unusable without their ops -- losing them
would produce a matmul against block-quantized storage rather than a clear
failure."""
d = tmp_path / "pack"
d.mkdir()
(d / "loader.py").write_text(
"def gguf_sd_loader(path):\n return {'w': 'quantized'}\n")
(d / "ops.py").write_text("class GGMLOps:\n pass\n")
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(d))
sd, options, note = gguf_support.load_state_dict("/whatever.gguf")
assert sd == {"w": "quantized"}
assert "custom_operations" in options
assert type(options["custom_operations"]).__name__ == "GGMLOps"
assert "quantized" in note
def test_container_magic_beats_the_extension(tmp_path):
"""A .gguf renamed to .safetensors reached the safetensors parser, which
read its binary header as UTF-8 JSON and failed with a decode error --
true, and no help at all."""
renamed = tmp_path / "ltx-Q4_K_M.safetensors"
renamed.write_bytes(b"GGUF\x03\x00\x00\x00" + b"\x80" * 200)
assert gguf_support.has_gguf_magic(str(renamed))
# ...and the name alone still says nothing.
assert not gguf_support.is_gguf(renamed.name)
def test_a_real_safetensors_is_not_mistaken_for_gguf(tmp_path):
st = tmp_path / "real.safetensors"
st.write_bytes((8).to_bytes(8, "little") + b'{"a":{}}')
assert not gguf_support.has_gguf_magic(str(st))
def test_magic_check_survives_a_missing_file():
assert gguf_support.has_gguf_magic("/definitely/not/here.safetensors") is False
def test_pack_modules_can_use_relative_imports(tmp_path, monkeypatch):
"""ComfyUI-GGUF's loader.py does `from .ops import ...`. Loading it as a
standalone module by file path raised "attempted relative import with no
known parent package"; mounting the directory as a package is what makes
it resolve."""
d = tmp_path / "ComfyUI-GGUF"
d.mkdir()
(d / "__init__.py").write_text("raise AssertionError('the pack __init__ must not run')")
(d / "ops.py").write_text("class GGMLOps:\n pass\n")
(d / "dequant.py").write_text("MARKER = 'dequant'\n")
(d / "loader.py").write_text(
"from .ops import GGMLOps\n"
"from .dequant import MARKER\n"
"def gguf_sd_loader(path):\n"
" return {'w': MARKER}\n"
)
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(d))
monkeypatch.delitem(sys.modules, gguf_support._PKG_NAME, raising=False)
sd, options, note = gguf_support.load_state_dict("/whatever.gguf")
assert sd == {"w": "dequant"}
assert type(options["custom_operations"]).__name__ == "GGMLOps"
def test_switching_pack_directories_remounts(tmp_path, monkeypatch):
"""The synthesized package is cached in sys.modules; a different directory
must not keep resolving to the first one."""
for name, marker in (("a", "first"), ("b", "second")):
d = tmp_path / name
d.mkdir()
(d / "ops.py").write_text("class GGMLOps:\n pass\n")
(d / "loader.py").write_text(
f"def gguf_sd_loader(path):\n return {{'w': '{marker}'}}\n")
monkeypatch.delitem(sys.modules, gguf_support._PKG_NAME, raising=False)
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(tmp_path / "a"))
assert gguf_support.load_state_dict("/x.gguf")[0] == {"w": "first"}
# No manual cache clearing: remounting a different directory must purge it
# by itself.
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(tmp_path / "b"))
assert gguf_support.load_state_dict("/x.gguf")[0] == {"w": "second"}
def test_pack_refusing_an_architecture_falls_back(tmp_path, monkeypatch):
"""ComfyUI-GGUF raises "Unexpected architecture type in GGUF file:
'minimax_h3'" for architectures it has no handling for. That is a fair
refusal on its part and a dead end for us -- but the pack DEPENDS on the
gguf package, so the fallback is always there."""
d = tmp_path / "pack"
d.mkdir()
(d / "ops.py").write_text("class GGMLOps:\n pass\n")
(d / "loader.py").write_text(
"def gguf_sd_loader(path):\n"
" raise ValueError(\"Unexpected architecture type in GGUF file: 'minimax_h3'\")\n")
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(d))
monkeypatch.delitem(sys.modules, gguf_support._PKG_NAME, raising=False)
monkeypatch.setattr(gguf_support.importlib.util, "find_spec",
lambda name: object() if name == "gguf" else None)
monkeypatch.setattr(gguf_support, "_load_native",
lambda p: ({"w": 1}, {}, "gguf: dequantized at load"))
sd, options, note = gguf_support.load_state_dict("/h3.gguf")
assert sd == {"w": 1}
# Both halves must be in the note: what refused, and what happened instead.
assert "minimax_h3" in note
assert "dequantized at load" in note
# The quantized ops must NOT be carried over -- these tensors are plain.
assert "custom_operations" not in options
def test_a_pack_refusal_without_the_library_still_raises(tmp_path, monkeypatch):
"""No silent success: with nothing to fall back to, the original refusal stands."""
d = tmp_path / "pack"
d.mkdir()
(d / "ops.py").write_text("class GGMLOps:\n pass\n")
(d / "loader.py").write_text(
"def gguf_sd_loader(path):\n raise ValueError('nope')\n")
monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(d))
monkeypatch.delitem(sys.modules, gguf_support._PKG_NAME, raising=False)
monkeypatch.setattr(gguf_support.importlib.util, "find_spec", lambda name: None)
with pytest.raises(ValueError, match="nope"):
gguf_support.load_state_dict("/h3.gguf")
def test_gguf_is_a_declared_requirement():
"""The advice in UNAVAILABLE is "re-run FunPack's requirements", which is
only true while gguf is actually in them."""
import pathlib
reqs = pathlib.Path(__file__).resolve().parents[3] / "requirements.txt"
lines = [ln.strip() for ln in reqs.read_text(encoding="utf-8").splitlines()
if ln.strip() and not ln.startswith("#")]
assert any(ln == "gguf" or ln.startswith("gguf") for ln in lines)
@@ -200,3 +200,132 @@ def test_a_torch_build_without_the_flag_reports_that_rather_than_pretending():
from modules.loaders import common
if getattr(getattr(torch.backends, "cuda", None), "matmul", None) is None:
assert common.set_fp16_accumulation(True) is None
# --- GGUF routing: does the WIRING actually reach gguf_support.py, not just ---
# --- whether gguf_support.py itself works in isolation -------------------------
def test_a_gguf_model_name_is_routed_through_gguf_support(monkeypatch):
"""A .gguf model file must never reach load_torch_file -- that parser
reads a safetensors header, and a GGUF container is not one."""
import comfy.sd
from modules.loaders import gguf_support
from modules.loaders.diffusion_model import nodes
monkeypatch.setattr(gguf_support, "gguf_path", lambda folder, name: f"/fake/{name}")
monkeypatch.setattr(gguf_support, "load_state_dict",
lambda path: ({"w": 1}, {"custom_operations": "ops"}, "gguf: quantized"))
monkeypatch.setattr(comfy.sd, "load_diffusion_model_state_dict",
lambda sd, model_options, metadata: object() if sd == {"w": 1}
and model_options.get("custom_operations") == "ops" else None)
out = nodes.FunPackDiffusionModelLoader.execute(
model_name="model-Q4_K_M.gguf", weight_dtype="default",
compute_dtype="default", attention="default")
assert out.result[0] is not None
assert "gguf: quantized" in out.result[1]
def test_a_safetensors_named_file_that_is_actually_gguf_is_still_routed(monkeypatch):
"""The CONTENT decides, not the extension -- a .gguf renamed to
.safetensors must not reach the safetensors parser and fail with a
decode error that names nothing useful."""
import comfy.sd
import folder_paths
from modules.loaders import gguf_support
from modules.loaders.diffusion_model import nodes
monkeypatch.setattr(folder_paths, "get_full_path_or_raise", lambda kind, name: "/fake/path")
monkeypatch.setattr(gguf_support, "has_gguf_magic", lambda path: True)
monkeypatch.setattr(gguf_support, "load_state_dict",
lambda path: ({"w": 1}, {}, "gguf: dequantized at load"))
monkeypatch.setattr(comfy.sd, "load_diffusion_model_state_dict",
lambda sd, model_options, metadata: object())
out = nodes.FunPackDiffusionModelLoader.execute(
model_name="model.safetensors", weight_dtype="default",
compute_dtype="default", attention="default")
assert "named .safetensors but is a GGUF container" in out.result[1]
def test_an_ordinary_safetensors_model_never_touches_gguf_support(monkeypatch):
"""The common case must not pay for the uncommon one: a real
.safetensors file goes through load_torch_file, not gguf_support."""
import comfy.sd
import comfy.utils
import folder_paths
from modules.loaders import gguf_support
from modules.loaders.diffusion_model import nodes
monkeypatch.setattr(folder_paths, "get_full_path_or_raise", lambda kind, name: "/fake/path")
monkeypatch.setattr(gguf_support, "has_gguf_magic", lambda path: False)
monkeypatch.setattr(gguf_support, "load_state_dict",
lambda path: (_ for _ in ()).throw(AssertionError("gguf path taken")))
monkeypatch.setattr(comfy.utils, "load_torch_file",
lambda path, return_metadata: ({"w": 1}, {}))
monkeypatch.setattr(comfy.sd, "load_diffusion_model_state_dict",
lambda sd, model_options, metadata: object())
nodes.FunPackDiffusionModelLoader.execute(
model_name="model.safetensors", weight_dtype="default",
compute_dtype="default", attention="default")
def test_a_gguf_text_encoder_is_routed_through_gguf_support(monkeypatch):
"""A .gguf encoder cannot go through load_clip -- it reads files itself
and does not understand the container."""
import comfy.sd
from modules.loaders import gguf_support
from modules.loaders.clip import nodes
monkeypatch.setattr(gguf_support, "gguf_path", lambda folder, name: f"/fake/{name}")
monkeypatch.setattr(gguf_support, "load_clip_state_dict",
lambda path: ({"w": 1}, {}, "gguf: quantized"))
monkeypatch.setattr(comfy.sd, "load_text_encoder_state_dicts",
lambda state_dicts, **kw: object() if state_dicts == [{"w": 1}] else None)
out = nodes.FunPackCLIPLoader.execute(clip_name1="encoder-Q5_K.gguf", type="ltxv")
assert out.result[0] is not None
assert "gguf: quantized" in out.result[1]
def test_a_gguf_encoder_alongside_a_plain_connector_loads_both(monkeypatch):
"""LTX-2.3's normal shape: a GGUF encoder plus a .safetensors connector
in the same list. Each slot must be read the way ITS file needs, not
however the first slot in the list happened to be."""
import comfy.sd
import comfy.utils
import folder_paths
from modules.loaders import gguf_support
from modules.loaders.clip import nodes
monkeypatch.setattr(gguf_support, "gguf_path", lambda folder, name: f"/fake/{name}")
monkeypatch.setattr(gguf_support, "load_clip_state_dict",
lambda path: ({"gguf": 1}, {}, "gguf: quantized"))
monkeypatch.setattr(folder_paths, "get_full_path_or_raise", lambda kind, name: f"/fake/{name}")
monkeypatch.setattr(comfy.utils, "load_torch_file", lambda path: {"plain": 1})
seen = {}
monkeypatch.setattr(comfy.sd, "load_text_encoder_state_dicts",
lambda state_dicts, **kw: seen.update(sd=state_dicts) or object())
nodes.FunPackCLIPLoader.execute(
clip_name1="encoder-Q5_K.gguf", clip_name2="connector.safetensors", type="ltxv")
assert seen["sd"] == [{"gguf": 1}, {"plain": 1}]
def test_a_gguf_encoder_that_vanished_since_being_listed_is_refused_by_name(monkeypatch):
"""gguf_path() returns None once a listed file is gone. Without a check,
that None reaches load_clip_state_dict and fails deep inside gguf/pack
machinery instead of naming the missing file, the way the diffusion model
loader already refuses the identical condition."""
from modules.loaders import gguf_support
from modules.loaders.clip import nodes
monkeypatch.setattr(gguf_support, "gguf_path", lambda folder, name: None)
with pytest.raises(RuntimeError, match="encoder-Q5_K.gguf.*no longer where it was listed"):
nodes.FunPackCLIPLoader.execute(clip_name1="encoder-Q5_K.gguf", type="ltxv")