LoRA Loader: up to eight LoRAs in one node, applied in order; slot 1 keeps its inputs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjU1A5dzqoK4stWcmzk
This commit is contained in:
Claude
2026-10-29 19:23:48 +00:00
parent 15bd8b8480
commit 3862f3ec36
4 changed files with 108 additions and 50 deletions
+1
View File
@@ -4,6 +4,7 @@
### Added
- **LoRA Loader: up to 8 LoRAs in one node.** Slots 2 to 8 add a file and their own model and text encoder strengths; they apply in order after slot 1, and a slot left on None is skipped. Slot 1 keeps its old inputs, so saved pipelines load unchanged, and chaining several loaders still works.
- **Two stages: the first stage keeps the shape of the full picture.** Each side was rounded on its own, which bent the shape by up to 6 percent (a 18x31 latent got a 12x22 first stage), so the layout was decided in a squashed picture and then stretched. The grid is now chosen so width over height matches the full one (within a few percent, in even steps).
- **Two stages: Latent upscaler as an option** (Settings > Engine > Sampling, "How the hand-over is enlarged"). Bicubic stays the default; plain choices Mitchell, Catmull-Rom, Lanczos, Bislerp, Bilinear, Trilinear (same as Bilinear here, time is never resampled), Area and Nearest-exact are there too (Lanczos is done on the numbers, not through 8-bit pictures as ComfyUI's would, which a latent cannot survive). Or pick Latent upscaler and a file from models/latent_upscale_models (H3's convolution upscaler) to enlarge the first stage's clean guess with the learned network instead. It runs at the ratio that reaches the full size (it works in even steps, then a bicubic trim fits the exact grid), once per run, on a small latent; the file is loaded once and kept while the upscaler is the chosen method, and leaves memory when you switch to another method or turn Two stages off. A missing or unreadable file falls back to bicubic with a note. Untested on a GPU.
- **Upscale no longer makes the next generation reload the models.** It ran as an extra ComfyUI prompt without the workflow's loaders, and ComfyUI's cache lets go of loaders a prompt does not use; it now runs as a background job of FunUI, beside ComfyUI's queue (Interrupt in ComfyUI no longer stops it).
+1
View File
@@ -87,6 +87,7 @@ FROZEN = {
],
"FunUILoraLoader": [
"model", "lora_name", "strength_model", "clip", "strength_clip",
*[f"{k}_{n}" for n in range(2, 9) for k in ("lora_name", "strength_model", "strength_clip")],
],
"FunUIVAELoader": ["vae_name", "dtype", "decode_vae_name"],
"FunUILoadMedia": ["media_id", "blank_width", "blank_height", "blank_frames", "full", "prepare"],
+74 -50
View File
@@ -1,10 +1,16 @@
"""The LoRA loader.
One LoRA per node, chained for more. v4 carried N LoRAs in a single node behind a
JSON list widget and a `FlexibleOptionalInputType` -- a dict subclass that lied to
ComfyUI's validator so arbitrary keys would pass. Chaining needs neither: it is
what ComfyUI's own LoraLoader does, every strength is a real socket, and the graph
shows how many LoRAs are actually applied instead of hiding the count in a string.
Up to eight LoRAs in one node, applied in order. Slot 1 keeps the original input
names (`lora_name`, `strength_model`, `strength_clip`) so every saved pipeline
still loads; slots 2..8 add `lora_name_N`, `strength_model_N`, `strength_clip_N`
as optional inputs, which is backwards-compatible with every saved graph.
Chaining several nodes still works and gives the same result.
The slots are numbered combos rather than ComfyUI's autogrow input, for the same
reason the text encoder loader uses them: autogrow turns a Combo template into a
row of sockets, so the file picker would stop being a picker. v4 used a JSON list
in a string widget and a dict subclass that lied to the validator; neither is
needed, and every strength here is a real, validated input.
"""
import comfy.utils
@@ -16,28 +22,34 @@ from .keys import match
NONE = "None"
MAX_LORAS = 8
class FunUILoraLoader(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
files = [NONE] + folder_paths.get_filename_list("loras")
inputs = [io.Model.Input("model")]
for n in range(1, MAX_LORAS + 1):
sfx = "" if n == 1 else f"_{n}"
first = n == 1
inputs += [
io.Combo.Input(f"lora_name{sfx}", options=files, default=NONE, optional=not first,
tooltip="None leaves the model untouched." if first
else "Another LoRA, applied after the ones above it. None skips this slot."),
io.Float.Input(f"strength_model{sfx}", default=1.0, min=-100.0, max=100.0, step=0.01, optional=not first,
tooltip="How strongly the LoRA changes the model. 0 = not at all, negative = the opposite."),
]
if first: # the original order: clip sits between slot 1's two strengths
inputs.append(io.Clip.Input("clip", optional=True, tooltip="Leave unwired for model-only LoRAs."))
inputs.append(io.Float.Input(f"strength_clip{sfx}", default=1.0, min=-100.0, max=100.0, step=0.01, optional=True,
tooltip="How strongly the LoRA changes the text encoder. Only used when a clip is wired."))
return io.Schema(
node_id="FunUILoraLoader",
display_name="FunUI LoRA Loader",
category="FunUI/Loaders",
description="Apply one LoRA to a model, and optionally its text encoder. "
"Chain these for more than one.",
inputs=[
io.Model.Input("model"),
io.Combo.Input("lora_name", options=[NONE] + folder_paths.get_filename_list("loras"),
default=NONE, tooltip="None leaves the model untouched."),
io.Float.Input("strength_model", default=1.0, min=-100.0, max=100.0, step=0.01,
tooltip="How strongly the LoRA changes the model. 0 = not at all, negative = the opposite."),
io.Clip.Input("clip", optional=True,
tooltip="Leave unwired for a model-only LoRA."),
io.Float.Input("strength_clip", default=1.0, min=-100.0, max=100.0, step=0.01,
optional=True, tooltip="How strongly the LoRA changes the text encoder. Only used when a clip is wired."),
],
description=f"Apply up to {MAX_LORAS} LoRAs to a model, and optionally its text encoder, in order.",
inputs=inputs,
outputs=[
io.Model.Output(display_name="model"),
io.Clip.Output(display_name="clip"),
@@ -46,40 +58,52 @@ class FunUILoraLoader(io.ComfyNode):
)
@classmethod
def execute(cls, model, lora_name: str, strength_model: float,
clip=None, strength_clip: float = 1.0) -> io.NodeOutput:
if lora_name == NONE: # kept in the pipeline so a LoRA is one pick away; nothing picked = no effect
return io.NodeOutput(model, clip, "no LoRA picked")
def execute(cls, model, lora_name=NONE, strength_model=1.0, clip=None, strength_clip=1.0, **kw) -> io.NodeOutput:
kw.update(lora_name=lora_name, strength_model=strength_model, strength_clip=strength_clip)
notes = []
for n in range(1, MAX_LORAS + 1):
sfx = "" if n == 1 else f"_{n}"
name = kw.get(f"lora_name{sfx}", NONE)
if name in (None, NONE):
if n == 1 and not any(kw.get(f"lora_name_{k}", NONE) not in (None, NONE) for k in range(2, MAX_LORAS + 1)):
notes.append("no LoRA picked")
continue
model, clip, note = _apply(model, clip, name, kw.get(f"strength_model{sfx}", 1.0),
kw.get(f"strength_clip{sfx}", 1.0))
notes.append(note)
return io.NodeOutput(model, clip, "\n".join(notes))
def _apply(model, clip, lora_name, strength_model, strength_clip):
"""One LoRA onto (model, clip). Returns (model, clip, status line)."""
if strength_model == 0 and (clip is None or strength_clip == 0):
# Doing nothing is a valid request, but it must return the SAME objects
# rather than clones: a clone here would silently drop patches a later
# loader in the chain applied to the original.
if strength_model == 0 and (clip is None or strength_clip == 0):
# Loud, because "applied a LoRA at strength 0" is indistinguishable
# from "forgot to set the strength" by looking at the picture.
log.alert("FunUI LoRA Loader",
f"{lora_name} has both strengths at 0 and was not applied")
return io.NodeOutput(model, clip, f"{lora_name}: both strengths are 0, not applied")
# Loud, because "applied a LoRA at strength 0" is indistinguishable
# from "forgot to set the strength" by looking at the picture.
log.alert("FunUI LoRA Loader", f"{lora_name} has both strengths at 0 and was not applied")
return model, clip, f"{lora_name}: both strengths are 0, not applied"
path = folder_paths.get_full_path_or_raise("loras", lora_name)
lora = comfy.utils.load_torch_file(path, safe_load=True)
patches, how, dropped = match(model, clip, lora)
if not patches:
log.alert("FunUI LoRA Loader", f"{lora_name} matched NOTHING in this model, so it does nothing: "
"it is for another model, or names its layers in a way not known here")
return io.NodeOutput(model, clip, f"{lora_name}: matched nothing, not applied")
patched_model = model.clone()
patched_model.add_patches(patches, strength_model)
patched_clip = None
if clip is not None:
patched_clip = clip.clone()
patched_clip.add_patches(patches, strength_clip)
if dropped:
log.alert("FunUI LoRA Loader", f"{lora_name}: {dropped} of {len(patches) + dropped} weights do not fit this "
"model's shapes and were left out (trained on another variant of it)")
path = folder_paths.get_full_path_or_raise("loras", lora_name)
lora = comfy.utils.load_torch_file(path, safe_load=True)
patches, how, dropped = match(model, clip, lora)
if not patches:
log.alert("FunUI LoRA Loader", f"{lora_name} matched NOTHING in this model, so it does nothing: "
"it is for another model, or names its layers in a way not known here")
return model, clip, f"{lora_name}: matched nothing, not applied"
patched_model = model.clone()
patched_model.add_patches(patches, strength_model)
patched_clip = None
if clip is not None:
patched_clip = clip.clone()
patched_clip.add_patches(patches, strength_clip)
if dropped:
log.alert("FunUI LoRA Loader", f"{lora_name}: {dropped} of {len(patches) + dropped} weights do not fit this "
"model's shapes and were left out (trained on another variant of it)")
applied = [f"model={strength_model}"]
applied.append(f"clip={strength_clip}" if clip is not None else "clip=not wired")
found = f"{len(patches)} weights" + (f", keys {how}" if how != "as-is" else "") + (f", {dropped} dropped" if dropped else "")
log.info("FunUI LoRA Loader", f"{lora_name} applied ({', '.join(applied)}; {found}). {sysinfo.memory_line()}")
return io.NodeOutput(patched_model, patched_clip,
f"FunUI LoRA Loader | {lora_name} ({', '.join(applied)}; {found})")
applied = [f"model={strength_model}"]
applied.append(f"clip={strength_clip}" if clip is not None else "clip=not wired")
found = f"{len(patches)} weights" + (f", keys {how}" if how != "as-is" else "") + (f", {dropped} dropped" if dropped else "")
log.info("FunUI LoRA Loader", f"{lora_name} applied ({', '.join(applied)}; {found}). {sysinfo.memory_line()}")
return patched_model, patched_clip, f"FunUI LoRA Loader | {lora_name} ({', '.join(applied)}; {found})"
@@ -427,3 +427,35 @@ def test_the_vae_loader_hands_the_file_header_to_the_vae(comfyui, tmp_path, monk
monkeypatch.setattr(comfy.sd, "VAE", Vae)
FunUIVAELoader.execute("audio_vae.safetensors", "bfloat16")
assert seen["metadata"] == {"config": "{}"} and seen["keys"] == ["autoencoder.x"]
def test_several_loras_in_one_node_apply_in_order(monkeypatch):
import comfy.utils
import folder_paths
from modules.loaders.lora import nodes
seen = []
class M:
def clone(self):
return self
def add_patches(self, patches, strength):
seen.append((patches["tag"], strength))
monkeypatch.setattr(folder_paths, "get_full_path_or_raise", lambda kind, name: name)
monkeypatch.setattr(comfy.utils, "load_torch_file", lambda p, **kw: {"name": p})
monkeypatch.setattr(nodes, "match", lambda m, c, l: ({"tag": l["name"]}, "as-is", 0))
out = nodes.FunUILoraLoader.execute(
model=M(), lora_name="a", strength_model=0.5,
lora_name_2="None", lora_name_3="c", strength_model_3=1.5,
lora_name_8="h", strength_model_8=-1.0)
assert seen == [("a", 0.5), ("c", 1.5), ("h", -1.0)]
assert out.result[2].count("FunUI LoRA Loader |") == 3
def test_a_skipped_slot_between_two_loras_does_not_break_the_chain(monkeypatch):
from modules.loaders.lora import nodes
model = object()
out = nodes.FunUILoraLoader.execute(model=model, lora_name="None", lora_name_2="None")
assert out.result[0] is model and out.result[2] == "no LoRA picked"