Files
2026-02-27 16:53:20 -08:00

378 lines
14 KiB
Python
Raw Permalink 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.
"""
LoRA Trigger Word Management
Handles fetching, filtering, and processing of LoRA trigger words from Civitai
"""
import re
from functools import lru_cache
from .lora_utils import load_and_save_tags
from .config_utils import parse_lora_definition
def _should_omit_trigger(trigger, omit_normalized):
"""
Check if a trigger word should be omitted using token-based word boundary matching.
Uses regex word boundaries so "blue" omits "blue" but not "blueberry".
Falls back to exact match for multi-word omit terms.
Args:
trigger: The cleaned trigger string (lowercase)
omit_normalized: List of normalized omit terms (lowercase, stripped)
Returns:
True if the trigger should be omitted
"""
for omit_term in omit_normalized:
# Use word boundary regex: ensures "blue" matches "blue" but not "blueberry"
if re.search(r'\b' + re.escape(omit_term) + r'\b', trigger, re.IGNORECASE):
return True
return False
@lru_cache(maxsize=128)
def _get_filtered_lora_triggers_cached(lora_string, omit_tuple):
"""
Cached internal implementation. Takes hashable args (tuple instead of list).
"""
active_loras = parse_lora_definition(lora_string)
trigger_list = []
# Normalize omit list: strip whitespace and trailing commas, lowercase
omit_normalized = [str(t).lower().strip().rstrip(',').strip() for t in omit_tuple]
for lora_def in active_loras:
lname, lstr_m, lstr_c = lora_def
try:
civitai_tags_list = load_and_save_tags(lname, force_fetch=False)
if len(civitai_tags_list) > 0:
for tags in civitai_tags_list:
# Clean the trigger: strip whitespace and trailing commas
cleaned_tags = tags.strip().rstrip(',').strip()
# Check if this trigger should be omitted (token-based word boundary matching)
if not _should_omit_trigger(cleaned_tags.lower(), omit_normalized):
trigger_list.append(cleaned_tags)
except Exception as e:
pass
return trigger_list
def get_filtered_lora_triggers(lora_string, omit_list, lookup_triggers=True):
"""
Get LoRA trigger words with filtering applied.
Args:
lora_string: LoRA definition string (e.g., "lora1.safetensors:0.8:0.6 + lora2.safetensors:1.0:1.0")
omit_list: List of trigger words to omit
lookup_triggers: Whether to lookup triggers from Civitai
Returns:
List of filtered trigger words
"""
if not lookup_triggers or lora_string == "None":
return []
# Convert list to tuple for hashable cache key
return list(_get_filtered_lora_triggers_cached(lora_string, tuple(omit_list)))
def get_trigger_placement_for_lora(lora_name, config, lora_triggerwords_mode):
"""
Determine where to place trigger words for a specific LoRA.
Args:
lora_name: The LoRA filename (e.g., "XL/Tmp-Trendy/lora1.safetensors")
config: Configuration dictionary
lora_triggerwords_mode: The global mode (None, Append To End, Append To Start, Read From Config)
Returns:
str: "start", "end", or "none"
"""
if lora_triggerwords_mode == "None":
return "none"
elif lora_triggerwords_mode == "Append To End":
return "end"
elif lora_triggerwords_mode == "Append To Start":
return "start"
elif lora_triggerwords_mode == "Read From Config":
# Check if config has lora_triggerwords_append_settings
settings = config.get("lora_triggerwords_append_settings", {})
# Normalize the lora_name path (handle both / and \)
normalized_lora = lora_name.replace('\\', '/')
# print(f"[DEBUG] lora_name: {lora_name}")
# print(f"[DEBUG] normalized_lora: {normalized_lora}")
# print(f"[DEBUG] settings: {settings}")
# Try exact match first (with original path)
if lora_name in settings:
placement = settings[lora_name].lower()
if placement in ["start", "end"]:
# print(f"[DEBUG] Exact match (original): {placement}")
return placement
# Try exact match with normalized path
if normalized_lora != lora_name and normalized_lora in settings:
placement = settings[normalized_lora].lower()
if placement in ["start", "end"]:
# print(f"[DEBUG] Exact match (normalized): {placement}")
return placement
# Try matching without extension (normalized)
lora_base = normalized_lora.replace('.safetensors', '').replace('.ckpt', '').replace('.pt', '')
if lora_base in settings:
placement = settings[lora_base].lower()
if placement in ["start", "end"]:
# print(f"[DEBUG] Base name match: {placement}")
return placement
# Try matching against folders (check if lora is in any configured folder)
for setting_key, placement in settings.items():
# Normalize the setting key as well
normalized_key = setting_key.replace('\\', '/')
# print(f"[DEBUG] Checking folder: '{normalized_key}' vs '{normalized_lora}'")
# Check if this is a folder setting (ends with /)
if normalized_key.endswith('/'):
# Check if the normalized lora path starts with this normalized folder path
if normalized_lora.startswith(normalized_key):
placement_lower = placement.lower()
if placement_lower in ["start", "end"]:
# print(f"[DEBUG] Folder match! '{normalized_lora}' starts with '{normalized_key}' -> {placement_lower}")
return placement_lower
# Default to end if not specified in config
# print(f"[DEBUG] No match found, defaulting to 'end'")
return "end"
return "none"
def collect_unique_prompts_with_triggers(expanded_configs, lora_triggerwords_mode):
"""
Collect all unique prompts from configs, applying LoRA trigger words where needed.
Args:
expanded_configs: List of expanded configuration dictionaries
lora_triggerwords_mode: Mode for trigger word placement
Returns:
tuple: (unique_positives set, unique_negatives set)
"""
unique_positives = set()
unique_negatives = set()
for conf in expanded_configs:
full_positive = conf["positive"]
if lora_triggerwords_mode != "None" and conf["lora"] != "None":
omit_list = conf.get("lora_omit_triggers", [])
trigger_list = get_filtered_lora_triggers(
conf["lora"],
omit_list,
lookup_triggers=True
)
if not trigger_list:
print(f"[GridTester] ℹ️ No trigger words found for LoRA: {conf['lora'][:60]}")
if trigger_list:
# Parse the lora string to get individual loras
active_loras = parse_lora_definition(conf["lora"])
# Separate triggers by placement
start_triggers = []
end_triggers = []
for lora_def in active_loras:
lname, _, _ = lora_def
placement = get_trigger_placement_for_lora(lname, conf, lora_triggerwords_mode)
# Get triggers specific to this lora
try:
lora_triggers = load_and_save_tags(lname, force_fetch=False)
omit_normalized = [str(t).lower().strip().rstrip(',').strip() for t in omit_list]
for tag in lora_triggers:
cleaned_tag = tag.strip().rstrip(',').strip()
if not _should_omit_trigger(cleaned_tag.lower(), omit_normalized):
if placement == "start":
start_triggers.append(cleaned_tag)
elif placement == "end":
end_triggers.append(cleaned_tag)
except:
pass
# Build the full prompt with triggers in correct positions
parts = []
if start_triggers:
parts.append(", ".join(start_triggers))
parts.append(conf['positive'])
if end_triggers:
parts.append(", ".join(end_triggers))
full_positive = ", ".join(parts)
# Show omitted triggers only once during pre-encoding
if omit_list:
all_triggers = []
active_loras = parse_lora_definition(conf["lora"])
for lora_def in active_loras:
lname, _, _ = lora_def
try:
tags = load_and_save_tags(lname, force_fetch=False)
all_triggers.extend([t.strip().rstrip(',').strip() for t in tags])
except:
pass
omitted = set(all_triggers) - set(start_triggers + end_triggers)
if omitted:
print(f"[GridTester] 🚫 Omitted triggers: {', '.join(omitted)}")
# Apply model-specific prompt prefix/suffix (wraps entire prompt+triggers)
full_positive = _apply_model_prompt_affixes(full_positive, conf)
unique_positives.add(full_positive)
unique_negatives.add(conf["negative"])
return unique_positives, unique_negatives
_build_prompt_cache = {}
def clear_trigger_caches():
"""
Clear all trigger word caches. Must be called at the start of each generation run
to ensure trigger words are re-read from loras_tags.json (which may have been updated
by a previous run's CivitAI fetch or manual edit).
Without this, @lru_cache on _get_filtered_lora_triggers_cached and the module-level
_build_prompt_cache dict persist stale results across runs within the same ComfyUI session.
"""
global _build_prompt_cache
_build_prompt_cache.clear()
_get_filtered_lora_triggers_cached.cache_clear()
print("[GridTester] 🔄 Trigger word caches cleared")
def _apply_model_prompt_affixes(prompt, config):
"""
Apply model-specific prompt prefix and suffix to a prompt string.
These are quality tags that should always wrap the prompt for specific model families,
e.g. "score_9, score_8_up" for Pony or "masterpiece, best quality" for SD1.5.
Args:
prompt: The assembled prompt string (may already include LoRA triggers)
config: Configuration dictionary with optional model_prompt_prefix/suffix
Returns:
The prompt with prefix/suffix applied
"""
prefix = config.get("model_prompt_prefix", "").strip()
suffix = config.get("model_prompt_suffix", "").strip()
if not prefix and not suffix:
return prompt
parts = []
if prefix:
parts.append(prefix)
parts.append(prompt)
if suffix:
parts.append(suffix)
return ", ".join(parts)
def build_prompt_with_triggers(config, lora_triggerwords_mode):
"""
Build final prompt with LoRA triggers and model-specific prefix/suffix applied.
Results are cached based on relevant config fields to avoid redundant work.
Args:
config: Configuration dictionary
lora_triggerwords_mode: Mode for trigger word placement
Returns:
tuple: (final_prompt, trigger_string)
"""
if config["lora"] == "None" or lora_triggerwords_mode == "None":
prompt = _apply_model_prompt_affixes(config["positive"], config)
return prompt, ""
omit_list = config.get("lora_omit_triggers", [])
append_settings = config.get("lora_triggerwords_append_settings", {})
# Build a hashable cache key from relevant config fields
cache_key = (
config["lora"],
config["positive"],
tuple(omit_list),
tuple(sorted(append_settings.items())) if append_settings else (),
lora_triggerwords_mode,
config.get("model_prompt_prefix", ""),
config.get("model_prompt_suffix", "")
)
if cache_key in _build_prompt_cache:
return _build_prompt_cache[cache_key]
# Parse the lora string to get individual loras
active_loras = parse_lora_definition(config["lora"])
# Separate triggers by placement
start_triggers = []
end_triggers = []
for lora_def in active_loras:
lname, _, _ = lora_def
placement = get_trigger_placement_for_lora(lname, config, lora_triggerwords_mode)
# Get triggers specific to this lora
try:
lora_triggers = load_and_save_tags(lname, force_fetch=False)
omit_normalized = [str(t).lower().strip().rstrip(',').strip() for t in omit_list]
for tag in lora_triggers:
cleaned_tag = tag.strip().rstrip(',').strip()
if not _should_omit_trigger(cleaned_tag.lower(), omit_normalized):
if placement == "start":
start_triggers.append(cleaned_tag)
elif placement == "end":
end_triggers.append(cleaned_tag)
except:
pass
# Build the trigger string for return value
all_triggers = start_triggers + end_triggers
trigger_string = ""
if all_triggers:
trigger_string = ", " + ", ".join(all_triggers)
print(f"[GridTester] 🏷️ Trigger words for {config['lora'][:40]}: {', '.join(all_triggers[:5])}" +
(f" (+{len(all_triggers)-5} more)" if len(all_triggers) > 5 else ""))
# Build the full prompt with triggers in correct positions
parts = []
if start_triggers:
parts.append(", ".join(start_triggers))
parts.append(config['positive'])
if end_triggers:
parts.append(", ".join(end_triggers))
final_prompt = ", ".join(parts)
# Apply model-specific prefix/suffix (wraps the entire prompt+triggers)
final_prompt = _apply_model_prompt_affixes(final_prompt, config)
result = (final_prompt, trigger_string)
# Store in cache (limit size to prevent memory issues)
if len(_build_prompt_cache) > 256:
_build_prompt_cache.clear()
_build_prompt_cache[cache_key] = result
return result