feat(loaders): load .gguf diffusion models and text encoders
Core lists no .gguf, so the files were invisible. Backend is whichever is installed: ComfyUI-GGUF keeps weights quantized, the gguf package dequantizes at load. Family detection reads GGUF containers too.
This commit is contained in:
@@ -3,6 +3,15 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **FunPack's loaders take `.gguf` files**, for diffusion models and text encoders alike, and
|
||||
a text-encoder list may mix a `.gguf` with `.safetensors` slots (the usual LTX-2.3 shape).
|
||||
Core's extension set has no `.gguf`, so those files were on disk and invisible to every
|
||||
picker. The runtime is whichever backend is present: ComfyUI-GGUF keeps the weights
|
||||
quantized in VRAM, which is the point of GGUF; failing that, the `gguf` package is used to
|
||||
dequantize at load — the file loads, but at full size, and the status output says which of
|
||||
the two happened. With neither, the loader names both remedies instead of failing obscurely.
|
||||
Model-family detection reads GGUF containers too, using the same architecture signatures.
|
||||
|
||||
- **Pin up to three shortcuts to the timeline toolbar.** Settings is a window with a sidebar,
|
||||
and its useful places — a specific node's page, one Engine category — are several clicks
|
||||
deep: fine to walk once, tedious while dialling something in. `📌 Pin to a button` in the
|
||||
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
"""Load .gguf model files through FunPack's own loaders.
|
||||
|
||||
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.util
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
GGUF_EXT = ".gguf"
|
||||
|
||||
|
||||
def is_gguf(name: str) -> bool:
|
||||
return str(name or "").lower().endswith(GGUF_EXT)
|
||||
|
||||
|
||||
# ── 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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 file is loaded directly.
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"_funpack_gguf_{mod}", os.path.join(dirname, f"{mod}.py"))
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
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. Either install ComfyUI-GGUF (custom node pack — keeps the "
|
||||
"weights quantized in VRAM, which is the point of GGUF), or `pip install gguf` into "
|
||||
"ComfyUI's environment for a load-time dequantize that works but gives back no memory."
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
which = backend()
|
||||
if which is None:
|
||||
raise RuntimeError(UNAVAILABLE)
|
||||
if which == "pack":
|
||||
d = _pack_dir()
|
||||
loader = _load_pack_module(d, "loader")
|
||||
ops = _load_pack_module(d, "ops")
|
||||
sd = loader.gguf_sd_loader(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)})"
|
||||
return _load_native(path)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
which = backend()
|
||||
if which is None:
|
||||
raise RuntimeError(UNAVAILABLE)
|
||||
if which == "pack":
|
||||
d = _pack_dir()
|
||||
loader = _load_pack_module(d, "loader")
|
||||
ops = _load_pack_module(d, "ops")
|
||||
read = getattr(loader, "gguf_clip_loader", None) or loader.gguf_sd_loader
|
||||
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)})"
|
||||
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 shapes reversed relative to torch.
|
||||
shape = tuple(int(d) for d in reversed(tensor.shape))
|
||||
if shape and t.numel() == int(torch.tensor(shape).prod()):
|
||||
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 file "
|
||||
f"loads, but it occupies its full size in VRAM. Install ComfyUI-GGUF to keep it "
|
||||
f"quantized.")
|
||||
return sd, {}, note
|
||||
+75
-14
@@ -19,11 +19,29 @@ import torch
|
||||
|
||||
try:
|
||||
from .widgets import field, list_widget, parse_rows
|
||||
from . import sla_attention
|
||||
from . import gguf_support, sla_attention
|
||||
except ImportError: # standalone tests import the modules directly
|
||||
from widgets import field, list_widget, parse_rows
|
||||
import gguf_support
|
||||
import sla_attention
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def encoder_file_choices():
|
||||
"""Text encoder files, `.gguf` included. Same reasoning as model_file_choices()."""
|
||||
return list(folder_paths.get_filename_list("text_encoders")) + \
|
||||
gguf_support.gguf_names("text_encoders")
|
||||
|
||||
|
||||
WEIGHT_DTYPES = ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2", "fp16", "bf16", "fp32"]
|
||||
COMPUTE_DTYPES = ["default", "fp16", "bf16", "fp32"]
|
||||
VAE_DTYPES = ["default", "fp16", "bf16", "fp32"]
|
||||
@@ -124,8 +142,10 @@ class FunPackDiffusionModelLoader:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_name": (folder_paths.get_filename_list("diffusion_models"), {
|
||||
"tooltip": "The diffusion model file, from ComfyUI/models/diffusion_models."}),
|
||||
"model_name": (model_file_choices(), {
|
||||
"tooltip": "The diffusion model file, from ComfyUI/models/diffusion_models. "
|
||||
".gguf files are listed too (see the status output for which "
|
||||
"GGUF backend loaded it)."}),
|
||||
"weight_dtype": (WEIGHT_DTYPES, {
|
||||
"default": "default",
|
||||
"tooltip": "How weights are stored in VRAM. fp8_e4m3fn roughly halves the "
|
||||
@@ -220,8 +240,20 @@ class FunPackDiffusionModelLoader:
|
||||
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"ERROR: {model_name} is no longer where it was listed from.")
|
||||
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 merged 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:
|
||||
path = folder_paths.get_full_path_or_raise("diffusion_models", model_name)
|
||||
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:
|
||||
@@ -283,7 +315,7 @@ class FunPackCLIPLoader:
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
encoders = folder_paths.get_filename_list("text_encoders")
|
||||
encoders = encoder_file_choices()
|
||||
types = cls.clip_types()
|
||||
return {
|
||||
"required": {
|
||||
@@ -292,7 +324,8 @@ class FunPackCLIPLoader:
|
||||
[field("clip_name", "combo", label="file", choices=encoders)],
|
||||
add_label="+ Add slot",
|
||||
tooltip="Text encoder files, in load order. One slot for LTX-2.5 (Gemma4); "
|
||||
"two for LTX-2.3 (Gemma3 + its connector)."),
|
||||
"two for LTX-2.3 (Gemma3 + its connector). .gguf files are listed "
|
||||
"too, and may be mixed with .safetensors ones."),
|
||||
"type": (types, {
|
||||
"default": "ltxv" if "ltxv" in types else (types[0] if types else "ltxv"),
|
||||
"tooltip": "Which model family the encoder is for. LTX-2 (all point "
|
||||
@@ -317,15 +350,43 @@ class FunPackCLIPLoader:
|
||||
if device == "cpu":
|
||||
model_options["load_device"] = model_options["offload_device"] = torch.device("cpu")
|
||||
|
||||
paths = [folder_paths.get_full_path_or_raise("text_encoders", n) for n 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,
|
||||
)
|
||||
gguf_notes = []
|
||||
if 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 — which is the normal LTX-2.3 shape.
|
||||
state_dicts = []
|
||||
for n in names:
|
||||
if gguf_support.is_gguf(n):
|
||||
gpath = gguf_support.gguf_path("text_encoders", n)
|
||||
if not gpath:
|
||||
raise RuntimeError(f"FunPack CLIP Loader: {n} is no longer where it "
|
||||
f"was listed from.")
|
||||
sd, gopts, gnote = gguf_support.load_clip_state_dict(gpath)
|
||||
state_dicts.append(sd)
|
||||
model_options = {**model_options, **gopts}
|
||||
gguf_notes.append(f"{n}: {gnote}")
|
||||
else:
|
||||
state_dicts.append(comfy.utils.load_torch_file(
|
||||
folder_paths.get_full_path_or_raise("text_encoders", 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:
|
||||
paths = [folder_paths.get_full_path_or_raise("text_encoders", n) for n 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,
|
||||
)
|
||||
status = "FunPack CLIP Loader | type={} device={}\n{}".format(
|
||||
type, device, "\n".join(f" {i + 1}. {n}" for i, n in enumerate(names)))
|
||||
for gn in gguf_notes:
|
||||
status += f"\n {gn}"
|
||||
return (clip, status)
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,25 @@ def read_safetensors_keys(path: str | Path) -> Optional[list[str]]:
|
||||
return [k for k in header if k != "__metadata__"]
|
||||
|
||||
|
||||
def read_gguf_keys(path: str | Path) -> Optional[list[str]]:
|
||||
"""Tensor names from a GGUF file, or None when they cannot be read.
|
||||
|
||||
Delegated to llama.cpp's own `gguf` package rather than hand-parsing the container:
|
||||
the header is a typed key-value section of variable-length records, and getting that
|
||||
subtly wrong would misidentify a model rather than fail. The package is optional, so
|
||||
None here means "cannot tell", never "wrong family".
|
||||
"""
|
||||
try:
|
||||
import gguf # noqa: PLC0415
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
reader = gguf.GGUFReader(str(path))
|
||||
return [str(t.name) for t in reader.tensors]
|
||||
except Exception: # noqa: BLE001 — any malformed container is simply unreadable
|
||||
return None
|
||||
|
||||
|
||||
def detect_arch(keys: Iterable[str]) -> Optional[str]:
|
||||
"""`minimax_h3` | `ltxav` | `ltxv`, or None when the keys match no architecture we wire.
|
||||
|
||||
@@ -86,6 +105,13 @@ def detect_family(path: str | Path) -> dict:
|
||||
if not p.is_file():
|
||||
return {"family": None, "arch": None, "detected": False,
|
||||
"reason": f"{p.name}: file not found"}
|
||||
if p.suffix.lower() == ".gguf":
|
||||
keys = read_gguf_keys(p)
|
||||
if keys is None:
|
||||
return {"family": None, "arch": None, "detected": False,
|
||||
"reason": f"{p.name}: a .gguf can only be inspected with the `gguf` package "
|
||||
f"installed — set the family from Models ▸ Model family instead"}
|
||||
return _from_keys(p, keys)
|
||||
if p.suffix.lower() != ".safetensors":
|
||||
# .ckpt / .pth are pickles; reading them means executing them, which is not worth it
|
||||
# for a family probe. The declared family stands and the caller is told why.
|
||||
@@ -95,6 +121,12 @@ def detect_family(path: str | Path) -> dict:
|
||||
if keys is None:
|
||||
return {"family": None, "arch": None, "detected": False,
|
||||
"reason": f"{p.name}: not a readable safetensors header"}
|
||||
return _from_keys(p, keys)
|
||||
|
||||
|
||||
def _from_keys(p: Path, keys: Iterable[str]) -> dict:
|
||||
"""Same verdict from either container: the signatures are key names, so it does not
|
||||
matter which format they were read out of."""
|
||||
arch = detect_arch(keys)
|
||||
if arch is None:
|
||||
return {"family": None, "arch": None, "detected": False,
|
||||
|
||||
@@ -148,3 +148,41 @@ def test_an_unidentifiable_checkpoint_proposes_no_family(tmp_path):
|
||||
out = model_probe.probe_models({"slots": [slot(model_name="wan.safetensors")]},
|
||||
resolve=lambda name: str(f))
|
||||
assert out["family"] is None
|
||||
|
||||
|
||||
def test_gguf_without_the_library_says_so_and_proposes_no_family(tmp_path, monkeypatch):
|
||||
"""A .gguf that cannot be read must never fall through to a guess — the previous family
|
||||
stands, and the panel says why plus where to set it by hand."""
|
||||
import movie_editor.backend.model_probe as mp
|
||||
p = tmp_path / "ltx-Q4_K_M.gguf"
|
||||
p.write_bytes(b"GGUF\x03\x00\x00\x00")
|
||||
monkeypatch.setattr(mp, "read_gguf_keys", lambda _p: None)
|
||||
out = mp.detect_family(p)
|
||||
assert out["family"] is None
|
||||
assert out["detected"] is False
|
||||
assert "gguf" in out["reason"].lower()
|
||||
assert "Model family" in out["reason"]
|
||||
|
||||
|
||||
def test_gguf_is_identified_from_the_same_signatures(tmp_path, monkeypatch):
|
||||
"""The architecture signatures are KEY NAMES, so the container they came out of is
|
||||
irrelevant — a GGUF and a safetensors of the same model must agree."""
|
||||
import movie_editor.backend.model_probe as mp
|
||||
p = tmp_path / "h3-Q8_0.gguf"
|
||||
p.write_bytes(b"GGUF")
|
||||
monkeypatch.setattr(mp, "read_gguf_keys",
|
||||
lambda _p: ["video_patch_proj.weight", "audio_patch_proj.weight"])
|
||||
out = mp.detect_family(p)
|
||||
assert out["arch"] == "minimax_h3"
|
||||
assert out["family"] == "minimax_h3"
|
||||
assert out["detected"] is True
|
||||
|
||||
|
||||
def test_gguf_with_no_known_signature_proposes_nothing(tmp_path, monkeypatch):
|
||||
import movie_editor.backend.model_probe as mp
|
||||
p = tmp_path / "something.gguf"
|
||||
p.write_bytes(b"GGUF")
|
||||
monkeypatch.setattr(mp, "read_gguf_keys", lambda _p: ["blk.0.attn_q.weight"])
|
||||
out = mp.detect_family(p)
|
||||
assert out["family"] is None
|
||||
assert "no LTX or MiniMax H3 signature" in out["reason"]
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
import gguf_support # noqa: E402
|
||||
|
||||
|
||||
@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.
|
||||
assert "ComfyUI-GGUF" in msg
|
||||
assert "pip install gguf" 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
|
||||
Reference in New Issue
Block a user