mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Refactor recipe management to enhance initialization and metadata handling. Improve error logging during cache pre-warming, streamline recipe data structure, and ensure proper handling of generation parameters. Update UI components for missing LoRAs with improved summary and toggle functionality. Add new methods for adding recipes to cache and loading recipe data from JSON files.
This commit is contained in:
@@ -48,14 +48,26 @@ class RecipeRoutes:
|
||||
"""Initialize cache on startup"""
|
||||
print("Pre-warming recipe cache...", file=sys.stderr)
|
||||
try:
|
||||
# Diagnose lora scanner first
|
||||
await self.recipe_scanner._lora_scanner.diagnose_hash_index()
|
||||
# First, ensure the lora scanner is fully initialized
|
||||
print("Initializing lora scanner...", file=sys.stderr)
|
||||
lora_scanner = self.recipe_scanner._lora_scanner
|
||||
|
||||
# Force a cache refresh
|
||||
# Get lora cache to ensure it's initialized
|
||||
lora_cache = await lora_scanner.get_cached_data()
|
||||
print(f"Lora scanner initialized with {len(lora_cache.raw_data)} loras", file=sys.stderr)
|
||||
|
||||
# Verify hash index is built
|
||||
if hasattr(lora_scanner, '_hash_index'):
|
||||
hash_index_size = len(lora_scanner._hash_index._hash_to_path) if hasattr(lora_scanner._hash_index, '_hash_to_path') else 0
|
||||
print(f"Lora hash index contains {hash_index_size} entries", file=sys.stderr)
|
||||
|
||||
# Now that lora scanner is initialized, initialize recipe cache
|
||||
print("Initializing recipe cache...", file=sys.stderr)
|
||||
await self.recipe_scanner.get_cached_data(force_refresh=True)
|
||||
print("Recipe cache pre-warming complete", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"Error pre-warming recipe cache: {e}", file=sys.stderr)
|
||||
logger.error(f"Error pre-warming recipe cache: {e}", exc_info=True)
|
||||
|
||||
async def get_recipes(self, request: web.Request) -> web.Response:
|
||||
"""API endpoint for getting paginated recipes"""
|
||||
@@ -221,6 +233,7 @@ class RecipeRoutes:
|
||||
# Check if this LoRA exists locally by SHA256 hash
|
||||
exists_locally = False
|
||||
local_path = None
|
||||
sha256 = ''
|
||||
|
||||
if civitai_info and 'files' in civitai_info:
|
||||
# Find the model file (type="Model") in the files list
|
||||
@@ -234,7 +247,7 @@ class RecipeRoutes:
|
||||
if exists_locally:
|
||||
local_path = self.recipe_scanner._lora_scanner.get_lora_path_by_hash(sha256)
|
||||
|
||||
# Create LoRA entry
|
||||
# Create LoRA entry for frontend display
|
||||
lora_entry = {
|
||||
'id': model_version_id,
|
||||
'name': resource.get('modelName', ''),
|
||||
@@ -243,6 +256,8 @@ class RecipeRoutes:
|
||||
'weight': resource.get('weight', 1.0),
|
||||
'existsLocally': exists_locally,
|
||||
'localPath': local_path,
|
||||
'file_name': os.path.splitext(os.path.basename(local_path))[0] if local_path else '',
|
||||
'hash': sha256,
|
||||
'thumbnailUrl': '',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
@@ -267,9 +282,24 @@ class RecipeRoutes:
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
# Extract generation parameters for recipe metadata
|
||||
gen_params = {
|
||||
'prompt': metadata.get('prompt', ''),
|
||||
'negative_prompt': metadata.get('negative_prompt', ''),
|
||||
'checkpoint': checkpoint,
|
||||
'steps': metadata.get('steps', ''),
|
||||
'sampler': metadata.get('sampler', ''),
|
||||
'cfg_scale': metadata.get('cfg_scale', ''),
|
||||
'seed': metadata.get('seed', ''),
|
||||
'size': metadata.get('size', ''),
|
||||
'clip_skip': metadata.get('clip_skip', '')
|
||||
}
|
||||
|
||||
return web.json_response({
|
||||
'base_model': base_model,
|
||||
'loras': loras
|
||||
'loras': loras,
|
||||
'gen_params': gen_params,
|
||||
'raw_metadata': metadata # Include the raw metadata for saving
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -350,6 +380,39 @@ class RecipeRoutes:
|
||||
|
||||
# Create the recipe JSON
|
||||
current_time = time.time()
|
||||
|
||||
# Format loras data according to the recipe.json format
|
||||
loras_data = []
|
||||
for lora in metadata.get("loras", []):
|
||||
# Convert frontend lora format to recipe format
|
||||
lora_entry = {
|
||||
"file_name": lora.get("file_name", "") or os.path.splitext(os.path.basename(lora.get("localPath", "")))[0],
|
||||
"hash": lora.get("hash", "").lower() if lora.get("hash") else "",
|
||||
"strength": float(lora.get("weight", 1.0)),
|
||||
"modelVersionId": lora.get("id", ""),
|
||||
"modelName": lora.get("name", ""),
|
||||
"modelVersionName": lora.get("version", "")
|
||||
}
|
||||
loras_data.append(lora_entry)
|
||||
|
||||
# Format gen_params according to the recipe.json format
|
||||
gen_params = metadata.get("gen_params", {})
|
||||
if not gen_params and "raw_metadata" in metadata:
|
||||
# Extract from raw metadata if available
|
||||
raw_metadata = metadata.get("raw_metadata", {})
|
||||
gen_params = {
|
||||
"prompt": raw_metadata.get("prompt", ""),
|
||||
"negative_prompt": raw_metadata.get("negative_prompt", ""),
|
||||
"checkpoint": raw_metadata.get("checkpoint", {}),
|
||||
"steps": raw_metadata.get("steps", ""),
|
||||
"sampler": raw_metadata.get("sampler", ""),
|
||||
"cfg_scale": raw_metadata.get("cfg_scale", ""),
|
||||
"seed": raw_metadata.get("seed", ""),
|
||||
"size": raw_metadata.get("size", ""),
|
||||
"clip_skip": raw_metadata.get("clip_skip", "")
|
||||
}
|
||||
|
||||
# Create the recipe data structure
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"file_path": image_path,
|
||||
@@ -357,8 +420,8 @@ class RecipeRoutes:
|
||||
"modified": current_time,
|
||||
"created_date": current_time,
|
||||
"base_model": metadata.get("base_model", ""),
|
||||
"loras": metadata.get("loras", []),
|
||||
"gen_params": metadata.get("gen_params", {})
|
||||
"loras": loras_data,
|
||||
"gen_params": gen_params
|
||||
}
|
||||
|
||||
# Add tags if provided
|
||||
@@ -370,8 +433,11 @@ class RecipeRoutes:
|
||||
json_path = os.path.join(recipes_dir, json_filename)
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||
# Force refresh the recipe cache
|
||||
await self.recipe_scanner.get_cached_data(force_refresh=True)
|
||||
|
||||
# Add the new recipe directly to the cache instead of forcing a refresh
|
||||
cache = await self.recipe_scanner.get_cached_data()
|
||||
await cache.add_recipe(recipe_data)
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'recipe_id': recipe_id,
|
||||
|
||||
@@ -27,11 +27,11 @@ class RecipeCache:
|
||||
reverse=True
|
||||
)
|
||||
|
||||
async def update_recipe_metadata(self, file_path: str, metadata: Dict) -> bool:
|
||||
async def update_recipe_metadata(self, recipe_id: str, metadata: Dict) -> bool:
|
||||
"""Update metadata for a specific recipe in all cached data
|
||||
|
||||
Args:
|
||||
file_path: The file path of the recipe to update
|
||||
recipe_id: The ID of the recipe to update
|
||||
metadata: The new metadata
|
||||
|
||||
Returns:
|
||||
@@ -40,7 +40,7 @@ class RecipeCache:
|
||||
async with self._lock:
|
||||
# Update in raw_data
|
||||
for item in self.raw_data:
|
||||
if item['file_path'] == file_path:
|
||||
if item.get('id') == recipe_id:
|
||||
item.update(metadata)
|
||||
break
|
||||
else:
|
||||
@@ -48,4 +48,14 @@ class RecipeCache:
|
||||
|
||||
# Resort to reflect changes
|
||||
await self.resort()
|
||||
return True
|
||||
return True
|
||||
|
||||
async def add_recipe(self, recipe_data: Dict) -> None:
|
||||
"""Add a new recipe to the cache
|
||||
|
||||
Args:
|
||||
recipe_data: The recipe data to add
|
||||
"""
|
||||
async with self._lock:
|
||||
self.raw_data.append(recipe_data)
|
||||
await self.resort()
|
||||
@@ -5,49 +5,13 @@ import json
|
||||
import re
|
||||
from typing import List, Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..config import config
|
||||
from .recipe_cache import RecipeCache
|
||||
from .lora_scanner import LoraScanner
|
||||
from .civitai_client import CivitaiClient
|
||||
import sys
|
||||
|
||||
print("Recipe Scanner module loaded", file=sys.stderr)
|
||||
|
||||
def setup_logger():
|
||||
"""Configure logger for recipe scanner"""
|
||||
# First, print directly to stderr
|
||||
print("Setting up recipe scanner logger", file=sys.stderr)
|
||||
|
||||
# Create a stderr handler
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
# Configure recipe logger
|
||||
recipe_logger = logging.getLogger(__name__)
|
||||
recipe_logger.setLevel(logging.INFO)
|
||||
|
||||
# Remove existing handlers if any
|
||||
for h in recipe_logger.handlers:
|
||||
recipe_logger.removeHandler(h)
|
||||
|
||||
recipe_logger.addHandler(handler)
|
||||
recipe_logger.propagate = False
|
||||
|
||||
# Also ensure the root logger has a handler
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
||||
# Check if the root logger already has handlers
|
||||
if not root_logger.handlers:
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
print(f"Logger setup complete: {__name__}", file=sys.stderr)
|
||||
return recipe_logger
|
||||
|
||||
# Use our configured logger
|
||||
logger = setup_logger()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RecipeScanner:
|
||||
"""Service for scanning and managing recipe images"""
|
||||
@@ -132,13 +96,7 @@ class RecipeScanner:
|
||||
if self._lora_scanner:
|
||||
logger.info("Recipe Manager: Waiting for lora scanner initialization to complete")
|
||||
|
||||
# Force a fresh initialization of the lora scanner to ensure it's complete
|
||||
lora_cache = await self._lora_scanner.get_cached_data(force_refresh=True)
|
||||
|
||||
# Add a delay to ensure any background tasks complete
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Get the cache again to ensure we have the latest data
|
||||
# Get the lora cache to ensure it's initialized
|
||||
lora_cache = await self._lora_scanner.get_cached_data()
|
||||
logger.info(f"Recipe Manager: Lora scanner initialized with {len(lora_cache.raw_data)} loras")
|
||||
|
||||
@@ -146,18 +104,6 @@ class RecipeScanner:
|
||||
if hasattr(self._lora_scanner, '_hash_index'):
|
||||
hash_index_size = len(self._lora_scanner._hash_index._hash_to_path) if hasattr(self._lora_scanner._hash_index, '_hash_to_path') else 0
|
||||
logger.info(f"Recipe Manager: Lora hash index contains {hash_index_size} entries")
|
||||
|
||||
# If hash index is empty but we have loras, consider this an error condition
|
||||
if hash_index_size == 0 and len(lora_cache.raw_data) > 0:
|
||||
logger.error("Recipe Manager: Lora hash index is empty despite having loras in cache")
|
||||
await self._lora_scanner.diagnose_hash_index()
|
||||
|
||||
# Wait another moment for hash index to potentially initialize
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Try to check again
|
||||
hash_index_size = len(self._lora_scanner._hash_index._hash_to_path) if hasattr(self._lora_scanner._hash_index, '_hash_to_path') else 0
|
||||
logger.info(f"Recipe Manager: Lora hash index now contains {hash_index_size} entries")
|
||||
else:
|
||||
logger.warning("Recipe Manager: No lora hash index available")
|
||||
else:
|
||||
@@ -187,7 +133,7 @@ class RecipeScanner:
|
||||
)
|
||||
|
||||
async def scan_all_recipes(self) -> List[Dict]:
|
||||
"""Scan all recipe images and return metadata"""
|
||||
"""Scan all recipe JSON files and return metadata"""
|
||||
recipes = []
|
||||
recipes_dir = self.recipes_dir
|
||||
|
||||
@@ -195,20 +141,20 @@ class RecipeScanner:
|
||||
logger.warning(f"Recipes directory not found: {recipes_dir}")
|
||||
return recipes
|
||||
|
||||
# Get all jpg/jpeg files in the recipes directory
|
||||
image_files = []
|
||||
logger.info(f"Scanning for recipe images in {recipes_dir}")
|
||||
# Get all recipe JSON files in the recipes directory
|
||||
recipe_files = []
|
||||
logger.info(f"Scanning for recipe JSON files in {recipes_dir}")
|
||||
for root, _, files in os.walk(recipes_dir):
|
||||
image_count = sum(1 for f in files if f.lower().endswith(('.jpg', '.jpeg')))
|
||||
if image_count > 0:
|
||||
logger.info(f"Found {image_count} potential recipe images in {root}")
|
||||
recipe_count = sum(1 for f in files if f.lower().endswith('.recipe.json'))
|
||||
if recipe_count > 0:
|
||||
logger.info(f"Found {recipe_count} recipe files in {root}")
|
||||
for file in files:
|
||||
if file.lower().endswith(('.jpg', '.jpeg')):
|
||||
image_files.append(os.path.join(root, file))
|
||||
if file.lower().endswith('.recipe.json'):
|
||||
recipe_files.append(os.path.join(root, file))
|
||||
|
||||
# Process each image
|
||||
for image_path in image_files:
|
||||
recipe_data = await self._process_recipe_image(image_path)
|
||||
# Process each recipe file
|
||||
for recipe_path in recipe_files:
|
||||
recipe_data = await self._load_recipe_file(recipe_path)
|
||||
if recipe_data:
|
||||
recipes.append(recipe_data)
|
||||
logger.info(f"Processed recipe: {recipe_data.get('title')}")
|
||||
@@ -217,87 +163,54 @@ class RecipeScanner:
|
||||
|
||||
return recipes
|
||||
|
||||
async def _process_recipe_image(self, image_path: str) -> Optional[Dict]:
|
||||
"""Process a single recipe image and return metadata"""
|
||||
async def _load_recipe_file(self, recipe_path: str) -> Optional[Dict]:
|
||||
"""Load recipe data from a JSON file"""
|
||||
try:
|
||||
print(f"Processing recipe image: {image_path}", file=sys.stderr)
|
||||
logger.info(f"Processing recipe image: {image_path}")
|
||||
logger.info(f"Loading recipe file: {recipe_path}")
|
||||
|
||||
# Extract EXIF UserComment
|
||||
user_comment = ExifUtils.extract_user_comment(image_path)
|
||||
if not user_comment:
|
||||
print(f"No EXIF UserComment found in {image_path}", file=sys.stderr)
|
||||
logger.warning(f"No EXIF UserComment found in {image_path}")
|
||||
return None
|
||||
else:
|
||||
print(f"Found UserComment: {user_comment[:50]}...", file=sys.stderr)
|
||||
with open(recipe_path, 'r', encoding='utf-8') as f:
|
||||
recipe_data = json.load(f)
|
||||
|
||||
# Parse generation parameters from UserComment
|
||||
gen_params = ExifUtils.parse_recipe_metadata(user_comment)
|
||||
if not gen_params:
|
||||
print(f"Failed to parse recipe metadata from {image_path}", file=sys.stderr)
|
||||
logger.warning(f"Failed to parse recipe metadata from {image_path}")
|
||||
# Validate recipe data
|
||||
if not recipe_data or not isinstance(recipe_data, dict):
|
||||
logger.warning(f"Invalid recipe data in {recipe_path}")
|
||||
return None
|
||||
|
||||
# Get file info
|
||||
stat = os.stat(image_path)
|
||||
file_name = os.path.basename(image_path)
|
||||
title = os.path.splitext(file_name)[0]
|
||||
# Ensure required fields exist
|
||||
required_fields = ['id', 'file_path', 'title']
|
||||
for field in required_fields:
|
||||
if field not in recipe_data:
|
||||
logger.warning(f"Missing required field '{field}' in {recipe_path}")
|
||||
return None
|
||||
|
||||
# Check for existing recipe metadata
|
||||
recipe_data = self._extract_recipe_metadata(user_comment)
|
||||
if not recipe_data:
|
||||
# Create new recipe data
|
||||
recipe_data = {
|
||||
'id': file_name,
|
||||
'file_path': image_path,
|
||||
'title': title,
|
||||
'modified': stat.st_mtime,
|
||||
'created_date': stat.st_ctime,
|
||||
'file_size': stat.st_size,
|
||||
'loras': [],
|
||||
'gen_params': {}
|
||||
}
|
||||
|
||||
# Copy loras from gen_params to recipe_data with proper structure
|
||||
for lora in gen_params.get('loras', []):
|
||||
recipe_lora = {
|
||||
'file_name': '',
|
||||
'hash': lora.get('hash', '').lower() if lora.get('hash') else '',
|
||||
'strength': lora.get('weight', 1.0),
|
||||
'modelVersionId': lora.get('modelVersionId', ''),
|
||||
'modelName': lora.get('modelName', ''),
|
||||
'modelVersionName': lora.get('modelVersionName', '')
|
||||
}
|
||||
recipe_data['loras'].append(recipe_lora)
|
||||
# Ensure the image file exists
|
||||
image_path = recipe_data.get('file_path')
|
||||
if not os.path.exists(image_path):
|
||||
logger.warning(f"Recipe image not found: {image_path}")
|
||||
# Try to find the image in the same directory as the recipe
|
||||
recipe_dir = os.path.dirname(recipe_path)
|
||||
image_filename = os.path.basename(image_path)
|
||||
alternative_path = os.path.join(recipe_dir, image_filename)
|
||||
if os.path.exists(alternative_path):
|
||||
logger.info(f"Found alternative image path: {alternative_path}")
|
||||
recipe_data['file_path'] = alternative_path
|
||||
else:
|
||||
logger.warning(f"Could not find alternative image path for {image_path}")
|
||||
|
||||
# Add generation parameters to recipe_data.gen_params instead of top level
|
||||
recipe_data['gen_params'] = {
|
||||
'prompt': gen_params.get('prompt', ''),
|
||||
'negative_prompt': gen_params.get('negative_prompt', ''),
|
||||
'checkpoint': gen_params.get('checkpoint', None),
|
||||
'steps': gen_params.get('steps', ''),
|
||||
'sampler': gen_params.get('sampler', ''),
|
||||
'cfg_scale': gen_params.get('cfg_scale', ''),
|
||||
'seed': gen_params.get('seed', ''),
|
||||
'size': gen_params.get('size', ''),
|
||||
'clip_skip': gen_params.get('clip_skip', '')
|
||||
}
|
||||
# Ensure loras array exists
|
||||
if 'loras' not in recipe_data:
|
||||
recipe_data['loras'] = []
|
||||
|
||||
# Update recipe metadata with missing information
|
||||
metadata_updated = await self._update_recipe_metadata(recipe_data, user_comment)
|
||||
recipe_data['_metadata_updated'] = metadata_updated
|
||||
# Ensure gen_params exists
|
||||
if 'gen_params' not in recipe_data:
|
||||
recipe_data['gen_params'] = {}
|
||||
|
||||
# If metadata was updated, save back to image
|
||||
if metadata_updated:
|
||||
print(f"Updating metadata for {image_path}", file=sys.stderr)
|
||||
logger.info(f"Updating metadata for {image_path}")
|
||||
self._save_updated_metadata(image_path, user_comment, recipe_data)
|
||||
# Update lora information with local paths and availability
|
||||
await self._update_lora_information(recipe_data)
|
||||
|
||||
return recipe_data
|
||||
except Exception as e:
|
||||
print(f"Error processing recipe image {image_path}: {e}", file=sys.stderr)
|
||||
logger.error(f"Error processing recipe image {image_path}: {e}")
|
||||
logger.error(f"Error loading recipe file {recipe_path}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return None
|
||||
@@ -438,97 +351,7 @@ class RecipeScanner:
|
||||
logger.error(f"Error getting hash from Civitai: {e}")
|
||||
return None
|
||||
|
||||
def _save_updated_metadata(self, image_path: str, original_comment: str, recipe_data: Dict) -> None:
|
||||
"""Save updated metadata back to image file"""
|
||||
try:
|
||||
# Check if we already have a recipe metadata section
|
||||
recipe_metadata_exists = "recipe metadata:" in original_comment.lower()
|
||||
|
||||
# Prepare recipe metadata
|
||||
recipe_metadata = {
|
||||
'id': recipe_data.get('id', ''),
|
||||
'file_path': recipe_data.get('file_path', ''),
|
||||
'title': recipe_data.get('title', ''),
|
||||
'modified': recipe_data.get('modified', 0),
|
||||
'created_date': recipe_data.get('created_date', 0),
|
||||
'base_model': recipe_data.get('base_model', ''),
|
||||
'loras': [],
|
||||
'gen_params': recipe_data.get('gen_params', {})
|
||||
}
|
||||
|
||||
# Add lora data with only necessary fields (removing weight, adding modelVersionName)
|
||||
for lora in recipe_data.get('loras', []):
|
||||
lora_entry = {
|
||||
'file_name': lora.get('file_name', ''),
|
||||
'hash': lora.get('hash', '').lower() if lora.get('hash') else '',
|
||||
'strength': lora.get('strength', 1.0),
|
||||
'modelVersionId': lora.get('modelVersionId', ''),
|
||||
'modelName': lora.get('modelName', ''),
|
||||
'modelVersionName': lora.get('modelVersionName', '')
|
||||
}
|
||||
recipe_metadata['loras'].append(lora_entry)
|
||||
|
||||
# Convert to JSON
|
||||
recipe_metadata_json = json.dumps(recipe_metadata)
|
||||
|
||||
# Create or update the recipe metadata section
|
||||
if recipe_metadata_exists:
|
||||
# Replace existing recipe metadata
|
||||
updated_comment = re.sub(
|
||||
r'recipe metadata: \{.*\}',
|
||||
f'recipe metadata: {recipe_metadata_json}',
|
||||
original_comment,
|
||||
flags=re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
else:
|
||||
# Append recipe metadata to the end
|
||||
updated_comment = f"{original_comment}, recipe metadata: {recipe_metadata_json}"
|
||||
|
||||
# Save back to image
|
||||
logger.info(f"Saving updated metadata to {image_path}")
|
||||
ExifUtils.update_user_comment(image_path, updated_comment)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving updated metadata: {e}", exc_info=True)
|
||||
|
||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None):
|
||||
"""Get paginated and filtered recipe data
|
||||
|
||||
Args:
|
||||
page: Current page number (1-based)
|
||||
page_size: Number of items per page
|
||||
sort_by: Sort method ('name' or 'date')
|
||||
search: Search term
|
||||
"""
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# Get base dataset
|
||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if search.lower() in str(item.get('title', '')).lower() or
|
||||
search.lower() in str(item.get('prompt', '')).lower()
|
||||
]
|
||||
|
||||
# Calculate pagination
|
||||
total_items = len(filtered_data)
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = min(start_idx + page_size, total_items)
|
||||
|
||||
result = {
|
||||
'items': filtered_data[start_idx:end_idx],
|
||||
'total': total_items,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total_items + page_size - 1) // page_size
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def _update_recipe_metadata(self, recipe_data: Dict, original_comment: str) -> bool:
|
||||
async def _update_recipe_metadata(self, recipe_data: Dict) -> bool:
|
||||
"""Update recipe metadata with missing information
|
||||
|
||||
Returns:
|
||||
@@ -587,9 +410,9 @@ class RecipeScanner:
|
||||
metadata_updated = True
|
||||
|
||||
# Determine the base_model for the recipe based on loras
|
||||
if recipe_data.get('loras'):
|
||||
if recipe_data.get('loras') and not recipe_data.get('base_model'):
|
||||
base_model = await self._determine_base_model(recipe_data.get('loras', []))
|
||||
if base_model and (not recipe_data.get('base_model') or recipe_data['base_model'] != base_model):
|
||||
if base_model:
|
||||
recipe_data['base_model'] = base_model
|
||||
metadata_updated = True
|
||||
|
||||
@@ -651,22 +474,49 @@ class RecipeScanner:
|
||||
logger.error(f"Error getting base model for lora: {e}")
|
||||
return None
|
||||
|
||||
def _extract_recipe_metadata(self, user_comment: str) -> Optional[Dict]:
|
||||
"""Extract recipe metadata section from UserComment if it exists"""
|
||||
try:
|
||||
# Look for recipe metadata section
|
||||
recipe_match = re.search(r'recipe metadata: (\{.*\})', user_comment, re.IGNORECASE | re.DOTALL)
|
||||
if not recipe_match:
|
||||
return None
|
||||
|
||||
recipe_json = recipe_match.group(1)
|
||||
recipe_data = json.loads(recipe_json)
|
||||
|
||||
# Ensure loras array exists
|
||||
if 'loras' not in recipe_data:
|
||||
recipe_data['loras'] = []
|
||||
|
||||
return recipe_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting recipe metadata: {e}")
|
||||
return None
|
||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None):
|
||||
"""Get paginated and filtered recipe data
|
||||
|
||||
Args:
|
||||
page: Current page number (1-based)
|
||||
page_size: Number of items per page
|
||||
sort_by: Sort method ('name' or 'date')
|
||||
search: Search term
|
||||
"""
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# Get base dataset
|
||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if search.lower() in str(item.get('title', '')).lower() or
|
||||
search.lower() in str(item.get('prompt', '')).lower()
|
||||
]
|
||||
|
||||
# Calculate pagination
|
||||
total_items = len(filtered_data)
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = min(start_idx + page_size, total_items)
|
||||
|
||||
# Get paginated items
|
||||
paginated_items = filtered_data[start_idx:end_idx]
|
||||
|
||||
# Add inLibrary information for each lora
|
||||
for item in paginated_items:
|
||||
if 'loras' in item:
|
||||
for lora in item['loras']:
|
||||
if 'hash' in lora and lora['hash']:
|
||||
lora['inLibrary'] = self._lora_scanner.has_lora_hash(lora['hash'].lower())
|
||||
|
||||
result = {
|
||||
'items': paginated_items,
|
||||
'total': total_items,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total_items + page_size - 1) // page_size
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "3",
|
||||
"file_path": "D:/Workspace/ComfyUI/models/loras/recipes/3.jpg",
|
||||
"title": "3",
|
||||
"id": "0448c06d-de1b-46ab-975c-c5aa60d90dbc",
|
||||
"file_path": "D:/Workspace/ComfyUI/models/loras/recipes/0448c06d-de1b-46ab-975c-c5aa60d90dbc.jpg",
|
||||
"title": "a mysterious, steampunk-inspired character standing in a dramatic pose",
|
||||
"modified": 1741837612.3931093,
|
||||
"created_date": 1741492786.5581934,
|
||||
"base_model": "Flux.1 D",
|
||||
@@ -23,7 +23,7 @@
|
||||
"modelVersionName": "ink-dynamic"
|
||||
},
|
||||
{
|
||||
"file_name": "",
|
||||
"file_name": "ck-painterly-fantasy-000017",
|
||||
"hash": "48c67064e2936aec342580a2a729d91d75eb818e45ecf993b9650cc66c94c420",
|
||||
"strength": 0.2,
|
||||
"modelVersionId": 1189379,
|
||||
@@ -39,7 +39,7 @@
|
||||
"modelVersionName": "v1.0"
|
||||
},
|
||||
{
|
||||
"file_name": "",
|
||||
"file_name": "Mezzotint_Artstyle_for_Flux_-_by_Ethanar",
|
||||
"hash": "e6961502769123bf23a66c5c5298d76264fd6b9610f018319a0ccb091bfc308e",
|
||||
"strength": 0.2,
|
||||
"modelVersionId": 757030,
|
||||
|
||||
@@ -395,26 +395,77 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Missing LoRAs summary section */
|
||||
/* Improved Missing LoRAs summary section */
|
||||
.missing-loras-summary {
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--lora-error);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.missing-loras-summary h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
.summary-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.summary-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.total-size-info {
|
||||
margin-bottom: var(--space-2);
|
||||
.lora-count-badge {
|
||||
font-size: 0.9em;
|
||||
font-weight: normal;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.total-size-badge {
|
||||
font-size: 0.85em;
|
||||
font-weight: normal;
|
||||
background: var(--lora-surface);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
.toggle-list-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.toggle-list-btn:hover {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.missing-loras-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
transition: max-height 0.3s ease, margin-top 0.3s ease, padding-top 0.3s ease;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.missing-loras-list.collapsed {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.missing-loras-list:not(.collapsed) {
|
||||
margin-top: var(--space-1);
|
||||
padding-top: var(--space-1);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.missing-lora-item {
|
||||
@@ -429,13 +480,45 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.missing-lora-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.missing-lora-name {
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lora-base-model {
|
||||
font-size: 0.85em;
|
||||
color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.missing-lora-size {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Recipe name input select-all behavior */
|
||||
#recipeName:focus {
|
||||
outline: 2px solid var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Prevent layout shift with scrollbar */
|
||||
.modal-content {
|
||||
overflow-y: scroll; /* Always show scrollbar */
|
||||
scrollbar-gutter: stable; /* Reserve space for scrollbar */
|
||||
}
|
||||
|
||||
/* For browsers that don't support scrollbar-gutter */
|
||||
@supports not (scrollbar-gutter: stable) {
|
||||
.modal-content {
|
||||
padding-right: calc(var(--space-2) + var(--scrollbar-width)); /* Add extra padding for scrollbar */
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,11 @@ export class ImportManager {
|
||||
throw new Error('No LoRA information found in this image');
|
||||
}
|
||||
|
||||
// Store generation parameters if available
|
||||
if (this.recipeData.gen_params) {
|
||||
console.log('Generation parameters found:', this.recipeData.gen_params);
|
||||
}
|
||||
|
||||
// Find missing LoRAs
|
||||
this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally);
|
||||
|
||||
@@ -202,9 +207,24 @@ export class ImportManager {
|
||||
showRecipeDetailsStep() {
|
||||
this.showStep('detailsStep');
|
||||
|
||||
// Set default recipe name from image filename
|
||||
// Set default recipe name from prompt or image filename
|
||||
const recipeName = document.getElementById('recipeName');
|
||||
if (this.recipeImage && !recipeName.value) {
|
||||
if (this.recipeData && this.recipeData.gen_params && this.recipeData.gen_params.prompt) {
|
||||
// Use the first 15 words from the prompt as the default recipe name
|
||||
const promptWords = this.recipeData.gen_params.prompt.split(' ');
|
||||
const truncatedPrompt = promptWords.slice(0, 15).join(' ');
|
||||
recipeName.value = truncatedPrompt;
|
||||
this.recipeName = truncatedPrompt;
|
||||
|
||||
// Set up click handler to select all text for easy editing
|
||||
if (!recipeName.hasSelectAllHandler) {
|
||||
recipeName.addEventListener('click', function() {
|
||||
this.select();
|
||||
});
|
||||
recipeName.hasSelectAllHandler = true;
|
||||
}
|
||||
} else if (this.recipeImage && !recipeName.value) {
|
||||
// Fallback to image filename if no prompt is available
|
||||
const fileName = this.recipeImage.name.split('.')[0];
|
||||
recipeName.value = fileName;
|
||||
this.recipeName = fileName;
|
||||
@@ -386,17 +406,40 @@ export class ImportManager {
|
||||
totalSizeDisplay.textContent = this.formatFileSize(totalSize);
|
||||
}
|
||||
|
||||
// Update header to include count of missing LoRAs
|
||||
const missingLorasHeader = document.querySelector('.summary-header h3');
|
||||
if (missingLorasHeader) {
|
||||
missingLorasHeader.innerHTML = `Missing LoRAs <span class="lora-count-badge">(${this.missingLoras.length})</span> <span id="totalDownloadSize" class="total-size-badge">${this.formatFileSize(totalSize)}</span>`;
|
||||
}
|
||||
|
||||
// Generate missing LoRAs list
|
||||
missingLorasList.innerHTML = this.missingLoras.map(lora => {
|
||||
const sizeDisplay = lora.size ? this.formatFileSize(lora.size) : 'Unknown size';
|
||||
const baseModel = lora.baseModel ? `<span class="lora-base-model">${lora.baseModel}</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="missing-lora-item">
|
||||
<div class="missing-lora-name">${lora.name}</div>
|
||||
<div class="missing-lora-info">
|
||||
<div class="missing-lora-name">${lora.name}</div>
|
||||
${baseModel}
|
||||
</div>
|
||||
<div class="missing-lora-size">${sizeDisplay}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Set up toggle for missing LoRAs list
|
||||
const toggleBtn = document.getElementById('toggleMissingLorasList');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
missingLorasList.classList.toggle('collapsed');
|
||||
const icon = toggleBtn.querySelector('i');
|
||||
if (icon) {
|
||||
icon.classList.toggle('fa-chevron-down');
|
||||
icon.classList.toggle('fa-chevron-up');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch LoRA roots
|
||||
@@ -470,7 +513,16 @@ export class ImportManager {
|
||||
formData.append('image', this.recipeImage);
|
||||
formData.append('name', this.recipeName);
|
||||
formData.append('tags', JSON.stringify(this.recipeTags));
|
||||
formData.append('metadata', JSON.stringify(this.recipeData));
|
||||
|
||||
// Prepare complete metadata including generation parameters
|
||||
const completeMetadata = {
|
||||
base_model: this.recipeData.base_model || "",
|
||||
loras: this.recipeData.loras || [],
|
||||
gen_params: this.recipeData.gen_params || {},
|
||||
raw_metadata: this.recipeData.raw_metadata || {}
|
||||
};
|
||||
|
||||
formData.append('metadata', JSON.stringify(completeMetadata));
|
||||
|
||||
// Send save request
|
||||
const response = await fetch('/api/recipes/save', {
|
||||
@@ -481,8 +533,7 @@ export class ImportManager {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Handle successful save
|
||||
// Show success message for recipe save
|
||||
showToast(`Recipe "${this.recipeName}" saved successfully`, 'success');
|
||||
|
||||
|
||||
// Check if we need to download LoRAs
|
||||
if (this.missingLoras.length > 0) {
|
||||
@@ -602,6 +653,9 @@ export class ImportManager {
|
||||
showToast(`Downloaded ${completedDownloads} of ${this.missingLoras.length} LoRAs`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
// Show success message for recipe save
|
||||
showToast(`Recipe "${this.recipeName}" saved successfully`, 'success');
|
||||
|
||||
// Close modal and reload recipes
|
||||
modalManager.closeModal('importModal');
|
||||
|
||||
@@ -68,13 +68,15 @@
|
||||
<!-- Step 3: Download Location (if needed) -->
|
||||
<div class="import-step" id="locationStep" style="display: none;">
|
||||
<div class="location-selection">
|
||||
<!-- Add missing LoRAs summary section -->
|
||||
<!-- Improved missing LoRAs summary section -->
|
||||
<div class="missing-loras-summary">
|
||||
<h3>Missing LoRAs to Download</h3>
|
||||
<div class="total-size-info">
|
||||
Total download size: <span id="totalDownloadSize">Calculating...</span>
|
||||
<div class="summary-header">
|
||||
<h3>Missing LoRAs <span class="lora-count-badge">(0)</span> <span id="totalDownloadSize" class="total-size-badge">Calculating...</span></h3>
|
||||
<button id="toggleMissingLorasList" class="toggle-list-btn">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="missingLorasList" class="missing-loras-list">
|
||||
<div id="missingLorasList" class="missing-loras-list collapsed">
|
||||
<!-- Missing LoRAs will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user