Files

525 lines
16 KiB
Python

ALL_LORA_FOLDERS = "All LoRAs"
MAX_LORA_BANKS = 5
def _normalize_lora_path(value):
return str(value or "").strip().replace("\\", "/").strip("/")
def _lora_folder_prefixes(lora_names):
prefixes = set()
for lora_name in lora_names:
parts = _normalize_lora_path(lora_name).split("/")[:-1]
prefixes.update("/".join(parts[:index]) for index in range(1, len(parts) + 1))
return [ALL_LORA_FOLDERS, *sorted(prefixes, key=str.casefold)]
def _lora_is_in_folder(lora_name, folder):
if folder == ALL_LORA_FOLDERS:
return True
normalized_name = _normalize_lora_path(lora_name).casefold()
normalized_folder = _normalize_lora_path(folder).casefold()
return bool(normalized_folder) and normalized_name.startswith(normalized_folder + "/")
def _lora_display_name(lora_name, folder):
normalized_name = _normalize_lora_path(lora_name)
if folder == ALL_LORA_FOLDERS:
return normalized_name
normalized_folder = _normalize_lora_path(folder)
return normalized_name[len(normalized_folder) + 1 :]
def _parse_lora_allow_list(allow_list):
return {
_normalize_lora_path(line).casefold()
for line in str(allow_list or "").splitlines()
if _normalize_lora_path(line)
}
def _filtered_lora_names(lora_names, folder, allow_list=""):
allowed = _parse_lora_allow_list(allow_list)
filtered = []
for lora_name in lora_names:
if not _lora_is_in_folder(lora_name, folder):
continue
display_name = _lora_display_name(lora_name, folder)
if allowed and not {
_normalize_lora_path(lora_name).casefold(),
_normalize_lora_path(display_name).casefold(),
} & allowed:
continue
filtered.append((lora_name, display_name))
return filtered
class _EnviralLoraLoaderBase:
def __init__(self):
self.loaded_lora = None
@staticmethod
def _lora_names():
import folder_paths
return folder_paths.get_filename_list("loras")
@classmethod
def _lora_name_input(cls):
lora_names = cls._lora_names()
default = lora_names[0] if lora_names else ""
return (
"STRING,COMBO",
{
"default": default,
"widgetType": "COMBO",
"options": lora_names,
"tooltip": (
"LoRA name. Also accepts a linked STRING, as long as it "
"matches a LoRA path known to ComfyUI."
),
},
)
@classmethod
def _folder_input(cls):
return (
"STRING,COMBO",
{
"default": ALL_LORA_FOLDERS,
"widgetType": "COMBO",
"options": _lora_folder_prefixes(cls._lora_names()),
"tooltip": "Limits the LoRA dropdown to this folder and its subfolders.",
},
)
@classmethod
def _resolve_folder(cls, folder):
folder = str(folder or "").strip()
folders = _lora_folder_prefixes(cls._lora_names())
if folder in folders:
return folder
normalized_folder = _normalize_lora_path(folder).casefold()
matches = [
candidate
for candidate in folders
if _normalize_lora_path(candidate).casefold() == normalized_folder
]
if len(matches) == 1:
return matches[0]
raise ValueError(f"folder value {folder!r} must be one of the current LoRA folders")
@staticmethod
def _resolve_lora_path(lora_name):
import folder_paths
lora_name = str(lora_name or "").strip()
if not lora_name:
raise ValueError("lora_name must not be empty")
return folder_paths.get_full_path_or_raise("loras", lora_name)
@classmethod
def validate_inputs(cls, lora_name, **kwargs):
if lora_name is None:
return True
try:
cls._resolve_lora_path(lora_name)
except Exception as err:
return str(err)
return True
def _load_lora_file(self, lora_name):
from comfy import utils
lora_path = self._resolve_lora_path(lora_name)
if self.loaded_lora is not None and self.loaded_lora[0] == lora_path:
return self.loaded_lora[1], self.loaded_lora[2]
lora, lora_metadata = utils.load_torch_file(
lora_path,
safe_load=True,
return_metadata=True,
)
self.loaded_lora = (lora_path, lora, lora_metadata)
return lora, lora_metadata
class EnviralLoadLora(_EnviralLoraLoaderBase):
DEPRECATED = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": (
"MODEL",
{"tooltip": "The diffusion model the LoRA will be applied to."},
),
"clip": (
"CLIP",
{"tooltip": "The CLIP model the LoRA will be applied to."},
),
"lora_name": cls._lora_name_input(),
"strength_model": (
"FLOAT",
{
"default": 1.0,
"min": -100.0,
"max": 100.0,
"step": 0.01,
"tooltip": "How strongly to modify the diffusion model.",
},
),
"strength_clip": (
"FLOAT",
{
"default": 1.0,
"min": -100.0,
"max": 100.0,
"step": 0.01,
"tooltip": "How strongly to modify the CLIP model.",
},
),
},
}
RETURN_TYPES = ("MODEL", "CLIP")
RETURN_NAMES = ("model", "clip")
OUTPUT_TOOLTIPS = ("The modified diffusion model.", "The modified CLIP model.")
FUNCTION = "load_lora"
CATEGORY = "EnviralDesign/model"
DESCRIPTION = """
Native-style LoRA loader with a string-linkable LoRA dropdown.
"""
SEARCH_ALIASES = [
"lora",
"load lora",
"apply lora",
"lora loader",
"lora model",
"string lora",
]
def load_lora(self, model, clip, lora_name, strength_model, strength_clip):
if strength_model == 0 and strength_clip == 0:
return (model, clip)
from comfy import sd
lora, lora_metadata = self._load_lora_file(lora_name)
return sd.load_lora_for_models(
model,
clip,
lora,
strength_model,
strength_clip,
lora_metadata=lora_metadata,
)
class EnviralLoadLoraFiltered(EnviralLoadLora):
DEPRECATED = False
@staticmethod
def _bank_input_name(input_name, bank_index):
if bank_index == 1:
return input_name
return f"{input_name}_{bank_index}"
@classmethod
def _bank_values(
cls,
lora_name,
strength_model,
strength_clip,
bank_count,
kwargs,
):
try:
bank_count = int(bank_count)
except (TypeError, ValueError) as err:
raise ValueError(
f"bank_count must be between 1 and {MAX_LORA_BANKS}"
) from err
if not 1 <= bank_count <= MAX_LORA_BANKS:
raise ValueError(f"bank_count must be between 1 and {MAX_LORA_BANKS}")
banks = [(1, lora_name, strength_model, strength_clip)]
for bank_index in range(2, bank_count + 1):
banks.append(
(
bank_index,
kwargs.get(cls._bank_input_name("lora_name", bank_index)),
kwargs.get(cls._bank_input_name("strength_model", bank_index), 0.0)
or 0.0,
kwargs.get(cls._bank_input_name("strength_clip", bank_index), 0.0)
or 0.0,
)
)
return banks
@staticmethod
def _is_bypassed(strength_model, strength_clip):
return strength_model == 0 and strength_clip == 0
@classmethod
def _validate_lora_bank(cls, folder, lora_name, bank_index):
if not str(lora_name or "").strip():
raise ValueError(
f"LoRA bank {bank_index} must select a LoRA when either strength is non-zero"
)
cls._resolve_lora_path(lora_name)
if not _lora_is_in_folder(lora_name, folder):
raise ValueError(f"LoRA {lora_name!r} is not inside folder {folder!r}")
@classmethod
def _available_loras(cls, folder, allow_list=""):
return _filtered_lora_names(cls._lora_names(), folder, allow_list)
@classmethod
def INPUT_TYPES(cls):
required = super().INPUT_TYPES()["required"]
optional = {
"clip": required["clip"],
"allow_list": (
"STRING",
{
"forceInput": True,
"multiline": True,
"tooltip": (
"Optional newline-separated LoRA allow-list. Connect ComfyUI's "
"Text (Multiline) node; entries may use displayed names or full paths."
),
},
),
}
for bank_index in range(2, MAX_LORA_BANKS + 1):
for input_name in ("lora_name", "strength_model", "strength_clip"):
input_type, options = required[input_name]
optional[cls._bank_input_name(input_name, bank_index)] = (
input_type,
{
**options,
"default": 0.0 if input_name.startswith("strength_") else options["default"],
"display_name": f"{input_name} {bank_index}",
},
)
return {
"required": {
"model": required["model"],
"folder": cls._folder_input(),
"bank_count": (
"INT",
{
"default": 1,
"min": 1,
"max": MAX_LORA_BANKS,
"display_name": "banks",
"tooltip": "Number of LoRA banks shown and applied.",
},
),
"lora_name": (
required["lora_name"][0],
{**required["lora_name"][1], "display_name": "lora_name 1"},
),
"strength_model": (
required["strength_model"][0],
{
**required["strength_model"][1],
"display_name": "strength_model 1",
},
),
"strength_clip": (
required["strength_clip"][0],
{
**required["strength_clip"][1],
"display_name": "strength_clip 1",
},
),
},
"optional": optional,
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
RETURN_NAMES = ("model", "clip", "lora_list")
OUTPUT_TOOLTIPS = (
"The modified diffusion model.",
"The modified CLIP model.",
"The visible LoRA options, one display name per line.",
)
FUNCTION = "load_lora_filtered"
DESCRIPTION = """
LoRA loader with folder and text allow-list filters plus independently bypassable banks.
"""
SEARCH_ALIASES = [
"lora",
"load lora",
"filtered lora",
"folder lora",
"curated lora",
]
@classmethod
def VALIDATE_INPUTS(
cls,
folder,
lora_name,
strength_model=1.0,
strength_clip=1.0,
bank_count=1,
allow_list="",
**kwargs,
):
try:
folder = cls._resolve_folder(folder)
available_loras = {
lora_name for lora_name, _ in cls._available_loras(folder, allow_list)
}
banks = cls._bank_values(
lora_name,
strength_model,
strength_clip,
bank_count,
kwargs,
)
active_banks = [
bank
for bank in banks
if not cls._is_bypassed(bank[2], bank[3])
]
if not active_banks:
return True
for bank_index, lora_name, _, _ in active_banks:
cls._validate_lora_bank(folder, lora_name, bank_index)
if lora_name not in available_loras:
raise ValueError(
f"LoRA {lora_name!r} is not included by the current allow-list"
)
except Exception as err:
return str(err)
return True
@classmethod
def validate_inputs(cls, folder, lora_name, **kwargs):
return cls.VALIDATE_INPUTS(folder, lora_name, **kwargs)
def load_lora_filtered(
self,
model,
folder,
lora_name,
strength_model,
strength_clip,
bank_count=1,
clip=None,
allow_list="",
**kwargs,
):
folder = self._resolve_folder(folder)
available_loras = self._available_loras(folder, allow_list)
available_paths = {lora_name for lora_name, _ in available_loras}
lora_list = "\n".join(display_name for _, display_name in available_loras)
banks = self._bank_values(
lora_name,
strength_model,
strength_clip,
bank_count,
kwargs,
)
if clip is None:
banks = [
(bank_index, lora_name, strength_model, 0.0)
for bank_index, lora_name, strength_model, _ in banks
]
active_banks = [
bank for bank in banks if not self._is_bypassed(bank[2], bank[3])
]
if not active_banks:
return (model, clip, lora_list)
for bank_index, lora_name, strength_model, strength_clip in active_banks:
self._validate_lora_bank(folder, lora_name, bank_index)
if lora_name not in available_paths:
raise ValueError(
f"LoRA {lora_name!r} is not included by the current allow-list"
)
model, clip = self.load_lora(
model, clip, lora_name, strength_model, strength_clip
)
return (model, clip, lora_list)
class EnviralLoadLoraModelOnly(_EnviralLoraLoaderBase):
DEPRECATED = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": (
"MODEL",
{"tooltip": "The diffusion model the LoRA will be applied to."},
),
"lora_name": cls._lora_name_input(),
"strength_model": (
"FLOAT",
{
"default": 1.0,
"min": -100.0,
"max": 100.0,
"step": 0.01,
"tooltip": "How strongly to modify the diffusion model.",
},
),
},
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
OUTPUT_TOOLTIPS = ("The modified diffusion model.",)
FUNCTION = "load_lora_model_only"
CATEGORY = "EnviralDesign/model"
DESCRIPTION = """
Native-style model-only LoRA loader with a string-linkable LoRA dropdown.
"""
SEARCH_ALIASES = [
"lora",
"load lora",
"apply lora",
"lora loader",
"lora model only",
"string lora",
]
def load_lora_model_only(self, model, lora_name, strength_model):
if strength_model == 0:
return (model,)
from comfy import sd
lora, lora_metadata = self._load_lora_file(lora_name)
model_lora, _ = sd.load_lora_for_models(
model,
None,
lora,
strength_model,
0,
lora_metadata=lora_metadata,
)
return (model_lora,)
NODE_CLASS_MAPPINGS = {
"EnviralLoadLora": EnviralLoadLora,
"EnviralLoadLoraFiltered": EnviralLoadLoraFiltered,
"EnviralLoadLoraModelOnly": EnviralLoadLoraModelOnly,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"EnviralLoadLora": "Enviral Load LoRA (Deprecated)",
"EnviralLoadLoraFiltered": "Enviral Load LoRA",
"EnviralLoadLoraModelOnly": "Enviral Load LoRA (Model Only, Deprecated)",
}