186 lines
6.2 KiB
Python
186 lines
6.2 KiB
Python
import logging
|
|
|
|
try:
|
|
from .utils import FlexibleOptionalInputType, any_type
|
|
except ImportError: # importable for tests without package context
|
|
from utils import FlexibleOptionalInputType, any_type
|
|
|
|
log = logging.getLogger("MoonPack")
|
|
|
|
LORA_PREFIX = "lora_"
|
|
NONE_SENTINEL = "None"
|
|
|
|
|
|
def _norm(path: str) -> str:
|
|
"""Normalises a lora path for comparison (workflows saved on Windows use '\\')."""
|
|
return path.replace("\\", "/")
|
|
|
|
|
|
def _as_float(value, fallback: float) -> float:
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return fallback
|
|
|
|
|
|
def iter_lora_rows(kwargs: dict) -> list:
|
|
"""Returns [(key, row)] for every lora row widget, in display order.
|
|
|
|
Order is the kwargs insertion order, never the numeric suffix: the frontend
|
|
serialises widgets top to bottom, so a row moved up or down keeps its
|
|
original 'lora_N' name but changes position. Sorting by suffix would
|
|
silently ignore reordering.
|
|
"""
|
|
rows = []
|
|
for key, value in kwargs.items():
|
|
if not key.startswith(LORA_PREFIX) or not isinstance(value, dict):
|
|
continue
|
|
rows.append((key, value))
|
|
return rows
|
|
|
|
|
|
def join_trigger_texts(rows, separator: str) -> str:
|
|
"""Joins the trigger texts of enabled rows with the separator, verbatim.
|
|
|
|
Each text is stripped; empty texts and disabled rows contribute nothing.
|
|
"""
|
|
texts = []
|
|
for row in rows:
|
|
if not row.get("on"):
|
|
continue
|
|
text = row.get("text")
|
|
if not isinstance(text, str):
|
|
continue
|
|
text = text.strip()
|
|
if text:
|
|
texts.append(text)
|
|
return str(separator if separator is not None else "").join(texts)
|
|
|
|
|
|
def resolve_lora_filename(name, available):
|
|
"""Maps a stored lora name onto an actual file, or None if it is gone.
|
|
|
|
Falls back from exact match to case-insensitive, then to a basename match
|
|
so a lora moved between subfolders still resolves.
|
|
"""
|
|
if not isinstance(name, str) or not name or name == NONE_SENTINEL:
|
|
return None
|
|
|
|
wanted = _norm(name)
|
|
for candidate in available:
|
|
if _norm(candidate) == wanted:
|
|
return candidate
|
|
|
|
lowered = wanted.lower()
|
|
for candidate in available:
|
|
if _norm(candidate).lower() == lowered:
|
|
return candidate
|
|
|
|
base = lowered.rsplit("/", 1)[-1]
|
|
for candidate in available:
|
|
if _norm(candidate).lower().rsplit("/", 1)[-1] == base:
|
|
return candidate
|
|
|
|
return None
|
|
|
|
|
|
class MoonLoraLoader:
|
|
"""Applies any number of LoRAs and emits the trigger words of the enabled ones."""
|
|
|
|
DESCRIPTION = (
|
|
"Applies a list of LoRAs to MODEL/CLIP and concatenates the trigger text of "
|
|
"every enabled row into a single string. Rows are added, toggled, reordered "
|
|
"and removed directly on the node. Fully local — no Civitai lookup."
|
|
)
|
|
SEARCH_ALIASES = ["lora", "power", "trigger", "prompt", "stack", "moonlora"]
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"separator": ("STRING", {
|
|
"default": ". ", "multiline": False,
|
|
"tooltip": "Inserted verbatim between the trigger texts of enabled LoRAs.",
|
|
}),
|
|
},
|
|
# Rows arrive as undeclared 'lora_N' inputs from the frontend widgets.
|
|
"optional": FlexibleOptionalInputType(any_type, data={
|
|
"clip": ("CLIP",),
|
|
"trigger_text": ("STRING", {"forceInput": True}),
|
|
}),
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
|
|
RETURN_NAMES = ("MODEL", "CLIP", "trigger_text")
|
|
FUNCTION = "load_loras"
|
|
CATEGORY = "MoonPack/lora"
|
|
|
|
def load_loras(self, model, separator=". ", clip=None, trigger_text=None, **kwargs):
|
|
rows = iter_lora_rows(kwargs)
|
|
applied = []
|
|
if isinstance(trigger_text, str):
|
|
applied.append({"on": True, "text": trigger_text})
|
|
if not rows:
|
|
return (model, clip, join_trigger_texts(applied, separator))
|
|
|
|
# Imported here so the module stays importable outside ComfyUI.
|
|
import folder_paths
|
|
from nodes import LoraLoader
|
|
|
|
available = folder_paths.get_filename_list("loras")
|
|
loader = LoraLoader()
|
|
warned_missing_clip = False
|
|
|
|
for key, row in rows:
|
|
if not row.get("on"):
|
|
continue
|
|
|
|
resolved = resolve_lora_filename(row.get("lora"), available)
|
|
if resolved is None:
|
|
# Its trigger text is dropped too: emitting trigger words for a
|
|
# lora that was never applied would be misleading.
|
|
log.warning(
|
|
"MoonLoRA Loader: %s -> '%s' not found in the loras folder; row skipped.",
|
|
key, row.get("lora"),
|
|
)
|
|
continue
|
|
|
|
strength_model = _as_float(row.get("strength"), 1.0)
|
|
strength_two = row.get("strengthTwo")
|
|
if clip is None:
|
|
if strength_two is not None and _as_float(strength_two, 0.0) != 0.0 \
|
|
and not warned_missing_clip:
|
|
log.warning(
|
|
"MoonLoRA Loader: clip strengths are set but no CLIP is connected; "
|
|
"applying model strengths only."
|
|
)
|
|
warned_missing_clip = True
|
|
strength_clip = 0
|
|
elif strength_two is None:
|
|
strength_clip = strength_model
|
|
else:
|
|
strength_clip = _as_float(strength_two, strength_model)
|
|
|
|
# Collected before the zero-strength check: a zero-strength row is a
|
|
# no-op for the model, but the user still asked for its trigger words.
|
|
applied.append(row)
|
|
|
|
if strength_model == 0 and strength_clip == 0:
|
|
continue
|
|
|
|
model, clip = loader.load_lora(
|
|
model, clip, resolved, strength_model, strength_clip
|
|
)
|
|
|
|
return (model, clip, join_trigger_texts(applied, separator))
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"MoonPack_MoonLoraLoader": MoonLoraLoader,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"MoonPack_MoonLoraLoader": "MoonLoRA Loader",
|
|
}
|