Files
2026-03-22 23:39:02 -06:00

180 lines
6.7 KiB
Python

"""
LoRA Iterator Node for ComfyUI
-----------------------------------------
Takes a model + clip, applies one LoRA, outputs the patched pair.
"control_after_generate" controls which LoRA is picked on the NEXT run:
fixed — always use the same LoRA
increment — step forward one LoRA each run
decrement — step backward one LoRA each run
randomize — pick a random LoRA each run
Queue 10 runs with increment and it walks through 10 LoRAs automatically.
Install:
Drop this folder into ComfyUI/custom_nodes/ and restart.
Node appears under "loaders/lora" as "LoRA Iterator"
"""
import os
import random as _random
import folder_paths
import comfy.sd
import comfy.utils
CONTROL_MODES = ["fixed", "increment", "decrement", "randomize"]
_state: dict[str, int] = {}
# ── helpers ───────────────────────────────────────────────────────────────────
def _scan_directories() -> list[str]:
root_dirs = folder_paths.get_folder_paths("loras")
root = root_dirs[0] if root_dirs else ""
dirs = ["[All]"]
if root and os.path.isdir(root):
for entry in sorted(os.scandir(root), key=lambda e: e.name.lower()):
if entry.is_dir():
dirs.append(entry.name)
return dirs
def _scan_loras(directory_filter: str) -> list[str]:
all_loras = folder_paths.get_filename_list("loras")
if directory_filter == "[All]":
return sorted(all_loras, key=lambda p: p.lower())
prefix = directory_filter.lower() + os.sep
return sorted(
[p for p in all_loras
if p.lower().startswith(prefix) or
os.path.dirname(p).lower() == directory_filter.lower()],
key=lambda p: p.lower()
)
def _resolve_index(node_id: str, lora_list: list[str], current_lora: str, mode: str) -> int:
total = len(lora_list)
if node_id not in _state:
_state[node_id] = lora_list.index(current_lora) if current_lora in lora_list else 0
idx = _state[node_id]
if mode == "fixed":
return idx
if mode == "increment":
_state[node_id] = (idx + 1) % total
return idx
if mode == "decrement":
_state[node_id] = (idx - 1) % total
return idx
if mode == "randomize":
chosen = _random.randint(0, total - 1)
_state[node_id] = _random.randint(0, total - 1)
return chosen
return idx
# ── node ──────────────────────────────────────────────────────────────────────
class LoRADirectoryIterator:
@classmethod
def INPUT_TYPES(cls):
directories = _scan_directories()
default_dir = directories[1] if len(directories) > 1 else "[All]"
all_loras = _scan_loras("[All]")
default_lora = all_loras[0] if all_loras else ""
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"directory": (directories, {"default": default_dir}),
"lora_name": (all_loras if all_loras else ["(no loras found)"], {"default": default_lora}),
"strength_model": ("FLOAT", {
"default": 1.0, "min": -10.0, "max": 10.0,
"step": 0.01, "display": "slider",
}),
"strength_clip": ("FLOAT", {
"default": 1.0, "min": -10.0, "max": 10.0,
"step": 0.01, "display": "slider",
}),
"control_after_generate": (CONTROL_MODES, {"default": "increment"}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "INT", "INT")
RETURN_NAMES = ("model", "clip", "lora_name", "current_index", "total_loras")
FUNCTION = "load_lora"
CATEGORY = "loaders/lora"
DESCRIPTION = (
"Loads one LoRA and patches model + CLIP. "
"control_after_generate advances the LoRA selection after each run: "
"fixed / increment / decrement / randomize."
)
@classmethod
def IS_CHANGED(cls, directory, lora_name, strength_model, strength_clip,
control_after_generate, unique_id="default"):
if control_after_generate == "fixed":
return f"{directory}:{lora_name}:{strength_model}:{strength_clip}"
import random
return random.random()
def load_lora(self, model, clip, directory, lora_name, strength_model,
strength_clip, control_after_generate, unique_id="default"):
lora_list = _scan_loras(directory)
if not lora_list:
print(f"[LoRADirectoryIterator] No LoRAs found in: {directory!r}")
return (model, clip, "", 0, 0)
total = len(lora_list)
idx = _resolve_index(unique_id, lora_list, lora_name, control_after_generate)
name = lora_list[idx]
print(
f"[LoRADirectoryIterator] [{idx + 1}/{total}] {name} | "
f"strength_model={strength_model} strength_clip={strength_clip} | "
f"mode={control_after_generate}"
)
lora_path = folder_paths.get_full_path("loras", name)
if lora_path is None or not os.path.isfile(lora_path):
print(f"[LoRADirectoryIterator] ERROR: file not found: {name!r}")
return (model, clip, name, idx, total)
lora_weights = comfy.utils.load_torch_file(lora_path, safe_load=True)
model_patched, clip_patched = comfy.sd.load_lora_for_models(
model, clip, lora_weights, strength_model, strength_clip,
)
return (model_patched, clip_patched, name, idx, total)
# ── REST endpoint ─────────────────────────────────────────────────────────────
try:
from aiohttp import web
from server import PromptServer
@PromptServer.instance.routes.get("/lora_iterator/loras")
async def lora_iterator_get_loras(request):
directory = request.rel_url.query.get("directory", "[All]")
return web.json_response({"loras": _scan_loras(directory)})
except Exception as _e:
print(f"[LoRADirectoryIterator] Could not register REST endpoint: {_e}")
# ── registration ──────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {"LoRADirectoryIterator": LoRADirectoryIterator}
NODE_DISPLAY_NAME_MAPPINGS = {"LoRADirectoryIterator": "LoRA Iterator"}