Files
olivv-cs-ComfyUI-FunPack/modules/loaders/clip/nodes.py
T
digital-garbageandClaude Sonnet 5 7a226830b1 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>
2026-09-11 18:14:46 +03:00

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}")