Three ComfyUI nodes: Multi LoRA Stack (MODEL+CLIP), Model Only, and an Ortho variant that orthogonalizes LoRA down-projection row spaces at load time to reduce interference between stacked LoRAs. Includes strength_1-10 override inputs and dynamic JS UI for all nodes.
570 lines
21 KiB
Python
570 lines
21 KiB
Python
import torch
|
|
from nodes import LoraLoader, LoraLoaderModelOnly
|
|
|
|
class MultiLoRAStack:
|
|
"""A powerful node to load multiple LoRAs with individual controls."""
|
|
|
|
def __init__(self):
|
|
# Use a single instance for caching benefits
|
|
self.lora_loader = LoraLoader()
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
_strength_inputs = {
|
|
f"strength_{i}": ("FLOAT", {
|
|
"default": 1.0, "min": -2.0, "max": 2.0, "step": 0.01, "forceInput": True,
|
|
}) for i in range(1, 11)
|
|
}
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"clip": ("CLIP",),
|
|
"lora_stack": ("STRING", {
|
|
"multiline": False,
|
|
"default": "[]",
|
|
"forceInput": False
|
|
}),
|
|
},
|
|
"optional": _strength_inputs,
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL", "CLIP")
|
|
RETURN_NAMES = ("MODEL", "CLIP")
|
|
FUNCTION = "load_loras"
|
|
CATEGORY = "loaders"
|
|
|
|
def load_loras(self, model, clip, lora_stack="[]", **kwargs):
|
|
"""Loads multiple LoRAs based on the configuration."""
|
|
import json
|
|
|
|
# Debug output
|
|
print(f"\n=== MultiLoRAStack Debug ===")
|
|
print(f"Received lora_stack: {lora_stack}")
|
|
|
|
# Parse lora_stack if it's a string
|
|
lora_configs = []
|
|
if isinstance(lora_stack, str):
|
|
try:
|
|
lora_configs = json.loads(lora_stack)
|
|
print(f"✅ Parsed {len(lora_configs)} LoRA configs from JSON")
|
|
for i, config in enumerate(lora_configs):
|
|
print(f" Config {i}: {config}")
|
|
except Exception as e:
|
|
print(f"❌ Failed to parse JSON: {e}")
|
|
return (model, clip)
|
|
else:
|
|
lora_configs = lora_stack if isinstance(lora_stack, list) else []
|
|
|
|
applied_count = 0
|
|
current_model = model
|
|
current_clip = clip
|
|
|
|
# Process each LoRA configuration
|
|
for i, config in enumerate(lora_configs):
|
|
print(f"\n--- Processing LoRA {i+1} ---")
|
|
|
|
if not isinstance(config, dict):
|
|
print(f"❌ Skipping invalid config at index {i}: {config}")
|
|
continue
|
|
|
|
# Check if this LoRA should be applied
|
|
if not config.get('on', False):
|
|
print(f"⏸️ Skipping disabled LoRA: {config.get('lora', 'Unknown')}")
|
|
continue
|
|
|
|
lora_name = config.get('lora')
|
|
if not lora_name or lora_name == "None":
|
|
print(f"❌ Skipping empty/None LoRA")
|
|
continue
|
|
|
|
strength_model = float(config.get('strength', 1.0))
|
|
# strength_N input overrides the widget value (1-indexed slot position)
|
|
override = kwargs.get(f"strength_{i + 1}")
|
|
if override is not None:
|
|
strength_model = float(override)
|
|
strength_clip = float(config.get('strengthTwo', strength_model))
|
|
|
|
# Skip if both strengths are 0
|
|
if strength_model == 0 and strength_clip == 0:
|
|
print(f"⏸️ Skipping zero-strength LoRA: {lora_name}")
|
|
continue
|
|
|
|
print(f"🔄 Attempting to load LoRA:")
|
|
print(f" Name: {lora_name}")
|
|
print(f" Model Strength: {strength_model}")
|
|
print(f" Clip Strength: {strength_clip}")
|
|
|
|
# Apply the LoRA using ComfyUI's standard loader
|
|
try:
|
|
# Apply the LoRA to current model and clip
|
|
current_model, current_clip = self.lora_loader.load_lora(
|
|
current_model, current_clip, lora_name, strength_model, strength_clip
|
|
)
|
|
applied_count += 1
|
|
print(f"✅ Successfully applied LoRA: {lora_name}")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Failed to load LoRA '{lora_name}': {str(e)}")
|
|
print(f" Error type: {type(e).__name__}")
|
|
import traceback
|
|
print(f" Full traceback: {traceback.format_exc()}")
|
|
|
|
print(f"\n=== Summary ===")
|
|
print(f"Applied {applied_count} out of {len(lora_configs)} LoRAs")
|
|
print(f"===================\n")
|
|
|
|
return (current_model, current_clip)
|
|
|
|
|
|
class MultiLoRAStackModelOnly:
|
|
"""A powerful node to load multiple LoRAs with individual controls (Model Only)."""
|
|
|
|
def __init__(self):
|
|
# Use a single instance for caching benefits
|
|
self.lora_loader = LoraLoaderModelOnly()
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
_strength_inputs = {
|
|
f"strength_{i}": ("FLOAT", {
|
|
"default": 1.0, "min": -2.0, "max": 2.0, "step": 0.01, "forceInput": True,
|
|
}) for i in range(1, 11)
|
|
}
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"lora_stack": ("STRING", {
|
|
"multiline": False,
|
|
"default": "[]",
|
|
"forceInput": False
|
|
}),
|
|
},
|
|
"optional": _strength_inputs,
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL",)
|
|
RETURN_NAMES = ("MODEL",)
|
|
FUNCTION = "load_loras"
|
|
CATEGORY = "loaders"
|
|
|
|
def load_loras(self, model, lora_stack="[]", **kwargs):
|
|
"""Loads multiple LoRAs based on the configuration (Model Only)."""
|
|
import json
|
|
|
|
# Debug output
|
|
print(f"\n=== MultiLoRAStackModelOnly Debug ===")
|
|
print(f"Received lora_stack: {lora_stack}")
|
|
|
|
# Parse lora_stack if it's a string
|
|
lora_configs = []
|
|
if isinstance(lora_stack, str):
|
|
try:
|
|
lora_configs = json.loads(lora_stack)
|
|
print(f"✅ Parsed {len(lora_configs)} LoRA configs from JSON")
|
|
for i, config in enumerate(lora_configs):
|
|
print(f" Config {i}: {config}")
|
|
except Exception as e:
|
|
print(f"❌ Failed to parse JSON: {e}")
|
|
return (model,)
|
|
else:
|
|
lora_configs = lora_stack if isinstance(lora_stack, list) else []
|
|
|
|
applied_count = 0
|
|
current_model = model
|
|
|
|
# Process each LoRA configuration
|
|
for i, config in enumerate(lora_configs):
|
|
print(f"\n--- Processing LoRA {i+1} ---")
|
|
|
|
if not isinstance(config, dict):
|
|
print(f"❌ Skipping invalid config at index {i}: {config}")
|
|
continue
|
|
|
|
# Check if this LoRA should be applied
|
|
if not config.get('on', False):
|
|
print(f"⏸️ Skipping disabled LoRA: {config.get('lora', 'Unknown')}")
|
|
continue
|
|
|
|
lora_name = config.get('lora')
|
|
if not lora_name or lora_name == "None":
|
|
print(f"❌ Skipping empty/None LoRA")
|
|
continue
|
|
|
|
strength_model = float(config.get('strength', 1.0))
|
|
# strength_N input overrides the widget value (1-indexed slot position)
|
|
override = kwargs.get(f"strength_{i + 1}")
|
|
if override is not None:
|
|
strength_model = float(override)
|
|
|
|
# Skip if strength is 0
|
|
if strength_model == 0:
|
|
print(f"⏸️ Skipping zero-strength LoRA: {lora_name}")
|
|
continue
|
|
|
|
print(f"🔄 Attempting to load LoRA (Model Only):")
|
|
print(f" Name: {lora_name}")
|
|
print(f" Model Strength: {strength_model}")
|
|
|
|
# Apply the LoRA using ComfyUI's model-only loader
|
|
try:
|
|
# FIXED: Use the correct method name
|
|
current_model, = self.lora_loader.load_lora_model_only(
|
|
current_model, lora_name, strength_model
|
|
)
|
|
applied_count += 1
|
|
print(f"✅ Successfully applied LoRA (Model Only): {lora_name}")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Failed to load LoRA '{lora_name}': {str(e)}")
|
|
print(f" Error type: {type(e).__name__}")
|
|
import traceback
|
|
print(f" Full traceback: {traceback.format_exc()}")
|
|
|
|
print(f"\n=== Summary ===")
|
|
print(f"Applied {applied_count} out of {len(lora_configs)} LoRAs (Model Only)")
|
|
print(f"===================\n")
|
|
|
|
return (current_model,)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Orthogonal stacking helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _extract_up_down(patch):
|
|
"""Return (lora_up, lora_down) from a ComfyUI patch, or (None, None).
|
|
|
|
Handles:
|
|
LoRAAdapter object — comfy.weight_adapter.lora, weights attr holds tensors
|
|
("lora", (up, down, alpha, dora)) — tagged string format (modern)
|
|
(up, down, ...) — tensor-first format (old)
|
|
(alpha_float, (up, down, ...)) — float-prefixed format (some versions)
|
|
"""
|
|
# comfy.weight_adapter.lora.LoRAAdapter — newer ComfyUI
|
|
if hasattr(patch, "weights"):
|
|
w = patch.weights
|
|
if isinstance(w, (tuple, list)) and len(w) >= 2:
|
|
return w[0], w[1]
|
|
return None, None
|
|
|
|
if isinstance(patch, (list, tuple)) and len(patch) >= 2:
|
|
first = patch[0]
|
|
|
|
if isinstance(first, str):
|
|
# ("lora", (up, down, alpha, dora))
|
|
v = patch[1]
|
|
if isinstance(v, (tuple, list)) and len(v) >= 2:
|
|
return v[0], v[1]
|
|
|
|
elif isinstance(first, torch.Tensor):
|
|
# (up, down, ...)
|
|
return patch[0], patch[1]
|
|
|
|
elif isinstance(first, (int, float)):
|
|
rest = patch[1]
|
|
if isinstance(rest, str) and len(patch) >= 3:
|
|
inner = patch[2]
|
|
if isinstance(inner, (tuple, list)) and len(inner) >= 2:
|
|
return inner[0], inner[1]
|
|
elif isinstance(rest, (tuple, list)) and len(rest) >= 2:
|
|
if isinstance(rest[0], torch.Tensor):
|
|
return rest[0], rest[1]
|
|
elif isinstance(rest[0], str) and len(rest) >= 2:
|
|
inner = rest[1]
|
|
if isinstance(inner, (tuple, list)) and len(inner) >= 2:
|
|
return inner[0], inner[1]
|
|
|
|
return None, None
|
|
|
|
|
|
def _replace_down(patch, new_down):
|
|
"""Return a copy of patch with lora_down replaced by new_down."""
|
|
import copy
|
|
|
|
# comfy.weight_adapter.lora.LoRAAdapter
|
|
if hasattr(patch, "weights"):
|
|
new_patch = copy.copy(patch)
|
|
w = patch.weights
|
|
new_patch.weights = (w[0], new_down) + tuple(w[2:])
|
|
return new_patch
|
|
|
|
if not isinstance(patch, (list, tuple)) or len(patch) < 2:
|
|
return patch
|
|
|
|
first = patch[0]
|
|
|
|
if isinstance(first, str):
|
|
v = list(patch[1])
|
|
v[1] = new_down
|
|
return (patch[0], tuple(v))
|
|
|
|
elif isinstance(first, torch.Tensor):
|
|
v = list(patch)
|
|
v[1] = new_down
|
|
return tuple(v)
|
|
|
|
elif isinstance(first, (int, float)):
|
|
rest = patch[1]
|
|
if isinstance(rest, str) and len(patch) >= 3:
|
|
inner = list(patch[2])
|
|
inner[1] = new_down
|
|
return (patch[0], patch[1], tuple(inner))
|
|
elif isinstance(rest, (tuple, list)) and len(rest) >= 2:
|
|
if isinstance(rest[0], torch.Tensor):
|
|
inner = list(rest)
|
|
inner[1] = new_down
|
|
return (patch[0], tuple(inner))
|
|
elif isinstance(rest[0], str) and len(rest) >= 2:
|
|
inner = list(rest[1])
|
|
inner[1] = new_down
|
|
return (patch[0], (rest[0], tuple(inner)))
|
|
|
|
return patch
|
|
|
|
|
|
def _gram_schmidt_ortho_downs(downs_list):
|
|
"""Gram-Schmidt orthogonalization of down projection matrices in input space.
|
|
|
|
Each successive down matrix has its rows projected onto the orthogonal
|
|
complement of the row spaces already committed by earlier LoRAs, so that
|
|
LoRA_i reads from input directions that LoRA_1..i-1 do not respond to.
|
|
|
|
downs_list: list of (rank, in_dim) float tensors
|
|
Returns: list of orthogonalized tensors, same device/dtype as inputs
|
|
"""
|
|
result = []
|
|
Q = None # (k, in_dim) — accumulated orthonormal row basis
|
|
|
|
for down in downs_list:
|
|
orig_dtype = down.dtype
|
|
orig_device = down.device
|
|
d = down.float().to(orig_device)
|
|
|
|
if d.dim() != 2:
|
|
result.append(down)
|
|
continue
|
|
|
|
if Q is not None and Q.shape[0] > 0:
|
|
# Project rows of d out of the committed subspace
|
|
proj = (d @ Q.T) @ Q # (r, in_dim)
|
|
d_ortho = d - proj
|
|
else:
|
|
d_ortho = d.clone()
|
|
|
|
result.append(d_ortho.to(orig_dtype))
|
|
|
|
# Extend Q with the row space of d_ortho
|
|
fro = d_ortho.norm(p="fro")
|
|
if fro.item() < 1e-10:
|
|
continue
|
|
try:
|
|
_, S, Vh = torch.linalg.svd(d_ortho, full_matrices=False)
|
|
# Vh: (r, in_dim) — rows are right singular vectors
|
|
tol = max(S[0].item() * 1e-6, 1e-10) if S.numel() > 0 else 1e-10
|
|
keep = S > tol
|
|
if not keep.any():
|
|
continue
|
|
Q_new = Vh[keep] # (k', in_dim) orthonormal rows
|
|
|
|
if Q is None:
|
|
Q = Q_new
|
|
else:
|
|
# Concatenate and re-orthogonalize
|
|
combined = torch.cat([Q, Q_new], dim=0).T # (in_dim, k+k')
|
|
Q_upd, R = torch.linalg.qr(combined, mode="reduced")
|
|
r_diag = R.diagonal().abs()
|
|
if r_diag.numel() > 0 and r_diag.max().item() > 0:
|
|
keep_q = r_diag > r_diag.max().item() * 1e-6
|
|
Q = Q_upd[:, keep_q].T # (k_new, in_dim)
|
|
else:
|
|
Q = Q_upd.T
|
|
except Exception as exc:
|
|
print(f"[MultiLoRAOrtho] SVD/QR failed during basis update: {exc}")
|
|
|
|
return result
|
|
|
|
|
|
class MultiLoRAStackModelOnlyOrtho:
|
|
"""Multi LoRA Stack with Gram-Schmidt input-space orthogonalization.
|
|
|
|
Reduces interference between stacked LoRAs by projecting each LoRA's
|
|
down matrix onto the orthogonal complement of the row spaces already
|
|
claimed by earlier LoRAs. Ports the OrthoLoRA principle from anima_lora
|
|
(Cayley-rotated SVD basis, networks/lora_modules/ortho.py) to
|
|
inference-time stacking without requiring retraining.
|
|
|
|
merge_method:
|
|
sequential — identical to the plain MultiLoRAStackModelOnly node.
|
|
ortho_gs — Gram-Schmidt projection in input space before applying.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.lora_loader = LoraLoaderModelOnly()
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
_strength_inputs = {
|
|
f"strength_{i}": ("FLOAT", {
|
|
"default": 1.0, "min": -2.0, "max": 2.0, "step": 0.01, "forceInput": True,
|
|
}) for i in range(1, 11)
|
|
}
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"lora_stack": ("STRING", {
|
|
"multiline": False,
|
|
"default": "[]",
|
|
"forceInput": False,
|
|
}),
|
|
"merge_method": (["ortho_gs", "sequential"], {
|
|
"default": "ortho_gs",
|
|
}),
|
|
},
|
|
"optional": _strength_inputs,
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL",)
|
|
RETURN_NAMES = ("MODEL",)
|
|
FUNCTION = "load_loras"
|
|
CATEGORY = "loaders"
|
|
|
|
def load_loras(self, model, lora_stack="[]", merge_method="ortho_gs", **kwargs):
|
|
import json
|
|
|
|
print(f"\n=== MultiLoRAStackModelOnlyOrtho [{merge_method}] ===")
|
|
|
|
lora_configs = []
|
|
if isinstance(lora_stack, str):
|
|
try:
|
|
lora_configs = json.loads(lora_stack)
|
|
except Exception as e:
|
|
print(f"❌ JSON parse error: {e}")
|
|
return (model,)
|
|
else:
|
|
lora_configs = lora_stack if isinstance(lora_stack, list) else []
|
|
|
|
enabled = []
|
|
for i, cfg in enumerate(lora_configs):
|
|
if not isinstance(cfg, dict) or not cfg.get("on", False):
|
|
continue
|
|
name = cfg.get("lora")
|
|
if not name or name == "None":
|
|
continue
|
|
strength = float(cfg.get("strength", 1.0))
|
|
# strength_N input overrides the widget value (1-indexed slot position)
|
|
override = kwargs.get(f"strength_{i + 1}")
|
|
if override is not None:
|
|
strength = float(override)
|
|
if strength == 0.0:
|
|
continue
|
|
enabled.append((name, strength))
|
|
|
|
if not enabled:
|
|
print("No enabled LoRAs.")
|
|
return (model,)
|
|
|
|
if merge_method == "sequential" or len(enabled) == 1:
|
|
return self._sequential(model, enabled)
|
|
|
|
return self._ortho_gs(model, enabled)
|
|
|
|
# ------------------------------------------------------------------
|
|
def _sequential(self, model, lora_list):
|
|
current = model
|
|
for name, strength in lora_list:
|
|
print(f" sequential: {name} @ {strength}")
|
|
current, = self.lora_loader.load_lora_model_only(current, name, strength)
|
|
return (current,)
|
|
|
|
# ------------------------------------------------------------------
|
|
def _ortho_gs(self, model, lora_list):
|
|
import comfy.utils
|
|
import comfy.lora
|
|
import folder_paths
|
|
|
|
# Build model key map
|
|
key_map = {}
|
|
try:
|
|
key_map = comfy.lora.model_lora_keys_unet(model.model, key_map)
|
|
except Exception as e:
|
|
print(f"[MultiLoRAOrtho] model_lora_keys_unet failed ({e}); falling back to sequential")
|
|
return self._sequential(model, lora_list)
|
|
|
|
# Load all LoRAs into per-key patch dicts
|
|
all_patches = [] # list of ({model_key: patch_tuple}, strength, name)
|
|
for name, strength in lora_list:
|
|
path = folder_paths.get_full_path("loras", name)
|
|
if not path:
|
|
print(f"[MultiLoRAOrtho] ❌ LoRA not found: {name}")
|
|
continue
|
|
try:
|
|
lora_sd = comfy.utils.load_torch_file(path, safe_load=True)
|
|
patches = comfy.lora.load_lora(lora_sd, key_map)
|
|
all_patches.append((patches, strength, name))
|
|
print(f" loaded: {name} @ {strength} ({len(patches)} keys)")
|
|
except Exception as e:
|
|
print(f"[MultiLoRAOrtho] ❌ Failed to load {name}: {e}")
|
|
|
|
if not all_patches:
|
|
return (model,)
|
|
|
|
# Group by model key
|
|
key_to_entries = {} # key -> [(patch_tuple, strength, name)]
|
|
for patches, strength, name in all_patches:
|
|
for key, patch in patches.items():
|
|
key_to_entries.setdefault(key, []).append((patch, strength, name))
|
|
|
|
new_model = model.clone()
|
|
ortho_count = 0
|
|
sequential_count = 0
|
|
|
|
for key, entries in key_to_entries.items():
|
|
if len(entries) == 1:
|
|
patch, strength, _ = entries[0]
|
|
new_model.add_patches({key: patch}, strength)
|
|
sequential_count += 1
|
|
continue
|
|
|
|
# Extract (up, down) from each entry
|
|
components = []
|
|
fallback = False
|
|
for patch, strength, name in entries:
|
|
up, down = _extract_up_down(patch)
|
|
if up is None or down is None or down.dim() != 2:
|
|
# Non-LoRA or conv patch — skip orthogonalization
|
|
fallback = True
|
|
break
|
|
components.append((up, down, patch, strength))
|
|
|
|
if fallback:
|
|
for patch, strength, _ in entries:
|
|
new_model.add_patches({key: patch}, strength)
|
|
sequential_count += len(entries)
|
|
continue
|
|
|
|
# Gram-Schmidt on the down matrices
|
|
downs = [c[1] for c in components]
|
|
ortho_downs = _gram_schmidt_ortho_downs(downs)
|
|
|
|
for (up, down, patch, strength), ortho_down in zip(components, ortho_downs):
|
|
new_patch = _replace_down(patch, ortho_down.to(down.dtype))
|
|
new_model.add_patches({key: new_patch}, strength)
|
|
ortho_count += 1
|
|
|
|
print(f" ortho keys: {ortho_count}, pass-through: {sequential_count}")
|
|
print("===================\n")
|
|
return (new_model,)
|
|
|
|
|
|
# Register the nodes
|
|
NODE_CLASS_MAPPINGS = {
|
|
"MultiLoRAStack": MultiLoRAStack,
|
|
"MultiLoRAStackModelOnly": MultiLoRAStackModelOnly,
|
|
"MultiLoRAStackModelOnlyOrtho": MultiLoRAStackModelOnlyOrtho,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"MultiLoRAStack": "Multi LoRA Stack",
|
|
"MultiLoRAStackModelOnly": "Multi LoRA Stack (Model Only)",
|
|
"MultiLoRAStackModelOnlyOrtho": "Multi LoRA Stack Ortho (Model Only)",
|
|
} |