diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c57055..9141813a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/core/tests/test_shipped_nodes.py b/core/tests/test_shipped_nodes.py index b5830d7a..54bc0b13 100644 --- a/core/tests/test_shipped_nodes.py +++ b/core/tests/test_shipped_nodes.py @@ -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"], diff --git a/modules/loaders/lora/nodes.py b/modules/loaders/lora/nodes.py index f9dc7052..ebb038dc 100644 --- a/modules/loaders/lora/nodes.py +++ b/modules/loaders/lora/nodes.py @@ -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})" diff --git a/modules/loaders/tests/test_loader_behaviour.py b/modules/loaders/tests/test_loader_behaviour.py index 7730bf3e..b5a08b4c 100644 --- a/modules/loaders/tests/test_loader_behaviour.py +++ b/modules/loaders/tests/test_loader_behaviour.py @@ -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"