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:
Will Miao
2025-03-15 20:08:26 +08:00
parent 9f2289329c
commit edd36427ac
7 changed files with 351 additions and 286 deletions

View File

@@ -48,14 +48,26 @@ class RecipeRoutes:
"""Initialize cache on startup""" """Initialize cache on startup"""
print("Pre-warming recipe cache...", file=sys.stderr) print("Pre-warming recipe cache...", file=sys.stderr)
try: try:
# Diagnose lora scanner first # First, ensure the lora scanner is fully initialized
await self.recipe_scanner._lora_scanner.diagnose_hash_index() 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) await self.recipe_scanner.get_cached_data(force_refresh=True)
print("Recipe cache pre-warming complete", file=sys.stderr) print("Recipe cache pre-warming complete", file=sys.stderr)
except Exception as e: except Exception as e:
print(f"Error pre-warming recipe cache: {e}", file=sys.stderr) 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: async def get_recipes(self, request: web.Request) -> web.Response:
"""API endpoint for getting paginated recipes""" """API endpoint for getting paginated recipes"""
@@ -221,6 +233,7 @@ class RecipeRoutes:
# Check if this LoRA exists locally by SHA256 hash # Check if this LoRA exists locally by SHA256 hash
exists_locally = False exists_locally = False
local_path = None local_path = None
sha256 = ''
if civitai_info and 'files' in civitai_info: if civitai_info and 'files' in civitai_info:
# Find the model file (type="Model") in the files list # Find the model file (type="Model") in the files list
@@ -234,7 +247,7 @@ class RecipeRoutes:
if exists_locally: if exists_locally:
local_path = self.recipe_scanner._lora_scanner.get_lora_path_by_hash(sha256) local_path = self.recipe_scanner._lora_scanner.get_lora_path_by_hash(sha256)
# Create LoRA entry # Create LoRA entry for frontend display
lora_entry = { lora_entry = {
'id': model_version_id, 'id': model_version_id,
'name': resource.get('modelName', ''), 'name': resource.get('modelName', ''),
@@ -243,6 +256,8 @@ class RecipeRoutes:
'weight': resource.get('weight', 1.0), 'weight': resource.get('weight', 1.0),
'existsLocally': exists_locally, 'existsLocally': exists_locally,
'localPath': local_path, 'localPath': local_path,
'file_name': os.path.splitext(os.path.basename(local_path))[0] if local_path else '',
'hash': sha256,
'thumbnailUrl': '', 'thumbnailUrl': '',
'baseModel': '', 'baseModel': '',
'size': 0, 'size': 0,
@@ -267,9 +282,24 @@ class RecipeRoutes:
loras.append(lora_entry) 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({ return web.json_response({
'base_model': base_model, '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: except Exception as e:
@@ -350,6 +380,39 @@ class RecipeRoutes:
# Create the recipe JSON # Create the recipe JSON
current_time = time.time() 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 = { recipe_data = {
"id": recipe_id, "id": recipe_id,
"file_path": image_path, "file_path": image_path,
@@ -357,8 +420,8 @@ class RecipeRoutes:
"modified": current_time, "modified": current_time,
"created_date": current_time, "created_date": current_time,
"base_model": metadata.get("base_model", ""), "base_model": metadata.get("base_model", ""),
"loras": metadata.get("loras", []), "loras": loras_data,
"gen_params": metadata.get("gen_params", {}) "gen_params": gen_params
} }
# Add tags if provided # Add tags if provided
@@ -370,8 +433,11 @@ class RecipeRoutes:
json_path = os.path.join(recipes_dir, json_filename) json_path = os.path.join(recipes_dir, json_filename)
with open(json_path, 'w', encoding='utf-8') as f: with open(json_path, 'w', encoding='utf-8') as f:
json.dump(recipe_data, f, indent=4, ensure_ascii=False) 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({ return web.json_response({
'success': True, 'success': True,
'recipe_id': recipe_id, 'recipe_id': recipe_id,

View File

@@ -27,11 +27,11 @@ class RecipeCache:
reverse=True 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 """Update metadata for a specific recipe in all cached data
Args: Args:
file_path: The file path of the recipe to update recipe_id: The ID of the recipe to update
metadata: The new metadata metadata: The new metadata
Returns: Returns:
@@ -40,7 +40,7 @@ class RecipeCache:
async with self._lock: async with self._lock:
# Update in raw_data # Update in raw_data
for item in self.raw_data: for item in self.raw_data:
if item['file_path'] == file_path: if item.get('id') == recipe_id:
item.update(metadata) item.update(metadata)
break break
else: else:
@@ -48,4 +48,14 @@ class RecipeCache:
# Resort to reflect changes # Resort to reflect changes
await self.resort() 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()

View File

@@ -5,49 +5,13 @@ import json
import re import re
from typing import List, Dict, Optional, Any from typing import List, Dict, Optional, Any
from datetime import datetime from datetime import datetime
from ..utils.exif_utils import ExifUtils
from ..config import config from ..config import config
from .recipe_cache import RecipeCache from .recipe_cache import RecipeCache
from .lora_scanner import LoraScanner from .lora_scanner import LoraScanner
from .civitai_client import CivitaiClient from .civitai_client import CivitaiClient
import sys import sys
print("Recipe Scanner module loaded", file=sys.stderr) logger = logging.getLogger(__name__)
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()
class RecipeScanner: class RecipeScanner:
"""Service for scanning and managing recipe images""" """Service for scanning and managing recipe images"""
@@ -132,13 +96,7 @@ class RecipeScanner:
if self._lora_scanner: if self._lora_scanner:
logger.info("Recipe Manager: Waiting for lora scanner initialization to complete") logger.info("Recipe Manager: Waiting for lora scanner initialization to complete")
# Force a fresh initialization of the lora scanner to ensure it's complete # Get the lora cache to ensure it's initialized
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
lora_cache = await self._lora_scanner.get_cached_data() lora_cache = await self._lora_scanner.get_cached_data()
logger.info(f"Recipe Manager: Lora scanner initialized with {len(lora_cache.raw_data)} loras") 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'): 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 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") 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: else:
logger.warning("Recipe Manager: No lora hash index available") logger.warning("Recipe Manager: No lora hash index available")
else: else:
@@ -187,7 +133,7 @@ class RecipeScanner:
) )
async def scan_all_recipes(self) -> List[Dict]: 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 = []
recipes_dir = self.recipes_dir recipes_dir = self.recipes_dir
@@ -195,20 +141,20 @@ class RecipeScanner:
logger.warning(f"Recipes directory not found: {recipes_dir}") logger.warning(f"Recipes directory not found: {recipes_dir}")
return recipes return recipes
# Get all jpg/jpeg files in the recipes directory # Get all recipe JSON files in the recipes directory
image_files = [] recipe_files = []
logger.info(f"Scanning for recipe images in {recipes_dir}") logger.info(f"Scanning for recipe JSON files in {recipes_dir}")
for root, _, files in os.walk(recipes_dir): for root, _, files in os.walk(recipes_dir):
image_count = sum(1 for f in files if f.lower().endswith(('.jpg', '.jpeg'))) recipe_count = sum(1 for f in files if f.lower().endswith('.recipe.json'))
if image_count > 0: if recipe_count > 0:
logger.info(f"Found {image_count} potential recipe images in {root}") logger.info(f"Found {recipe_count} recipe files in {root}")
for file in files: for file in files:
if file.lower().endswith(('.jpg', '.jpeg')): if file.lower().endswith('.recipe.json'):
image_files.append(os.path.join(root, file)) recipe_files.append(os.path.join(root, file))
# Process each image # Process each recipe file
for image_path in image_files: for recipe_path in recipe_files:
recipe_data = await self._process_recipe_image(image_path) recipe_data = await self._load_recipe_file(recipe_path)
if recipe_data: if recipe_data:
recipes.append(recipe_data) recipes.append(recipe_data)
logger.info(f"Processed recipe: {recipe_data.get('title')}") logger.info(f"Processed recipe: {recipe_data.get('title')}")
@@ -217,87 +163,54 @@ class RecipeScanner:
return recipes return recipes
async def _process_recipe_image(self, image_path: str) -> Optional[Dict]: async def _load_recipe_file(self, recipe_path: str) -> Optional[Dict]:
"""Process a single recipe image and return metadata""" """Load recipe data from a JSON file"""
try: try:
print(f"Processing recipe image: {image_path}", file=sys.stderr) logger.info(f"Loading recipe file: {recipe_path}")
logger.info(f"Processing recipe image: {image_path}")
# Extract EXIF UserComment with open(recipe_path, 'r', encoding='utf-8') as f:
user_comment = ExifUtils.extract_user_comment(image_path) recipe_data = json.load(f)
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)
# Parse generation parameters from UserComment # Validate recipe data
gen_params = ExifUtils.parse_recipe_metadata(user_comment) if not recipe_data or not isinstance(recipe_data, dict):
if not gen_params: logger.warning(f"Invalid recipe data in {recipe_path}")
print(f"Failed to parse recipe metadata from {image_path}", file=sys.stderr)
logger.warning(f"Failed to parse recipe metadata from {image_path}")
return None return None
# Get file info # Ensure required fields exist
stat = os.stat(image_path) required_fields = ['id', 'file_path', 'title']
file_name = os.path.basename(image_path) for field in required_fields:
title = os.path.splitext(file_name)[0] if field not in recipe_data:
logger.warning(f"Missing required field '{field}' in {recipe_path}")
return None
# Check for existing recipe metadata # Ensure the image file exists
recipe_data = self._extract_recipe_metadata(user_comment) image_path = recipe_data.get('file_path')
if not recipe_data: if not os.path.exists(image_path):
# Create new recipe data logger.warning(f"Recipe image not found: {image_path}")
recipe_data = { # Try to find the image in the same directory as the recipe
'id': file_name, recipe_dir = os.path.dirname(recipe_path)
'file_path': image_path, image_filename = os.path.basename(image_path)
'title': title, alternative_path = os.path.join(recipe_dir, image_filename)
'modified': stat.st_mtime, if os.path.exists(alternative_path):
'created_date': stat.st_ctime, logger.info(f"Found alternative image path: {alternative_path}")
'file_size': stat.st_size, recipe_data['file_path'] = alternative_path
'loras': [], else:
'gen_params': {} logger.warning(f"Could not find alternative image path for {image_path}")
}
# 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)
# Add generation parameters to recipe_data.gen_params instead of top level # Ensure loras array exists
recipe_data['gen_params'] = { if 'loras' not in recipe_data:
'prompt': gen_params.get('prompt', ''), recipe_data['loras'] = []
'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', '')
}
# Update recipe metadata with missing information # Ensure gen_params exists
metadata_updated = await self._update_recipe_metadata(recipe_data, user_comment) if 'gen_params' not in recipe_data:
recipe_data['_metadata_updated'] = metadata_updated recipe_data['gen_params'] = {}
# If metadata was updated, save back to image # Update lora information with local paths and availability
if metadata_updated: await self._update_lora_information(recipe_data)
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)
return recipe_data return recipe_data
except Exception as e: except Exception as e:
print(f"Error processing recipe image {image_path}: {e}", file=sys.stderr) logger.error(f"Error loading recipe file {recipe_path}: {e}")
logger.error(f"Error processing recipe image {image_path}: {e}")
import traceback import traceback
traceback.print_exc(file=sys.stderr) traceback.print_exc(file=sys.stderr)
return None return None
@@ -438,97 +351,7 @@ class RecipeScanner:
logger.error(f"Error getting hash from Civitai: {e}") logger.error(f"Error getting hash from Civitai: {e}")
return None return None
def _save_updated_metadata(self, image_path: str, original_comment: str, recipe_data: Dict) -> None: async def _update_recipe_metadata(self, recipe_data: Dict) -> bool:
"""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:
"""Update recipe metadata with missing information """Update recipe metadata with missing information
Returns: Returns:
@@ -587,9 +410,9 @@ class RecipeScanner:
metadata_updated = True metadata_updated = True
# Determine the base_model for the recipe based on loras # 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', [])) 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 recipe_data['base_model'] = base_model
metadata_updated = True metadata_updated = True
@@ -651,22 +474,49 @@ class RecipeScanner:
logger.error(f"Error getting base model for lora: {e}") logger.error(f"Error getting base model for lora: {e}")
return None return None
def _extract_recipe_metadata(self, user_comment: str) -> Optional[Dict]: async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None):
"""Extract recipe metadata section from UserComment if it exists""" """Get paginated and filtered recipe data
try:
# Look for recipe metadata section Args:
recipe_match = re.search(r'recipe metadata: (\{.*\})', user_comment, re.IGNORECASE | re.DOTALL) page: Current page number (1-based)
if not recipe_match: page_size: Number of items per page
return None sort_by: Sort method ('name' or 'date')
search: Search term
recipe_json = recipe_match.group(1) """
recipe_data = json.loads(recipe_json) cache = await self.get_cached_data()
# Ensure loras array exists # Get base dataset
if 'loras' not in recipe_data: filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
recipe_data['loras'] = []
# Apply search filter
return recipe_data if search:
except Exception as e: filtered_data = [
logger.error(f"Error extracting recipe metadata: {e}") item for item in filtered_data
return None 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

View File

@@ -1,7 +1,7 @@
{ {
"id": "3", "id": "0448c06d-de1b-46ab-975c-c5aa60d90dbc",
"file_path": "D:/Workspace/ComfyUI/models/loras/recipes/3.jpg", "file_path": "D:/Workspace/ComfyUI/models/loras/recipes/0448c06d-de1b-46ab-975c-c5aa60d90dbc.jpg",
"title": "3", "title": "a mysterious, steampunk-inspired character standing in a dramatic pose",
"modified": 1741837612.3931093, "modified": 1741837612.3931093,
"created_date": 1741492786.5581934, "created_date": 1741492786.5581934,
"base_model": "Flux.1 D", "base_model": "Flux.1 D",
@@ -23,7 +23,7 @@
"modelVersionName": "ink-dynamic" "modelVersionName": "ink-dynamic"
}, },
{ {
"file_name": "", "file_name": "ck-painterly-fantasy-000017",
"hash": "48c67064e2936aec342580a2a729d91d75eb818e45ecf993b9650cc66c94c420", "hash": "48c67064e2936aec342580a2a729d91d75eb818e45ecf993b9650cc66c94c420",
"strength": 0.2, "strength": 0.2,
"modelVersionId": 1189379, "modelVersionId": 1189379,
@@ -39,7 +39,7 @@
"modelVersionName": "v1.0" "modelVersionName": "v1.0"
}, },
{ {
"file_name": "", "file_name": "Mezzotint_Artstyle_for_Flux_-_by_Ethanar",
"hash": "e6961502769123bf23a66c5c5298d76264fd6b9610f018319a0ccb091bfc308e", "hash": "e6961502769123bf23a66c5c5298d76264fd6b9610f018319a0ccb091bfc308e",
"strength": 0.2, "strength": 0.2,
"modelVersionId": 757030, "modelVersionId": 757030,

View File

@@ -395,26 +395,77 @@
opacity: 0.8; opacity: 0.8;
} }
/* Missing LoRAs summary section */ /* Improved Missing LoRAs summary section */
.missing-loras-summary { .missing-loras-summary {
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
padding: var(--space-2); padding: var(--space-2);
background: var(--bg-color); background: var(--bg-color);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
border: 1px solid var(--lora-error); border: 1px solid var(--border-color);
} }
.missing-loras-summary h3 { .summary-header {
margin-top: 0; display: flex;
margin-bottom: var(--space-2); justify-content: space-between;
align-items: center;
margin-bottom: 0;
}
.summary-header h3 {
margin: 0;
font-size: 1.1em; font-size: 1.1em;
color: var(--text-color); color: var(--text-color);
display: flex;
align-items: center;
gap: var(--space-1);
} }
.total-size-info { .lora-count-badge {
margin-bottom: var(--space-2);
font-size: 0.9em; 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); 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 { .missing-lora-item {
@@ -429,13 +480,45 @@
border-bottom: none; border-bottom: none;
} }
.missing-lora-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.missing-lora-name { .missing-lora-name {
font-weight: 500; 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 { .missing-lora-size {
font-size: 0.9em; font-size: 0.9em;
color: var(--text-color); color: var(--text-color);
opacity: 0.8; 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 */
}
} }

View File

@@ -186,6 +186,11 @@ export class ImportManager {
throw new Error('No LoRA information found in this image'); 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 // Find missing LoRAs
this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally); this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally);
@@ -202,9 +207,24 @@ export class ImportManager {
showRecipeDetailsStep() { showRecipeDetailsStep() {
this.showStep('detailsStep'); this.showStep('detailsStep');
// Set default recipe name from image filename // Set default recipe name from prompt or image filename
const recipeName = document.getElementById('recipeName'); 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]; const fileName = this.recipeImage.name.split('.')[0];
recipeName.value = fileName; recipeName.value = fileName;
this.recipeName = fileName; this.recipeName = fileName;
@@ -386,17 +406,40 @@ export class ImportManager {
totalSizeDisplay.textContent = this.formatFileSize(totalSize); 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 // Generate missing LoRAs list
missingLorasList.innerHTML = this.missingLoras.map(lora => { missingLorasList.innerHTML = this.missingLoras.map(lora => {
const sizeDisplay = lora.size ? this.formatFileSize(lora.size) : 'Unknown size'; const sizeDisplay = lora.size ? this.formatFileSize(lora.size) : 'Unknown size';
const baseModel = lora.baseModel ? `<span class="lora-base-model">${lora.baseModel}</span>` : '';
return ` return `
<div class="missing-lora-item"> <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 class="missing-lora-size">${sizeDisplay}</div>
</div> </div>
`; `;
}).join(''); }).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 // Fetch LoRA roots
@@ -470,7 +513,16 @@ export class ImportManager {
formData.append('image', this.recipeImage); formData.append('image', this.recipeImage);
formData.append('name', this.recipeName); formData.append('name', this.recipeName);
formData.append('tags', JSON.stringify(this.recipeTags)); 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 // Send save request
const response = await fetch('/api/recipes/save', { const response = await fetch('/api/recipes/save', {
@@ -481,8 +533,7 @@ export class ImportManager {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// Handle successful save // Handle successful save
// Show success message for recipe save
showToast(`Recipe "${this.recipeName}" saved successfully`, 'success');
// Check if we need to download LoRAs // Check if we need to download LoRAs
if (this.missingLoras.length > 0) { if (this.missingLoras.length > 0) {
@@ -602,6 +653,9 @@ export class ImportManager {
showToast(`Downloaded ${completedDownloads} of ${this.missingLoras.length} LoRAs`, 'warning'); 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 // Close modal and reload recipes
modalManager.closeModal('importModal'); modalManager.closeModal('importModal');

View File

@@ -68,13 +68,15 @@
<!-- Step 3: Download Location (if needed) --> <!-- Step 3: Download Location (if needed) -->
<div class="import-step" id="locationStep" style="display: none;"> <div class="import-step" id="locationStep" style="display: none;">
<div class="location-selection"> <div class="location-selection">
<!-- Add missing LoRAs summary section --> <!-- Improved missing LoRAs summary section -->
<div class="missing-loras-summary"> <div class="missing-loras-summary">
<h3>Missing LoRAs to Download</h3> <div class="summary-header">
<div class="total-size-info"> <h3>Missing LoRAs <span class="lora-count-badge">(0)</span> <span id="totalDownloadSize" class="total-size-badge">Calculating...</span></h3>
Total download size: <span id="totalDownloadSize">Calculating...</span> <button id="toggleMissingLorasList" class="toggle-list-btn">
<i class="fas fa-chevron-down"></i>
</button>
</div> </div>
<div id="missingLorasList" class="missing-loras-list"> <div id="missingLorasList" class="missing-loras-list collapsed">
<!-- Missing LoRAs will be populated here --> <!-- Missing LoRAs will be populated here -->
</div> </div>
</div> </div>