Files
ComfyUI-Lora-Manager/py/utils/utils.py
T
Martial Michel e9aff35957 feat: add image metadata loader with native LoRA Manager integration
Add Load Image Metadata (LoraManager) to extract reusable prompts,
model references, LoRA stacks, and sampling settings from images.

Prefer saved A1111-style parameters by default, with optional workflow
and subgraph sampler selection. Resolve local model and LoRA names,
report missing resources, and recover extraction failures with explicit
defaults and readable diagnostics.

Include parser, resource-resolution, and node regression tests, plus
usage documentation.
2026-09-22 22:18:03 -04:00

747 lines
27 KiB
Python

from difflib import SequenceMatcher
import logging
import os
import re
from typing import Any, Dict, List, Optional
from ..services.service_registry import ServiceRegistry
from ..config import config
from ..services.settings_manager import get_settings_manager
import asyncio
logger = logging.getLogger(__name__)
def get_lora_info(lora_name):
"""Get the lora path and trigger words from cache"""
async def _get_lora_info_async():
scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data()
lora_name_normalized = lora_name.replace("\\", "/")
lora_name_no_ext = lora_name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if lora_name_no_ext.lower().endswith(ext):
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
break
has_path = "/" in lora_name_no_ext
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
best_fallback = None
for item in cache.raw_data:
file_name = item.get("file_name", "")
folder = item.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if lora_name_no_ext not in (file_name_no_ext, path_name):
if has_path and file_name_no_ext == basename:
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = item
elif best_fallback is None:
best_fallback = item
continue
file_path = item.get("file_path")
if not file_path:
continue
all_roots = list(config.loras_roots or []) + list(
config.extra_loras_roots or []
)
for root in all_roots:
root = root.replace(os.sep, "/")
if file_path.startswith(root):
relative_path = os.path.relpath(file_path, root).replace(
os.sep, "/"
)
civitai = item.get("civitai", {})
trigger_words = (
civitai.get("trainedWords", []) if civitai else []
)
return relative_path, trigger_words
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
if best_fallback:
file_path = best_fallback.get("file_path")
if file_path:
civitai = best_fallback.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
return lora_name, []
try:
# Check if we're already in an event loop
loop = asyncio.get_running_loop()
# If we're in a running loop, we need to use a different approach
# Create a new thread to run the async code
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(_get_lora_info_async())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
# No event loop is running, we can use asyncio.run()
return asyncio.run(_get_lora_info_async())
def get_lora_info_absolute(lora_name):
"""Get the absolute lora path and trigger words from cache
Returns:
tuple: (absolute_path, trigger_words) where absolute_path is the full
file system path to the LoRA file, or original lora_name if not found
"""
async def _get_lora_info_absolute_async():
scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data()
# Stack producers can resolve an exact business path. Preserve it even
# when several indexed LoRAs share the same basename.
if os.path.isabs(lora_name):
for item in cache.raw_data:
file_path = item.get("file_path")
if file_path and os.path.abspath(file_path) == os.path.abspath(lora_name):
civitai = item.get("civitai") or {}
return file_path, civitai.get("trainedWords", [])
return lora_name, []
lora_name_normalized = lora_name.replace("\\", "/")
lora_name_no_ext = lora_name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if lora_name_no_ext.lower().endswith(ext):
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
break
has_path = "/" in lora_name_no_ext
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
best_fallback = None
for item in cache.raw_data:
file_name = item.get("file_name", "")
folder = item.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if lora_name_no_ext == file_name_no_ext:
file_path = item.get("file_path")
if file_path:
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
if lora_name_no_ext == path_name:
file_path = item.get("file_path")
if file_path:
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
if has_path and file_name_no_ext == basename:
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = item
elif best_fallback is None:
best_fallback = item
if best_fallback:
file_path = best_fallback.get("file_path")
if file_path:
civitai = best_fallback.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
return lora_name, []
try:
# Check if we're already in an event loop
loop = asyncio.get_running_loop()
# If we're in a running loop, we need to use a different approach
# Create a new thread to run the async code
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(_get_lora_info_absolute_async())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
# No event loop is running, we can use asyncio.run()
return asyncio.run(_get_lora_info_absolute_async())
def get_checkpoint_info_absolute(checkpoint_name):
"""Get the absolute checkpoint path and metadata from cache
Supports ComfyUI-style model names (e.g., "folder/model_name.ext")
Args:
checkpoint_name: The model name, can be:
- ComfyUI format: "folder/model_name.safetensors"
- Simple name: "model_name"
Returns:
tuple: (absolute_path, metadata) where absolute_path is the full
file system path to the checkpoint file, or original checkpoint_name if not found,
metadata is the full model metadata dict or None
"""
async def _get_checkpoint_info_absolute_async():
from ..services.service_registry import ServiceRegistry
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get model roots for matching
model_roots = scanner.get_model_roots()
# Normalize the checkpoint name
normalized_name = checkpoint_name.replace(os.sep, "/")
for item in cache.raw_data:
file_path = item.get("file_path", "")
if not file_path:
continue
# Format the stored path as ComfyUI-style name
formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
# Match by formatted name (normalize separators for robust comparison)
if formatted_name.replace(os.sep, "/") == normalized_name or formatted_name == checkpoint_name:
return file_path, item
# Also try matching by basename only (for backward compatibility)
file_name = item.get("file_name", "")
if (
file_name == checkpoint_name
or file_name == os.path.splitext(normalized_name)[0]
):
return file_path, item
return checkpoint_name, None
try:
# Check if we're already in an event loop
loop = asyncio.get_running_loop()
# If we're in a running loop, we need to use a different approach
# Create a new thread to run the async code
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(
_get_checkpoint_info_absolute_async()
)
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
# No event loop is running, we can use asyncio.run()
return asyncio.run(_get_checkpoint_info_absolute_async())
def _format_model_name_for_comfyui(file_path: str, model_roots: list[str]) -> str:
"""Format file path to ComfyUI-style model name (relative path with extension)
Example: /path/to/checkpoints/Illustrious/model.safetensors -> Illustrious/model.safetensors
Args:
file_path: Absolute path to the model file
model_roots: List of model root directories
Returns:
ComfyUI-style model name with relative path and extension
"""
# Find the matching root and get relative path
for root in model_roots:
try:
# Normalize paths for comparison
norm_file = os.path.normcase(os.path.abspath(file_path))
norm_root = os.path.normcase(os.path.abspath(root))
# Add trailing separator for prefix check
if not norm_root.endswith(os.sep):
norm_root += os.sep
if norm_file.startswith(norm_root):
# Use os.path.relpath to get relative path with OS-native separator
return os.path.relpath(file_path, root)
except (ValueError, TypeError):
continue
# If no root matches, just return the basename with extension
return os.path.basename(file_path)
def model_patcher_to_name(model_patcher: Any) -> Optional[str]:
"""Extract a ComfyUI-style model name from a MODEL (ModelPatcher) object.
Core ComfyUI loaders record the absolute weight file path on the patcher's
``cached_patcher_init`` attribute:
- load_checkpoint_guess_config -> (fn, (ckpt_path, ...), index)
- load_diffusion_model -> (fn, (unet_path, model_options))
Patcher clones (LoRA loaders, model merges, ...) preserve the attribute,
so the name is recoverable anywhere downstream of a core loader — including
from LoRA Manager's own loaders (CheckpointLoaderLM / UNETLoaderLM), which
call the same core load functions.
The absolute path is converted to the ComfyUI-style relative name used by
the metadata pipeline (covering standard ComfyUI roots and LoRA Manager
extra folder paths).
Returns None when the path cannot be recovered (e.g. third-party loaders
that never set ``cached_patcher_init``).
"""
init = getattr(model_patcher, "cached_patcher_init", None)
if not isinstance(init, (tuple, list)) or len(init) < 2:
return None
args = init[1]
abs_path = args[0] if args else None
if not isinstance(abs_path, str) or not abs_path:
return None
return _abs_model_path_to_name(abs_path)
def sampler_object_to_name(sampler: Any) -> Optional[str]:
"""Extract a ComfyUI-style sampler name from a SAMPLER (KSAMPLER) object.
Standard outputs (KSamplerSelect, most built-in sampler nodes) round-trip
losslessly via the underlying sampler function's ``__name__``
(``sample_euler`` -> ``euler``). A few edge cases need special-casing
because the function name diverges from the ``SAMPLER_NAMES`` entry:
- ``dpm_fast`` / ``dpm_adaptive`` are local closures inside
``comfy.samplers.ksampler`` (``dpm_fast_function`` / ``dpm_adaptive_function``)
- ``uni_pc`` / ``uni_pc_bh2`` use ``sample_unipc`` / ``sample_unipc_bh2``
``ddim`` is constructed by ComfyUI as ``euler`` with random inpaint, so
the original ``ddim`` name is unrecoverable (extracts as ``euler``).
Custom sampler nodes that pass non-``sample_*`` functions return None.
Returns None when the name cannot be recovered.
"""
sampler_function = getattr(sampler, "sampler_function", None)
func_name = getattr(sampler_function, "__name__", None)
if not isinstance(func_name, str) or not func_name:
return None
if func_name == "dpm_fast_function":
return "dpm_fast"
if func_name == "dpm_adaptive_function":
return "dpm_adaptive"
if func_name.startswith("sample_"):
name = func_name[len("sample_"):]
if name == "unipc":
return "uni_pc"
if name == "unipc_bh2":
return "uni_pc_bh2"
return name or None
return None
def _abs_model_path_to_name(abs_path: str) -> str:
"""Convert an absolute model path to a ComfyUI-style relative name.
Tries standard ComfyUI model roots plus LoRA Manager extra folder paths;
falls back to the bare filename.
"""
try:
roots: List[str] = list(config.base_models_roots or [])
roots.extend(config.extra_checkpoints_roots or [])
roots.extend(config.extra_unet_roots or [])
formatted = _format_model_name_for_comfyui(abs_path, roots)
if formatted:
return formatted
except Exception:
pass
return os.path.basename(abs_path)
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
"""
Check if text matches pattern using fuzzy matching.
Returns True if similarity ratio is above threshold.
"""
if not pattern or not text:
return False
# Convert both to lowercase for case-insensitive matching
text = text.lower()
pattern = pattern.lower()
# Split pattern into words
search_words = pattern.split()
# Check each word
for word in search_words:
# First check if word is a substring (faster)
if word in text:
continue
# If not found as substring, try fuzzy matching
# Check if any part of the text matches this word
found_match = False
for text_part in text.split():
ratio = SequenceMatcher(None, text_part, word).ratio()
if ratio >= threshold:
found_match = True
break
if not found_match:
return False
# All words found either as substrings or fuzzy matches
return True
def sanitize_folder_name(name: str, replacement: str = "_") -> str:
"""Sanitize a folder name by removing or replacing invalid characters.
Args:
name: The original folder name.
replacement: The character to use when replacing invalid characters.
Returns:
A sanitized folder name safe to use across common filesystems.
"""
if not name:
return ""
# Replace invalid characters commonly restricted on Windows and POSIX
invalid_chars_pattern = r'[<>:"/\\|?*\x00-\x1f]'
sanitized = re.sub(invalid_chars_pattern, replacement, name)
# Trim whitespace introduced during sanitization
sanitized = sanitized.strip()
# Collapse repeated replacement characters to a single instance
if replacement:
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
# Combine stripping to be idempotent:
# Right side: strip replacement, space, and dot (Windows restriction)
# Left side: strip replacement and space (leading dots are allowed)
sanitized = sanitized.rstrip(" ." + replacement).lstrip(" " + replacement)
else:
# If no replacement, just strip spaces and dots from right, spaces from left
sanitized = sanitized.rstrip(" .").lstrip(" ")
if not sanitized:
return "unnamed"
return sanitized
def calculate_recipe_fingerprint(loras):
"""
Calculate a unique fingerprint for a recipe based on its LoRAs.
The fingerprint is created by sorting LoRA hashes, filtering invalid entries,
normalizing strength values to 2 decimal places, and joining in format:
hash1:strength1|hash2:strength2|...
Args:
loras (list): List of LoRA dictionaries with hash and strength values
Returns:
str: The calculated fingerprint
"""
if not loras:
return ""
valid_loras = []
for lora in loras:
if lora.get("exclude", False):
continue
hash_value = lora.get("hash", "")
if isinstance(hash_value, str):
hash_value = hash_value.lower()
else:
hash_value = str(hash_value).lower() if hash_value else ""
if not hash_value and lora.get("modelVersionId"):
hash_value = str(lora.get("modelVersionId"))
if not hash_value:
continue
# Normalize strength to 2 decimal places (check both strength and weight fields)
strength_val = lora.get("strength", lora.get("weight", 1.0))
try:
strength = round(float(strength_val), 2)
except (ValueError, TypeError):
strength = 1.0
valid_loras.append((hash_value, strength))
# Sort by hash
valid_loras.sort()
# Join in format hash1:strength1|hash2:strength2|...
fingerprint = "|".join(
[f"{hash_value}:{strength}" for hash_value, strength in valid_loras]
)
return fingerprint
def normalize_prompt_for_dedup(prompt) -> str:
"""Normalize a positive prompt for duplicate recipe matching.
Applies casefolding, collapses whitespace runs into single spaces, and
trims leading/trailing whitespace. Missing or non-string prompts
normalize to an empty string.
Args:
prompt: The positive prompt text (or None)
Returns:
str: The normalized prompt
"""
if not prompt or not isinstance(prompt, str):
return ""
return re.sub(r"\s+", " ", prompt).strip().casefold()
def calculate_relative_path_for_model(
model_data: Dict[str, Any], model_type: str = "lora"
) -> str:
"""Calculate relative path for existing model using template from settings
Args:
model_data: Model data from scanner cache
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns:
Relative path string (empty string for flat structure)
"""
# Get path template from settings for specific model type
settings_manager = get_settings_manager()
path_template = settings_manager.get_download_path_template(model_type)
# If template is empty, return empty path (flat structure)
if not path_template:
return ""
# Get base model name from model metadata
civitai_data = model_data.get("civitai", {})
# For CivitAI models, prefer civitai data only if 'id' exists; for non-CivitAI models, use model_data directly
if civitai_data and civitai_data.get("id") is not None:
base_model = model_data.get("base_model", "")
# Get author from civitai creator data
creator_info = civitai_data.get("creator") or {}
author = creator_info.get("username") or "Anonymous"
else:
# Fallback to model_data fields for non-CivitAI models
base_model = model_data.get("base_model", "")
author = "Anonymous" # Default for non-CivitAI models
model_tags = model_data.get("tags", [])
# Apply mapping if available
base_model_mappings = settings_manager.get("base_model_path_mappings", {})
mapped_base_model = base_model_mappings.get(base_model, base_model)
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
lowercase_tags = [tag.lower() for tag in model_tags if isinstance(tag, str)]
first_tag = settings_manager.resolve_priority_tag_for_model(
lowercase_tags, model_type
)
if not first_tag:
first_tag = "no tags" # Default if no tags available
# Format the template with available data
model_name = sanitize_folder_name(model_data.get("model_name", ""))
version_name = ""
if isinstance(civitai_data, dict):
version_name = sanitize_folder_name(civitai_data.get("name") or "")
formatted_path = path_template
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
formatted_path = formatted_path.replace("{first_tag}", first_tag)
formatted_path = formatted_path.replace("{author}", author)
formatted_path = formatted_path.replace("{model_name}", model_name)
formatted_path = formatted_path.replace("{version_name}", version_name)
if model_type == "embedding":
formatted_path = formatted_path.replace(" ", "_")
# Sanitize the resolved path to prevent path traversal
formatted_path = formatted_path.lstrip("/")
while "//" in formatted_path:
formatted_path = formatted_path.replace("//", "/")
formatted_path = formatted_path.rstrip("/")
return formatted_path
def calculate_filename_for_model(
model_data: Dict[str, Any], model_type: str = "lora"
) -> str:
"""Calculate the filename stem for a model using the filename template.
Mirrors the data extraction of :func:`calculate_relative_path_for_model`
but renders a single filename (no path segments). Missing values resolve
to empty segments instead of the path-oriented defaults ("Anonymous" /
"no tags") so templates degrade gracefully.
Args:
model_data: Model data from scanner cache
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns:
Sanitized filename stem without extension, or an empty string when no
template is configured, the template is invalid, or the rendered name
is empty.
"""
settings_manager = get_settings_manager()
template = settings_manager.get_download_filename_template(model_type)
if not template:
return ""
# A filename template must render a single name, never folder segments.
if "/" in template or "\\" in template:
logger.warning(
"Filename template for %s contains a path separator and is ignored: %r",
model_type,
template,
)
return ""
civitai_data = model_data.get("civitai", {})
author = ""
if isinstance(civitai_data, dict) and civitai_data.get("id") is not None:
creator_info = civitai_data.get("creator") or {}
author = creator_info.get("username") or ""
base_model = model_data.get("base_model", "")
base_model_mappings = settings_manager.get("base_model_path_mappings", {})
mapped_base_model = base_model_mappings.get(base_model, base_model)
lowercase_tags = [
tag.lower() for tag in model_data.get("tags", []) if isinstance(tag, str)
]
first_tag = settings_manager.resolve_priority_tag_for_model(
lowercase_tags, model_type
)
model_name = model_data.get("model_name", "")
version_name = ""
if isinstance(civitai_data, dict):
version_name = civitai_data.get("name") or ""
sha256 = model_data.get("sha256") or ""
hash_short = sha256[:10].lower() if isinstance(sha256, str) else ""
file_path = model_data.get("file_path") or ""
if isinstance(file_path, str) and file_path:
original_name = os.path.splitext(os.path.basename(file_path))[0]
else:
original_name = os.path.splitext(str(model_data.get("file_name", "")))[0]
def _sanitize_value(value: Any) -> str:
# sanitize_folder_name falls back to "unnamed" for empty input; for
# templates an empty value must stay empty so segments collapse.
text = str(value) if value else ""
return sanitize_folder_name(text) if text else ""
replacements = {
"{model_name}": _sanitize_value(model_name),
"{version_name}": _sanitize_value(version_name),
"{base_model}": _sanitize_value(mapped_base_model),
"{author}": _sanitize_value(author),
"{first_tag}": _sanitize_value(first_tag),
"{hash_short}": hash_short,
"{original_name}": _sanitize_value(original_name),
}
result = template
for placeholder, value in replacements.items():
result = result.replace(placeholder, value)
if model_type == "embedding":
result = result.replace(" ", "_")
# Strip characters that are illegal in filenames on common filesystems.
result = re.sub(r'[:*?"<>|]', "", result)
# Collapse runs of identical separators introduced by empty substitutions.
result = re.sub(r"([-_. ])\1+", r"\1", result)
# Drop separators left dangling next to each other ("- -" -> "-").
result = re.sub(r" ?([-_.]) (?=[-_.])", r"\1", result)
# A stem must not start or end with separators, spaces or dots.
result = result.strip("-_. ")
return result
def remove_empty_dirs(path):
"""Recursively remove empty directories starting from the given path.
Args:
path (str): Root directory to start cleaning from
Returns:
int: Number of empty directories removed
"""
removed_count = 0
if not os.path.isdir(path):
return removed_count
# List all files in directory
files = os.listdir(path)
# Process all subdirectories first
for file in files:
full_path = os.path.join(path, file)
if os.path.isdir(full_path):
removed_count += remove_empty_dirs(full_path)
# Check if directory is now empty (after processing subdirectories)
if not os.listdir(path):
try:
os.rmdir(path)
removed_count += 1
except OSError:
pass
return removed_count