Files
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
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
2026-10-25 13:11:03 +00:00

177 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 `funui_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/funui_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` ones only when the ComfyUI-GGUF pack is there to read them."""
ggufs = gguf_support.gguf_names("text_encoders") if gguf_support.backend() == "pack" else []
return list(folder_paths.get_filename_list("text_encoders")) + ggufs
class FunUICLIPLoader(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="FunUICLIPLoader",
display_name="FunUI CLIP Loader",
category="FunUI/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("FunUI 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"FunUI 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("FunUI 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"FunUI CLIP Loader | type={type} device={device}\n{listing}")