175 lines
6.1 KiB
Python
175 lines
6.1 KiB
Python
"""
|
|
Ultimate Lora Loader for ComfyUI
|
|
--------------------------------
|
|
A Power-Lora-Loader-style node (dynamic list of LoRAs, each with its own
|
|
enabled/strength controls) whose "Add Lora" picker shows your actual
|
|
on-disk folder structure instead of a flattened list of
|
|
"subfolder/name.safetensors" strings.
|
|
|
|
Files:
|
|
nodes.py <- this file (backend node + API route)
|
|
js/ultimate_lora_loader.js <- frontend widget (button, popup tree browser, per-row UI)
|
|
"""
|
|
|
|
import os
|
|
import folder_paths
|
|
import comfy.sd
|
|
import comfy.utils
|
|
from aiohttp import web
|
|
from server import PromptServer
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_lora_tree():
|
|
"""
|
|
Build a nested dict representing the folder structure of every
|
|
configured `loras` search path, e.g.:
|
|
|
|
{
|
|
"__files__": ["root_lora.safetensors"],
|
|
"characters": {
|
|
"__files__": ["lora1.safetensors", "lora2.safetensors"],
|
|
},
|
|
"styles": {
|
|
"__files__": ["anime_style.safetensors"],
|
|
"watercolor": { "__files__": ["v2.safetensors"] }
|
|
}
|
|
}
|
|
|
|
Uses folder_paths.get_filename_list("loras") as the source of truth for
|
|
*which* files count (respects ComfyUI's extension filtering + any extra
|
|
search paths from extra_model_paths.yaml), then re-nests them by their
|
|
relative path instead of leaving them flattened with "/" in the name.
|
|
"""
|
|
tree = {"__files__": []}
|
|
|
|
all_loras = folder_paths.get_filename_list("loras")
|
|
|
|
for rel_path in all_loras:
|
|
# rel_path looks like "characters/lora1.safetensors" or "root_lora.safetensors"
|
|
parts = rel_path.replace("\\", "/").split("/")
|
|
filename = parts[-1]
|
|
folders = parts[:-1]
|
|
|
|
node = tree
|
|
for folder in folders:
|
|
node = node.setdefault(folder, {"__files__": []})
|
|
node.setdefault("__files__", [])
|
|
node["__files__"].append(filename)
|
|
# store the full relative path alongside the display name so the
|
|
# frontend can send back the exact string get_filename_list expects
|
|
node.setdefault("__full_paths__", {})
|
|
node["__full_paths__"][filename] = rel_path
|
|
|
|
return tree
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API route: GET /ultimate_lora_loader/tree
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@PromptServer.instance.routes.get("/ultimate_lora_loader/tree")
|
|
async def get_lora_tree(request):
|
|
try:
|
|
tree = _build_lora_tree()
|
|
return web.json_response(tree)
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Node
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class UltimateLoraLoader:
|
|
"""
|
|
Dynamic-stack LoRA loader. The list of loras is stored as a single
|
|
JSON-serialized widget value ("loras_data") that the JS frontend
|
|
manages entirely (add/remove/reorder/toggle/strength). Python just
|
|
reads that JSON and applies each enabled entry in order.
|
|
"""
|
|
|
|
NODE_NAME = "Ultimate Lora Loader"
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
# Hidden-ish widget that actually carries the data; the JS
|
|
# widget draws the real UI (rows, add-lora button, popup)
|
|
# and keeps this string in sync.
|
|
"loras_data": ("STRING", {"default": "[]", "multiline": False}),
|
|
},
|
|
"optional": {
|
|
# CLIP is optional: some pipelines (video/upscale/ControlNet-only
|
|
# workflows) never touch CLIP, and some users want to apply
|
|
# LoRAs to just the UNet even when CLIP exists elsewhere. When
|
|
# not connected, we skip the CLIP side of each LoRA patch
|
|
# entirely (mirroring ComfyUI core's own LoraLoaderModelOnly,
|
|
# which calls load_lora_for_models(model, None, ..., strength_clip=0)
|
|
# - clip=None is an explicitly supported case, not a hack).
|
|
"clip": ("CLIP",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL", "CLIP")
|
|
RETURN_NAMES = ("MODEL", "CLIP")
|
|
FUNCTION = "load_loras"
|
|
CATEGORY = "loaders"
|
|
|
|
def load_loras(self, model, loras_data, clip=None):
|
|
import json
|
|
|
|
try:
|
|
entries = json.loads(loras_data) if loras_data else []
|
|
except Exception:
|
|
entries = []
|
|
|
|
out_model = model
|
|
out_clip = clip
|
|
|
|
for entry in entries:
|
|
if not entry.get("enabled", True):
|
|
continue
|
|
|
|
lora_name = entry.get("lora")
|
|
if not lora_name:
|
|
continue
|
|
|
|
strength_model = float(entry.get("strengthModel", entry.get("strength", 1.0)))
|
|
# If clip isn't connected, force strength_clip to 0 regardless of
|
|
# what's stored in the entry - there's no CLIP object to patch,
|
|
# and comfy.sd.load_lora_for_models expects strength_clip=0 when
|
|
# clip is None (this is the exact pattern ComfyUI core's
|
|
# LoraLoaderModelOnly uses internally).
|
|
strength_clip = float(entry.get("strengthClip", entry.get("strength", 1.0))) if out_clip is not None else 0.0
|
|
|
|
if strength_model == 0 and strength_clip == 0:
|
|
continue
|
|
|
|
lora_path = folder_paths.get_full_path("loras", lora_name)
|
|
if lora_path is None:
|
|
print(f"[UltimateLoraLoader] Could not find lora: {lora_name}, skipping.")
|
|
continue
|
|
|
|
lora_sd = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
|
|
|
out_model, out_clip = comfy.sd.load_lora_for_models(
|
|
out_model, out_clip, lora_sd, strength_model, strength_clip
|
|
)
|
|
|
|
return (out_model, out_clip)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"UltimateLoraLoader": UltimateLoraLoader,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"UltimateLoraLoader": "Ultimate Lora Loader",
|
|
}
|