1889 lines
80 KiB
Python
1889 lines
80 KiB
Python
"""
|
|
Selective LoRA Loaders for ComfyUI
|
|
User-friendly loaders with architecture-specific controls.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
from typing import Dict, List, Optional
|
|
|
|
import torch
|
|
import folder_paths
|
|
import comfy.sd
|
|
from safetensors.torch import load_file
|
|
|
|
|
|
def _detect_architecture(keys):
|
|
"""Identify LoRA architecture from key patterns."""
|
|
keys_lower = [k.lower() for k in keys]
|
|
keys_str = ' '.join(keys_lower)
|
|
num_keys = len(keys)
|
|
|
|
if any(
|
|
re.search(r'(?:diffusion_model|transformer)\.blocks[._]\d+\..*(?:qkv_proj|out_proj|mlp\.fc[12])', k)
|
|
for k in keys_lower
|
|
) or any(
|
|
re.search(r'lora_unet_blocks_\d+_(?:attn_qkv_proj|attn_out_proj|mlp_fc[12])', k)
|
|
for k in keys_lower
|
|
):
|
|
return 'MINIMAX_H3'
|
|
if any('transformer_blocks' in k and any(x in k for x in ['img_mlp', 'txt_mlp', 'img_mod', 'txt_mod']) for k in keys_lower):
|
|
return 'QWEN_IMAGE'
|
|
if 'lora_krea2' in keys_str or 'krea2' in keys_str or 'krea_2' in keys_str:
|
|
return 'KREA2'
|
|
if any(x in keys_str for x in ['txtfusion', 'txtmlp', 'tmlp', 'tproj']) and any(re.search(r'blocks[._]\d+', k) for k in keys_lower):
|
|
return 'KREA2'
|
|
if any(re.search(r'blocks[._]\d+[._].*(?:attn[._])?(?:wq|wk|wv|wo|gate)', k) for k in keys_lower):
|
|
return 'KREA2'
|
|
if any('diffusion_model.layers.' in k and ('attention' in k or 'adaln' in k.lower()) for k in keys_lower):
|
|
return 'ZIMAGE'
|
|
# Musubi Tuner Z-Image format (lora_unet_layers_N_attention_...)
|
|
if any('lora_unet_layers_' in k and 'attention' in k for k in keys_lower):
|
|
return 'ZIMAGE'
|
|
# FLUX detection - check AI-Toolkit format BEFORE Z-Image single_transformer_blocks check
|
|
# AI-Toolkit uses: transformer.transformer_blocks.N (double) and transformer.single_transformer_blocks.N (single)
|
|
if any('transformer.single_transformer_blocks' in k or 'transformer.transformer_blocks' in k for k in keys_lower):
|
|
return 'FLUX'
|
|
# Kohya/other format: lora_transformer_single_transformer_blocks / lora_transformer_double_blocks (underscores)
|
|
if any('transformer_single_transformer_blocks' in k or 'transformer_double_blocks' in k for k in keys_lower):
|
|
return 'FLUX'
|
|
if any('double_blocks' in k or 'single_blocks' in k for k in keys_lower):
|
|
return 'FLUX'
|
|
# Z-Image old format (single_transformer_blocks WITHOUT transformer. prefix)
|
|
if any('single_transformer_blocks' in k and 'transformer.single_transformer_blocks' not in k for k in keys_lower):
|
|
return 'ZIMAGE'
|
|
if any(('blocks.' in k or 'blocks_' in k) and any(x in k for x in ['self_attn', 'cross_attn', 'ffn'])
|
|
for k in keys_lower):
|
|
return 'WAN'
|
|
has_te1 = 'lora_te1_' in keys_str or 'text_encoder_1' in keys_str
|
|
has_te2 = 'lora_te2_' in keys_str or 'text_encoder_2' in keys_str
|
|
if has_te1 and has_te2:
|
|
return 'SDXL'
|
|
if num_keys > 1500:
|
|
return 'SDXL'
|
|
if any('input_blocks_7' in k or 'input_blocks_8' in k or
|
|
'input_blocks.7' in k or 'input_blocks.8' in k for k in keys_lower):
|
|
return 'SDXL'
|
|
if any('lora_unet_' in k or 'lora_te_' in k for k in keys_lower):
|
|
return 'SD15'
|
|
if num_keys > 1000:
|
|
return 'SDXL'
|
|
if any('input_blocks' in k for k in keys_lower):
|
|
return 'SD15'
|
|
return 'UNKNOWN'
|
|
|
|
|
|
def _get_architecture_blocks(architecture: str) -> List[str]:
|
|
"""Get the ordered list of block names for string chaining."""
|
|
if architecture == 'SDXL':
|
|
return [
|
|
'text_encoder_1', 'text_encoder_2', 'input_4', 'input_5', 'input_7',
|
|
'input_8', 'unet_mid', 'output_0', 'output_1', 'output_2', 'output_3',
|
|
'output_4', 'output_5',
|
|
]
|
|
if architecture == 'ZIMAGE':
|
|
return [f'layer_{i}' for i in range(30)]
|
|
if architecture == 'FLUX':
|
|
return [f'double_{i}' for i in range(19)] + [f'single_{i}' for i in range(38)]
|
|
if architecture == 'WAN':
|
|
return [f'block_{i}' for i in range(40)]
|
|
if architecture == 'QWEN':
|
|
return [f'block_{i}' for i in range(60)]
|
|
if architecture == 'KREA2':
|
|
return [f'block_{i}' for i in range(28)]
|
|
if architecture == 'MINIMAX_H3':
|
|
return [f'block_{i}' for i in range(50)]
|
|
if architecture == 'FLUX_KLEIN':
|
|
return [f'double_{i}' for i in range(8)] + [f'single_{i}' for i in range(24)]
|
|
return []
|
|
|
|
|
|
def _parse_block_weights_string(weights_str: str, architecture: str) -> Optional[Dict[str, tuple[bool, float]]]:
|
|
"""
|
|
Parse block weights from either:
|
|
- positional format: "1.0, 0.0, 0.5, ..."
|
|
- named format: "%default=1.0, te1=0.5, in7-8=1.2"
|
|
"""
|
|
if not weights_str or not weights_str.strip():
|
|
return None
|
|
|
|
weights_str = weights_str.strip()
|
|
|
|
if not weights_str.startswith('%'):
|
|
try:
|
|
values = [float(v.strip()) for v in weights_str.split(',') if v.strip()]
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
block_names = _get_architecture_blocks(architecture)
|
|
if not values or len(values) not in (len(block_names), len(block_names) + 1):
|
|
return None
|
|
|
|
block_values = values[:len(block_names)]
|
|
parsed = {
|
|
block_name: (value != 0.0, value)
|
|
for block_name, value in zip(block_names, block_values)
|
|
}
|
|
other_value = values[-1] if len(values) == len(block_names) + 1 else 1.0
|
|
parsed["other_weights"] = (other_value != 0.0, other_value)
|
|
return parsed
|
|
|
|
pairs = [part.strip() for part in weights_str[1:].split(',') if part.strip()]
|
|
if not pairs:
|
|
return None
|
|
|
|
block_names = _get_architecture_blocks(architecture)
|
|
parsed: Dict[str, Optional[tuple[bool, float]]] = {block_name: None for block_name in block_names}
|
|
parsed["other_weights"] = None
|
|
default_val = 1.0
|
|
|
|
def set_named_targets(prefix: str, value: float):
|
|
for block_name in block_names:
|
|
if block_name.startswith(prefix):
|
|
parsed[block_name] = (value != 0.0, value)
|
|
|
|
def set_numeric_range(prefix: str, spec: str, value: float):
|
|
for chunk in spec.split(','):
|
|
chunk = chunk.strip()
|
|
if not chunk:
|
|
continue
|
|
if '-' in chunk:
|
|
start_s, end_s = chunk.split('-', 1)
|
|
start_i = int(start_s)
|
|
end_i = int(end_s)
|
|
for idx in range(start_i, end_i + 1):
|
|
parsed[f"{prefix}_{idx}"] = (value != 0.0, value)
|
|
else:
|
|
parsed[f"{prefix}_{int(chunk)}"] = (value != 0.0, value)
|
|
|
|
alias_map = {
|
|
"te1": ["text_encoder_1"],
|
|
"te2": ["text_encoder_2"],
|
|
"mid": ["unet_mid"],
|
|
"other": ["other_weights"],
|
|
}
|
|
|
|
for pair in pairs:
|
|
if '=' not in pair:
|
|
return None
|
|
key, value_str = [part.strip() for part in pair.split('=', 1)]
|
|
try:
|
|
value = float(value_str)
|
|
except ValueError:
|
|
return None
|
|
|
|
key_lower = key.lower()
|
|
if key_lower == "default":
|
|
default_val = value
|
|
continue
|
|
|
|
if key_lower in alias_map:
|
|
for block_name in alias_map[key_lower]:
|
|
parsed[block_name] = (value != 0.0, value)
|
|
continue
|
|
|
|
if architecture == 'SDXL':
|
|
if key_lower == "in":
|
|
for name in ["input_4", "input_5", "input_7", "input_8"]:
|
|
parsed[name] = (value != 0.0, value)
|
|
continue
|
|
if key_lower.startswith("in"):
|
|
set_numeric_range("input", key_lower[2:], value)
|
|
continue
|
|
if key_lower == "out":
|
|
for name in [f"output_{i}" for i in range(6)]:
|
|
parsed[name] = (value != 0.0, value)
|
|
continue
|
|
if key_lower.startswith("out"):
|
|
set_numeric_range("output", key_lower[3:], value)
|
|
continue
|
|
elif architecture == 'FLUX':
|
|
if key_lower == "double":
|
|
set_named_targets("double_", value)
|
|
continue
|
|
if key_lower.startswith("double"):
|
|
set_numeric_range("double", key_lower[6:], value)
|
|
continue
|
|
if key_lower == "single":
|
|
set_named_targets("single_", value)
|
|
continue
|
|
if key_lower.startswith("single"):
|
|
set_numeric_range("single", key_lower[6:], value)
|
|
continue
|
|
elif architecture in ('ZIMAGE', 'WAN', 'QWEN', 'KREA2', 'MINIMAX_H3'):
|
|
prefix = "layer" if architecture == 'ZIMAGE' else "block"
|
|
if key_lower == prefix:
|
|
set_named_targets(f"{prefix}_", value)
|
|
continue
|
|
if key_lower.startswith(prefix):
|
|
set_numeric_range(prefix, key_lower[len(prefix):], value)
|
|
continue
|
|
|
|
if key_lower in parsed:
|
|
parsed[key_lower] = (value != 0.0, value)
|
|
continue
|
|
|
|
return None
|
|
|
|
final_parsed: Dict[str, tuple[bool, float]] = {}
|
|
for block_name, current in parsed.items():
|
|
final_parsed[block_name] = current if current is not None else (default_val != 0.0, default_val)
|
|
|
|
return final_parsed
|
|
|
|
|
|
def _extract_block_id_sdxl(key: str) -> str:
|
|
"""Extract block ID for SDXL/SD15 architecture."""
|
|
key_lower = key.lower()
|
|
|
|
te = re.search(r'lora_te(\d?)_', key_lower)
|
|
if te:
|
|
return f"text_encoder_{te.group(1) or '1'}"
|
|
|
|
down = re.search(r'down_blocks?[._]?(\d+)', key_lower)
|
|
if down:
|
|
return f"unet_down_{down.group(1)}"
|
|
if 'mid_block' in key_lower or 'middle_block' in key_lower:
|
|
return "unet_mid"
|
|
up = re.search(r'up_blocks?[._]?(\d+)', key_lower)
|
|
if up:
|
|
return f"unet_up_{up.group(1)}"
|
|
|
|
inp = re.search(r'input_blocks?[._]?(\d+)', key_lower)
|
|
if inp:
|
|
return f"input_{inp.group(1)}"
|
|
out = re.search(r'output_blocks?[._]?(\d+)', key_lower)
|
|
if out:
|
|
return f"output_{out.group(1)}"
|
|
|
|
return 'other'
|
|
|
|
|
|
def _extract_layer_num_zimage(key: str) -> Optional[int]:
|
|
"""Extract layer number for Z-Image architecture."""
|
|
match = re.search(r'diffusion_model\.layers\.(\d+)', key)
|
|
if match:
|
|
return int(match.group(1))
|
|
match = re.search(r'single_transformer_blocks\.(\d+)', key)
|
|
if match:
|
|
return int(match.group(1))
|
|
return None
|
|
|
|
|
|
def _extract_block_id_flux(key: str) -> str:
|
|
"""Extract block ID for FLUX architecture."""
|
|
key_lower = key.lower()
|
|
|
|
# FLUX has double blocks (19) and single blocks (38)
|
|
# Different trainers use different naming:
|
|
# - Standard: double_blocks.N, single_blocks.N
|
|
# - AI-Toolkit: transformer.transformer_blocks.N (double), transformer.single_transformer_blocks.N (single)
|
|
# - Kohya/other: lora_transformer_single_transformer_blocks_N, lora_transformer_double_blocks_N
|
|
|
|
# Check single blocks FIRST (because "single_transformer_blocks" contains "transformer_blocks")
|
|
# Handles: single_transformer_blocks.N, single_transformer_blocks_N, transformer_single_transformer_blocks_N
|
|
single = re.search(r'single_transformer_blocks[._]?(\d+)', key_lower)
|
|
if single:
|
|
return f"single_{single.group(1)}"
|
|
single = re.search(r'single_blocks[._]?(\d+)', key_lower)
|
|
if single:
|
|
return f"single_{single.group(1)}"
|
|
|
|
# Double blocks - standard format
|
|
double = re.search(r'(?:transformer\.)?double_blocks?[._]?(\d+)', key_lower)
|
|
if double:
|
|
return f"double_{double.group(1)}"
|
|
# AI-Toolkit format: transformer.transformer_blocks.N (these are double blocks)
|
|
double = re.search(r'transformer\.transformer_blocks[._]?(\d+)', key_lower)
|
|
if double:
|
|
return f"double_{double.group(1)}"
|
|
# Kohya/other format: transformer_double_blocks_N (underscores, these are double blocks)
|
|
double = re.search(r'transformer_double_blocks[._]?(\d+)', key_lower)
|
|
if double:
|
|
return f"double_{double.group(1)}"
|
|
|
|
return 'other'
|
|
|
|
|
|
def _extract_block_id_wan(key: str) -> Optional[int]:
|
|
"""Extract block number for Wan architecture."""
|
|
# Handle both blocks.N and blocks_N patterns
|
|
match = re.search(r'blocks[._](\d+)', key)
|
|
if match:
|
|
return int(match.group(1))
|
|
return None
|
|
|
|
|
|
def _extract_block_id_qwen(key: str) -> Optional[int]:
|
|
"""Extract block number for Qwen-Image architecture."""
|
|
match = re.search(r'transformer_blocks[._](\d+)', key)
|
|
if match:
|
|
return int(match.group(1))
|
|
return None
|
|
|
|
|
|
def _extract_block_id_krea2(key: str) -> Optional[int]:
|
|
"""Extract main SingleStreamBlock number for Krea 2 architecture."""
|
|
key_lower = key.lower()
|
|
if any(part in key_lower for part in ['txtfusion', 'txtmlp', 'tmlp', 'tproj', 'first', 'last']):
|
|
return None
|
|
match = re.search(r'blocks[._](\d+)', key_lower)
|
|
if match:
|
|
return int(match.group(1))
|
|
return None
|
|
|
|
|
|
def _extract_block_id_minimax_h3(key: str) -> Optional[int]:
|
|
"""Extract a top-level MiniMax H3 DiT block, excluding token-refiner blocks."""
|
|
key_lower = key.lower()
|
|
match = re.search(r'(?:^|_)lora_unet_blocks_(\d+)_', key_lower)
|
|
if match:
|
|
return int(match.group(1))
|
|
match = re.search(r'(?:^|\.)(?:diffusion_model|transformer)\.blocks[._](\d+)', key_lower)
|
|
if match:
|
|
return int(match.group(1))
|
|
return None
|
|
|
|
|
|
def _coerce_scalar_strength(value) -> float:
|
|
"""Normalize a ComfyUI strength input before it reaches the LoRA patcher."""
|
|
if isinstance(value, torch.Tensor):
|
|
if value.numel() != 1:
|
|
raise ValueError("Overall LoRA strength must be a single number")
|
|
value = value.item()
|
|
elif isinstance(value, (list, tuple)):
|
|
if len(value) != 1:
|
|
raise ValueError("Overall LoRA strength must be a single number")
|
|
value = value[0]
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError("Overall LoRA strength must be a single number") from error
|
|
|
|
|
|
def _scale_minimax_h3_tensor(key: str, value, strength: float):
|
|
"""Scale one LoRA factor so a block strength stays linear and signed."""
|
|
strength = _coerce_scalar_strength(strength)
|
|
if strength == 1.0:
|
|
return value
|
|
|
|
key_lower = key.lower()
|
|
if key_lower.endswith((".alpha", ".dora_scale", ".reshape_weight")):
|
|
return value
|
|
|
|
# ComfyUI multiplies the two LoRA factors together at load time. Scaling
|
|
# only the output/up factor avoids turning a 0.5 weight into 0.25 and
|
|
# preserves negative block strengths.
|
|
is_output_factor = (
|
|
re.search(r"(?:^|[._])lora_(?:up|b)(?:\.default)?(?:\.weight)?$", key_lower)
|
|
or re.search(r"(?:^|[._])lora\.up(?:\.weight)?$", key_lower)
|
|
or key_lower.endswith(".lora_linear_layer.up.weight")
|
|
or "lokr_w1" in key_lower
|
|
or "hada_w1" in key_lower
|
|
)
|
|
return value * strength if is_output_factor else value
|
|
|
|
|
|
def _save_minimax_h3_filtered_lora(
|
|
filtered_lora: dict,
|
|
source_path: str,
|
|
save_path: str,
|
|
save_filename: str,
|
|
) -> Optional[str]:
|
|
"""Save the exact tensor dictionary applied by the MiniMax H3 loader."""
|
|
if not save_path or not save_path.strip():
|
|
return None
|
|
|
|
output_dir = os.path.expanduser(save_path.strip())
|
|
try:
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
except OSError as error:
|
|
print(f"[MiniMax H3 Selective Loader] Could not create save directory: {error}")
|
|
return None
|
|
|
|
base_name = save_filename.strip() if save_filename else "minimax_h3_selective"
|
|
if base_name.lower().endswith(".safetensors"):
|
|
base_name = base_name[:-12]
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
output_path = os.path.join(output_dir, f"{base_name}_{timestamp}.safetensors")
|
|
|
|
metadata = {}
|
|
if source_path.lower().endswith(".safetensors"):
|
|
try:
|
|
with safe_open(source_path, framework="pt", device="cpu") as source:
|
|
metadata = dict(source.metadata() or {})
|
|
except Exception as error:
|
|
print(f"[MiniMax H3 Selective Loader] Could not read source metadata: {error}")
|
|
|
|
metadata.update({
|
|
"refined_by": "Selective LoRA Loader (MiniMax H3)",
|
|
"refined_date": datetime.now().isoformat(),
|
|
"refined_source": os.path.basename(source_path),
|
|
})
|
|
|
|
try:
|
|
save_file(filtered_lora, output_path, metadata=metadata)
|
|
print(f"[MiniMax H3 Selective Loader] Saved filtered LoRA: {output_path}")
|
|
return output_path
|
|
except Exception as error:
|
|
print(f"[MiniMax H3 Selective Loader] Could not save filtered LoRA: {error}")
|
|
return None
|
|
|
|
|
|
# SDXL block presets - only blocks with attention layers that LoRA trains
|
|
# SDXL has: text_encoder_1, text_encoder_2, input_4/5/7/8, unet_mid, output_0-5
|
|
# Other input/output blocks are ResNet-only (no attention) and not trained by standard LoRA
|
|
SDXL_VALID_BLOCKS = {"text_encoder_1", "text_encoder_2", "input_4", "input_5", "input_7", "input_8", "unet_mid", "output_0", "output_1", "output_2", "output_3", "output_4", "output_5"}
|
|
|
|
SDXL_PRESETS = {
|
|
"All Blocks": SDXL_VALID_BLOCKS.copy(),
|
|
"All Off": set(), # All blocks disabled including other_weights
|
|
"UNet Only": {"input_4", "input_5", "input_7", "input_8", "unet_mid", "output_0", "output_1", "output_2", "output_3", "output_4", "output_5"},
|
|
"High Impact": {"input_7", "input_8", "unet_mid", "output_0", "output_1", "output_2"},
|
|
"Text Encoders Only": {"text_encoder_1", "text_encoder_2"},
|
|
"Decoders Only": {"output_0", "output_1", "output_2", "output_3", "output_4", "output_5"},
|
|
"Encoders Only": {"input_4", "input_5", "input_7", "input_8"},
|
|
"Style Focus": {"output_1", "output_2"}, # output_1 is strongest for style/color
|
|
"Composition Focus": {"input_8", "unet_mid", "output_0"}, # composition and structure
|
|
"Face Focus": {"input_7", "input_8", "unet_mid", "output_0", "output_1", "output_2", "output_3"}, # OUT3 best for faces, upper blocks for identity
|
|
"Custom": None, # Use individual toggles
|
|
}
|
|
|
|
# Z-Image layer presets
|
|
ZIMAGE_PRESETS = {
|
|
"All Layers": set(range(30)),
|
|
"All Off": set(), # All layers disabled including other_weights
|
|
"Late Only (20-29)": set(range(20, 30)),
|
|
"Mid-Late (15-29)": set(range(15, 30)),
|
|
"Skip Early (10-29)": set(range(10, 30)),
|
|
"Mid Only (10-19)": set(range(10, 20)),
|
|
"Early Only (0-9)": set(range(10)),
|
|
"Peak Impact (18-25)": set(range(18, 26)),
|
|
"Face Priority (16-24)": set(range(16, 25)),
|
|
"Face Priority Aggressive (14-25)": set(range(14, 26)),
|
|
"Evens Only": set(range(0, 30, 2)), # 0,2,4,6,8,10,12,14,16,18,20,22,24,26,28
|
|
"Odds Only": set(range(1, 30, 2)), # 1,3,5,7,9,11,13,15,17,19,21,23,25,27,29
|
|
"Custom": None, # Use individual toggles
|
|
}
|
|
|
|
# FLUX block presets - 19 double blocks (0-18) + 38 single blocks (0-37) = 57 total
|
|
FLUX_ALL_BLOCKS = (
|
|
{f"double_{i}" for i in range(19)} |
|
|
{f"single_{i}" for i in range(38)}
|
|
)
|
|
|
|
# Facial layers from lora-the-explorer (github.com/shootthesound/lora-the-explorer)
|
|
# Double blocks: 7, 12, 16 | Single blocks: 7, 12, 16, 20
|
|
FLUX_FACE_DOUBLE = {"double_7", "double_12", "double_16"}
|
|
FLUX_FACE_SINGLE = {"single_7", "single_12", "single_16", "single_20"}
|
|
FLUX_FACE_BLOCKS = FLUX_FACE_DOUBLE | FLUX_FACE_SINGLE
|
|
|
|
# Aggressive facial (for overtrained LoRAs) - excludes out-of-range double_19
|
|
FLUX_FACE_AGGRESSIVE_DOUBLE = {"double_4", "double_7", "double_8", "double_12", "double_15", "double_16"}
|
|
FLUX_FACE_AGGRESSIVE_SINGLE = {"single_4", "single_7", "single_8", "single_12", "single_15", "single_16", "single_19", "single_20"}
|
|
FLUX_FACE_AGGRESSIVE = FLUX_FACE_AGGRESSIVE_DOUBLE | FLUX_FACE_AGGRESSIVE_SINGLE
|
|
|
|
# Style = all blocks except facial
|
|
FLUX_STYLE_BLOCKS = FLUX_ALL_BLOCKS - FLUX_FACE_BLOCKS
|
|
|
|
FLUX_PRESETS = {
|
|
"All Blocks": FLUX_ALL_BLOCKS.copy(),
|
|
"All Off": set(), # All blocks disabled including other_weights
|
|
"Double Blocks Only": {f"double_{i}" for i in range(19)},
|
|
"Single Blocks Only": {f"single_{i}" for i in range(38)},
|
|
"High Impact Double": {f"double_{i}" for i in range(6, 19)}, # double_6-18 tend to be highest
|
|
"Core Double": {f"double_{i}" for i in range(8, 18)}, # Peak impact range
|
|
"Face Focus": FLUX_FACE_BLOCKS.copy(), # double 7,12,16 + single 7,12,16,20
|
|
"Face Aggressive": FLUX_FACE_AGGRESSIVE.copy(), # Extended for overtrained LoRAs
|
|
"Style Only (No Face)": FLUX_STYLE_BLOCKS.copy(), # All except facial layers
|
|
"Evens Only": {f"double_{i}" for i in range(0, 19, 2)} | {f"single_{i}" for i in range(0, 38, 2)},
|
|
"Odds Only": {f"double_{i}" for i in range(1, 19, 2)} | {f"single_{i}" for i in range(1, 38, 2)},
|
|
"Custom": None, # Use individual toggles
|
|
}
|
|
|
|
# Wan 2.2 block presets - 40 transformer blocks
|
|
WAN_PRESETS = {
|
|
"All Blocks": set(range(40)),
|
|
"All Off": set(), # All blocks disabled including other_weights
|
|
"Late Only (30-39)": set(range(30, 40)),
|
|
"Mid-Late (20-39)": set(range(20, 40)),
|
|
"Skip Early (10-39)": set(range(10, 40)),
|
|
"Mid Only (15-25)": set(range(15, 26)),
|
|
"Early Only (0-19)": set(range(20)),
|
|
"Evens Only": set(range(0, 40, 2)),
|
|
"Odds Only": set(range(1, 40, 2)),
|
|
"Custom": None, # Use individual toggles
|
|
}
|
|
|
|
# Qwen-Image block presets - 60 transformer blocks
|
|
QWEN_PRESETS = {
|
|
"All Blocks": set(range(60)),
|
|
"All Off": set(), # All blocks disabled including other_weights
|
|
"Late Only (45-59)": set(range(45, 60)),
|
|
"Mid-Late (30-59)": set(range(30, 60)),
|
|
"Skip Early (15-59)": set(range(15, 60)),
|
|
"Mid Only (20-40)": set(range(20, 41)),
|
|
"Early Only (0-29)": set(range(30)),
|
|
"Evens Only": set(range(0, 60, 2)),
|
|
"Odds Only": set(range(1, 60, 2)),
|
|
"Custom": None, # Use individual toggles
|
|
}
|
|
|
|
# Krea 2 block presets - 28 main SingleStreamBlocks
|
|
KREA2_PRESETS = {
|
|
"All Blocks": set(range(28)),
|
|
"All Off": set(), # All blocks disabled including other_weights
|
|
"Late Only (21-27)": set(range(21, 28)),
|
|
"Mid-Late (14-27)": set(range(14, 28)),
|
|
"Skip Early (7-27)": set(range(7, 28)),
|
|
"Mid Only (9-18)": set(range(9, 19)),
|
|
"Early Only (0-8)": set(range(9)),
|
|
"Evens Only": set(range(0, 28, 2)),
|
|
"Odds Only": set(range(1, 28, 2)),
|
|
"Custom": None, # Use individual toggles
|
|
}
|
|
|
|
# MiniMax H3 block presets - 50 top-level packed DiT blocks
|
|
MINIMAX_H3_PRESETS = {
|
|
"All Blocks": set(range(50)),
|
|
"All Off": set(),
|
|
"Late Only (38-49)": set(range(38, 50)),
|
|
"Mid-Late (25-49)": set(range(25, 50)),
|
|
"Skip Early (13-49)": set(range(13, 50)),
|
|
"Mid Only (17-32)": set(range(17, 33)),
|
|
"Early Only (0-16)": set(range(17)),
|
|
"Evens Only": set(range(0, 50, 2)),
|
|
"Odds Only": set(range(1, 50, 2)),
|
|
"Custom": None,
|
|
}
|
|
|
|
|
|
class SDXLSelectiveLoRALoader:
|
|
"""
|
|
Selective LoRA Loader for SDXL models.
|
|
|
|
Toggle individual blocks on/off to control which parts of the LoRA are applied.
|
|
Use the LoRA Analyzer first to see which blocks have the most impact.
|
|
|
|
Block Guide (13 blocks with attention layers):
|
|
- text_encoder_1/2: CLIP text encoders (CLIP-L and CLIP-G)
|
|
- input_4, input_5: Mid encoder blocks with attention
|
|
- input_7, input_8: Deep encoder blocks (high impact, composition)
|
|
- unet_mid: Bottleneck (moderate-high impact)
|
|
- output_0: Primary decoder (composition, high impact)
|
|
- output_1: Style block (strongest for style/color)
|
|
- output_2-5: Decoder blocks (decreasing impact)
|
|
|
|
Note: Other input/output blocks (0-3, 6, 9-11) are ResNet-only without
|
|
attention layers and are not trained by standard LoRA.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"clip": ("CLIP",),
|
|
"lora_name": (folder_paths.get_filename_list("loras"), {
|
|
"tooltip": "SDXL LoRA file to load"
|
|
}),
|
|
"strength": ("FLOAT", {
|
|
"default": 1.0,
|
|
"min": -5.0,
|
|
"max": 5.0,
|
|
"step": 0.05,
|
|
"tooltip": "Overall LoRA strength"
|
|
}),
|
|
"preset": (list(SDXL_PRESETS.keys()), {
|
|
"default": "All Blocks",
|
|
"tooltip": "Quick preset selection. Choose 'Custom' to use individual toggles below."
|
|
}),
|
|
# Text encoders
|
|
"text_encoder_1": ("BOOLEAN", {"default": True}),
|
|
"text_encoder_1_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
"text_encoder_2": ("BOOLEAN", {"default": True}),
|
|
"text_encoder_2_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
# Input blocks with attention (only 4, 5, 7, 8 have attention in SDXL)
|
|
"input_4": ("BOOLEAN", {"default": True}),
|
|
"input_4_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
"input_5": ("BOOLEAN", {"default": True}),
|
|
"input_5_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
"input_7": ("BOOLEAN", {"default": True}),
|
|
"input_7_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
"input_8": ("BOOLEAN", {"default": True}),
|
|
"input_8_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
# Mid block
|
|
"unet_mid": ("BOOLEAN", {"default": True}),
|
|
"unet_mid_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
# Output blocks with attention (only 0-5 have attention in SDXL)
|
|
"output_0": ("BOOLEAN", {"default": True}),
|
|
"output_0_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
"output_1": ("BOOLEAN", {"default": True}),
|
|
"output_1_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
"output_2": ("BOOLEAN", {"default": True}),
|
|
"output_2_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
"output_3": ("BOOLEAN", {"default": True}),
|
|
"output_3_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
"output_4": ("BOOLEAN", {"default": True}),
|
|
"output_4_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
"output_5": ("BOOLEAN", {"default": True}),
|
|
"output_5_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
# Other weights (keys not matching known blocks)
|
|
"other_weights": ("BOOLEAN", {"default": True}),
|
|
"other_weights_str": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
|
|
},
|
|
"optional": {
|
|
"lora_path_opt": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer to use its selected LoRA"}),
|
|
"analysis_json": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer for impact-colored checkboxes"}),
|
|
"block_weights_string": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": "Input/Output block profile string. Positional text syncs with the UI. String input overrides UI values."
|
|
}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
|
RETURN_NAMES = ("model", "clip", "info", "weights_output")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "load_lora"
|
|
CATEGORY = "loaders/lora"
|
|
DESCRIPTION = """Selective LoRA loader for SDXL. Toggle blocks on/off.
|
|
|
|
TIP: Use 'LoRA Loader + Analyzer' first to see which blocks matter for your LoRA.
|
|
Then disable low-scoring blocks here to reduce unwanted effects.
|
|
|
|
SDXL has 13 blocks with attention layers that LoRA trains.
|
|
output_1 is strongest for style/color, input_8/output_0 for composition."""
|
|
|
|
def load_lora(self, model, clip, lora_name, strength, preset,
|
|
text_encoder_1, text_encoder_1_str, text_encoder_2, text_encoder_2_str,
|
|
input_4, input_4_str, input_5, input_5_str, input_7, input_7_str, input_8, input_8_str,
|
|
unet_mid, unet_mid_str,
|
|
output_0, output_0_str, output_1, output_1_str, output_2, output_2_str,
|
|
output_3, output_3_str, output_4, output_4_str, output_5, output_5_str,
|
|
other_weights, other_weights_str,
|
|
lora_path_opt=None, analysis_json=None, block_weights_string=""):
|
|
# Store analysis_json for UI callback
|
|
self._analysis_json = analysis_json
|
|
|
|
# Valid SDXL blocks (only those with attention layers)
|
|
all_valid_blocks = ["text_encoder_1", "text_encoder_2", "input_4", "input_5", "input_7", "input_8", "unet_mid", "output_0", "output_1", "output_2", "output_3", "output_4", "output_5"]
|
|
|
|
parsed_weights = _parse_block_weights_string(block_weights_string, 'SDXL')
|
|
if parsed_weights:
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
for block_name, (enabled, blk_str) in parsed_weights.items():
|
|
if block_name == "other_weights":
|
|
other_enabled = enabled
|
|
other_str = blk_str
|
|
elif enabled:
|
|
enabled_blocks.add(block_name)
|
|
block_strengths[block_name] = blk_str
|
|
using_preset = "String Input"
|
|
else:
|
|
block_settings = {
|
|
"text_encoder_1": (text_encoder_1, text_encoder_1_str),
|
|
"text_encoder_2": (text_encoder_2, text_encoder_2_str),
|
|
"input_4": (input_4, input_4_str),
|
|
"input_5": (input_5, input_5_str),
|
|
"input_7": (input_7, input_7_str),
|
|
"input_8": (input_8, input_8_str),
|
|
"unet_mid": (unet_mid, unet_mid_str),
|
|
"output_0": (output_0, output_0_str),
|
|
"output_1": (output_1, output_1_str),
|
|
"output_2": (output_2, output_2_str),
|
|
"output_3": (output_3, output_3_str),
|
|
"output_4": (output_4, output_4_str),
|
|
"output_5": (output_5, output_5_str),
|
|
}
|
|
|
|
if preset != "Custom":
|
|
enabled_blocks = SDXL_PRESETS[preset].copy()
|
|
block_strengths = {b: 1.0 for b in enabled_blocks}
|
|
other_enabled = preset != "All Off"
|
|
other_str = 1.0
|
|
using_preset = preset
|
|
else:
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
for block_id, (enabled, blk_str) in block_settings.items():
|
|
if enabled:
|
|
enabled_blocks.add(block_id)
|
|
block_strengths[block_id] = blk_str
|
|
other_enabled = other_weights
|
|
other_str = other_weights_str
|
|
using_preset = None
|
|
|
|
# Load LoRA - use optional path if provided, otherwise use dropdown selection
|
|
if lora_path_opt and os.path.exists(lora_path_opt):
|
|
lora_path = lora_path_opt
|
|
else:
|
|
lora_path = folder_paths.get_full_path("loras", lora_name)
|
|
if not lora_path or not os.path.exists(lora_path):
|
|
return (model, clip, "Error: LoRA not found")
|
|
|
|
if lora_path.endswith('.safetensors'):
|
|
lora_state_dict = load_file(lora_path)
|
|
else:
|
|
lora_state_dict = torch.load(lora_path, map_location='cpu')
|
|
|
|
# Filter and scale tensors by block strength
|
|
filtered_dict = {}
|
|
for key, value in lora_state_dict.items():
|
|
block_id = _extract_block_id_sdxl(key)
|
|
if block_id in enabled_blocks:
|
|
blk_str = block_strengths.get(block_id, 1.0)
|
|
filtered_dict[key] = value * blk_str if blk_str != 1.0 else value
|
|
elif block_id == 'other' and other_enabled:
|
|
filtered_dict[key] = value * other_str if other_str != 1.0 else value
|
|
|
|
original_count = len(lora_state_dict)
|
|
filtered_count = len(filtered_dict)
|
|
|
|
if filtered_count == 0:
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model, clip, "Warning: All blocks disabled, no LoRA applied", "")}
|
|
|
|
# Apply filtered LoRA
|
|
model_lora, clip_lora = comfy.sd.load_lora_for_models(
|
|
model, clip, filtered_dict, strength, strength
|
|
)
|
|
|
|
disabled = [b for b in all_valid_blocks if b not in enabled_blocks]
|
|
scaled = [f"{b}={block_strengths[b]:.2f}" for b in enabled_blocks if block_strengths.get(b, 1.0) != 1.0]
|
|
|
|
info = f"Loaded {filtered_count}/{original_count} tensors\n"
|
|
if using_preset:
|
|
info += f"Preset: {using_preset}\n"
|
|
else:
|
|
info += "Preset: Custom\n"
|
|
info += f"Enabled: {len(enabled_blocks)}/13 blocks\n"
|
|
if scaled:
|
|
info += f"Scaled: {', '.join(scaled)}\n"
|
|
if disabled:
|
|
info += f"Disabled: {', '.join(disabled)}"
|
|
else:
|
|
info += "All blocks enabled"
|
|
|
|
weights_output = ", ".join(
|
|
f"{(block_strengths.get(block, 0.0) if block in enabled_blocks else 0.0):.2f}"
|
|
for block in all_valid_blocks
|
|
)
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model_lora, clip_lora, info, weights_output)}
|
|
|
|
|
|
class ZImageSelectiveLoRALoader:
|
|
"""
|
|
Selective LoRA Loader for Z-Image Turbo models.
|
|
|
|
Toggle individual layers (0-29) on/off to control which parts of the LoRA are applied.
|
|
Use the LoRA Analyzer first to see which layers have the most impact.
|
|
|
|
Layer Guide:
|
|
- Layers 0-9: Early processing (usually low impact, ~7-25%)
|
|
- Layers 10-19: Mid processing (moderate impact, ~25-70%)
|
|
- Layers 20-29: Late processing (usually highest impact, ~70-100%)
|
|
|
|
Most LoRAs have their main effect in layers 18-29.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
inputs = {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"clip": ("CLIP",),
|
|
"lora_name": (folder_paths.get_filename_list("loras"), {
|
|
"tooltip": "Z-Image LoRA file to load"
|
|
}),
|
|
"strength": ("FLOAT", {
|
|
"default": 1.0,
|
|
"min": -5.0,
|
|
"max": 5.0,
|
|
"step": 0.05,
|
|
"tooltip": "Overall LoRA strength"
|
|
}),
|
|
"preset": (list(ZIMAGE_PRESETS.keys()), {
|
|
"default": "All Layers",
|
|
"tooltip": "Quick preset selection. Choose 'Custom' to use individual toggles below."
|
|
}),
|
|
},
|
|
}
|
|
|
|
# Add layer toggles and strengths (0-29)
|
|
for i in range(30):
|
|
inputs["required"][f"layer_{i}"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"][f"layer_{i}_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
# Other weights (keys not matching known layers)
|
|
inputs["required"]["other_weights"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"]["other_weights_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
inputs["optional"] = {
|
|
"lora_path_opt": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer to use its selected LoRA"}),
|
|
"analysis_json": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer for impact-colored checkboxes"}),
|
|
"block_weights_string": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": "Input/Output block profile string. Positional text syncs with the UI. String input overrides UI values."
|
|
}),
|
|
}
|
|
|
|
return inputs
|
|
|
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
|
RETURN_NAMES = ("model", "clip", "info", "weights_output")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "load_lora"
|
|
CATEGORY = "loaders/lora"
|
|
DESCRIPTION = """Selective LoRA loader for Z-Image Turbo. Toggle each layer on/off.
|
|
|
|
TIP: Use 'LoRA Loader + Analyzer' first to see which layers matter for YOUR LoRA.
|
|
Late layers (20-29) usually have the most effect.
|
|
Try disabling early layers (0-9) to reduce style bleed while keeping identity."""
|
|
|
|
def load_lora(self, model, clip, lora_name, strength, preset, **kwargs):
|
|
# Get optional inputs from kwargs
|
|
lora_path_opt = kwargs.get("lora_path_opt")
|
|
analysis_json = kwargs.get("analysis_json")
|
|
block_weights_string = kwargs.get("block_weights_string", "")
|
|
|
|
# Store analysis_json for UI callback
|
|
self._analysis_json = analysis_json
|
|
parsed_weights = _parse_block_weights_string(block_weights_string, 'ZIMAGE')
|
|
if parsed_weights:
|
|
enabled_layers = set()
|
|
layer_strengths = {}
|
|
for block_name, (enabled, blk_str) in parsed_weights.items():
|
|
if block_name == "other_weights":
|
|
other_enabled = enabled
|
|
other_str = blk_str
|
|
elif block_name.startswith("layer_") and enabled:
|
|
layer_num = int(block_name.split("_")[1])
|
|
enabled_layers.add(layer_num)
|
|
layer_strengths[layer_num] = blk_str
|
|
using_preset = "String Input"
|
|
elif preset != "Custom":
|
|
enabled_layers = ZIMAGE_PRESETS[preset].copy()
|
|
layer_strengths = {i: 1.0 for i in enabled_layers}
|
|
# All Off preset disables other_weights too
|
|
other_enabled = preset != "All Off"
|
|
other_str = 1.0
|
|
using_preset = preset
|
|
else:
|
|
# Build from individual toggles and strengths
|
|
enabled_layers = set()
|
|
layer_strengths = {}
|
|
for i in range(30):
|
|
if kwargs.get(f"layer_{i}", True):
|
|
enabled_layers.add(i)
|
|
layer_strengths[i] = kwargs.get(f"layer_{i}_str", 1.0)
|
|
other_enabled = kwargs.get("other_weights", True)
|
|
other_str = kwargs.get("other_weights_str", 1.0)
|
|
using_preset = None
|
|
|
|
# Load LoRA - use optional path if provided, otherwise use dropdown selection
|
|
if lora_path_opt and os.path.exists(lora_path_opt):
|
|
lora_path = lora_path_opt
|
|
else:
|
|
lora_path = folder_paths.get_full_path("loras", lora_name)
|
|
if not lora_path or not os.path.exists(lora_path):
|
|
return (model, clip, "Error: LoRA not found")
|
|
|
|
if lora_path.endswith('.safetensors'):
|
|
lora_state_dict = load_file(lora_path)
|
|
else:
|
|
lora_state_dict = torch.load(lora_path, map_location='cpu')
|
|
|
|
# Filter and scale tensors by layer strength
|
|
filtered_dict = {}
|
|
for key, value in lora_state_dict.items():
|
|
layer_num = _extract_layer_num_zimage(key)
|
|
if layer_num is not None:
|
|
if layer_num in enabled_layers:
|
|
lyr_str = layer_strengths.get(layer_num, 1.0)
|
|
filtered_dict[key] = value * lyr_str if lyr_str != 1.0 else value
|
|
elif other_enabled:
|
|
# Include non-layer keys (text encoder, etc.) based on other_weights setting
|
|
filtered_dict[key] = value * other_str if other_str != 1.0 else value
|
|
|
|
original_count = len(lora_state_dict)
|
|
filtered_count = len(filtered_dict)
|
|
|
|
if filtered_count == 0:
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model, clip, "Warning: All layers disabled, no LoRA applied", "")}
|
|
|
|
# Apply filtered LoRA
|
|
model_lora, clip_lora = comfy.sd.load_lora_for_models(
|
|
model, clip, filtered_dict, strength, strength
|
|
)
|
|
|
|
disabled_layers = [i for i in range(30) if i not in enabled_layers]
|
|
scaled = [f"{i}={layer_strengths[i]:.2f}" for i in enabled_layers if layer_strengths.get(i, 1.0) != 1.0]
|
|
|
|
info = f"Loaded {filtered_count}/{original_count} tensors\n"
|
|
if using_preset:
|
|
info += f"Preset: {using_preset}\n"
|
|
else:
|
|
info += "Preset: Custom\n"
|
|
info += f"Enabled: {len(enabled_layers)}/30 layers\n"
|
|
if scaled:
|
|
info += f"Scaled: {', '.join(scaled)}\n"
|
|
if disabled_layers:
|
|
info += f"Disabled: {', '.join(str(l) for l in disabled_layers)}"
|
|
else:
|
|
info += "All layers enabled"
|
|
|
|
weights_output = ", ".join(
|
|
f"{(layer_strengths.get(i, 0.0) if i in enabled_layers else 0.0):.2f}"
|
|
for i in range(30)
|
|
)
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model_lora, clip_lora, info, weights_output)}
|
|
|
|
|
|
class FLUXSelectiveLoRALoader:
|
|
"""
|
|
Selective LoRA Loader for FLUX models.
|
|
|
|
Toggle individual blocks on/off to control which parts of the LoRA are applied.
|
|
Use the LoRA Analyzer first to see which blocks have the most impact.
|
|
|
|
Block Guide (57 total):
|
|
- double_0-18: Double transformer blocks (19 blocks, higher impact)
|
|
- single_0-37: Single transformer blocks (38 blocks, lower impact)
|
|
|
|
Double blocks typically have higher impact than single blocks.
|
|
Peak impact is usually in double_8-17 range.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
inputs = {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"clip": ("CLIP",),
|
|
"lora_name": (folder_paths.get_filename_list("loras"), {
|
|
"tooltip": "FLUX LoRA file to load"
|
|
}),
|
|
"strength": ("FLOAT", {
|
|
"default": 1.0,
|
|
"min": -5.0,
|
|
"max": 5.0,
|
|
"step": 0.05,
|
|
"tooltip": "Overall LoRA strength"
|
|
}),
|
|
"preset": (list(FLUX_PRESETS.keys()), {
|
|
"default": "All Blocks",
|
|
"tooltip": "Quick preset selection. Choose 'Custom' to use individual toggles below."
|
|
}),
|
|
},
|
|
}
|
|
|
|
# Add double block toggles and strengths (0-18)
|
|
for i in range(19):
|
|
inputs["required"][f"double_{i}"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"][f"double_{i}_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
# Add single block toggles and strengths (0-37)
|
|
for i in range(38):
|
|
inputs["required"][f"single_{i}"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"][f"single_{i}_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
# Other weights (keys not matching known blocks)
|
|
inputs["required"]["other_weights"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"]["other_weights_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
inputs["optional"] = {
|
|
"lora_path_opt": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer to use its selected LoRA"}),
|
|
"analysis_json": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer for impact-colored checkboxes"}),
|
|
"block_weights_string": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": "Input/Output block profile string. Positional text syncs with the UI. String input overrides UI values."
|
|
}),
|
|
}
|
|
|
|
return inputs
|
|
|
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
|
RETURN_NAMES = ("model", "clip", "info", "weights_output")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "load_lora"
|
|
CATEGORY = "loaders/lora"
|
|
DESCRIPTION = """Selective LoRA loader for FLUX. Toggle blocks on/off.
|
|
|
|
TIP: Use 'LoRA Loader + Analyzer' first to see which blocks matter for your LoRA.
|
|
Double blocks (0-18) typically have more impact than single blocks (0-37)."""
|
|
|
|
def load_lora(self, model, clip, lora_name, strength, preset, **kwargs):
|
|
# Get optional inputs from kwargs
|
|
lora_path_opt = kwargs.get("lora_path_opt")
|
|
analysis_json = kwargs.get("analysis_json")
|
|
block_weights_string = kwargs.get("block_weights_string", "")
|
|
|
|
# Store analysis_json for UI callback
|
|
self._analysis_json = analysis_json
|
|
parsed_weights = _parse_block_weights_string(block_weights_string, 'FLUX')
|
|
if parsed_weights:
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
for block_name, (enabled, blk_str) in parsed_weights.items():
|
|
if block_name == "other_weights":
|
|
other_enabled = enabled
|
|
other_str = blk_str
|
|
elif enabled:
|
|
enabled_blocks.add(block_name)
|
|
block_strengths[block_name] = blk_str
|
|
using_preset = "String Input"
|
|
elif preset != "Custom":
|
|
enabled_blocks = FLUX_PRESETS[preset].copy()
|
|
block_strengths = {b: 1.0 for b in enabled_blocks}
|
|
# All Off preset disables other_weights too
|
|
other_enabled = preset != "All Off"
|
|
other_str = 1.0
|
|
using_preset = preset
|
|
else:
|
|
# Build from individual toggles and strengths
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
for i in range(19):
|
|
block_id = f"double_{i}"
|
|
if kwargs.get(block_id, True):
|
|
enabled_blocks.add(block_id)
|
|
block_strengths[block_id] = kwargs.get(f"{block_id}_str", 1.0)
|
|
for i in range(38):
|
|
block_id = f"single_{i}"
|
|
if kwargs.get(block_id, True):
|
|
enabled_blocks.add(block_id)
|
|
block_strengths[block_id] = kwargs.get(f"{block_id}_str", 1.0)
|
|
other_enabled = kwargs.get("other_weights", True)
|
|
other_str = kwargs.get("other_weights_str", 1.0)
|
|
using_preset = None
|
|
|
|
# Load LoRA - use optional path if provided, otherwise use dropdown selection
|
|
if lora_path_opt and os.path.exists(lora_path_opt):
|
|
lora_path = lora_path_opt
|
|
else:
|
|
lora_path = folder_paths.get_full_path("loras", lora_name)
|
|
if not lora_path or not os.path.exists(lora_path):
|
|
return (model, clip, "Error: LoRA not found")
|
|
|
|
if lora_path.endswith('.safetensors'):
|
|
lora_state_dict = load_file(lora_path)
|
|
else:
|
|
lora_state_dict = torch.load(lora_path, map_location='cpu')
|
|
|
|
# Filter and scale tensors by block strength
|
|
filtered_dict = {}
|
|
for key, value in lora_state_dict.items():
|
|
block_id = _extract_block_id_flux(key)
|
|
if block_id in enabled_blocks:
|
|
blk_str = block_strengths.get(block_id, 1.0)
|
|
filtered_dict[key] = value * blk_str if blk_str != 1.0 else value
|
|
elif block_id == 'other' and other_enabled:
|
|
filtered_dict[key] = value * other_str if other_str != 1.0 else value
|
|
|
|
original_count = len(lora_state_dict)
|
|
filtered_count = len(filtered_dict)
|
|
|
|
if filtered_count == 0:
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model, clip, "Warning: All blocks disabled, no LoRA applied", "")}
|
|
|
|
# Apply filtered LoRA
|
|
model_lora, clip_lora = comfy.sd.load_lora_for_models(
|
|
model, clip, filtered_dict, strength, strength
|
|
)
|
|
|
|
all_blocks = [f"double_{i}" for i in range(19)] + [f"single_{i}" for i in range(38)]
|
|
disabled = [b for b in all_blocks if b not in enabled_blocks]
|
|
scaled = [f"{b}={block_strengths[b]:.2f}" for b in enabled_blocks if block_strengths.get(b, 1.0) != 1.0]
|
|
|
|
info = f"Loaded {filtered_count}/{original_count} tensors\n"
|
|
if using_preset:
|
|
info += f"Preset: {using_preset}\n"
|
|
else:
|
|
info += "Preset: Custom\n"
|
|
info += f"Enabled: {len(enabled_blocks)}/57 blocks\n"
|
|
if scaled:
|
|
info += f"Scaled: {', '.join(scaled[:10])}" # Limit to first 10 for readability
|
|
if len(scaled) > 10:
|
|
info += f" (+{len(scaled)-10} more)\n"
|
|
else:
|
|
info += "\n"
|
|
if disabled:
|
|
# Summarize disabled blocks
|
|
disabled_double = [b for b in disabled if b.startswith("double_")]
|
|
disabled_single = [b for b in disabled if b.startswith("single_")]
|
|
if disabled_double:
|
|
info += f"Disabled double: {', '.join(b.replace('double_', '') for b in disabled_double)}\n"
|
|
if disabled_single:
|
|
info += f"Disabled single: {', '.join(b.replace('single_', '') for b in disabled_single)}"
|
|
else:
|
|
info += "All blocks enabled"
|
|
|
|
weights_output = ", ".join(
|
|
[f"{(block_strengths.get(f'double_{i}', 0.0) if f'double_{i}' in enabled_blocks else 0.0):.2f}" for i in range(19)] +
|
|
[f"{(block_strengths.get(f'single_{i}', 0.0) if f'single_{i}' in enabled_blocks else 0.0):.2f}" for i in range(38)]
|
|
)
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model_lora, clip_lora, info, weights_output)}
|
|
|
|
|
|
class WanSelectiveLoRALoader:
|
|
"""
|
|
Selective LoRA Loader for Wan 2.2 models.
|
|
|
|
Toggle individual transformer blocks on/off to control which parts of the LoRA are applied.
|
|
Use the LoRA Analyzer first to see which blocks have the most impact.
|
|
|
|
Block Guide (40 total):
|
|
- block_0-9: Early transformer blocks
|
|
- block_10-19: Early-mid blocks
|
|
- block_20-29: Mid-late blocks
|
|
- block_30-39: Late blocks
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
inputs = {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"clip": ("CLIP",),
|
|
"lora_name": (folder_paths.get_filename_list("loras"), {
|
|
"tooltip": "Wan 2.2 LoRA file to load"
|
|
}),
|
|
"strength": ("FLOAT", {
|
|
"default": 1.0,
|
|
"min": -5.0,
|
|
"max": 5.0,
|
|
"step": 0.05,
|
|
"tooltip": "Overall LoRA strength"
|
|
}),
|
|
"preset": (list(WAN_PRESETS.keys()), {
|
|
"default": "All Blocks",
|
|
"tooltip": "Quick preset selection. Choose 'Custom' to use individual toggles below."
|
|
}),
|
|
},
|
|
}
|
|
|
|
# Add block toggles and strengths (0-39)
|
|
for i in range(40):
|
|
inputs["required"][f"block_{i}"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"][f"block_{i}_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
# Other weights (keys not matching known blocks)
|
|
inputs["required"]["other_weights"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"]["other_weights_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
inputs["optional"] = {
|
|
"lora_path_opt": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer to use its selected LoRA"}),
|
|
"analysis_json": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer for impact-colored checkboxes"}),
|
|
"block_weights_string": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": "Input/Output block profile string. Positional text syncs with the UI. String input overrides UI values."
|
|
}),
|
|
}
|
|
|
|
return inputs
|
|
|
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
|
RETURN_NAMES = ("model", "clip", "info", "weights_output")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "load_lora"
|
|
CATEGORY = "loaders/lora"
|
|
DESCRIPTION = """Selective LoRA loader for Wan 2.2. Toggle blocks on/off.
|
|
|
|
TIP: Use 'LoRA Loader + Analyzer' first to see which blocks matter for your LoRA."""
|
|
|
|
def load_lora(self, model, clip, lora_name, strength, preset, **kwargs):
|
|
# Get optional inputs from kwargs
|
|
lora_path_opt = kwargs.get("lora_path_opt")
|
|
analysis_json = kwargs.get("analysis_json")
|
|
block_weights_string = kwargs.get("block_weights_string", "")
|
|
|
|
# Store analysis_json for UI callback
|
|
self._analysis_json = analysis_json
|
|
parsed_weights = _parse_block_weights_string(block_weights_string, 'WAN')
|
|
if parsed_weights:
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
for block_name, (enabled, blk_str) in parsed_weights.items():
|
|
if block_name == "other_weights":
|
|
other_enabled = enabled
|
|
other_str = blk_str
|
|
elif block_name.startswith("block_") and enabled:
|
|
block_num = int(block_name.split("_")[1])
|
|
enabled_blocks.add(block_num)
|
|
block_strengths[block_num] = blk_str
|
|
using_preset = "String Input"
|
|
elif preset != "Custom":
|
|
enabled_blocks = WAN_PRESETS[preset].copy()
|
|
block_strengths = {i: 1.0 for i in enabled_blocks}
|
|
# All Off preset disables other_weights too
|
|
other_enabled = preset != "All Off"
|
|
other_str = 1.0
|
|
using_preset = preset
|
|
else:
|
|
# Build from individual toggles and strengths
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
for i in range(40):
|
|
if kwargs.get(f"block_{i}", True):
|
|
enabled_blocks.add(i)
|
|
block_strengths[i] = kwargs.get(f"block_{i}_str", 1.0)
|
|
other_enabled = kwargs.get("other_weights", True)
|
|
other_str = kwargs.get("other_weights_str", 1.0)
|
|
using_preset = None
|
|
|
|
# Load LoRA - use optional path if provided, otherwise use dropdown selection
|
|
if lora_path_opt and os.path.exists(lora_path_opt):
|
|
lora_path = lora_path_opt
|
|
else:
|
|
lora_path = folder_paths.get_full_path("loras", lora_name)
|
|
if not lora_path or not os.path.exists(lora_path):
|
|
return (model, clip, "Error: LoRA not found")
|
|
|
|
if lora_path.endswith('.safetensors'):
|
|
lora_state_dict = load_file(lora_path)
|
|
else:
|
|
lora_state_dict = torch.load(lora_path, map_location='cpu')
|
|
|
|
# Filter and scale tensors by block strength
|
|
filtered_dict = {}
|
|
for key, value in lora_state_dict.items():
|
|
block_num = _extract_block_id_wan(key)
|
|
if block_num is not None:
|
|
if block_num in enabled_blocks:
|
|
blk_str = block_strengths.get(block_num, 1.0)
|
|
filtered_dict[key] = value * blk_str if blk_str != 1.0 else value
|
|
elif other_enabled:
|
|
# Include non-block keys based on other_weights setting
|
|
filtered_dict[key] = value * other_str if other_str != 1.0 else value
|
|
|
|
original_count = len(lora_state_dict)
|
|
filtered_count = len(filtered_dict)
|
|
|
|
if filtered_count == 0:
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model, clip, "Warning: All blocks disabled, no LoRA applied", "")}
|
|
|
|
# Apply filtered LoRA
|
|
model_lora, clip_lora = comfy.sd.load_lora_for_models(
|
|
model, clip, filtered_dict, strength, strength
|
|
)
|
|
|
|
disabled_blocks = [i for i in range(40) if i not in enabled_blocks]
|
|
scaled = [f"{i}={block_strengths[i]:.2f}" for i in enabled_blocks if block_strengths.get(i, 1.0) != 1.0]
|
|
|
|
info = f"Loaded {filtered_count}/{original_count} tensors\n"
|
|
if using_preset:
|
|
info += f"Preset: {using_preset}\n"
|
|
else:
|
|
info += "Preset: Custom\n"
|
|
info += f"Enabled: {len(enabled_blocks)}/40 blocks\n"
|
|
if scaled:
|
|
info += f"Scaled: {', '.join(scaled[:10])}"
|
|
if len(scaled) > 10:
|
|
info += f" (+{len(scaled)-10} more)\n"
|
|
else:
|
|
info += "\n"
|
|
if disabled_blocks:
|
|
info += f"Disabled: {', '.join(str(b) for b in disabled_blocks)}"
|
|
else:
|
|
info += "All blocks enabled"
|
|
|
|
weights_output = ", ".join(
|
|
f"{(block_strengths.get(i, 0.0) if i in enabled_blocks else 0.0):.2f}"
|
|
for i in range(40)
|
|
)
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model_lora, clip_lora, info, weights_output)}
|
|
|
|
|
|
class QwenSelectiveLoRALoader:
|
|
"""
|
|
Selective LoRA Loader for Qwen-Image models.
|
|
|
|
Toggle individual transformer blocks on/off to control which parts of the LoRA are applied.
|
|
Use the LoRA Analyzer first to see which blocks have the most impact.
|
|
|
|
Block Guide (60 total):
|
|
- block_0-14: Early transformer blocks
|
|
- block_15-29: Early-mid blocks
|
|
- block_30-44: Mid-late blocks
|
|
- block_45-59: Late blocks
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
inputs = {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"clip": ("CLIP",),
|
|
"lora_name": (folder_paths.get_filename_list("loras"), {
|
|
"tooltip": "Qwen-Image LoRA file to load"
|
|
}),
|
|
"strength": ("FLOAT", {
|
|
"default": 1.0,
|
|
"min": -5.0,
|
|
"max": 5.0,
|
|
"step": 0.05,
|
|
"tooltip": "Overall LoRA strength"
|
|
}),
|
|
"preset": (list(QWEN_PRESETS.keys()), {
|
|
"default": "All Blocks",
|
|
"tooltip": "Quick preset selection. Choose 'Custom' to use individual toggles below."
|
|
}),
|
|
},
|
|
}
|
|
|
|
# Add block toggles and strengths (0-59)
|
|
for i in range(60):
|
|
inputs["required"][f"block_{i}"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"][f"block_{i}_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
# Other weights (keys not matching known blocks)
|
|
inputs["required"]["other_weights"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"]["other_weights_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
inputs["optional"] = {
|
|
"lora_path_opt": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer to use its selected LoRA"}),
|
|
"analysis_json": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer for impact-colored checkboxes"}),
|
|
"block_weights_string": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": "Input/Output block profile string. Positional text syncs with the UI. String input overrides UI values."
|
|
}),
|
|
}
|
|
|
|
return inputs
|
|
|
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
|
RETURN_NAMES = ("model", "clip", "info", "weights_output")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "load_lora"
|
|
CATEGORY = "loaders/lora"
|
|
DESCRIPTION = """Selective LoRA loader for Qwen-Image. Toggle blocks on/off.
|
|
|
|
TIP: Use 'LoRA Loader + Analyzer' first to see which blocks matter for your LoRA."""
|
|
|
|
def load_lora(self, model, clip, lora_name, strength, preset, **kwargs):
|
|
# Get optional inputs from kwargs
|
|
lora_path_opt = kwargs.get("lora_path_opt")
|
|
analysis_json = kwargs.get("analysis_json")
|
|
block_weights_string = kwargs.get("block_weights_string", "")
|
|
|
|
# Store analysis_json for UI callback
|
|
self._analysis_json = analysis_json
|
|
parsed_weights = _parse_block_weights_string(block_weights_string, 'QWEN')
|
|
if parsed_weights:
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
for block_name, (enabled, blk_str) in parsed_weights.items():
|
|
if block_name == "other_weights":
|
|
other_enabled = enabled
|
|
other_str = blk_str
|
|
elif block_name.startswith("block_") and enabled:
|
|
block_num = int(block_name.split("_")[1])
|
|
enabled_blocks.add(block_num)
|
|
block_strengths[block_num] = blk_str
|
|
using_preset = "String Input"
|
|
elif preset != "Custom":
|
|
enabled_blocks = QWEN_PRESETS[preset].copy()
|
|
block_strengths = {i: 1.0 for i in enabled_blocks}
|
|
# All Off preset disables other_weights too
|
|
other_enabled = preset != "All Off"
|
|
other_str = 1.0
|
|
using_preset = preset
|
|
else:
|
|
# Build from individual toggles and strengths
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
for i in range(60):
|
|
if kwargs.get(f"block_{i}", True):
|
|
enabled_blocks.add(i)
|
|
block_strengths[i] = kwargs.get(f"block_{i}_str", 1.0)
|
|
other_enabled = kwargs.get("other_weights", True)
|
|
other_str = kwargs.get("other_weights_str", 1.0)
|
|
using_preset = None
|
|
|
|
# Load LoRA - use optional path if provided, otherwise use dropdown selection
|
|
if lora_path_opt and os.path.exists(lora_path_opt):
|
|
lora_path = lora_path_opt
|
|
else:
|
|
lora_path = folder_paths.get_full_path("loras", lora_name)
|
|
if not lora_path or not os.path.exists(lora_path):
|
|
return (model, clip, "Error: LoRA not found")
|
|
|
|
if lora_path.endswith('.safetensors'):
|
|
lora_state_dict = load_file(lora_path)
|
|
else:
|
|
lora_state_dict = torch.load(lora_path, map_location='cpu')
|
|
|
|
# Filter and scale tensors by block strength
|
|
filtered_dict = {}
|
|
for key, value in lora_state_dict.items():
|
|
block_num = _extract_block_id_qwen(key)
|
|
if block_num is not None:
|
|
if block_num in enabled_blocks:
|
|
blk_str = block_strengths.get(block_num, 1.0)
|
|
filtered_dict[key] = value * blk_str if blk_str != 1.0 else value
|
|
elif other_enabled:
|
|
# Include non-block keys based on other_weights setting
|
|
filtered_dict[key] = value * other_str if other_str != 1.0 else value
|
|
|
|
original_count = len(lora_state_dict)
|
|
filtered_count = len(filtered_dict)
|
|
|
|
if filtered_count == 0:
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model, clip, "Warning: All blocks disabled, no LoRA applied", "")}
|
|
|
|
# Apply filtered LoRA
|
|
model_lora, clip_lora = comfy.sd.load_lora_for_models(
|
|
model, clip, filtered_dict, strength, strength
|
|
)
|
|
|
|
disabled_blocks = [i for i in range(60) if i not in enabled_blocks]
|
|
scaled = [f"{i}={block_strengths[i]:.2f}" for i in enabled_blocks if block_strengths.get(i, 1.0) != 1.0]
|
|
|
|
info = f"Loaded {filtered_count}/{original_count} tensors\n"
|
|
if using_preset:
|
|
info += f"Preset: {using_preset}\n"
|
|
else:
|
|
info += "Preset: Custom\n"
|
|
info += f"Enabled: {len(enabled_blocks)}/60 blocks\n"
|
|
if scaled:
|
|
info += f"Scaled: {', '.join(scaled[:10])}"
|
|
if len(scaled) > 10:
|
|
info += f" (+{len(scaled)-10} more)\n"
|
|
else:
|
|
info += "\n"
|
|
if disabled_blocks:
|
|
info += f"Disabled: {', '.join(str(b) for b in disabled_blocks)}"
|
|
else:
|
|
info += "All blocks enabled"
|
|
|
|
weights_output = ", ".join(
|
|
f"{(block_strengths.get(i, 0.0) if i in enabled_blocks else 0.0):.2f}"
|
|
for i in range(60)
|
|
)
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model_lora, clip_lora, info, weights_output)}
|
|
|
|
|
|
class Krea2SelectiveLoRALoader:
|
|
"""
|
|
Selective LoRA Loader for Krea 2 models.
|
|
|
|
Toggle individual main SingleStreamBlocks on/off to control which parts of the LoRA are applied.
|
|
Non-main-block Linear layers such as first, last.linear, tmlp, txtmlp, tproj, and txtfusion are
|
|
controlled by other_weights.
|
|
|
|
Block Guide (28 total):
|
|
- block_0-8: Early main blocks
|
|
- block_9-18: Mid main blocks
|
|
- block_19-27: Late main blocks
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
inputs = {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"clip": ("CLIP",),
|
|
"lora_name": (folder_paths.get_filename_list("loras"), {
|
|
"tooltip": "Krea 2 LoRA file to load"
|
|
}),
|
|
"strength": ("FLOAT", {
|
|
"default": 1.0,
|
|
"min": -5.0,
|
|
"max": 5.0,
|
|
"step": 0.05,
|
|
"tooltip": "Overall LoRA strength"
|
|
}),
|
|
"preset": (list(KREA2_PRESETS.keys()), {
|
|
"default": "All Blocks",
|
|
"tooltip": "Quick preset selection. Choose 'Custom' to use individual toggles below."
|
|
}),
|
|
},
|
|
}
|
|
|
|
# Add main block toggles and strengths (0-27)
|
|
for i in range(28):
|
|
inputs["required"][f"block_{i}"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"][f"block_{i}_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
# Other Krea 2 Linear layers: first, last.linear, tmlp, txtmlp, tproj, txtfusion, etc.
|
|
inputs["required"]["other_weights"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"]["other_weights_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
inputs["optional"] = {
|
|
"lora_path_opt": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer to use its selected LoRA"}),
|
|
"analysis_json": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer for impact-colored checkboxes"}),
|
|
"block_weights_string": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": "Input/Output block profile string. Positional text syncs with the UI. String input overrides UI values."
|
|
}),
|
|
}
|
|
|
|
return inputs
|
|
|
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
|
RETURN_NAMES = ("model", "clip", "info", "weights_output")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "load_lora"
|
|
CATEGORY = "loaders/lora"
|
|
DESCRIPTION = """Selective LoRA loader for Krea 2. Toggle the 28 main SingleStreamBlocks on/off.
|
|
|
|
TIP: Use 'LoRA Loader + Analyzer' first to see which blocks matter for your LoRA.
|
|
Use other_weights for non-main-block Krea 2 modules like txtfusion, tmlp, txtmlp, tproj, first, and last.linear."""
|
|
|
|
def load_lora(self, model, clip, lora_name, strength, preset, **kwargs):
|
|
# Get optional inputs from kwargs
|
|
lora_path_opt = kwargs.get("lora_path_opt")
|
|
analysis_json = kwargs.get("analysis_json")
|
|
block_weights_string = kwargs.get("block_weights_string", "")
|
|
|
|
# Store analysis_json for UI callback
|
|
self._analysis_json = analysis_json
|
|
parsed_weights = _parse_block_weights_string(block_weights_string, 'KREA2')
|
|
if parsed_weights:
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
for block_name, (enabled, block_strength) in parsed_weights.items():
|
|
if block_name == "other_weights":
|
|
other_enabled = enabled
|
|
other_str = block_strength
|
|
elif block_name.startswith("block_") and enabled:
|
|
block_num = int(block_name.split("_")[1])
|
|
enabled_blocks.add(block_num)
|
|
block_strengths[block_num] = block_strength
|
|
using_preset = "String Input"
|
|
elif preset != "Custom":
|
|
enabled_blocks = KREA2_PRESETS[preset].copy()
|
|
block_strengths = {i: 1.0 for i in enabled_blocks}
|
|
# All Off preset disables other_weights too
|
|
other_enabled = preset != "All Off"
|
|
other_str = 1.0
|
|
using_preset = preset
|
|
else:
|
|
# Build from individual toggles and strengths
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
for i in range(28):
|
|
if kwargs.get(f"block_{i}", True):
|
|
enabled_blocks.add(i)
|
|
block_strengths[i] = kwargs.get(f"block_{i}_str", 1.0)
|
|
other_enabled = kwargs.get("other_weights", True)
|
|
other_str = kwargs.get("other_weights_str", 1.0)
|
|
using_preset = None
|
|
|
|
# Load LoRA - use optional path if provided, otherwise use dropdown selection
|
|
if lora_path_opt and os.path.exists(lora_path_opt):
|
|
lora_path = lora_path_opt
|
|
else:
|
|
lora_path = folder_paths.get_full_path("loras", lora_name)
|
|
if not lora_path or not os.path.exists(lora_path):
|
|
return (model, clip, "Error: LoRA not found", "")
|
|
|
|
if lora_path.endswith('.safetensors'):
|
|
lora_state_dict = load_file(lora_path)
|
|
else:
|
|
lora_state_dict = torch.load(lora_path, map_location='cpu')
|
|
|
|
# Filter and scale tensors by block strength
|
|
filtered_dict = {}
|
|
for key, value in lora_state_dict.items():
|
|
block_num = _extract_block_id_krea2(key)
|
|
if block_num is not None:
|
|
if block_num in enabled_blocks:
|
|
blk_str = block_strengths.get(block_num, 1.0)
|
|
filtered_dict[key] = value * blk_str if blk_str != 1.0 else value
|
|
elif other_enabled:
|
|
# Include non-main-block keys based on other_weights setting
|
|
filtered_dict[key] = value * other_str if other_str != 1.0 else value
|
|
|
|
original_count = len(lora_state_dict)
|
|
filtered_count = len(filtered_dict)
|
|
|
|
if filtered_count == 0:
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model, clip, "Warning: All blocks disabled, no LoRA applied", "")}
|
|
|
|
# Apply filtered LoRA
|
|
model_lora, clip_lora = comfy.sd.load_lora_for_models(
|
|
model, clip, filtered_dict, strength, strength
|
|
)
|
|
|
|
disabled_blocks = [i for i in range(28) if i not in enabled_blocks]
|
|
scaled = [f"{i}={block_strengths[i]:.2f}" for i in enabled_blocks if block_strengths.get(i, 1.0) != 1.0]
|
|
|
|
info = f"Loaded {filtered_count}/{original_count} tensors\n"
|
|
if using_preset:
|
|
info += f"Preset: {using_preset}\n"
|
|
else:
|
|
info += "Preset: Custom\n"
|
|
info += f"Enabled: {len(enabled_blocks)}/28 blocks\n"
|
|
info += f"Other weights: {'enabled' if other_enabled else 'disabled'}"
|
|
if other_enabled and other_str != 1.0:
|
|
info += f" ({other_str:.2f})"
|
|
info += "\n"
|
|
if scaled:
|
|
info += f"Scaled: {', '.join(scaled[:10])}"
|
|
if len(scaled) > 10:
|
|
info += f" (+{len(scaled)-10} more)\n"
|
|
else:
|
|
info += "\n"
|
|
if disabled_blocks:
|
|
info += f"Disabled: {', '.join(str(b) for b in disabled_blocks)}"
|
|
else:
|
|
info += "All blocks enabled"
|
|
|
|
output_values = [
|
|
block_strengths.get(i, 0.0) if i in enabled_blocks else 0.0
|
|
for i in range(28)
|
|
]
|
|
output_values.append(other_str if other_enabled else 0.0)
|
|
weights_output = ", ".join(f"{value:.2f}" for value in output_values)
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model_lora, clip_lora, info, weights_output)}
|
|
|
|
|
|
class MiniMaxH3SelectiveLoRALoader:
|
|
"""Selective LoRA loader for MiniMax H3's 50 packed DiT blocks."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
inputs = {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"clip": ("CLIP",),
|
|
"lora_name": (folder_paths.get_filename_list("loras"), {
|
|
"tooltip": "MiniMax H3 LoRA file to load"
|
|
}),
|
|
"strength": ("FLOAT", {
|
|
"default": 1.0,
|
|
"min": -5.0,
|
|
"max": 5.0,
|
|
"step": 0.05,
|
|
"tooltip": "Overall LoRA strength"
|
|
}),
|
|
"preset": (list(MINIMAX_H3_PRESETS.keys()), {
|
|
"default": "All Blocks",
|
|
"tooltip": "Quick preset selection. Choose 'Custom' to use individual toggles below."
|
|
}),
|
|
},
|
|
}
|
|
|
|
for i in range(50):
|
|
inputs["required"][f"block_{i}"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"][f"block_{i}_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
inputs["required"]["other_weights"] = ("BOOLEAN", {"default": True})
|
|
inputs["required"]["other_weights_str"] = ("FLOAT", {
|
|
"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05
|
|
})
|
|
|
|
inputs["optional"] = {
|
|
"lora_path_opt": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer to use its selected LoRA"}),
|
|
"analysis_json": ("STRING", {"forceInput": True, "tooltip": "Optional: Connect from LoRA Analyzer for impact-colored checkboxes"}),
|
|
"block_weights_string": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": "Optional positional or named block profile. A connected string overrides the individual controls."
|
|
}),
|
|
"save_refined_lora": ("BOOLEAN", {
|
|
"default": False,
|
|
"tooltip": "Save the exact filtered/scaled LoRA applied by this node."
|
|
}),
|
|
"save_path": ("STRING", {
|
|
"default": "",
|
|
"tooltip": "Directory where the filtered LoRA will be saved."
|
|
}),
|
|
"save_filename": ("STRING", {
|
|
"default": "",
|
|
"tooltip": "Base filename. A timestamp and .safetensors are added automatically."
|
|
}),
|
|
}
|
|
return inputs
|
|
|
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
|
RETURN_NAMES = ("model", "clip", "info", "weights_output")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "load_lora"
|
|
CATEGORY = "loaders/lora"
|
|
DESCRIPTION = """Selective LoRA loader for MiniMax H3. Toggle the 50 main packed DiT blocks on/off.
|
|
|
|
Use other_weights for token-refiner and any non-main H3 tensors. Supports both native
|
|
diffusion_model.blocks.* keys and lora_unet_blocks_* training keys."""
|
|
|
|
def load_lora(self, model, clip, lora_name, strength, preset, **kwargs):
|
|
strength = _coerce_scalar_strength(strength)
|
|
lora_path_opt = kwargs.get("lora_path_opt")
|
|
analysis_json = kwargs.get("analysis_json")
|
|
block_weights_string = kwargs.get("block_weights_string", "")
|
|
save_refined_lora = kwargs.get("save_refined_lora", False)
|
|
save_path = kwargs.get("save_path", "")
|
|
save_filename = kwargs.get("save_filename", "")
|
|
|
|
self._analysis_json = analysis_json
|
|
parsed_weights = _parse_block_weights_string(block_weights_string, "MINIMAX_H3")
|
|
if parsed_weights:
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
other_enabled = True
|
|
other_str = 1.0
|
|
for block_name, (enabled, blk_str) in parsed_weights.items():
|
|
if block_name == "other_weights":
|
|
other_enabled = enabled
|
|
other_str = blk_str
|
|
elif block_name.startswith("block_") and enabled:
|
|
block_num = int(block_name.split("_")[1])
|
|
enabled_blocks.add(block_num)
|
|
block_strengths[block_num] = blk_str
|
|
using_preset = "String Input"
|
|
elif preset != "Custom":
|
|
enabled_blocks = MINIMAX_H3_PRESETS[preset].copy()
|
|
block_strengths = {i: 1.0 for i in enabled_blocks}
|
|
other_enabled = preset != "All Off"
|
|
other_str = 1.0
|
|
using_preset = preset
|
|
else:
|
|
enabled_blocks = set()
|
|
block_strengths = {}
|
|
for i in range(50):
|
|
if kwargs.get(f"block_{i}", True):
|
|
enabled_blocks.add(i)
|
|
block_strengths[i] = kwargs.get(f"block_{i}_str", 1.0)
|
|
other_enabled = kwargs.get("other_weights", True)
|
|
other_str = kwargs.get("other_weights_str", 1.0)
|
|
using_preset = None
|
|
|
|
if lora_path_opt and os.path.exists(lora_path_opt):
|
|
lora_path = lora_path_opt
|
|
else:
|
|
lora_path = folder_paths.get_full_path("loras", lora_name)
|
|
if not lora_path or not os.path.exists(lora_path):
|
|
return (model, clip, "Error: LoRA not found", "")
|
|
|
|
if lora_path.endswith(".safetensors"):
|
|
lora_state_dict = load_file(lora_path)
|
|
else:
|
|
lora_state_dict = torch.load(lora_path, map_location="cpu")
|
|
|
|
filtered_dict = {}
|
|
for key, value in lora_state_dict.items():
|
|
block_num = _extract_block_id_minimax_h3(key)
|
|
if block_num is not None:
|
|
if block_num in enabled_blocks:
|
|
blk_str = block_strengths.get(block_num, 1.0)
|
|
filtered_dict[key] = _scale_minimax_h3_tensor(key, value, blk_str)
|
|
elif other_enabled:
|
|
filtered_dict[key] = _scale_minimax_h3_tensor(key, value, other_str)
|
|
|
|
original_count = len(lora_state_dict)
|
|
filtered_count = len(filtered_dict)
|
|
if filtered_count == 0:
|
|
output_values = [
|
|
block_strengths.get(i, 0.0) if i in enabled_blocks else 0.0
|
|
for i in range(50)
|
|
]
|
|
output_values.append(other_str if other_enabled else 0.0)
|
|
weights_output = ", ".join(f"{value:.2f}" for value in output_values)
|
|
return {
|
|
"ui": {"analysis_json": [analysis_json or ""]},
|
|
"result": (model, clip, "Warning: All blocks disabled, no LoRA applied", weights_output),
|
|
}
|
|
|
|
saved_path = None
|
|
if save_refined_lora and save_path.strip():
|
|
saved_path = _save_minimax_h3_filtered_lora(
|
|
filtered_dict, lora_path, save_path, save_filename
|
|
)
|
|
|
|
model_lora, clip_lora = comfy.sd.load_lora_for_models(
|
|
model, clip, filtered_dict, strength, strength
|
|
)
|
|
|
|
disabled_blocks = [i for i in range(50) if i not in enabled_blocks]
|
|
scaled = [
|
|
f"{i}={block_strengths[i]:.2f}"
|
|
for i in sorted(enabled_blocks)
|
|
if block_strengths.get(i, 1.0) != 1.0
|
|
]
|
|
|
|
info = f"Loaded {filtered_count}/{original_count} tensors\n"
|
|
info += f"Preset: {using_preset or 'Custom'}\n"
|
|
info += f"Enabled: {len(enabled_blocks)}/50 blocks\n"
|
|
info += f"Other weights: {'enabled' if other_enabled else 'disabled'}"
|
|
if other_enabled and other_str != 1.0:
|
|
info += f" ({other_str:.2f})"
|
|
info += "\n"
|
|
if scaled:
|
|
info += f"Scaled: {', '.join(scaled[:10])}"
|
|
if len(scaled) > 10:
|
|
info += f" (+{len(scaled)-10} more)\n"
|
|
else:
|
|
info += "\n"
|
|
if disabled_blocks:
|
|
info += f"Disabled: {', '.join(str(b) for b in disabled_blocks)}"
|
|
else:
|
|
info += "All blocks enabled"
|
|
if save_refined_lora:
|
|
if saved_path:
|
|
info += f"\nSaved: {saved_path}"
|
|
elif not save_path.strip():
|
|
info += "\nSave skipped: save_path is empty"
|
|
else:
|
|
info += "\nSave failed; check the ComfyUI console"
|
|
|
|
output_values = [
|
|
block_strengths.get(i, 0.0) if i in enabled_blocks else 0.0
|
|
for i in range(50)
|
|
]
|
|
output_values.append(other_str if other_enabled else 0.0)
|
|
weights_output = ", ".join(f"{value:.2f}" for value in output_values)
|
|
return {"ui": {"analysis_json": [analysis_json or ""]}, "result": (model_lora, clip_lora, info, weights_output)}
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"SDXLSelectiveLoRALoader": SDXLSelectiveLoRALoader,
|
|
"ZImageSelectiveLoRALoader": ZImageSelectiveLoRALoader,
|
|
"FLUXSelectiveLoRALoader": FLUXSelectiveLoRALoader,
|
|
"WanSelectiveLoRALoader": WanSelectiveLoRALoader,
|
|
"QwenSelectiveLoRALoader": QwenSelectiveLoRALoader,
|
|
"Krea2SelectiveLoRALoader": Krea2SelectiveLoRALoader,
|
|
"MiniMaxH3SelectiveLoRALoader": MiniMaxH3SelectiveLoRALoader,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"SDXLSelectiveLoRALoader": "Selective LoRA Loader (SDXL)",
|
|
"ZImageSelectiveLoRALoader": "Selective LoRA Loader (Z-Image)",
|
|
"FLUXSelectiveLoRALoader": "Selective LoRA Loader (FLUX)",
|
|
"WanSelectiveLoRALoader": "Selective LoRA Loader (Wan)",
|
|
"QwenSelectiveLoRALoader": "Selective LoRA Loader (Qwen)",
|
|
"Krea2SelectiveLoRALoader": "Selective LoRA Loader (Krea 2)",
|
|
"MiniMaxH3SelectiveLoRALoader": "Selective LoRA Loader (MiniMax H3)",
|
|
}
|