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>
178 lines
8.4 KiB
Python
178 lines
8.4 KiB
Python
"""The text encoder loader.
|
|
|
|
One node, up to four files, one CLIP. Unlike the LoRA loader this cannot be a
|
|
chain: the files are combined into a single encoder, so they have to arrive
|
|
together. One slot for LTX-2.5 (Gemma4); two for LTX-2.3 (Gemma3 plus its
|
|
connector).
|
|
|
|
The slots are numbered combos, which is what core's DualCLIPLoader and
|
|
TripleCLIPLoader use. Two alternatives were tried and rejected:
|
|
|
|
* **ComfyUI's autogrow input** looks like the right answer and is not.
|
|
`_AutogrowTemplate.__init__` sets `force_input = True` on any WidgetInput, so a
|
|
Combo template becomes a row of SOCKETS instead of dropdowns -- the file picker
|
|
stops being a picker. Every core usage of autogrow is a connection type (Image,
|
|
Audio, Splat), never a Combo.
|
|
* **v4's `funpack_list`**, a JSON array in a STRING widget, needed a hand-written
|
|
canvas widget to draw it, and without one it renders as raw JSON. The file it
|
|
documented (`web/funpack_list.js`) did not even exist, so on the stock canvas
|
|
the list was raw JSON in v4 too.
|
|
|
|
The cost of numbered slots is a fixed ceiling. Four is above anything shipping
|
|
(LTX-2.3 needs two), and raising it later only adds optional inputs, which is
|
|
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
|
|
|
|
|
|
def clip_types():
|
|
try:
|
|
return [t.name.lower() for t in comfy.sd.CLIPType]
|
|
except Exception: # noqa: BLE001
|
|
return ["stable_diffusion"]
|
|
|
|
|
|
def encoder_files():
|
|
"""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):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
types = clip_types()
|
|
files = encoder_files()
|
|
default_type = "ltxv" if "ltxv" in types else (types[0] if types else "stable_diffusion")
|
|
|
|
slots = [io.Combo.Input("clip_name1", options=files,
|
|
tooltip="Text encoder file. The first slot is the encoder itself.")]
|
|
# Optional, and "" means "no file here" -- an empty slot must not be a
|
|
# validation failure, because most families only need one.
|
|
slots += [
|
|
io.Combo.Input(f"clip_name{n}", options=[""] + files, default="", optional=True,
|
|
tooltip="Another encoder file, loaded after the ones above. "
|
|
"Leave empty if the family needs no more.")
|
|
for n in range(2, MAX_ENCODERS + 1)
|
|
]
|
|
|
|
return io.Schema(
|
|
node_id="FunPackCLIPLoader",
|
|
display_name="FunPack CLIP Loader",
|
|
category="FunPack/Loaders",
|
|
description="Load one or more text encoder files into a single CLIP.",
|
|
inputs=[
|
|
*slots,
|
|
io.Combo.Input("type", options=types, default=default_type,
|
|
tooltip="Which family the encoder is for. LTX-2 is 'ltxv'; "
|
|
"MiniMax H3 is 'minimax'."),
|
|
io.Combo.Input("device", options=["default", "cpu"], default="default",
|
|
optional=True,
|
|
tooltip="'cpu' keeps the encoder off the GPU: slower prompts, "
|
|
"more VRAM for the diffusion model."),
|
|
],
|
|
outputs=[
|
|
io.Clip.Output(display_name="clip"),
|
|
io.String.Output(display_name="status"),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, clip_name1: str, type: str, device: str = "default", **slots) -> io.NodeOutput:
|
|
# Order matters to load_clip, so the slots are read in slot order rather
|
|
# than in whatever order kwargs arrive.
|
|
names = [clip_name1] + [slots.get(f"clip_name{n}") for n in range(2, MAX_ENCODERS + 1)]
|
|
names = [name for name in names if name]
|
|
if not names:
|
|
raise RuntimeError("FunPack CLIP Loader: pick at least one text encoder file.")
|
|
|
|
# Refuse rather than repair. Falling back to STABLE_DIFFUSION loads the
|
|
# encoder against the wrong family and reports success, and a family
|
|
# mismatch in this project reads as an unrelated phantom fault rather
|
|
# than as a family error. Reachable when CLIPType renames a member that
|
|
# a saved workflow still names.
|
|
clip_type = getattr(comfy.sd.CLIPType, str(type).upper(), None)
|
|
if clip_type is None:
|
|
known = ", ".join(sorted(t.name.lower() for t in comfy.sd.CLIPType))
|
|
raise RuntimeError(
|
|
f"FunPack CLIP Loader: this ComfyUI has no encoder family {type!r}. "
|
|
f"Known families: {known}.")
|
|
model_options = {}
|
|
if device == "cpu":
|
|
model_options["load_device"] = model_options["offload_device"] = torch.device("cpu")
|
|
|
|
# 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.
|
|
raise RuntimeError(
|
|
f"Could not load a text encoder from {', '.join(names)} as type {type!r}.")
|
|
|
|
log.info("FunPack CLIP Loader",
|
|
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}")
|