Files
Kinglord-ComfyUI_LoRA_Sidebar/__init__.py
T
Dan 9617906e84 Massive update, huge for Custom Lora users!
- Features galore for Lora Info panel, add custom images, change previews, quickly access files and folders
- Quickly edit almost all the lora fields right from the info panel, perfect for Custom Loras
- Integrated support now for rgthree power loader lora node
- Option to use A1111 style lora tags for those with nodes that support it
- Heaps of small fixes
- One more update before I take a break to focus on performance for 10k+ LoRA data sets
2024-10-31 15:53:55 -05:00

2012 lines
90 KiB
Python

from server import PromptServer
import os
from aiohttp import web
import aiohttp
import folder_paths
import hashlib
import json
import logging
import io
import mimetypes
import shutil
import asyncio
import time
from collections import Counter
import logging
import glob
import subprocess
from typing import Optional, Dict, Any
# Set up logging
DEBUG = False
class ErrorOnlyFilter(logging.Filter):
def filter(self, record):
return record.levelno == logging.ERROR
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG if DEBUG else logging.ERROR)
handler = logging.StreamHandler()
handler.setLevel(logging.DEBUG if DEBUG else logging.ERROR)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
if not DEBUG:
error_filter = ErrorOnlyFilter()
handler.addFilter(error_filter)
# Define the paths
PLUGIN_DIR = os.path.dirname(os.path.realpath(__file__))
LORA_DATA_DIR = os.path.join(PLUGIN_DIR, "loraData")
os.makedirs(LORA_DATA_DIR, exist_ok=True)
# Rate limiting settings
MAX_REQUESTS_PER_MINUTE = 120 # Increase at your own risk, don't get banned!
RATE_LIMITER = None
# Test limit constant
TEST_LIMIT = 0
# Add a global variable to track if processing is in progress
is_processing = False
PROCESSED_LORAS_VERSION = 2 # used to force reprocessing LoRAs when data files change
LORA_FILE_INFO = {}
# hack for lora processing
class LoraDataStore:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(LoraDataStore, cls).__new__(cls)
cls._instance.data = None
return cls._instance
@classmethod
def set_data(cls, data):
cls._instance = cls()
cls._instance.data = data
logger.info(f"Data set in LoraDataStore: {cls._instance.data}")
@classmethod
def get_data(cls):
cls._instance = cls()
logger.info(f"Data retrieved from LoraDataStore: {cls._instance.data}")
return cls._instance.data
@classmethod
def clear_data(cls):
cls._instance = cls()
cls._instance.data = None
class RateLimiter:
def __init__(self, max_calls, period):
self.max_calls = max_calls
self.period = period # in seconds
self.calls = []
self.lock = asyncio.Lock()
async def acquire(self):
async with self.lock:
current = time.time()
# Remove timestamps older than the period
while self.calls and self.calls[0] <= current - self.period:
self.calls.pop(0)
if len(self.calls) >= self.max_calls:
wait_time = self.period - (current - self.calls[0])
logger.debug(f"Rate limit reached. Sleeping for {wait_time:.2f} seconds.")
await asyncio.sleep(wait_time)
self.calls.append(time.time())
# Initialize the RateLimiter
RATE_LIMITER = RateLimiter(MAX_REQUESTS_PER_MINUTE, 60) # 120 calls per 60 seconds
async def hash_file(filepath):
sha256_hash = hashlib.sha256()
with open(filepath, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
async def check_local_info(file_path):
# Try CivitAI first
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
logger.info(f"Checking for CivitAI metadata at: {civitai_info_path}")
if os.path.exists(civitai_info_path):
try:
logger.info(f"Found CivitAI metadata file, attempting to read: {civitai_info_path}")
with open(civitai_info_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
return {
"modelId": metadata.get("modelId"),
"id": metadata.get("id"), # Version ID from the info
"name": metadata.get("name"), # Version name
"model": {
"name": metadata.get("model", {}).get("name"),
"nsfw": metadata.get("model", {}).get("nsfw", False),
"tags": metadata.get("model", {}).get("tags", []),
"description": metadata.get("model", {}).get("description"), # Model description
"type": metadata.get("model", {}).get("type")
},
"trainedWords": metadata.get("trainedWords", []),
"baseModel": metadata.get("baseModel"),
"description": metadata.get("description"), # Version description
"images": metadata.get("images", []),
"local_metadata": True, # Add flag
"has_local_images": True
}
except Exception as e:
logger.error(f"Error reading civitai.info for {file_path}: {str(e)}")
# Try SMatrix second
matrix_info_path = f"{os.path.splitext(file_path)[0]}.cm-info.json"
logger.info(f"Checking for SMatrix metadata at: {matrix_info_path}")
if os.path.exists(matrix_info_path):
try:
logger.info(f"Found SMatrix metadata file, attempting to read: {matrix_info_path}")
with open(matrix_info_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
return {
"modelId": metadata.get("ModelId"),
"id": metadata.get("VersionId"), # Version ID
"name": metadata.get("VersionName"),
"model": {
"name": metadata.get("ModelName"),
"nsfw": metadata.get("Nsfw", False),
"tags": metadata.get("Tags", []),
"description": metadata.get("ModelDescription"),
"type": metadata.get("ModelType")
},
"trainedWords": metadata.get("TrainedWords", []),
"baseModel": metadata.get("BaseModel"),
"description": metadata.get("VersionDescription"),
"images": [],
"local_metadata": True,
"has_local_images": True
}
except Exception as e:
logger.error(f"Error reading cm-info.json for {file_path}: {str(e)}")
# Try rg3 third...get it?
rg3_info_path = f"{os.path.splitext(file_path)[0]}.safetensors.rgthree-info.json"
logger.info(f"Checking for RG3 metadata at: {rg3_info_path}")
if os.path.exists(rg3_info_path):
try:
logger.info(f"Found RG3 metadata file, attempting to read: {rg3_info_path}")
with open(rg3_info_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
civitai = metadata.get('raw', {}).get('civitai', {})
return_data = {
"modelId": civitai.get("modelId"),
"id": civitai.get("id"),
"name": civitai.get("name"), # version name
"model": {
"name": civitai.get("model", {}).get("name"),
"nsfw": civitai.get("model", {}).get("nsfw", False),
"description": None,
"type": civitai.get("model", {}).get("type"),
},
"trainedWords": civitai.get("trainedWords", []),
"baseModel": civitai.get("baseModel"),
"description": civitai.get("description"), # version desc
"images": [
{
"url": img.get("url"),
"type": img.get("type", "image"),
"hasMeta": img.get("hasMeta", False),
"nsfwLevel": img.get("nsfwLevel", 0)
}
for img in civitai.get("images", [])
],
"local_metadata": True,
"has_local_images": False
}
logger.info(f"Successfully parsed RG3 metadata for {file_path}")
logger.info(f"RG3 metadata {return_data}")
return return_data
except Exception as e:
logger.error(f"Error reading safetensors.rgthree-info.json for {file_path}: {str(e)}")
# No valid metadata found in any format
logger.info(f"No valid metadata found for: {file_path}")
return False
async def fetch_model_info(session, model_id):
url = f"https://civitai.com/api/v1/models/{model_id}"
await RATE_LIMITER.acquire() # Enforce rate limit
async with session.get(url) as response:
if response.status == 200:
return await response.json()
return None
async def fetch_version_info(session, file_hash):
url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
await RATE_LIMITER.acquire() # Enforce rate limit
async with session.get(url) as response:
if response.status == 200:
return await response.json()
return None
async def fetch_version_info_by_id(session, version_id):
url = f"https://civitai.com/api/v1/model-versions/{version_id}"
await RATE_LIMITER.acquire() # Enforce rate limit
async with session.get(url) as response:
if response.status == 200:
return await response.json()
return None
async def download_image(session, image_url, save_path):
# await RATE_LIMITER.acquire() # here in case but would rather not rate limit this
async with session.get(image_url) as response:
if response.status == 200:
content_type = response.headers.get('Content-Type', '')
ext = mimetypes.guess_extension(content_type) or '.jpg'
final_save_path = f"{os.path.splitext(save_path)[0]}{ext}"
with open(final_save_path, 'wb') as f:
f.write(await response.read())
return os.path.basename(final_save_path)
return None
async def copy_placeholder_as_preview(lora_id):
placeholder_path = os.path.join(LORA_DATA_DIR, "placeholder.jpeg")
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
preview_path = os.path.join(lora_folder, "preview.jpeg")
# Ensure the LoRA folder exists
os.makedirs(lora_folder, exist_ok=True)
if os.path.exists(placeholder_path) and not os.path.exists(preview_path):
shutil.copy(placeholder_path, preview_path)
logger.info(f"Placeholder image copied as preview for LoRA {lora_id}")
else:
if not os.path.exists(placeholder_path):
logger.warning(f"Placeholder image not found: {placeholder_path}")
if os.path.exists(preview_path):
logger.info(f"Preview already exists for LoRA {lora_id}: {preview_path}")
def get_subdir(file_path):
"""Get the subdirectory relative to the lora base path."""
try:
# Get all possible lora directories from folder_paths
lora_dirs = folder_paths.get_folder_paths("loras")
logger.debug(f"Input file_path: {file_path}")
logger.debug(f"Lora dirs from folder_paths: {lora_dirs}")
# Filter out output directories
lora_dirs = [d for d in lora_dirs if 'output' not in d.lower().split(os.sep)]
logger.debug(f"Lora dirs from folder_paths (excluding output dirs): {lora_dirs}")
if not lora_dirs:
return ""
# Normalize the input path
norm_path = os.path.normpath(file_path)
logger.debug(f"Normalized input path: {norm_path}")
# Handle cross-drive path comparison by normalizing drive letters
file_drive, file_path_tail = os.path.splitdrive(norm_path)
# Find the matching base directory
matching_base = None
matching_link = None
for base_dir in lora_dirs:
base_dir = os.path.normpath(base_dir)
logger.debug(f"\nChecking base dir: {base_dir}")
# Check for symlinks in this lora directory
try:
for item in os.listdir(base_dir):
link_path = os.path.join(base_dir, item)
if os.path.islink(link_path):
real_link = os.path.realpath(link_path)
logger.debug(f"Found symlink: {item} -> {real_link}")
# Apply drive neutralization to symlink target
link_drive, link_tail = os.path.splitdrive(real_link)
neutral_link = os.path.join("C:", link_tail)
neutral_file = os.path.join("C:", file_path_tail)
if neutral_file.startswith(neutral_link):
matching_base = real_link
matching_link = item
logger.debug(f"Found matching symlink: {item}")
break
except Exception as e:
logger.debug(f"Error checking symlinks in {base_dir}: {str(e)}")
continue
# Regular path matching as before
base_drive, base_path_tail = os.path.splitdrive(base_dir)
# Create drive-letter-neutral paths for comparison
neutral_file_path = os.path.join("C:", file_path_tail)
neutral_base_path = os.path.join("C:", base_path_tail)
logger.debug(f"Neutral paths for comparison:")
logger.debug(f" neutral_file_path: {neutral_file_path}")
logger.debug(f" neutral_base_path: {neutral_base_path}")
# Check if this base matches
if neutral_file_path.startswith(neutral_base_path):
logger.debug(f"Found matching base! Length: {len(base_dir)}")
if matching_base is None or len(base_dir) > len(matching_base):
matching_base = base_dir
logger.debug(f"Updated matching_base to: {matching_base}")
if not matching_base:
logger.debug("No matching base directory found")
return ""
logger.debug(f"Final matching base: {matching_base}")
# If we matched via symlink, construct the path accordingly
if matching_link:
# Get the part of the path after the symlink target
_, match_tail = os.path.splitdrive(matching_base)
neutral_match = os.path.join("C:", match_tail)
neutral_file = os.path.join("C:", file_path_tail)
after_base = neutral_file[len(neutral_match):].lstrip('\\/')
rel_path = os.path.join(matching_link, os.path.dirname(after_base))
logger.debug(f"Constructed symlink path: {rel_path}")
return rel_path
# Regular path handling as before
_, match_path_tail = os.path.splitdrive(matching_base)
neutral_match_path = os.path.join("C:", match_path_tail)
logger.debug(f"Neutral match path for relpath calc: {neutral_match_path}")
# Calculate relative path
try:
rel_path = os.path.relpath(os.path.dirname(neutral_file_path), neutral_match_path)
logger.debug(f"Calculated relpath: {rel_path}")
except Exception as e:
logger.error(f"Error calculating relpath: {str(e)}")
return ""
# Return empty string for root directory
if rel_path == ".":
logger.debug("Relpath is root directory")
return ""
logger.debug(f"Final relative path: {rel_path}")
return rel_path
except Exception as e:
logger.error(f"Error in get_subdir: {str(e)}")
logger.error(f"File path: {file_path}")
return ""
def find_custom_images(base_path, base_filename):
"""Find custom images for a LoRA in both internal and external locations"""
custom_images = []
# Get the directory containing the base file
base_dir = os.path.dirname(base_path)
# Get base name without extension for matching
name_without_ext = os.path.splitext(base_filename)[0]
# Common image extensions to look for
image_extensions = ('.jpg', '.jpeg', '.png', '.webp')
logger.info(f"Searching for custom images in {base_dir} for {base_filename}")
# For internal loras, look in the lora data directory
lora_data_path = os.path.join(LORA_DATA_DIR, name_without_ext)
if os.path.exists(lora_data_path):
for file in os.listdir(lora_data_path):
if file.lower().endswith(image_extensions) and not file.startswith('preview'):
logger.info(f"Found internal custom image: {file}")
custom_images.append({
"url": f"/lora_sidebar/custom_image/{name_without_ext}/{file}",
"type": "image",
"nsfwLevel": 0,
"hasMeta": True,
"custom": True,
"name": file
})
# Look for images in the LoRA's directory
if os.path.exists(base_dir):
for file in os.listdir(base_dir):
if (file.lower().startswith(name_without_ext.lower()) and
file.lower().endswith(image_extensions) and
not file.lower().endswith('.preview' + os.path.splitext(file)[1])):
logger.info(f"Found external custom image: {file}")
custom_images.append({
"url": f"/lora_sidebar/custom_image/{name_without_ext}/{file}",
"type": "image",
"nsfwLevel": 0,
"hasMeta": True,
"custom": True,
"name": file
})
logger.info(f"Found total of {len(custom_images)} custom images")
return custom_images
@PromptServer.instance.routes.get("/lora_sidebar/loras/list")
async def list_loras(request):
global LORA_FILE_INFO
logger.info(f"Pulling LoRA list from route: {request.path}")
logger.info("Pulling LoRA list")
lora_dirs = folder_paths.get_folder_paths("loras")
lora_files = []
for lora_dir in lora_dirs:
# Resolve symlinks to ensure proper path handling
resolved_lora_dir = os.path.realpath(lora_dir)
if os.path.exists(resolved_lora_dir):
# Use os.walk to recursively walk through subfolders and resolve symlinks
for root, dirs, files in os.walk(resolved_lora_dir, followlinks=True):
for filename in files:
# Skip hidden files and system files starting with "._"
if filename.startswith('.') or filename.startswith('._'):
continue
file_path = os.path.join(root, filename)
# Resolve any symlinks to their actual path and check if it's a file
resolved_file_path = os.path.realpath(file_path)
if os.path.isfile(resolved_file_path) and filename.lower().endswith(('.safetensors', '.ckpt', '.pt')):
lora_files.append({"filename": filename, "path": resolved_file_path})
# Store in LORA_FILE_INFO
LORA_FILE_INFO[os.path.splitext(filename)[0].strip()] = {"filename": filename, "path": resolved_file_path}
if TEST_LIMIT > 0 and len(lora_files) >= TEST_LIMIT:
break
if TEST_LIMIT > 0 and len(lora_files) >= TEST_LIMIT:
break
logger.info(f"LoRA list found, has {len(lora_files)}")
return lora_files
@PromptServer.instance.routes.get("/lora_sidebar/file_details/{lora_id}")
async def get_file_details(request):
lora_id = request.match_info['lora_id']
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
try:
return web.json_response({
"managed_dir": lora_folder,
"exists": os.path.exists(lora_folder)
})
except Exception as e:
logger.error(f"Error getting file details: {str(e)}")
return web.json_response({
"error": "Failed to get file details"
}, status=500)
@PromptServer.instance.routes.get("/lora_sidebar/preview/{lora_name}")
async def get_lora_preview(request):
# Load preview media with proper MIME type handling.
lora_name = request.match_info['lora_name']
def get_content_type(filepath):
ext = os.path.splitext(filepath)[1].lower()
content_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.mp4': 'video/mp4',
'.webm': 'video/webm'
}
return content_types.get(ext, 'application/octet-stream')
# Look in managed folder
lora_folder = os.path.join(LORA_DATA_DIR, lora_name)
for ext in ['.jpg', '.png', '.jpeg', '.mp4', '.webm']:
preview_path = os.path.join(lora_folder, f"preview{ext}")
if os.path.exists(preview_path):
return web.FileResponse(
preview_path,
headers={"Content-Type": get_content_type(preview_path)}
)
# Check if this LoRA uses local image data
info_path = os.path.join(LORA_DATA_DIR, lora_name, "info.json")
if os.path.exists(info_path):
with open(info_path, "r", encoding="utf-8") as f:
info = json.load(f)
logger.info(f"Loading info for {lora_name}: local_metadata={info.get('local_metadata')}, path={info.get('path')}")
if info.get("local_metadata") and info.get("path"):
base_path = os.path.splitext(info["path"])[0]
for ext in ['.preview.png', '.preview.jpg', '.preview.jpeg', '.preview.mp4', '.preview.webm']:
preview_path = f"{base_path}{ext}"
if os.path.exists(preview_path):
return web.FileResponse(
preview_path,
headers={"Content-Type": get_content_type(preview_path)}
)
# Fallback to placeholder
placeholder_path = os.path.join(LORA_DATA_DIR, "placeholder.jpeg")
if os.path.exists(placeholder_path):
return web.FileResponse(
placeholder_path,
headers={"Content-Type": "image/jpeg"}
)
return web.Response(status=404)
@PromptServer.instance.routes.get("/lora_sidebar/custom_image/{lora_name}/{image_name}")
async def get_lora_custom_image(request):
# Load custom images for a LoRA
lora_name = request.match_info['lora_name']
image_name = request.match_info['image_name']
def get_content_type(filepath):
ext = os.path.splitext(filepath)[1].lower()
content_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp'
}
return content_types.get(ext, 'application/octet-stream')
logger.info(f"Looking for custom image {image_name} for LoRA {lora_name}")
# Check internal lora data directory first (existing behavior)
custom_path = os.path.join(LORA_DATA_DIR, lora_name, image_name)
if os.path.exists(custom_path) and os.path.isfile(custom_path):
logger.info(f"Found image in internal directory: {custom_path}")
return web.FileResponse(
custom_path,
headers={"Content-Type": get_content_type(custom_path)}
)
# Then check actual LoRA location if we have the info
if lora_name in LORA_FILE_INFO:
file_info = LORA_FILE_INFO[lora_name]
base_dir = os.path.dirname(file_info['path'])
# Try exact match first
external_path = os.path.join(base_dir, image_name)
if os.path.exists(external_path) and os.path.isfile(external_path):
logger.info(f"Found exact match image in LoRA directory: {external_path}")
return web.FileResponse(
external_path,
headers={"Content-Type": get_content_type(external_path)}
)
# If no exact match, try looking for images with LoRA name prefix
lora_filename = os.path.splitext(file_info['filename'])[0]
for file in os.listdir(base_dir):
if (file.lower().startswith(lora_filename.lower()) and
file.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')) and
not file.lower().endswith('.preview.png')):
external_path = os.path.join(base_dir, file)
logger.info(f"Found matching prefixed image: {external_path}")
return web.FileResponse(
external_path,
headers={"Content-Type": get_content_type(external_path)}
)
logger.info(f"No matching image found for {lora_name}/{image_name}")
return web.Response(status=404)
@PromptServer.instance.routes.get("/lora_sidebar/placeholder")
async def get_placeholder(request):
placeholder_path = os.path.join(LORA_DATA_DIR, "placeholder.jpeg")
if os.path.exists(placeholder_path):
return web.FileResponse(placeholder_path)
return web.Response(status=404)
@PromptServer.instance.routes.get("/lora_sidebar/unprocessed_count")
async def get_unprocessed_count(request):
logger.info("Starting fresh: Scanning for unprocessed LoRAs")
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
# Initialize processed_loras with default structure
processed_loras = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
# Get the current LoRA files first
lora_files = await list_loras(request)
current_lora_names = [os.path.splitext(lf['filename'])[0].strip() for lf in lora_files]
# Check version and handle existing data
if os.path.exists(processed_loras_file):
with io.open(processed_loras_file, "r", encoding="utf-8") as f:
try:
loaded_data = json.load(f)
if not isinstance(loaded_data, dict) or not loaded_data.get('version') or loaded_data.get('version') < PROCESSED_LORAS_VERSION:
logger.info(f"Outdated or missing version, marking all LoRAs for reprocessing (current version: {PROCESSED_LORAS_VERSION})")
# Preserve favorites but clear loras list
favorites = loaded_data.get('favorites', []) if isinstance(loaded_data, dict) else []
processed_loras = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": favorites
}
# Save the updated structure
with io.open(processed_loras_file, "w", encoding="utf-8") as f:
json.dump(processed_loras, f, indent=4, ensure_ascii=False)
# All current loras need processing
response_data = {
"unprocessed_count": len(current_lora_names),
"new_loras": current_lora_names,
"moved_loras": [],
"duplicate_loras": [],
"missing_loras": []
}
LoraDataStore.set_data(response_data)
return web.json_response(response_data)
else:
processed_loras = loaded_data
except json.JSONDecodeError:
logger.error("Error reading processed_loras.json")
unprocessed_count = 0
new_loras = []
moved_loras = []
duplicate_loras = []
# Create a dictionary of processed LoRAs (keyed by filename)
processed_loras_dict = {lora['filename']: lora['path'] for lora in processed_loras['loras']}
# Create a dictionary of current LoRAs (keyed by filename) and count occurrences
current_loras = {os.path.splitext(lf['filename'])[0].strip(): lf['path'] for lf in lora_files}
filename_count = Counter(current_loras.keys())
potential_duplicates = [filename for filename, count in filename_count.items() if count > 1]
# Detect new, moved, and duplicate LoRAs
for base_filename, file_path in current_loras.items():
if base_filename in potential_duplicates:
if base_filename not in processed_loras_dict:
logger.info(f"New LoRA found (potential duplicate): {base_filename}")
new_loras.append(base_filename)
unprocessed_count += 1
elif processed_loras_dict[base_filename] != file_path:
logger.info(f"LoRA moved (potential duplicate): {base_filename}")
moved_loras.append(base_filename)
unprocessed_count += 1
duplicate_loras.append(base_filename)
else:
if base_filename not in processed_loras_dict:
logger.info(f"New LoRA found: {base_filename}")
new_loras.append(base_filename)
unprocessed_count += 1
elif processed_loras_dict[base_filename] != file_path:
logger.info(f"LoRA moved: {base_filename}")
moved_loras.append(base_filename)
unprocessed_count += 1
# Check for missing LoRAs
missing_loras = [lora for lora in processed_loras_dict.keys() if lora not in current_loras]
logger.info(f"Total LoRAs: {len(lora_files)}, Unprocessed: {unprocessed_count}")
logger.info(f"New: {len(new_loras)}, Moved: {len(moved_loras)}, Duplicates: {len(duplicate_loras)}, Missing: {len(missing_loras)}")
response_data = {
"unprocessed_count": unprocessed_count,
"new_loras": new_loras,
"moved_loras": moved_loras,
"duplicate_loras": duplicate_loras,
"missing_loras": missing_loras
}
LoraDataStore.set_data(response_data)
return web.json_response(response_data)
@PromptServer.instance.routes.get("/lora_sidebar/estimate")
async def estimate_processing_time(request):
# Extract 'count' from query parameters
params = request.rel_url.query
count_str = params.get('count', '0')
try:
num_unprocessed = int(count_str)
except ValueError:
logger.error(f"Invalid count parameter: {count_str}")
return web.json_response({"error": "Invalid count parameter"}, status=400)
# Calculate estimated seconds
estimated_seconds = 1.85 * num_unprocessed # 1.85 seconds per LoRA
# Determine estimated_time_minutes string
if estimated_seconds < 60:
estimated_time_minutes = "Less than 1 minute"
else:
estimated_minutes = estimated_seconds / 60
estimated_time_minutes = f"{round(estimated_minutes, 2)} minute(s)"
logger.debug(f"Estimated processing time: {estimated_seconds} seconds ({estimated_time_minutes})")
# Prepare and return JSON response
return web.json_response({
"total_unprocessed_loras": num_unprocessed,
"estimated_time_seconds": round(estimated_seconds, 2),
"estimated_time_minutes": estimated_time_minutes
})
@PromptServer.instance.routes.get("/lora_sidebar/is_processing")
async def is_processing_handler(request):
"""Endpoint to check if LoRA processing is currently running."""
return web.json_response({"is_processing": is_processing})
@PromptServer.instance.routes.get("/lora_sidebar/process")
async def process_loras(request):
global is_processing, LORA_FILE_INFO
# Check if processing is already in progress
if is_processing:
return web.json_response({
"status": "Processing already in progress",
"processed_count": 0,
"total_count": 0,
"skipped_count": 0
}, status=400)
is_processing = True
try:
# Check if we have the unprocessed data
unprocessed_info = LoraDataStore.get_data()
if unprocessed_info is None:
# If not, we need to call get_unprocessed_count
logger.info("Unprocessed data not found, calling get_unprocessed_count")
unprocessed_response = await get_unprocessed_count(request) # can remove later
unprocessed_info = LoraDataStore.get_data()
if unprocessed_info is None:
raise ValueError("Failed to retrieve unprocessed LoRAs data")
logger.info(f"Unprocessed info in process_loras: {unprocessed_info}")
new_loras = unprocessed_info.get('new_loras', [])
moved_loras = unprocessed_info.get('moved_loras', [])
missing_loras = unprocessed_info.get('missing_loras', [])
processed_count = 0
skipped_count = 0
total_count = len(new_loras) + len(moved_loras) + len(missing_loras)
# Path to the processed LoRAs JSON file
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
# Load existing processed LoRAs
if os.path.exists(processed_loras_file):
with io.open(processed_loras_file, "r", encoding="utf-8") as f:
try:
processed_loras = json.load(f)
if not isinstance(processed_loras, dict):
logger.warning("processed_loras.json is malformed. Expected a dictionary.")
processed_loras = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
except json.JSONDecodeError:
logger.error("Error decoding processed_loras.json. Starting with empty data.")
processed_loras = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
else:
processed_loras = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
logger.info("No processed_loras.json found. Starting with empty data.")
logger.info(f"Found {len(new_loras)} new LoRAs, {len(moved_loras)} moved LoRAs, and {len(missing_loras)} missing LoRAs to process.")
async with aiohttp.ClientSession() as session:
# Process both new and moved LoRAs
loras_to_process = new_loras + moved_loras # Combine both lists
# Process new LoRAs
for lora_file in loras_to_process:
filename = lora_file #should remove i think?
base_filename = lora_file
lora_folder = os.path.join(LORA_DATA_DIR, base_filename)
# Check if LoRA is already processed and up to date
info_json_path = os.path.join(lora_folder, "info.json")
if os.path.exists(info_json_path):
needs_reprocess = False
try:
with open(info_json_path, "r", encoding="utf-8") as f:
info_data = json.load(f)
# Check if info.json version is current
if not info_data.get('info_version') or info_data.get('info_version') < PROCESSED_LORAS_VERSION:
needs_reprocess = True
logger.info(f"Found outdated info.json (version: {info_data.get('info_version', 'none')} -> {PROCESSED_LORAS_VERSION}) for {filename}, reprocessing")
except Exception as e:
logger.error(f"Error checking info.json version for {filename}: {str(e)}")
logger.info(f"Will reprocess {filename} due to error checking version")
needs_reprocess = True
# Handle moved files
lora_info = LORA_FILE_INFO.get(base_filename)
if lora_info:
new_path = lora_info['path']
new_subdir = get_subdir(new_path)
path_updated = False
# Check if the LoRA exists in processed_loras.json
existing_entry = next((item for item in processed_loras["loras"]
if item.get("filename") == base_filename), None)
# Update processed_loras.json if needed
if existing_entry is None:
processed_loras["loras"].append({
"filename": base_filename,
"path": new_path
})
path_updated = True
elif existing_entry.get("path") != new_path:
existing_entry["path"] = new_path
path_updated = True
# If the path was updated, also update info.json
if path_updated:
try:
# Read current info.json
with open(info_json_path, "r", encoding="utf-8") as f:
info_data = json.load(f)
needs_update = False
# Check if we need to update metadata source
if info_data.get("local_metadata"):
# Check if .civitai.info still exists at new location
new_civitai_info = f"{os.path.splitext(new_path)[0]}.civitai.info"
if not os.path.exists(new_civitai_info):
# External metadata no longer available, switch to internal
info_data["local_metadata"] = False
needs_update = True
needs_reprocess = True # Force reprocess if we lost local metadata
logger.info(f"External metadata not found at new location for {filename}, switching to internal")
# Update path and subdir if needed
if info_data.get("path") != new_path or info_data.get("subdir") != new_subdir:
info_data["path"] = new_path
info_data["subdir"] = new_subdir
needs_update = True
# Write updates if needed
if needs_update:
with open(info_json_path, "w", encoding="utf-8") as f:
json.dump(info_data, f, indent=4)
logger.info(f"Updated info.json for moved LoRA: {filename}")
# Update processed_loras.json
existing_data = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
if os.path.exists(processed_loras_file):
with open(processed_loras_file, "r", encoding="utf-8") as f:
existing_data = json.load(f)
existing_data["loras"] = processed_loras["loras"]
with open(processed_loras_file, 'w', encoding="utf-8") as f:
json.dump(existing_data, f, indent=4, ensure_ascii=False)
# Handle move completion
if filename in moved_loras:
moved_loras.remove(filename)
processed_count += 1
progress = int(((processed_count + skipped_count) / total_count) * 100)
await PromptServer.instance.send_json("lora_process_progress", {
"progress": progress,
"completed": processed_count + skipped_count,
"total": total_count
})
except Exception as e:
logger.error(f"Error updating info for moved LoRA {filename}: {str(e)}")
needs_reprocess = True # Force reprocess if we had an error
# Only skip if we don't need to reprocess and haven't moved
if not needs_reprocess:
skipped_count += 1
progress = int(((processed_count + skipped_count) / total_count) * 100)
await PromptServer.instance.send_json("lora_process_progress", {
"progress": progress,
"completed": processed_count + skipped_count,
"total": total_count
})
logger.info(f"Skipping already processed LoRA (current version): {filename}")
continue
try:
# Process LoRA without creating the folder first
logger.info(f"Processing {filename}")
# Get the file path from LORA_FILE_INFO - let's add proper error handling here
if LORA_FILE_INFO is None:
raise ValueError(f"LORA_FILE_INFO is None when processing {filename}")
# Get the file path from LORA_FILE_INFO
lora_info = LORA_FILE_INFO.get(base_filename)
if not lora_info:
raise ValueError(f"No entry in LORA_FILE_INFO for {base_filename}. Available keys: {list(LORA_FILE_INFO.keys())[:5]}")
file_path = lora_info['path']
has_local_metadata = False # Initialize
# Check for local metadata first
version_info = await check_local_info(file_path)
if version_info:
has_local_metadata = True # Set flag if we found local metadata
logger.info("Got version info from local metadata")
else:
# If no local metadata, proceed with CivitAI API calls
file_hash = await hash_file(file_path)
version_info = await fetch_version_info(session, file_hash)
logger.info("Got version info from CivitAI")
# Calculate subdir, handling symlink issues and cross-drive paths
subdir = get_subdir(file_path)
if version_info:
# Prepare data to save
info_to_save = {
"name": version_info.get('model', {}).get('name', filename),
"modelId": version_info.get('modelId'),
"versionId": version_info.get('id'),
"versionName": version_info.get('name'),
"tags": [],
"trained_words": version_info.get('trainedWords', []),
"baseModel": version_info.get('baseModel'),
"images": [],
"nsfw": version_info.get('model', {}).get('nsfw', False),
"nsfwLevel": 0,
"version_desc": version_info.get('description'),
"reco_weight": 1,
"model_desc": version_info.get('model', {}).get('description'),
"type": version_info.get('model', {}).get('type'),
"subdir": subdir,
"path": file_path,
"local_metadata": has_local_metadata,
"info_version": PROCESSED_LORAS_VERSION,
"user_edits": []
}
# Fetch model info if available and we're not using local metadata
model_id = info_to_save["modelId"]
if model_id and not has_local_metadata:
model_info = await fetch_model_info(session, model_id)
if model_info:
info_to_save["tags"] = model_info.get('tags', [])
info_to_save["nsfwLevel"] = model_info.get('nsfwLevel', 0)
info_to_save["model_desc"] = model_info.get('description')
# Get custom images only for local metadata
custom_images = []
if has_local_metadata:
logger.info(f"Looking for custom images for local metadata LoRA: {filename}")
custom_images = find_custom_images(file_path, base_filename)
logger.info(f"Found {len(custom_images)} custom images")
# Process remote images from version_info
remote_images = []
for image in version_info.get('images', []):
remote_images.append({
"url": image.get('url'),
"type": image.get('type'),
"nsfwLevel": image.get('nsfwLevel', 0),
"hasMeta": image.get('hasMeta', False)
})
# Combine custom images first, then remote images
info_to_save['images'] = custom_images + remote_images
# Handle trained_words
trained_words = version_info.get('trainedWords', [])
if isinstance(trained_words, list):
if len(trained_words) == 1 and ',' in trained_words[0]:
# Split the comma-separated string into a list
info_to_save["trained_words"] = [word.strip() for word in trained_words[0].split(',') if word.strip()]
else:
# Already a list, just use it as is
info_to_save["trained_words"] = trained_words
elif isinstance(trained_words, str):
# If it's a single string, split it by commas
info_to_save["trained_words"] = [word.strip() for word in trained_words.split(',') if word.strip()]
else:
# Fallback to an empty list if it's neither a list nor a string
info_to_save["trained_words"] = []
# Download the first image as preview
has_local_images = version_info.get('has_local_images', False) if has_local_metadata else False
if info_to_save['images']:
if not has_local_images: # Download if not using local images or not local metadata
preview_path = os.path.join(LORA_DATA_DIR, filename, "preview")
os.makedirs(os.path.dirname(preview_path), exist_ok=True)
preview_filename = await download_image(session, info_to_save['images'][0]['url'], preview_path)
if preview_filename:
logger.info(f"Saved preview image as {preview_filename}")
else:
logger.info("Using existing local preview image")
else:
# Only copy placeholder if no local images
if not has_local_images:
logger.info("No images available and no local images - copying placeholder")
await copy_placeholder_as_preview(base_filename)
else:
logger.info("No images in metadata but using local images")
# Create the LoRA folder after successful processing
os.makedirs(lora_folder, exist_ok=True)
# Save information to a JSON file
info_file_path = os.path.join(lora_folder, "info.json")
with open(info_file_path, "w", encoding="utf-8") as f:
json.dump(info_to_save, f, indent=4)
else:
logger.info(f"Failed to fetch info for {filename}, treating it as a custom LoRA.")
custom_images = [] # Initialize first
try:
custom_images = find_custom_images(file_path, base_filename)
logger.info(f"Found {len(custom_images)} custom images for custom LoRA")
except Exception as e:
logger.error(f"Error finding custom images: {str(e)}")
custom_images = [] # Ensure we have an empty list if something fails
info_to_save = {
"name": filename, # Use filename as the name for custom LoRAs
"modelId": None,
"versionId": None,
"versionName": None,
"tags": [],
"trained_words": [],
"baseModel": "custom", # Custom for dropdown
"images": custom_images,
"nsfw": False,
"nsfwLevel": 0,
"version_desc": "Custom Lora",
"reco_weight": 1,
"model_desc": None,
"type": None,
"subdir": subdir,
"path": file_path,
"local_metadata": True,
"info_version": PROCESSED_LORAS_VERSION, # don't really need this for custom loras
"user_edits": []
}
# Handle preview image for custom LoRA
if custom_images:
try:
logger.info("Using first custom image as preview")
preview_path = os.path.join(LORA_DATA_DIR, filename, "preview")
os.makedirs(os.path.dirname(preview_path), exist_ok=True)
# For custom images, copy the first one as preview
first_image = custom_images[0]['name']
source_path = os.path.join(os.path.dirname(file_path), first_image)
if os.path.exists(source_path):
ext = os.path.splitext(first_image)[1]
shutil.copy2(source_path, os.path.join(preview_path + ext))
logger.info(f"Copied custom image as preview: {first_image}")
except Exception as e:
logger.error(f"Error setting preview image: {str(e)}")
await copy_placeholder_as_preview(base_filename)
else:
# Copy the placeholder image as preview
logger.info("No custom images found - copying placeholder")
await copy_placeholder_as_preview(base_filename)
# Create the LoRA folder
os.makedirs(lora_folder, exist_ok=True)
# Save the minimal info.json
info_file_path = os.path.join(lora_folder, "info.json")
with open(info_file_path, "w", encoding="utf-8") as f:
json.dump(info_to_save, f, indent=4)
logger.info(f"Processed {filename}")
processed_count += 1
processed_loras["loras"].append({
"filename": base_filename,
"path": file_path # Include the path here
})
with open(processed_loras_file, 'w', encoding="utf-8") as f:
json.dump(processed_loras, f, indent=4, ensure_ascii=False)
except Exception as e:
logger.error(f"Error processing {filename}: {str(e)}")
# Remove the folder if it was partially created
if os.path.exists(lora_folder):
shutil.rmtree(lora_folder)
# Send progress update
progress = int(((processed_count + skipped_count) / total_count) * 100)
await PromptServer.instance.send_json("lora_process_progress", {
"progress": progress,
"completed": processed_count + skipped_count,
"total": total_count
})
# Handle missing LoRAs
for missing_lora_name in missing_loras:
try:
lora_folder = os.path.join(LORA_DATA_DIR, missing_lora_name)
# Remove the folder if it exists
if os.path.exists(lora_folder):
shutil.rmtree(lora_folder)
logger.info(f"Removed folder for missing LoRA: {missing_lora_name}")
# Read current data to preserve version and favorites
existing_data = {
"version": PROCESSED_LORAS_VERSION,
"loras": [],
"favorites": []
}
if os.path.exists(processed_loras_file):
with open(processed_loras_file, "r", encoding="utf-8") as f:
existing_data = json.load(f)
# Update loras list
existing_data["loras"] = [
lora for lora in existing_data["loras"]
if lora.get("filename") != missing_lora_name
]
# Save updated data
with open(processed_loras_file, "w", encoding="utf-8") as f:
json.dump(existing_data, f, indent=4, ensure_ascii=False)
logger.info(f"Removed {missing_lora_name} from processed_loras.json")
processed_count += 1
except Exception as e:
logger.error(f"Error handling missing LoRA {missing_lora_name}: {str(e)}")
skipped_count += 1
# Send progress update
progress = int(((processed_count + skipped_count) / total_count) * 100)
await PromptServer.instance.send_json("lora_process_progress", {
"progress": progress,
"completed": processed_count + skipped_count,
"total": total_count
})
finally:
is_processing = False # Ensure flag is reset when processing finishes
LoraDataStore.clear_data()
return web.json_response({
"status": "Processing complete",
"processed_count": processed_count,
"total_count": total_count,
"skipped_count": skipped_count
})
@PromptServer.instance.routes.get("/lora_sidebar/data")
async def get_lora_data(request):
logger.info("Fetching LoRA data")
# Get offset and limit from query parameters
offset = int(request.query.get('offset', 0))
limit = int(request.query.get('limit', 500)) # Default to 500 if not provided
lora_data = []
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
# Load favorites
favorites = []
if os.path.exists(processed_loras_file):
with open(processed_loras_file, "r", encoding="utf-8") as f:
try:
processed_loras = json.load(f)
favorites = processed_loras.get('favorites', [])
except json.JSONDecodeError:
logger.error("Error reading processed_loras.json.")
favorites = []
# List all folders in the LoRA data directory
all_folders = [folder for folder in os.listdir(LORA_DATA_DIR)
if os.path.isdir(os.path.join(LORA_DATA_DIR, folder))]
# Ensure favorites are prioritized only in the first batch
favorite_folders = []
non_favorite_folders = []
for folder in all_folders:
if folder in favorites:
favorite_folders.append(folder)
else:
non_favorite_folders.append(folder)
# For the first batch (offset == 0), include the favorites
if offset == 0:
folders_to_process = favorite_folders[:limit] + non_favorite_folders[:max(0, limit - len(favorite_folders))]
else:
# For subsequent batches, only load non-favorites
folders_to_process = non_favorite_folders[offset - len(favorite_folders):offset + limit - len(favorite_folders)]
# Fetch data for the selected folders
processed_ids = set()
for folder in folders_to_process:
if folder not in processed_ids:
info_file = os.path.join(LORA_DATA_DIR, folder, "info.json")
if os.path.exists(info_file):
with open(info_file, "r", encoding="utf-8") as f:
try:
data = json.load(f)
data['id'] = folder # Add the folder name as 'id'
# Get the filename and path from LORA_FILE_INFO
if folder in LORA_FILE_INFO:
data['filename'] = LORA_FILE_INFO[folder]['filename']
data['path'] = LORA_FILE_INFO[folder]['path']
else:
# Fallback to default if not found
data['filename'] = f"{folder}.safetensors"
data['path'] = ""
lora_data.append(data)
processed_ids.add(folder)
except json.JSONDecodeError:
logger.error(f"Error reading {info_file}. Skipping.")
logger.info(f"Fetched data for {len(lora_data)} LoRAs (offset: {offset}, limit: {limit})")
# Check if there are more LoRAs to load
has_more = (offset + limit) < len(non_favorite_folders)
return web.json_response({
"loras": lora_data,
"favorites": favorites if offset == 0 else [], # Send favorites only in the first batch
"hasMore": has_more,
"totalCount": len(all_folders)
})
@PromptServer.instance.routes.post("/lora_sidebar/toggle_favorite")
async def toggle_favorite(request):
data = await request.json()
lora_id = data.get('id')
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
if os.path.exists(processed_loras_file):
with open(processed_loras_file, 'r', encoding="utf-8") as f:
try:
processed_loras = json.load(f)
except json.JSONDecodeError:
logger.error("Error reading processed_loras.json. Initializing.")
processed_loras = {"favorites": []}
else:
processed_loras = {"favorites": []}
if 'favorites' not in processed_loras:
processed_loras['favorites'] = []
if lora_id in processed_loras['favorites']:
processed_loras['favorites'].remove(lora_id)
is_favorite = False
else:
processed_loras['favorites'].append(lora_id)
is_favorite = True
with open(processed_loras_file, 'w', encoding="utf-8") as f:
json.dump(processed_loras, f, indent=4)
return web.json_response({"favorite": is_favorite})
@PromptServer.instance.routes.post("/lora_sidebar/set_preview")
async def set_preview_image(request):
try:
data = await request.json()
lora_id = data.get('id')
image_url = data.get('url')
if not lora_id or not image_url:
return web.json_response({"error": "Missing parameters"}, status=400)
# Handle internal URLs (custom images)
if image_url.startswith('/lora_sidebar/custom_image/'):
try:
# Parse the filename from the URL
parts = image_url.split('/')
if len(parts) >= 5: # Ensure we have enough parts
source_filename = parts[-1] # Get the last part as filename
# Source path (where the custom image is stored)
source_path = os.path.join(LORA_DATA_DIR, lora_id, source_filename)
if not os.path.exists(source_path):
return web.json_response({"error": "Source image not found"}, status=404)
# Remove any existing preview files
preview_dir = os.path.join(LORA_DATA_DIR, lora_id)
for filepath in glob.glob(os.path.join(preview_dir, 'preview.*')):
os.remove(filepath)
# Determine extension from source file
ext = os.path.splitext(source_filename)[1]
preview_path = os.path.join(preview_dir, f"preview{ext}")
# Copy the file
shutil.copy2(source_path, preview_path)
# Generate a unique version
preview_version = str(int(time.time()))
return web.json_response({
"status": "success",
"preview_version": preview_version
})
except Exception as e:
logger.error(f"Error setting preview from internal image: {str(e)}")
return web.json_response({"error": str(e)}, status=500)
# Handle external URLs (original code for external images)
async with aiohttp.ClientSession() as session:
async with session.get(image_url) as response:
if response.status == 200:
content_type = response.headers.get('Content-Type', '')
ext = mimetypes.guess_extension(content_type) or '.jpg'
# Remove any existing preview files
preview_dir = os.path.join(LORA_DATA_DIR, lora_id)
for filepath in glob.glob(os.path.join(preview_dir, 'preview.*')):
os.remove(filepath)
# Save the new preview image
preview_path = os.path.join(preview_dir, f"preview{ext}")
with open(preview_path, 'wb') as f:
f.write(await response.read())
# Generate a unique version for just this preview
preview_version = str(int(time.time()))
return web.json_response({
"status": "success",
"preview_version": preview_version
})
else:
return web.json_response({"error": "Failed to fetch image"}, status=400)
except Exception as e:
logger.error(f"Error setting preview image: {str(e)}")
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/refresh/{version_id}")
async def refresh_lora(request):
version_id = request.match_info['version_id']
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
# Define the standard field order
STANDARD_FIELD_ORDER = [
"name",
"modelId",
"versionId",
"versionName",
"tags",
"trained_words",
"baseModel",
"images",
"nsfw",
"nsfwLevel",
"version_desc",
"reco_weight",
"model_desc",
"type",
"subdir",
"path",
"local_metadata",
"info_version",
"user_edits"
]
async with aiohttp.ClientSession() as session:
try:
# Find the existing LoRA folder based on version ID
existing_lora_folder = None
existing_info = None
base_filename = None
for folder in os.listdir(LORA_DATA_DIR):
info_file_path = os.path.join(LORA_DATA_DIR, folder, "info.json")
if os.path.exists(info_file_path):
with open(info_file_path, "r") as f:
folder_info = json.load(f)
if str(folder_info.get("versionId")) == str(version_id):
existing_lora_folder = os.path.join(LORA_DATA_DIR, folder)
existing_info = folder_info
base_filename = folder
break
if not existing_lora_folder or not existing_info:
logger.warning(f"LoRA with version ID {version_id} not found.")
return web.json_response({
"status": "error",
"message": f"LoRA with version ID {version_id} not found"
}, status=404)
# Cache user edits and custom images
user_edits = existing_info.get('user_edits', [])
existing_custom_images = [img for img in existing_info.get('images', [])
if img.get('custom') is True]
# Check if this LoRA uses local metadata
if existing_info.get("local_metadata"):
version_info = await check_local_info(existing_info["path"])
if not version_info:
return web.json_response({
"status": "error",
"message": "Local metadata file no longer exists"
}, status=400)
# Also get remote info for images if needed
if not version_info.get('images'):
remote_info = await fetch_version_info_by_id(session, version_id)
if remote_info and remote_info.get('images'):
version_info['images'] = remote_info['images']
else:
version_info = await fetch_version_info_by_id(session, version_id)
# Initialize updates dictionary
updates = {}
# Get modelId from either source
model_id = version_info.get('modelId') or existing_info.get('modelId')
# Only fetch model info if we need it and aren't using local metadata
if model_id and not existing_info.get("local_metadata"):
logger.info(f"Fetching model info for model ID: {model_id}")
model_info = await fetch_model_info(session, model_id)
if model_info:
# Only update non-user-edited fields
if 'tags' not in user_edits and model_info.get('tags'):
updates['tags'] = model_info.get('tags', [])
if 'nsfwLevel' not in user_edits and model_info.get('nsfwLevel') is not None:
updates['nsfwLevel'] = model_info.get('nsfwLevel', 0)
if 'model_desc' not in user_edits and model_info.get('description'):
updates['model_desc'] = model_info.get('description')
# Update fields only if they're not in user_edits
if version_info:
if 'name' not in user_edits:
new_name = version_info.get('model', {}).get('name', existing_info.get('name'))
if new_name and new_name != existing_info.get('name'):
updates['name'] = new_name
if 'trained_words' not in user_edits:
new_trained_words = version_info.get('trainedWords', existing_info.get('trained_words'))
if new_trained_words:
if isinstance(new_trained_words, str):
new_trained_words = [word.strip() for word in new_trained_words.split(',') if word.strip()]
elif isinstance(new_trained_words, list):
new_trained_words = [str(word).strip() for word in new_trained_words]
if new_trained_words != existing_info.get('trained_words'):
updates['trained_words'] = new_trained_words
if 'baseModel' not in user_edits:
new_base_model = version_info.get('baseModel')
if new_base_model and new_base_model != existing_info.get('baseModel'):
updates['baseModel'] = new_base_model
if 'version_desc' not in user_edits:
new_description = version_info.get('description')
if new_description and new_description != existing_info.get('description'):
updates['description'] = new_description
# Handle remote images
new_remote_images = []
for image in version_info.get('images', []):
image_data = {
"url": image.get('url'),
"type": image.get('type'),
"nsfwLevel": image.get('nsfwLevel', 0),
"hasMeta": image.get('hasMeta', False)
}
if image_data['url']:
new_remote_images.append(image_data)
# Look for any new custom images
if LORA_FILE_INFO.get(base_filename):
file_path = LORA_FILE_INFO[base_filename]['path']
current_custom_images = find_custom_images(file_path, base_filename)
# Merge with existing custom images, avoiding duplicates
existing_custom_urls = {img['url'] for img in existing_custom_images}
new_custom_images = [img for img in current_custom_images
if img['url'] not in existing_custom_urls]
if new_custom_images:
existing_custom_images.extend(new_custom_images)
# Update images list
updates['images'] = existing_custom_images + new_remote_images
# Always check and update subdir if path has changed
if LORA_FILE_INFO.get(base_filename):
new_path = LORA_FILE_INFO[base_filename]['path']
new_subdir = get_subdir(new_path)
if new_path != existing_info.get('path') or new_subdir != existing_info.get('subdir'):
updates['path'] = new_path
updates['subdir'] = new_subdir
# Update processed_loras.json with new path
if os.path.exists(processed_loras_file):
with open(processed_loras_file, 'r', encoding="utf-8") as f:
processed_loras = json.load(f)
# Update the path in processed_loras
for lora in processed_loras.get('loras', []):
if lora.get('filename') == base_filename:
lora['path'] = new_path
break
with open(processed_loras_file, 'w', encoding="utf-8") as f:
json.dump(processed_loras, f, indent=4, ensure_ascii=False)
# If there are updates, apply them while preserving user edits
if updates:
logger.info(f"Applying updates to LoRA {existing_lora_folder}: {updates}")
# Apply updates while preserving user edits
for key, value in updates.items():
if key not in user_edits: # Only update if not user-edited
existing_info[key] = value
# Create new ordered dict using standard field order
ordered_info = {}
# First add all fields in standard order
for field in STANDARD_FIELD_ORDER:
if field in existing_info:
ordered_info[field] = existing_info[field]
# Then add any additional fields that might exist but aren't in standard order
for key in existing_info:
if key not in ordered_info:
ordered_info[key] = existing_info[key]
# Save updated information
info_file_path = os.path.join(existing_lora_folder, "info.json")
with open(info_file_path, "w", encoding="utf-8") as f:
json.dump(ordered_info, f, indent=4, ensure_ascii=False)
return web.json_response({
"status": "success",
"data": existing_info,
"updated_fields": list(updates.keys())
})
else:
logger.info(f"No updates required for LoRA with version ID {version_id}.")
return web.json_response({
"status": "success",
"message": "No updates required"
})
except Exception as e:
logger.error(f"Error refreshing LoRA: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/refresh/{lora_id}")
async def refresh_lora(request):
lora_id = request.match_info['lora_id']
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
info_json_path = os.path.join(lora_folder, "info.json")
if not os.path.exists(info_json_path):
return web.json_response({"status": "error", "message": "LoRA not found"}, status=404)
with open(info_json_path, "r", encoding="utf-8") as f:
info_data = json.load(f)
model_id = info_data.get("modelId")
version_id = info_data.get("versionId")
async with aiohttp.ClientSession() as session:
try:
# If no model or version ID, perform a hash lookup
if not model_id or not version_id:
logger.warning(f"No model or version ID found for {lora_id}, performing hash lookup.")
file_hash = await hash_file(os.path.join(lora_folder, f"{lora_id}.safetensors")) # Assuming safetensors format
version_info = await fetch_version_info(session, file_hash)
if not version_info:
return web.json_response({"status": "error", "message": "Could not refresh LoRA"}, status=400)
else:
version_info = await fetch_version_info_by_id(session, version_id)
# Update info.json with refreshed data
# (Code to update the info.json with new data)
return web.json_response({"status": "success", "data": info_data})
except Exception as e:
logger.info(f"Error refreshing LoRA: {str(e)}")
return web.json_response({"status": "error", "message": str(e)}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/update_info")
async def update_lora_info(request):
try:
data = await request.json()
lora_id = data.get('id')
field = data.get('field') # The field to update
value = data.get('value') # The new value
if not all([lora_id, field]):
return web.json_response({
"status": "error",
"message": "Missing required parameters"
}, status=400)
# Construct paths
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
info_file_path = os.path.join(lora_folder, "info.json")
if not os.path.exists(info_file_path):
return web.json_response({
"status": "error",
"message": "LoRA info file not found"
}, status=404)
# Read current info
with open(info_file_path, "r", encoding="utf-8") as f:
info_data = json.load(f)
# Initialize user_edits if it doesn't exist
if 'user_edits' not in info_data:
info_data['user_edits'] = []
# Special handling for arrays/lists
if isinstance(value, list) and field in ['tags', 'trained_words']:
# Ensure each item is a string and stripped
value = [str(item).strip() for item in value if str(item).strip()]
# Update the field
old_value = info_data.get(field)
info_data[field] = value
# Track the edit if it's not already in user_edits
if field not in info_data['user_edits']:
info_data['user_edits'].append(field)
# Save the updated info
with open(info_file_path, "w", encoding="utf-8") as f:
json.dump(info_data, f, indent=4, ensure_ascii=False)
# Prepare response data
response_data = {
"status": "success",
"message": f"Updated {field} successfully",
"field": field,
"old_value": old_value,
"new_value": value,
"user_edits": info_data['user_edits']
}
# Log the update
logger.info(f"Updated LoRA {lora_id} field '{field}': {old_value} -> {value}")
return web.json_response(response_data)
except json.JSONDecodeError as e:
logger.error(f"JSON decode error updating LoRA info: {str(e)}")
return web.json_response({
"status": "error",
"message": "Invalid JSON data"
}, status=400)
except Exception as e:
logger.error(f"Error updating LoRA info: {str(e)}")
return web.json_response({
"status": "error",
"message": f"Failed to update LoRA info: {str(e)}"
}, status=500)
@PromptServer.instance.routes.get("/lora_sidebar/latest_temp_image")
async def get_latest_temp_image(request):
temp_dir = folder_paths.get_temp_directory()
try:
# Get all png files in temp directory
temp_files = [f for f in os.listdir(temp_dir)
if f.endswith('.png') and os.path.isfile(os.path.join(temp_dir, f))]
if not temp_files:
return web.json_response({
"status": "error",
"message": "No generated images found"
}, status=404)
# Sort by modification time, newest first
latest_file = max(temp_files,
key=lambda f: os.path.getmtime(os.path.join(temp_dir, f)))
# Construct full URL using request information
host = request.headers.get('Host', 'localhost:8189')
scheme = request.scheme
# Format complete URL
image_url = f"{scheme}://{host}/api/view?filename={latest_file}&type=temp"
return web.json_response({
"status": "success",
"url": image_url
})
except Exception as e:
logger.error(f"Error getting latest temp image: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/upload_images")
async def upload_lora_images(request):
try:
# Check if we're getting a URL upload
if request.content_type == 'application/json':
data = await request.json()
lora_id = data.get('lora_id')
urls = data.get('urls', [])
if not lora_id or not urls:
return web.json_response({
"status": "error",
"message": "No valid URLs or LoRA ID provided"
}, status=400)
# Add duplicate check for temp images
info_path = os.path.join(LORA_DATA_DIR, lora_id, "info.json")
if os.path.exists(info_path):
with open(info_path, 'r', encoding='utf-8') as f:
existing_data = json.load(f)
existing_sources = {img.get('source_url') for img in existing_data.get('images', [])
if img.get('source_url')}
# Only check the temp image URLs
temp_urls = [url for url in urls if 'type=temp' in url]
if any(url in existing_sources for url in temp_urls):
return web.json_response({
"status": "error",
"message": "Image already added to this LoRA"
}, status=400)
images = []
async with aiohttp.ClientSession() as session:
for url in urls:
try:
# Validate URL
if not url.startswith(('http://', 'https://')):
continue
# Download image
async with session.get(url) as response:
if response.status != 200:
continue
# Get content type and file extension
content_type = response.headers.get('Content-Type', '')
if not content_type.startswith('image/'):
continue
ext = mimetypes.guess_extension(content_type) or '.jpg'
# Generate unique filename
filename = f"web_image_{int(time.time())}_{len(images)}{ext}"
# Save the file
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
os.makedirs(lora_folder, exist_ok=True)
file_path = os.path.join(lora_folder, filename)
with open(file_path, 'wb') as f:
f.write(await response.read())
images.append({
"url": f"/lora_sidebar/custom_image/{lora_id}/{filename}",
"type": "image",
"nsfwLevel": 0,
"hasMeta": True,
"custom": True,
"name": filename,
"source_url": url
})
except Exception as e:
logger.error(f"Error downloading image from {url}: {str(e)}")
continue
else:
# Handle multipart form data (existing local file upload code)
reader = await request.multipart()
lora_id = None
images = []
while True:
part = await reader.next()
if part is None:
break
if part.name == 'lora_id':
lora_id = (await part.read()).decode()
elif part.name == 'files[]':
file_data = await part.read()
filename = part.filename
if not filename:
continue
if not filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
continue
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
os.makedirs(lora_folder, exist_ok=True)
file_path = os.path.join(lora_folder, filename)
with open(file_path, 'wb') as f:
f.write(file_data)
images.append({
"url": f"/lora_sidebar/custom_image/{lora_id}/{filename}",
"type": "image",
"nsfwLevel": 0,
"hasMeta": True,
"custom": True,
"name": filename
})
if not lora_id or not images:
return web.json_response({
"status": "error",
"message": "No valid images or LoRA ID provided"
}, status=400)
# Update info.json with new images
info_path = os.path.join(LORA_DATA_DIR, lora_id, "info.json")
if os.path.exists(info_path):
with open(info_path, 'r', encoding='utf-8') as f:
info_data = json.load(f)
# Preserve ALL existing images and add new ones
existing_images = info_data.get('images', [])
# Only filter out duplicates if they're custom images with the same name
if images and existing_images:
new_image_names = set(img['name'] for img in images if img.get('custom'))
existing_images = [img for img in existing_images
if not (img.get('custom') and img.get('name') in new_image_names)]
info_data['images'] = existing_images + images
with open(info_path, 'w', encoding='utf-8') as f:
json.dump(info_data, f, indent=4, ensure_ascii=False)
return web.json_response({
"status": "success",
"message": f"Added {len(images)} images",
"images": images
})
except Exception as e:
logger.error(f"Error uploading images: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/open_folder")
async def open_folder(request):
try:
data = await request.json()
folder_path = data.get('path')
if not folder_path or not os.path.exists(folder_path):
return web.json_response({
"status": "error",
"message": "Invalid or non-existent path"
}, status=400)
try:
# Windows
if os.name == 'nt':
os.startfile(folder_path)
# Unix-like systems (Linux, macOS)
else:
subprocess.Popen(['xdg-open', folder_path])
return web.json_response({"status": "success"})
except Exception as e:
logger.error(f"Error opening folder: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
except Exception as e:
logger.error(f"Error processing request: {str(e)}")
return web.json_response({
"status": "error",
"message": str(e)
}, status=500)
@PromptServer.instance.routes.post("/lora_sidebar/delete_lora")
async def delete_lora(request):
data = await request.json()
lora_id = data.get('id')
if not lora_id:
return web.json_response({"status": "error", "message": "No LoRA ID provided"}, status=400)
lora_folder = os.path.join(LORA_DATA_DIR, lora_id)
processed_loras_file = os.path.join(LORA_DATA_DIR, "processed_loras.json")
try:
# Remove the LoRA folder
if os.path.exists(lora_folder):
shutil.rmtree(lora_folder)
# Update processed_loras.json
if os.path.exists(processed_loras_file):
with open(processed_loras_file, 'r', encoding="utf-8") as f:
processed_loras = json.load(f)
# Remove from processed list using the new format
if 'loras' in processed_loras:
processed_loras['loras'] = [
lora for lora in processed_loras['loras']
if lora.get('filename') != lora_id
]
if 'favorites' in processed_loras:
processed_loras['favorites'] = [f for f in processed_loras['favorites'] if f != lora_id]
# Write updated data back to file
with open(processed_loras_file, 'w', encoding="utf-8") as f:
json.dump(processed_loras, f, indent=4, ensure_ascii=False)
logger.info(f"Successfully deleted LoRA: {lora_id}")
return web.json_response({"status": "success", "message": f"LoRA {lora_id} deleted successfully"})
except Exception as e:
logger.error(f"Error deleting LoRA {lora_id}: {str(e)}")
return web.json_response({"status": "error", "message": f"Failed to delete LoRA: {str(e)}"}, status=500)
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]