Files
MDMAchine-ComfyUI_MD_Nodes/lora/MD_DynamicLoRAStacker.py
T

330 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
# █▓▒░ ░▒▓█
# █▓▒░ MD_Nodes/DynamicLoRAStacker – Style Butler v1.0.1 ░▒▓█
# █▓▒░ ░▒▓█
# ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
# ╠═ © 2026 MDMAchine
# ╠═ License: GNU General Public License v3.0 (GPL v3)
# ║
# ║ This program is free software: you can redistribute it and/or modify
# ║ it under the terms of the GNU General Public License as published by
# ║ the Free Software Foundation, either version 3 of the License, or
# ║ (at your option) any later version.
# ║
# ║ This program is distributed in the hope that it will be useful,
# ║ but WITHOUT ANY WARRANTY; without even the implied warranty of
# ║ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# ║ GNU General Public License for more details.
# ║
# ║ You should have received a copy of the GNU General Public License
# ║ along with this program. If not, see <https://www.gnu.org/licenses/>.
# ╠════════════════════════════════════════════════════════════════════════════
# ║ ░▒▓ ORIGIN: Original Implementation - Quality of Life Enhancement
# ║ ░▒▓ DESCRIPTION:
# ║ The Style Butler - YAML-driven LoRA stacking automation.
# ║ Loads multiple LoRAs with individual strengths from preset styles.
# ║ Automatically applies prompt prefix/suffix for complete style transformation.
# ║ NOTE: As an automation/routing utility, this runs entirely in the wrapper.
# ║
# ║ ░▒▓ CORE FEATURES:
# ║ ✔ YAML-Driven Presets: Load styles from lora_styles.yaml
# ║ ✔ Multi-LoRA Stacking: Automatically loads and applies up to 10 LoRAs
# ║ ✔ Prompt Enhancement: Auto-adds style-specific prefix and suffix safely
# ║ ✔ Fallback Handling: Never crashes if a LoRA is missing from your drive
# ║
# ║ ░▒▓ CHANGELOG:
# ║ v1.0.1 (2026-02-24) - Enterprise Standards Update
# ║ ├── FIX: Corrected prompt concatenation spacing issues (no squished words).
# ║ ├── FIX: Hardened `try/except` block around LoRA loading to prevent Comfy crashes.
# ║ └── VERIFIED: Tooltips meet strict v1.5.4 standards.
# ╚════════════════════════════════════════════════════════════════════════════
VERSION = "v1.0.1" # UPS v1.5.8
import os
import logging
import folder_paths
try:
import yaml
YAML_AVAILABLE = True
except ImportError:
YAML_AVAILABLE = False
logging.warning("[MD_DynamicLoRAStacker] PyYAML not available - install with: pip install pyyaml")
try:
import comfy.sd
LORA_LOADER_AVAILABLE = True
except ImportError:
LORA_LOADER_AVAILABLE = False
# =================================================================================
# == Configuration Constants
# =================================================================================
CONST_STYLES_FILENAME = "lora_styles.yaml"
CONST_MAX_LORAS_PER_STYLE = 10
CONST_STRENGTH_MIN = 0.0
CONST_STRENGTH_MAX = 2.0
CONST_STRENGTH_DEFAULT = 1.0
CONST_MULTIPLIER_MIN = 0.0
CONST_MULTIPLIER_MAX = 3.0
CONST_MULTIPLIER_DEFAULT = 1.0
CONST_MULTIPLIER_STEP = 0.05
CONST_FALLBACK_STYLE_NAME = "None"
CONST_EMPTY_PROMPT_PREFIX = ""
CONST_EMPTY_PROMPT_SUFFIX = ""
# =================================================================================
# == Core Node Class
# =================================================================================
class MD_DynamicLoRAStacker:
"""Automates LoRA loading and prompt enhancement based on YAML presets."""
@classmethod
def INPUT_TYPES(cls):
style_list = cls._load_style_names()
return {
"required": {
"model": ("MODEL", {
"tooltip": (
"MODEL INPUT\n"
"• Purpose: The base diffusion model.\n"
"• Action: Selected LoRAs will be patched into this model."
)
}),
"clip": ("CLIP", {
"tooltip": (
"CLIP INPUT\n"
"• Purpose: The text encoder.\n"
"• Action: Selected LoRAs will be patched into this encoder."
)
}),
"style_preset": (style_list, {
"default": style_list[0] if style_list else CONST_FALLBACK_STYLE_NAME,
"tooltip": (
"STYLE PRESET\n"
"• Purpose: Loads a pre-configured LoRA stack from lora_styles.yaml.\n"
"• Action: Applies multiple LoRAs, weights, and prompt tags instantly.\n"
"\n⭐ Tip: Edit the YAML file to create your own custom studio presets."
)
}),
"global_strength_multiplier": ("FLOAT", {
"default": CONST_MULTIPLIER_DEFAULT,
"min": CONST_MULTIPLIER_MIN,
"max": CONST_MULTIPLIER_MAX,
"step": CONST_MULTIPLIER_STEP,
"tooltip": (
"GLOBAL STRENGTH MULTIPLIER\n"
"• Purpose: Scales the intensity of the entire preset stack.\n"
"• Note: 1.0 uses YAML values exactly. 0.5 cuts all strengths in half.\n"
"\n⭐ Recommended: 1.0."
)
}),
},
"optional": {
"base_prompt": ("STRING", {
"multiline": True,
"default": "",
"tooltip": (
"BASE PROMPT\n"
"• Purpose: Your main subject prompt.\n"
"• Action: The node will automatically wrap this with the preset's prefix/suffix."
)
}),
"override_prefix": ("STRING", {
"default": "",
"tooltip": (
"OVERRIDE PREFIX\n"
"• Purpose: Replaces the preset's default prefix with your own."
)
}),
"override_suffix": ("STRING", {
"default": "",
"tooltip": (
"OVERRIDE SUFFIX\n"
"• Purpose: Replaces the preset's default suffix with your own."
)
}),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
RETURN_NAMES = ("model", "clip", "enhanced_prompt", "style_info")
FUNCTION = "apply_style"
CATEGORY = "MD_Nodes/LoRa"
@classmethod
def _load_style_names(cls):
if not YAML_AVAILABLE: return [CONST_FALLBACK_STYLE_NAME]
yaml_path = cls._get_yaml_path()
if not os.path.exists(yaml_path):
logging.warning(f"[MD_DynamicLoRAStacker] YAML not found: {yaml_path}")
return [CONST_FALLBACK_STYLE_NAME]
try:
with open(yaml_path, 'r', encoding='utf-8') as f:
styles_data = yaml.safe_load(f)
if styles_data and isinstance(styles_data, dict):
return [CONST_FALLBACK_STYLE_NAME] + list(styles_data.keys())
return [CONST_FALLBACK_STYLE_NAME]
except Exception as e:
logging.error(f"[MD_DynamicLoRAStacker] Error loading YAML: {e}")
return [CONST_FALLBACK_STYLE_NAME]
@classmethod
def _get_yaml_path(cls):
node_dir = os.path.dirname(os.path.realpath(__file__))
return os.path.join(node_dir, CONST_STYLES_FILENAME)
def _load_style_config(self, style_name):
if style_name == CONST_FALLBACK_STYLE_NAME: return None
yaml_path = self._get_yaml_path()
try:
with open(yaml_path, 'r', encoding='utf-8') as f:
styles_data = yaml.safe_load(f)
if style_name in styles_data: return styles_data[style_name]
logging.error(f"[MD_DynamicLoRAStacker] Style '{style_name}' not found in YAML")
return None
except Exception as e:
logging.error(f"[MD_DynamicLoRAStacker] Error loading config: {e}")
return None
def _apply_lora(self, model, clip, lora_name, strength_model, strength_clip):
if not LORA_LOADER_AVAILABLE: return (model, clip)
lora_path = folder_paths.get_full_path("loras", lora_name)
if lora_path is None or not os.path.exists(lora_path):
logging.warning(f"[MD_DynamicLoRAStacker] ⚠️ LoRA not found: {lora_name} - Skipping")
return (model, clip)
try:
import comfy.utils
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
model_lora, clip_lora = comfy.sd.load_lora_for_models(
model, clip, lora, strength_model, strength_clip
)
return (model_lora, clip_lora)
except Exception as e:
logging.error(f"[MD_DynamicLoRAStacker] Error loading LoRA {lora_name}: {e}")
return (model, clip)
def apply_style(self, model, clip, style_preset, global_strength_multiplier,
base_prompt="", override_prefix="", override_suffix=""):
try:
if style_preset == CONST_FALLBACK_STYLE_NAME:
return (model, clip, base_prompt, "No style applied - 'None' selected")
style_config = self._load_style_config(style_preset)
if style_config is None:
return (model, clip, base_prompt, f"⚠️ Failed to load style: {style_preset}")
loras_list = style_config.get("loras", [])
yaml_prefix = style_config.get("prompt_prefix", CONST_EMPTY_PROMPT_PREFIX)
yaml_suffix = style_config.get("prompt_suffix", CONST_EMPTY_PROMPT_SUFFIX)
description = style_config.get("description", "No description")
final_prefix = override_prefix if override_prefix else yaml_prefix
final_suffix = override_suffix if override_suffix else yaml_suffix
logging.info("\n" + "="*70)
logging.info(f"🎨 MD_DynamicLoRAStacker - Applying Style: {style_preset}")
logging.info(f"📝 Description: {description}")
logging.info("="*70)
lora_count = 0
skipped_count = 0
for lora_entry in loras_list[:CONST_MAX_LORAS_PER_STYLE]:
lora_name = lora_entry.get("name")
strength_model = lora_entry.get("strength_model", CONST_STRENGTH_DEFAULT)
strength_clip = lora_entry.get("strength_clip", CONST_STRENGTH_DEFAULT)
if not lora_name: continue
adjusted_model = max(CONST_STRENGTH_MIN, min(CONST_STRENGTH_MAX, strength_model * global_strength_multiplier))
adjusted_clip = max(CONST_STRENGTH_MIN, min(CONST_STRENGTH_MAX, strength_clip * global_strength_multiplier))
model_before = model
model, clip = self._apply_lora(model, clip, lora_name, adjusted_model, adjusted_clip)
if model is model_before:
logging.warning(f" ⚠️ SKIPPED: {lora_name}")
skipped_count += 1
else:
logging.info(f" ✅ LOADED: {lora_name}")
logging.info(f" Model: {adjusted_model:.2f} | CLIP: {adjusted_clip:.2f}")
lora_count += 1
# Formatting the prompt correctly
parts = []
if final_prefix.strip(): parts.append(final_prefix.strip())
if base_prompt.strip(): parts.append(base_prompt.strip())
if final_suffix.strip(): parts.append(final_suffix.strip())
enhanced_prompt = ", ".join(parts).replace(" ,", ",")
style_info = (
f"Style: {style_preset}\n"
f"LoRAs Loaded: {lora_count}\n"
f"LoRAs Skipped: {skipped_count}\n"
f"Global Multiplier: {global_strength_multiplier:.2f}\n"
f"Prefix: {final_prefix[:50]}{'...' if len(final_prefix) > 50 else ''}\n"
f"Suffix: {final_suffix[:50]}{'...' if len(final_suffix) > 50 else ''}"
)
logging.info("-"*70)
logging.info(f"📊 Summary:")
logging.info(f" ✅ LoRAs Loaded: {lora_count}")
if skipped_count > 0: print(f" ⚠️ LoRAs Skipped: {skipped_count}")
logging.info(f" 🔢 Global Multiplier: {global_strength_multiplier:.2f}")
logging.info("="*70 + "\n")
return (model, clip, enhanced_prompt, style_info)
except Exception as e:
logging.error(f"[MD_DynamicLoRAStacker] Error applying style: {e}")
return (model, clip, base_prompt, f"❌ Error: {str(e)}")
# =================================================================================
# == Node Registration
# =================================================================================
NODE_CLASS_MAPPINGS = { "MD_DynamicLoRAStacker": MD_DynamicLoRAStacker }
NODE_DISPLAY_NAME_MAPPINGS = { "MD_DynamicLoRAStacker": "MD: Dynamic LoRA Stacker (Style Butler)" }
# ==============================================================================
# == Unit Tests (smoke — runs without ComfyUI)
# ==============================================================================
if __name__ == "__main__":
print("\n🧪 Smoke tests: MD_DynamicLoRAStacker")
print(" VERSION :", VERSION)
_pass = _fail = 0
def _check(label, expr):
global _pass, _fail
if expr:
print(f" ✅ {label}")
_pass += 1
else:
print(f" ❌ {label}")
_fail += 1
_check("VERSION defined", VERSION == "v1.0.1")
_check("CONST CONST_STYLES_FILENAME defined", CONST_STYLES_FILENAME is not None)
_check("CONST CONST_MAX_LORAS_PER_STYLE defined", CONST_MAX_LORAS_PER_STYLE is not None)
_check("CONST CONST_STRENGTH_MIN defined", CONST_STRENGTH_MIN is not None)
_check("CONST CONST_STRENGTH_MAX defined", CONST_STRENGTH_MAX is not None)
_check("CONST CONST_STRENGTH_DEFAULT defined", CONST_STRENGTH_DEFAULT is not None)
_check("NODE_CLASS_MAPPINGS defined",
isinstance(NODE_CLASS_MAPPINGS, dict) and len(NODE_CLASS_MAPPINGS) > 0)
_check(" class MD_DynamicLoRAStacker in map", "MD_DynamicLoRAStacker" in NODE_CLASS_MAPPINGS)
print(f"\n {_pass} passed, {_fail} failed")
if _fail == 0:
print(" 🎉 All good.")