Files
2025-01-20 15:56:35 +11:00

439 lines
17 KiB
Python

import os
from pathlib import Path
import folder_paths
import torch
from typing import Dict, List, Optional, Tuple
import yaml
import logging
import json
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger('HunyuanLoraLoader')
class BaseHunyuanLoraLoader:
"""Base class for Hunyuan LoRA loading functionality"""
def __init__(self):
self.loaded_lora: Optional[Tuple[str, Dict[str, torch.Tensor]]] = None
@classmethod
def get_cache_dir(cls) -> str:
"""Get or create the cache directory for block settings"""
try:
from folder_paths import base_path, folder_names_and_paths
cache_dir = Path(folder_names_and_paths["custom_nodes"][0][0]) / "ComfyUI_PixelFun" / "cache"
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
return cache_dir
except Exception as e:
logger.error(f"Failed to create or access cache directory: {str(e)}")
raise
def get_settings_filename(self, lora_name: str) -> str:
"""Generate the settings filename for a given LoRA"""
base_name = os.path.splitext(lora_name)[0]
return os.path.join(self.get_cache_dir(), f"{base_name}_blocks.yaml")
def get_block_settings(self, lora_name: str, use_block_cache: bool = True, include_single_blocks: bool = False) -> dict:
"""Load block settings from cache or return defaults"""
# Initialize with all double blocks enabled and single blocks based on parameter
default_settings = {
**{f"double_blocks.{i}.": True for i in range(20)},
**{f"single_blocks.{i}.": include_single_blocks for i in range(40)}
}
if not use_block_cache:
return default_settings
try:
settings_file = self.get_settings_filename(lora_name)
if os.path.exists(settings_file):
cached_settings = yaml.safe_load(open(settings_file, 'r'))
# Merge cached settings with default single block settings
return {
**default_settings,
**cached_settings,
**{f"single_blocks.{i}.": include_single_blocks for i in range(40)} # Override single blocks
}
return default_settings
except Exception as e:
logger.error(f"Failed to load block settings for {lora_name}: {str(e)}")
return default_settings
def save_block_settings(self, lora_name: str, block_settings: dict):
"""Save block settings to cache"""
try:
settings_file = self.get_settings_filename(lora_name)
# Ensure directory exists
os.makedirs(os.path.dirname(settings_file), exist_ok=True)
save_settings = {k: v for k, v in block_settings.items() if k.startswith('double_blocks.')}
with open(settings_file, 'w') as f:
yaml.safe_dump(save_settings, f)
except Exception as e:
logger.error(f"Failed to save block settings for {lora_name}: {str(e)}")
def filter_lora_keys(self, lora: Dict[str, torch.Tensor], block_settings: dict) -> Dict[str, torch.Tensor]:
"""Filter LoRA keys based on block settings"""
filtered_blocks = {k: v for k, v in block_settings.items() if v is True}
return {key: value for key, value in lora.items()
if any(block in key for block in filtered_blocks)}
def load_lora_file(self, lora_name: str) -> Dict[str, torch.Tensor]:
"""Load LoRA file and cache it"""
from comfy.utils import load_torch_file
lora_path = folder_paths.get_full_path("loras", lora_name)
if not os.path.exists(lora_path):
raise Exception(f"LoRA {lora_name} not found at {lora_path}")
if self.loaded_lora is not None and self.loaded_lora[0] == lora_path:
return self.loaded_lora[1]
lora = load_torch_file(lora_path)
self.loaded_lora = (lora_path, lora)
return lora
def get_file_mtime(self, filepath: str) -> str:
"""Get modification time of file as string"""
try:
return str(os.path.getmtime(filepath))
except:
return "0"
def get_lora_mtime(self, lora_name: str) -> str:
"""Get modification time of LoRA file"""
try:
lora_path = folder_paths.get_full_path("loras", lora_name)
return self.get_file_mtime(lora_path)
except:
return "0"
def get_cache_mtime(self, lora_name: str) -> str:
"""Get modification time of cache file"""
try:
cache_file = self.get_settings_filename(lora_name)
return self.get_file_mtime(cache_file)
except:
return "0"
def apply_lora(self, model, lora_name: str, strength: float, block_settings: Optional[dict] = None) -> torch.nn.Module:
"""Apply LoRA to model with given settings"""
from comfy.sd import load_lora_for_models
if not lora_name:
return model
try:
lora = self.load_lora_file(lora_name)
if block_settings is None:
block_settings = self.get_block_settings(lora_name, True) # Always use cache for direct loading
filtered_lora = self.filter_lora_keys(lora, block_settings)
new_model, _ = load_lora_for_models(model, None, filtered_lora, strength, 0)
return new_model if new_model is not None else model
except Exception as e:
logger.error(f"Error applying LoRA {lora_name}: {str(e)}")
return model
class HunyuanLoadAndEditLoraBlocks(BaseHunyuanLoraLoader):
"""Interactive LoRA block editor"""
@classmethod
def INPUT_TYPES(s):
arg_dict = {
"required": {
"model": ("MODEL",),
"lora_name": (folder_paths.get_filename_list("loras"),),
"strength": ("FLOAT", {
"default": 1.0,
"min": -10.0,
"max": 10.0,
"step": 0.01,
"display": "number"
}),
"save_settings": ("BOOLEAN", {"default": True}),
"use_single_blocks": ("BOOLEAN", {"default": False}),
}
}
for i in range(20):
arg_dict["required"][f"double_blocks.{i}."] = ("BOOLEAN", {"default": True})
return arg_dict
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "load_lora"
CATEGORY = "loaders/hunyuan"
@classmethod
def IS_CHANGED(s, model, lora_name: str, strength: float, save_settings: bool, use_single_blocks: bool, **kwargs):
instance = s()
lora_mtime = instance.get_lora_mtime(lora_name)
return f"{lora_name}_{strength}_{lora_mtime}"
def load_lora(self, model, lora_name: str, strength: float, save_settings: bool, use_single_blocks: bool, **kwargs):
if not lora_name:
return (model,)
# Add single blocks settings based on the parameter
block_settings = {
**kwargs,
**{f"single_blocks.{i}.": use_single_blocks for i in range(40)}
}
if save_settings:
self.save_block_settings(lora_name, block_settings)
return (self.apply_lora(model, lora_name, strength, block_settings),)
class HunyuanLoadFromBlockCache(BaseHunyuanLoraLoader):
"""Load LoRA using cached block settings"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"lora_name": (folder_paths.get_filename_list("loras"),),
"strength": ("FLOAT", {
"default": 1.0,
"min": -10.0,
"max": 10.0,
"step": 0.01,
"display": "number"
}),
"use_single_blocks": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "load_lora"
CATEGORY = "loaders/hunyuan"
@classmethod
def IS_CHANGED(s, model, lora_name: str, strength: float, use_single_blocks: bool):
instance = s()
lora_mtime = instance.get_lora_mtime(lora_name)
cache_mtime = instance.get_cache_mtime(lora_name)
return f"{lora_name}_{strength}_{lora_mtime}_{cache_mtime}"
def load_lora(self, model, lora_name: str, strength: float, use_single_blocks: bool):
block_settings = self.get_block_settings(lora_name, True, use_single_blocks)
return (self.apply_lora(model, lora_name, strength, block_settings),)
class HunyuanLoraFromJson(BaseHunyuanLoraLoader):
"""Load multiple LoRAs from JSON configuration"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"json_data": ("STRING", {"multiline": True}),
}
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "process_json"
CATEGORY = "loaders/hunyuan"
@classmethod
def IS_CHANGED(s, model, json_data: str):
try:
instance = s()
lora_configs = json.loads(json_data)
if not isinstance(lora_configs, list):
return json_data
mtimes = []
for config in lora_configs:
if isinstance(config, dict) and 'filename' in config and 'strength' in config:
lora_mtime = instance.get_lora_mtime(config['filename'])
use_cache = config.get('use_block_cache', True)
cache_mtime = instance.get_cache_mtime(config['filename']) if use_cache else "0"
mtimes.append(f"{config['filename']}_{config['strength']}_{lora_mtime}_{cache_mtime}")
return "_".join(mtimes) if mtimes else json_data
except:
return json_data
def process_json(self, model, json_data: str):
try:
lora_configs = json.loads(json_data)
if not isinstance(lora_configs, list):
logger.error("JSON data must be an array of LoRA configurations")
return (model,)
current_model = model
for config in lora_configs:
if not isinstance(config, dict) or 'filename' not in config or 'strength' not in config:
logger.error(f"Invalid LoRA config: {config}")
continue
logger.info(f"Applying LoRA: {config['filename']}")
use_cache = config.get('use_block_cache', True)
use_single = config.get('use_single_blocks', False)
block_settings = self.get_block_settings(config['filename'], use_cache, use_single)
current_model = self.apply_lora(
current_model,
config['filename'],
float(config['strength']),
block_settings
)
return (current_model,)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON data: {str(e)}")
return (model,)
except Exception as e:
logger.error(f"Error processing LoRAs: {str(e)}")
return (model,)
class HunyuanLoraFromPrompt(BaseHunyuanLoraLoader):
"""Load LoRAs based on trigger phrases in the prompt"""
def __init__(self):
super().__init__()
self._default_config = "lora_triggers.yaml"
self._config_file = None
@property
def config_file(self):
"""Get the full path to the config file"""
if not self._config_file:
return os.path.join(self.get_cache_dir(), self._default_config)
return os.path.join(self.get_cache_dir(), self._config_file)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"prompt": ("STRING", {"multiline": True}),
"config_file": ("STRING", {
"default": "lora_triggers.yaml",
"multiline": False
}),
}
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "process_prompt"
CATEGORY = "loaders/hunyuan"
def create_default_config(self):
"""Create a default configuration file with example"""
default_config = """# LoRA Trigger Configuration
# Each LoRA entry should have:
# - filename: The name of the LoRA file
# - strength: The strength to apply the LoRA (typically between 0.0 and 1.0)
# - use_block_cache: Whether to use cached block settings (default: true)
# - use_single_blocks: Whether to enable single block layers (default: false)
# - keywords: List of trigger words or phrases that will activate this LoRA
# (phrases can contain multiple words like "red hair" or "wearing glasses")
loras:
# Example LoRA configuration (uncomment and modify to use):
# - filename: example_lora.safetensors
# strength: 0.75
# use_block_cache: true
# use_single_blocks: true
# keywords:
# - red hair
# - wearing glasses
# - simple keyword
"""
try:
with open(self.config_file, 'w') as f:
f.write(default_config)
except Exception as e:
logger.error(f"Failed to create default config: {str(e)}")
def load_config(self) -> List[dict]:
"""Load trigger configuration file"""
try:
if not os.path.exists(self.config_file):
logger.info("Creating default LoRA triggers config file")
self.create_default_config()
return []
with open(self.config_file, 'r') as f:
config = yaml.safe_load(f)
if not config or 'loras' not in config:
logger.warning("Config file exists but contains no LoRA configurations")
return []
return config['loras']
except Exception as e:
logger.error(f"Failed to load config file: {str(e)}")
return []
def get_matching_loras(self, prompt: str, config: List[dict]) -> List[dict]:
"""Find LoRAs with matching trigger phrases in the prompt"""
prompt = prompt.lower()
matching_loras = []
for lora_config in config:
if not all(key in lora_config for key in ['filename', 'strength', 'keywords']):
logger.warning(f"Skipping invalid LoRA config: {lora_config}")
continue
for keyword in lora_config['keywords']:
if keyword.lower() in prompt:
matching_loras.append(lora_config)
logger.info(f"Applying LoRA: {lora_config['filename']}")
break
return matching_loras
@classmethod
def IS_CHANGED(s, model, prompt: str, config_file: str):
instance = s()
instance._config_file = config_file
# Get trigger file modification time
trigger_mtime = instance.get_file_mtime(instance.config_file)
# Get matching LoRAs and their mtimes
config = instance.load_config()
matching_loras = instance.get_matching_loras(prompt, config)
mtimes = []
for lora_config in matching_loras:
lora_mtime = instance.get_lora_mtime(lora_config['filename'])
use_cache = lora_config.get('use_block_cache', True)
cache_mtime = instance.get_cache_mtime(lora_config['filename']) if use_cache else "0"
mtimes.append(f"{lora_config['filename']}_{lora_config['strength']}_{lora_mtime}_{cache_mtime}")
return f"{prompt}_{trigger_mtime}_{'_'.join(mtimes)}"
def process_prompt(self, model, prompt: str, config_file: str):
self._config_file = config_file
config = self.load_config()
if not config:
return (model,)
matching_loras = self.get_matching_loras(prompt, config)
if not matching_loras:
return (model,)
current_model = model
for lora_config in matching_loras:
use_cache = lora_config.get('use_block_cache', True)
use_single = lora_config.get('use_single_blocks', True)
block_settings = self.get_block_settings(lora_config['filename'], use_cache, use_single)
current_model = self.apply_lora(
current_model,
lora_config['filename'],
float(lora_config['strength']),
block_settings
)
return (current_model,)