| import os
|
| import random
|
| import hashlib
|
| import re
|
| import sys
|
| import io
|
| import logging
|
| import warnings
|
| from contextlib import contextmanager
|
| import folder_paths
|
| import comfy.utils
|
| import comfy.lora
|
|
|
|
|
| @contextmanager
|
| def suppress_comfy_logs():
|
| """Temporarily suppress stdout, stderr, logging, and warnings to hide verbose ComfyUI LoRA loading messages"""
|
|
|
| old_stdout = sys.stdout
|
| old_stderr = sys.stderr
|
| old_logging_level = logging.root.level
|
|
|
|
|
| old_stdout_fd = None
|
| old_stderr_fd = None
|
| devnull_fd = None
|
|
|
| try:
|
|
|
| logging.root.setLevel(logging.CRITICAL + 1)
|
| warnings.filterwarnings("ignore")
|
|
|
|
|
| devnull = io.StringIO()
|
| sys.stdout = devnull
|
| sys.stderr = devnull
|
|
|
|
|
|
|
| try:
|
| sys.stdout.flush()
|
| sys.stderr.flush()
|
|
|
|
|
| old_stdout_fd = os.dup(1)
|
| old_stderr_fd = os.dup(2)
|
|
|
|
|
| devnull_fd = os.open(os.devnull, os.O_WRONLY)
|
| os.dup2(devnull_fd, 1)
|
| os.dup2(devnull_fd, 2)
|
| except (OSError, AttributeError):
|
|
|
| pass
|
|
|
| yield
|
|
|
| finally:
|
|
|
| sys.stdout = old_stdout
|
| sys.stderr = old_stderr
|
| logging.root.setLevel(old_logging_level)
|
| warnings.filterwarnings("default")
|
|
|
|
|
| if old_stdout_fd is not None:
|
| try:
|
| sys.stdout.flush()
|
| sys.stderr.flush()
|
| os.dup2(old_stdout_fd, 1)
|
| os.dup2(old_stderr_fd, 2)
|
| os.close(old_stdout_fd)
|
| os.close(old_stderr_fd)
|
| if devnull_fd is not None:
|
| os.close(devnull_fd)
|
| except (OSError, AttributeError):
|
| pass
|
|
|
|
|
| class LoraBatchLoader:
|
| RETURN_TYPES = ("MODEL", "CLIP", "STRING")
|
| RETURN_NAMES = ("model", "clip", "filename")
|
| FUNCTION = "load_batch_loras"
|
| CATEGORY = "Batch Process"
|
|
|
| SUPPORTED_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".bin"}
|
|
|
| def __init__(self):
|
| self.lora_states = {}
|
| self.current_directory = ""
|
| self.loras = []
|
| self.search_states = {}
|
|
|
| @classmethod
|
| def INPUT_TYPES(cls):
|
| return {
|
| "required": {
|
| "model": ("MODEL",),
|
| "clip": ("CLIP",),
|
| "directory": ("STRING",),
|
| "search_title": ("STRING", {"default": ""}),
|
| "delimiter": ("STRING", {"default": ""}),
|
| "mode": (
|
| ["incremental", "random"],
|
| {"default": "incremental"},
|
| ),
|
| "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}),
|
| "filename_option": (
|
| [
|
| "filename",
|
| "prefix",
|
| "suffix",
|
| "prefix & suffix",
|
| "prefix nor suffix",
|
| ],
|
| ),
|
| "strength_model": (
|
| "FLOAT",
|
| {"default": 1.0, "min": -20.0, "max": 20.0, "step": 0.01},
|
| ),
|
| "strength_clip": (
|
| "FLOAT",
|
| {"default": 1.0, "min": -20.0, "max": 20.0, "step": 0.01},
|
| ),
|
| },
|
| }
|
|
|
| def set_directory(
|
| self, directory, filename_option="filename", search_title="", delimiter=""
|
| ):
|
| if (
|
| directory != self.current_directory
|
| or filename_option
|
| or search_title
|
| or delimiter
|
| ):
|
| if not os.path.isdir(directory):
|
| raise ValueError(
|
| f"The provided path '{directory}' is not a valid directory."
|
| )
|
|
|
| all_loras = [
|
| f
|
| for f in os.listdir(directory)
|
| if any(f.lower().endswith(ext) for ext in self.SUPPORTED_EXTENSIONS)
|
| ]
|
| filtered_loras = self.filter_loras(
|
| directory, all_loras, filename_option, search_title, delimiter
|
| )
|
|
|
| self.loras = sorted([os.path.join(directory, f) for f in filtered_loras])
|
| self.current_directory = directory
|
|
|
| search_key = (directory, filename_option, search_title, delimiter)
|
| if search_key not in self.search_states:
|
| self.search_states[search_key] = 0
|
|
|
| if not self.loras:
|
| print("No matching LoRA files found in the provided directory.")
|
| else:
|
| print(f"Found {len(self.loras)} LoRA files in directory.")
|
|
|
| def load_loras(self, directory):
|
| if not os.path.isdir(directory):
|
| raise ValueError(f"Invalid directory: {directory}")
|
|
|
| all_loras = [
|
| f
|
| for f in os.listdir(directory)
|
| if any(f.lower().endswith(ext) for ext in self.SUPPORTED_EXTENSIONS)
|
| ]
|
| return sorted([os.path.join(directory, f) for f in all_loras])
|
|
|
| def filter_loras(self, directory, files, filename_option, search_title, delimiter):
|
| def get_prefix(filename):
|
| if delimiter:
|
| return filename.split(delimiter)[0]
|
| else:
|
| return re.split(r"[^a-zA-Z0-9]", filename)[0]
|
|
|
| def get_suffix(filename):
|
| name_without_ext = os.path.splitext(filename)[0]
|
| if delimiter:
|
| return name_without_ext.split(delimiter)[-1]
|
| else:
|
| return re.split(r"[^a-zA-Z0-9]", name_without_ext)[-1]
|
|
|
| filtered_files = files
|
|
|
| if search_title:
|
| if filename_option == "filename":
|
| filtered_files = [f for f in filtered_files if search_title in f]
|
| elif filename_option == "prefix":
|
| search_prefix = get_prefix(search_title)
|
| filtered_files = [
|
| f for f in filtered_files if get_prefix(f) == search_prefix
|
| ]
|
| elif filename_option == "suffix":
|
| search_suffix = get_suffix(search_title)
|
| filtered_files = [
|
| f for f in filtered_files if get_suffix(f) == search_suffix
|
| ]
|
| elif filename_option == "prefix & suffix":
|
| search_prefix = get_prefix(search_title)
|
| search_suffix = get_suffix(search_title)
|
| filtered_files = [
|
| f
|
| for f in filtered_files
|
| if get_prefix(f) == search_prefix or get_suffix(f) == search_suffix
|
| ]
|
| elif filename_option == "prefix nor suffix":
|
| search_prefix = get_prefix(search_title)
|
| search_suffix = get_suffix(search_title)
|
| filtered_files = [
|
| f
|
| for f in filtered_files
|
| if get_prefix(f) != search_prefix and get_suffix(f) != search_suffix
|
| ]
|
|
|
| return filtered_files
|
|
|
| def load_batch_loras(
|
| self,
|
| model,
|
| clip,
|
| directory,
|
| search_title="",
|
| delimiter="",
|
| mode="incremental",
|
| seed=0,
|
| filename_option="filename",
|
| strength_model=1.0,
|
| strength_clip=1.0,
|
| ):
|
| self.set_directory(directory, filename_option, search_title, delimiter)
|
|
|
| if not self.loras:
|
| print("No LoRA files found, returning original model and clip.")
|
| return (model, clip, "no_loras_found")
|
|
|
| search_key = (directory, filename_option, search_title, delimiter)
|
|
|
| if mode == "incremental":
|
| return self.load_lora_by_index(
|
| model, clip, search_key, strength_model, strength_clip
|
| )
|
| elif mode == "random":
|
| random.seed(seed)
|
| rnd_index = random.randint(0, len(self.loras) - 1)
|
| print(
|
| f"[LoRA Batch Loader] Random mode - Index: {rnd_index + 1}/{len(self.loras)}"
|
| )
|
| return self.load_lora_by_path(
|
| model, clip, self.loras[rnd_index], strength_model, strength_clip
|
| )
|
| else:
|
| raise ValueError(f"Unknown mode: {mode}")
|
|
|
| def load_lora_by_index(
|
| self, model, clip, search_key, strength_model, strength_clip
|
| ):
|
| if not self.loras:
|
| print("No LoRAs loaded.")
|
| return model, clip, "no_loras"
|
|
|
| current_index = self.search_states[search_key]
|
| if current_index >= len(self.loras):
|
| current_index = 0
|
|
|
| file_path = self.loras[current_index]
|
| self.search_states[search_key] = (current_index + 1) % len(self.loras)
|
|
|
|
|
| print(f"[LoRA Batch Loader] Index: {current_index + 1}/{len(self.loras)}")
|
| return self.load_lora_by_path(
|
| model, clip, file_path, strength_model, strength_clip
|
| )
|
|
|
| def load_lora_by_path(self, model, clip, path, strength_model, strength_clip):
|
| try:
|
| filename = os.path.basename(path)
|
|
|
| filename_clean = os.path.splitext(filename)[0]
|
|
|
|
|
| print(f"[LoRA Batch Loader] Loading LoRA: {filename_clean}")
|
|
|
|
|
| with suppress_comfy_logs():
|
|
|
| lora = comfy.utils.load_torch_file(path, safe_load=True)
|
|
|
|
|
| model_lora_keys = comfy.lora.model_lora_keys_unet(model.model)
|
| clip_lora_keys = comfy.lora.model_lora_keys_clip(clip.cond_stage_model)
|
|
|
|
|
| key_map = {}
|
| key_map.update(model_lora_keys)
|
| key_map.update(clip_lora_keys)
|
|
|
|
|
| loaded = comfy.lora.load_lora(lora, key_map)
|
|
|
|
|
| new_modelpatcher = model.clone()
|
| k = {}
|
| for x in loaded:
|
| k[x] = loaded[x]
|
| new_modelpatcher.add_patches(k, strength_model)
|
|
|
|
|
| new_clip = clip.clone()
|
| k = {}
|
| for x in loaded:
|
| k[x] = loaded[x]
|
| new_clip.add_patches(k, strength_clip)
|
|
|
| return (new_modelpatcher, new_clip, filename_clean)
|
|
|
| except Exception as e:
|
| print(f"Error loading LoRA {path}: {str(e)}")
|
| return (model, clip, "error_loading_lora")
|
|
|
| @classmethod
|
| def IS_CHANGED(cls, directory, **kwargs):
|
| if not os.path.exists(directory):
|
| return ""
|
| try:
|
| loader = cls()
|
| paths = loader.load_loras(directory)
|
| return hashlib.sha256(",".join(paths).encode()).hexdigest()
|
| except Exception as e:
|
| print(f"Error checking for changes: {str(e)}")
|
| return ""
|
|
|