Files
MoonGoblinDev-Civicomfy/utils/helpers.py
T
2025-04-04 08:34:33 +07:00

239 lines
11 KiB
Python

# ================================================
# File: utils/helpers.py
# ================================================
import os
import urllib.parse
import re
from pathlib import Path
import folder_paths
# Import config values needed here
from ..config import MODELS_DIR, MODEL_TYPE_DIRS
def get_model_dir(model_type: str) -> str:
"""
Get the appropriate absolute directory path for the model type.
Ensures the directory exists.
"""
model_type_key = model_type.lower().strip()
if model_type_key in MODEL_TYPE_DIRS:
relative_path = MODEL_TYPE_DIRS[model_type_key][1] # Get the subfolder name
# Ensure the relative path is treated correctly even if it contains os-specific separators internally
# os.path.join will handle combining MODELS_DIR and the potentially complex relative_path
full_path = os.path.join(MODELS_DIR, relative_path)
else:
# Default to the 'other' directory if type is unknown
print(f"Warning: Unknown model type '{model_type}'. Saving to 'other' directory.")
relative_path = MODEL_TYPE_DIRS['other'][1]
full_path = os.path.join(MODELS_DIR, relative_path)
# Ensure the directory exists
try:
os.makedirs(full_path, exist_ok=True)
except OSError as e:
# Handle potential errors like permission denied
print(f"Error: Could not create directory '{full_path}': {e}")
# Fallback? Or raise error? For now, just print and return, download will likely fail.
# Consider falling back to a known-good temp location if MODELS_DIR itself is bad? Complex.
pass
return full_path
def parse_civitai_input(url_or_id: str) -> tuple[int | None, int | None]:
"""
Parses Civitai URL or ID string.
Returns: (model_id, version_id) tuple. Both can be None.
Handles URLs like /models/123 and /models/123?modelVersionId=456
"""
if not url_or_id:
return None, None
url_or_id = str(url_or_id).strip()
model_id: int | None = None
version_id: int | None = None
query_params = {}
# Check if it's just a number (could be model or version ID)
# Treat digits-only input as MODEL ID primarily, as users often copy just that.
# Version ID can be specified separately or via full URL query param.
if url_or_id.isdigit():
try:
# Assume it's a Model ID if just digits are provided.
model_id = int(url_or_id)
print(f"Parsed input '{url_or_id}' as Model ID.")
# Don't assume it's a version ID here. Let it be specified if needed.
return model_id, None
except (ValueError, TypeError):
print(f"Warning: Could not parse '{url_or_id}' as a numeric ID.")
return None, None
# If not just digits, try parsing as URL
try:
parsed_url = urllib.parse.urlparse(url_or_id)
# Basic check for URL structure and domain
if not parsed_url.scheme or not parsed_url.netloc:
# Maybe it's a path like /models/123 without the domain?
if url_or_id.startswith(("/models/", "/model-versions/")):
# Re-parse with a dummy scheme and domain
parsed_url = urllib.parse.urlparse("https://civitai.com" + url_or_id)
if not parsed_url.path: # If still fails, give up
print(f"Input '{url_or_id}' is not a recognizable Civitai path or URL.")
return None, None
else:
print(f"Input '{url_or_id}' is not a valid ID or Civitai URL/path.")
return None, None
# Check domain if it was present
if parsed_url.netloc and "civitai.com" not in parsed_url.netloc.lower():
print(f"Input URL '{url_or_id}' is not a Civitai URL.")
return None, None
# Extract path components and query parameters
path_parts = [p for p in parsed_url.path.split('/') if p] # Remove empty parts
query_params = urllib.parse.parse_qs(parsed_url.query)
# --- Logic ---
# 1. Check query params for modelVersionId FIRST (most explicit)
if 'modelVersionId' in query_params:
try:
version_id = int(query_params['modelVersionId'][0])
print(f"Found Version ID {version_id} in query parameters.")
except (ValueError, IndexError, TypeError):
print(f"Warning: Found modelVersionId in query but couldn't parse: {query_params.get('modelVersionId')}")
version_id = None # Reset if parsing failed
# 2. Check path for /models/ID
model_id_from_path = None
if "models" in path_parts:
try:
models_index = path_parts.index("models")
if models_index + 1 < len(path_parts):
# Take the part right after /models/ and check if it's digits
potential_id_str = path_parts[models_index + 1]
if potential_id_str.isdigit():
model_id_from_path = int(potential_id_str)
print(f"Found Model ID {model_id_from_path} in URL path.")
except (ValueError, IndexError, TypeError):
print(f"Warning: Found /models/ in path but couldn't parse ID from {path_parts}")
# 3. Check path for /model-versions/ID (less common, usually doesn't contain model ID)
version_id_from_path = None
if version_id is None and "model-versions" in path_parts: # Only check if not found in query
try:
versions_index = path_parts.index("model-versions")
if versions_index + 1 < len(path_parts):
potential_id_str = path_parts[versions_index + 1]
if potential_id_str.isdigit():
version_id_from_path = int(potential_id_str)
# Set version_id only if not already set by query param
if version_id is None:
version_id = version_id_from_path
print(f"Found Version ID {version_id} in URL path.")
except (ValueError, IndexError, TypeError):
print(f"Warning: Found /model-versions/ in path but couldn't parse ID from {path_parts}")
# 4. Assign final model ID (prefer path over digits-only assumption if URL was parsed)
if model_id_from_path is not None:
model_id = model_id_from_path
# If no model ID found yet and input looked like a URL, maybe it was ONLY a version URL?
elif model_id is None and version_id is not None:
print("Warning: Found Version ID but no Model ID in the URL. Model info might be incomplete.")
# Keep the initially parsed model_id if input was digits-only
except Exception as e:
print(f"Error parsing Civitai input '{url_or_id}': {e}")
return None, None
print(f"Parsed Civitai input: Model ID = {model_id}, Version ID = {version_id}")
# Return the determined IDs. It's the caller's responsibility to fetch model info if only version ID is present.
return model_id, version_id
# Updated sanitize_filename to be more restrictive
def sanitize_filename(filename: str, default_filename: str = "downloaded_model") -> str:
"""
Stricter filename sanitization. Replaces invalid characters, trims whitespace,
handles reserved names (Windows), and ensures it's not empty.
Aims for better cross-OS compatibility.
"""
if not filename:
return default_filename
# Decode if bytes
if isinstance(filename, bytes):
try:
filename = filename.decode('utf-8')
except UnicodeDecodeError:
# If decode fails, fall back to a safe default representation or hex
# For simplicity, just use default for now if decoding problematic bytes
return default_filename + "_decode_error"
# Remove characters invalid for Windows/Linux/MacOS filenames
# Invalid Chars: < > : " / \ | ? * and control characters (0-31)
# Also replace NULL character just in case.
sanitized = re.sub(r'[\x00-\x1f<>:"/\\|?*]', '_', filename)
# Replace sequences of multiple underscores or spaces introduced by replacement
sanitized = re.sub(r'[_ ]{2,}', '_', sanitized)
# Remove leading/trailing whitespace, dots, underscores
sanitized = sanitized.strip('. _')
# Windows Reserved Names (case-insensitive)
reserved_names = {'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'}
# Check base name without extension
base_name, ext = os.path.splitext(sanitized)
if base_name.upper() in reserved_names:
sanitized = f"_{base_name}_{ext}" # Prepend underscore
# Prevent names that are just '.' or '..' (though stripping dots should handle this)
if sanitized == '.' or sanitized == '..':
sanitized = default_filename + "_invalid_name"
# If sanitization results in an empty string (unlikely now), use default
if not sanitized:
sanitized = default_filename
# Optional: Limit overall length (e.g., 200 chars), considering path limits
# Be careful as some systems have path limits, not just filename limits
max_len = 200 # A reasonable limit for the filename itself
if len(sanitized) > max_len:
name_part, ext_part = os.path.splitext(sanitized)
# Truncate the name part, ensuring total length is within max_len
allowed_name_len = max_len - len(ext_part)
if allowed_name_len <= 0: # Handle case where extension itself is too long
sanitized = sanitized[:max_len] # Truncate forcefully
else:
sanitized = name_part[:allowed_name_len] + ext_part
print(f"Warning: Sanitized filename truncated to {max_len} characters.")
return sanitized
def create_placeholder_image(path: str):
"""Creates a simple placeholder image if Pillow is available."""
target_dir = os.path.dirname(path)
if not os.path.exists(target_dir):
try:
os.makedirs(target_dir, exist_ok=True)
except OSError as e:
print(f"Error creating directory for placeholder image '{target_dir}': {e}")
return # Cannot proceed
if os.path.exists(path):
return
try:
from PIL import Image, ImageDraw
img = Image.new('RGB', (200, 200), color = (50, 50, 50)) # Slightly larger size?
# Optional: Add text?
try:
d = ImageDraw.Draw(img)
# Simple text, requires no font file usually
d.text((10,90), "Placeholder", fill=(150,150,150))
except Exception: pass # Ignore text drawing errors silently
img.save(path)
print(f"[Helper] Created placeholder image: {path}")
except ImportError:
print("[Helper] Pillow not found, cannot create placeholder image. Please install with: pip install Pillow")
except Exception as e:
print(f"[Helper] Error creating placeholder image at {path}: {e}")