diff --git a/py/config.py b/py/config.py index 0de5bdb8..516a3921 100644 --- a/py/config.py +++ b/py/config.py @@ -87,9 +87,9 @@ class Config: def _init_lora_paths(self) -> List[str]: """Initialize and validate LoRA paths from ComfyUI settings""" - paths = list(set(path.replace(os.sep, "/") + paths = sorted(set(path.replace(os.sep, "/") for path in folder_paths.get_folder_paths("loras") - if os.path.exists(path))) + if os.path.exists(path)), key=lambda p: p.lower()) print("Found LoRA roots:", "\n - " + "\n - ".join(paths)) if not paths: diff --git a/py/routes/lora_routes.py b/py/routes/lora_routes.py index b65758c2..2c30968f 100644 --- a/py/routes/lora_routes.py +++ b/py/routes/lora_routes.py @@ -107,11 +107,29 @@ class LoraRoutes: settings=settings ) else: - # Normal flow + # Normal flow - get recipes with the same formatting as the API endpoint cache = await self.recipe_scanner.get_cached_data() + recipes_data = cache.sorted_by_name[:20] # Show first 20 recipes by name + + # Format the response data with static URLs for file paths - same as in recipe_routes + for item in recipes_data: + # Always ensure file_url is set + if 'file_path' in item: + item['file_url'] = self._format_recipe_file_url(item['file_path']) + else: + item['file_url'] = '/loras_static/images/no-preview.png' + + # Ensure loras array exists + if 'loras' not in item: + item['loras'] = [] + + # Ensure base_model field exists + if 'base_model' not in item: + item['base_model'] = "" + template = self.template_env.get_template('recipes.html') rendered = template.render( - recipes=cache.sorted_by_date[:20], # Show first 20 recipes by date + recipes=recipes_data, is_initializing=False, settings=settings ) @@ -128,6 +146,22 @@ class LoraRoutes: status=500 ) + def _format_recipe_file_url(self, file_path: str) -> str: + """Format file path for recipe image as a URL - same as in recipe_routes""" + try: + # Return the file URL directly for the first lora root's preview + recipes_dir = os.path.join(config.loras_roots[0], "recipes").replace(os.sep, '/') + if file_path.replace(os.sep, '/').startswith(recipes_dir): + relative_path = os.path.relpath(file_path, config.loras_roots[0]).replace(os.sep, '/') + return f"/loras_static/root1/preview/{relative_path}" + + # If not in recipes dir, try to create a valid URL from the file path + file_name = os.path.basename(file_path) + return f"/loras_static/root1/preview/recipes/{file_name}" + except Exception as e: + logger.error(f"Error formatting recipe file URL: {e}", exc_info=True) + return '/loras_static/images/no-preview.png' # Return default image on error + def setup_routes(self, app: web.Application): """Register routes with the application""" app.router.add_get('/loras', self.handle_loras_page) diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index 8e1ac310..4f01957d 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -67,9 +67,19 @@ class RecipeRoutes: # Format the response data with static URLs for file paths for item in result['items']: - item['preview_url'] = item['file_path'] - # Convert file path to URL - item['file_url'] = self._format_recipe_file_url(item['file_path']) + # Always ensure file_url is set + if 'file_path' in item: + item['file_url'] = self._format_recipe_file_url(item['file_path']) + else: + item['file_url'] = '/loras_static/images/no-preview.png' + + # 确保 loras 数组存在 + if 'loras' not in item: + item['loras'] = [] + + # 确保有 base_model 字段 + if 'base_model' not in item: + item['base_model'] = "" return web.json_response(result) except Exception as e: @@ -101,17 +111,19 @@ class RecipeRoutes: def _format_recipe_file_url(self, file_path: str) -> str: """Format file path for recipe image as a URL""" - # This is a simplified example - in real implementation, - # you would map this to a static route that can serve the file - - # For recipes folder in the first lora root - for idx, root in enumerate(config.loras_roots, start=1): - recipes_dir = os.path.join(root, "recipes") - if file_path.startswith(recipes_dir): - relative_path = os.path.relpath(file_path, root) - return f"/loras_static/root{idx}/{relative_path}" - - return file_path # Return original path if no mapping found + try: + # Return the file URL directly for the first lora root's preview + recipes_dir = os.path.join(config.loras_roots[0], "recipes").replace(os.sep, '/') + if file_path.replace(os.sep, '/').startswith(recipes_dir): + relative_path = os.path.relpath(file_path, config.loras_roots[0]).replace(os.sep, '/') + return f"/loras_static/root1/preview/{relative_path}" + + # If not in recipes dir, try to create a valid URL from the file path + file_name = os.path.basename(file_path) + return f"/loras_static/root1/preview/recipes/{file_name}" + except Exception as e: + logger.error(f"Error formatting recipe file URL: {e}", exc_info=True) + return '/loras_static/images/no-preview.png' # Return default image on error def _format_recipe_data(self, recipe: Dict) -> Dict: """Format recipe data for API response""" diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 5e244ab8..75e23e86 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -78,11 +78,8 @@ class RecipeScanner: if not config.loras_roots: return "" - # Sort the lora roots case-insensitively - sorted_roots = sorted(config.loras_roots, key=lambda x: x.lower()) - - # Use the first sorted lora root as base - recipes_dir = os.path.join(sorted_roots[0], "recipes") + # config.loras_roots already sorted case-insensitively, use the first one + recipes_dir = os.path.join(config.loras_roots[0], "recipes") os.makedirs(recipes_dir, exist_ok=True) logger.info(f"Using recipes directory: {recipes_dir}") @@ -126,9 +123,6 @@ class RecipeScanner: if self._cache is None: raise # If no cache, raise exception - logger.info(f"Recipe cache initialized with {len(self._cache.raw_data)} recipes") - logger.info(f"Recipe cache: {json.dumps(self._cache, indent=2)}") - return self._cache async def _initialize_cache(self) -> None: @@ -238,9 +232,9 @@ class RecipeScanner: else: print(f"Found UserComment: {user_comment[:50]}...", file=sys.stderr) - # Parse metadata from UserComment - recipe_data = ExifUtils.parse_recipe_metadata(user_comment) - if not recipe_data: + # 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}") return None @@ -250,15 +244,45 @@ class RecipeScanner: file_name = os.path.basename(image_path) title = os.path.splitext(file_name)[0] - # Add common metadata - recipe_data.update({ - 'id': file_name, - 'file_path': image_path, - 'title': title, - 'modified': stat.st_mtime, - 'created_date': stat.st_ctime, - 'file_size': stat.st_size - }) + # 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) + + # 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', '') + } # Update recipe metadata with missing information metadata_updated = await self._update_recipe_metadata(recipe_data, user_comment) @@ -417,51 +441,48 @@ class RecipeScanner: def _save_updated_metadata(self, image_path: str, original_comment: str, recipe_data: Dict) -> None: """Save updated metadata back to image file""" try: - # Update the resources section with the updated lora data - resources_match = re.search(r'(Civitai resources: )(\[.*?\])(?:,|\})', original_comment) - if not resources_match: - logger.warning(f"Could not find Civitai resources section in {image_path}") - return + # Check if we already have a recipe metadata section + recipe_metadata_exists = "recipe metadata:" in original_comment.lower() - resources_prefix = resources_match.group(1) + # 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', {}) + } - # Generate updated resources array - resources = [] + # 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) - # Add checkpoint if exists - if recipe_data.get('checkpoint'): - resources.append(recipe_data['checkpoint']) + # Convert to JSON + recipe_metadata_json = json.dumps(recipe_metadata) - # Add all loras - resources.extend(recipe_data.get('loras', [])) - - # Generate new resources JSON - updated_resources = json.dumps(resources) - - # Replace resources in original comment - updated_comment = original_comment.replace( - resources_match.group(0), - f"{resources_prefix}{updated_resources}," - ) - - # Update metadata section if it exists - metadata_match = re.search(r'(Civitai metadata: )(\{.*?\})', original_comment) - if metadata_match: - metadata_prefix = metadata_match.group(1) - - # Create metadata object with base_model - metadata = {} - if recipe_data.get('base_model'): - metadata['base_model'] = recipe_data['base_model'] - - # Generate new metadata JSON - updated_metadata = json.dumps(metadata) - - # Replace metadata in original comment - updated_comment = updated_comment.replace( - metadata_match.group(0), - f"{metadata_prefix}{updated_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}") @@ -537,6 +558,14 @@ class RecipeScanner: else: logger.warning(f"Could not get hash for modelVersionId {model_version_id}") + # If modelVersionId exists but no modelVersionName, try to get it from Civitai + if 'modelVersionId' in lora and not lora.get('modelVersionName'): + model_version_id = str(lora['modelVersionId']) + model_version_name = await self._get_model_version_name(model_version_id) + if model_version_name: + lora['modelVersionName'] = model_version_name + metadata_updated = True + # If has hash, check if it's in library if 'hash' in lora: hash_value = lora['hash'].lower() # Ensure lowercase when comparing @@ -551,11 +580,6 @@ class RecipeScanner: logger.info(f"Found lora in library: {file_name}") lora['file_name'] = file_name metadata_updated = True - - # Also get base_model from lora cache if possible - base_model = await self._get_base_model_for_lora(lora_path) - if base_model: - lora['base_model'] = base_model elif not in_library: # Lora not in library logger.info(f"LoRA with hash {hash_value[:8]}... not found in library") @@ -571,6 +595,24 @@ class RecipeScanner: return metadata_updated + async def _get_model_version_name(self, model_version_id: str) -> Optional[str]: + """Get model version name from Civitai API""" + try: + if not self._civitai_client: + return None + + logger.info(f"Fetching model version info from Civitai for ID: {model_version_id}") + version_info = await self._civitai_client.get_model_version_info(model_version_id) + + if version_info and 'name' in version_info: + return version_info['name'] + + logger.warning(f"No version name found for modelVersionId {model_version_id}") + return None + except Exception as e: + logger.error(f"Error getting model version name from Civitai: {e}") + return None + async def _determine_base_model(self, loras: List[Dict]) -> Optional[str]: """Determine the most common base model among LoRAs""" base_models = {} @@ -607,4 +649,24 @@ class RecipeScanner: return None except Exception as e: 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 \ No newline at end of file diff --git a/py/utils/exif_utils.py b/py/utils/exif_utils.py index f9b78cfa..827d5662 100644 --- a/py/utils/exif_utils.py +++ b/py/utils/exif_utils.py @@ -98,7 +98,15 @@ class ExifUtils: # Filter loras and checkpoints for resource in resources: if resource.get('type') == 'lora': - metadata['loras'].append(resource) + # 确保 weight 字段被正确保留 + lora_entry = resource.copy() + # 如果找不到 weight,默认为 1.0 + if 'weight' not in lora_entry: + lora_entry['weight'] = 1.0 + # Ensure modelVersionName is included + if 'modelVersionName' not in lora_entry: + lora_entry['modelVersionName'] = '' + metadata['loras'].append(lora_entry) elif resource.get('type') == 'checkpoint': metadata['checkpoint'] = resource except json.JSONDecodeError: @@ -107,4 +115,19 @@ class ExifUtils: return metadata except Exception as e: logger.error(f"Error parsing recipe metadata: {e}") - return {"prompt": user_comment, "loras": [], "checkpoint": None} \ No newline at end of file + return {"prompt": user_comment, "loras": [], "checkpoint": None} + + @staticmethod + def extract_recipe_metadata(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) + return json.loads(recipe_json) + except Exception as e: + logger.error(f"Error extracting recipe metadata: {e}") + return None \ No newline at end of file diff --git a/static/css/components/recipe-card.css b/static/css/components/recipe-card.css new file mode 100644 index 00000000..7acfb70d --- /dev/null +++ b/static/css/components/recipe-card.css @@ -0,0 +1,153 @@ +.recipe-card { + background: var(--lora-surface); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-base); + backdrop-filter: blur(16px); + transition: transform 160ms ease-out; + aspect-ratio: 896/1152; + max-width: 260px; + margin: 0 auto; + position: relative; + overflow: hidden; + cursor: pointer; +} + +.recipe-card:hover { + transform: translateY(-2px); + background: oklch(100% 0 0 / 0.6); +} + +.recipe-card:focus-visible { + outline: 2px solid var(--lora-accent); + outline-offset: 2px; +} + +.recipe-indicator { + position: absolute; + top: 8px; + left: 8px; + width: 24px; + height: 24px; + background: var(--lora-accent); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + z-index: 2; + font-size: 0.9em; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.recipe-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; + margin-top: var(--space-2); + padding-top: 4px; + padding-bottom: 4px; + max-width: 1400px; + margin-left: auto; + margin-right: auto; +} + +.card-preview { + position: relative; + width: 100%; + height: 100%; + border-radius: var(--border-radius-base); + overflow: hidden; +} + +.card-preview img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; +} + +.card-header { + position: absolute; + top: 0; + left: 0; + right: 0; + background: linear-gradient(oklch(0% 0 0 / 0.75), transparent 85%); + backdrop-filter: blur(8px); + color: white; + padding: var(--space-1); + display: flex; + justify-content: space-between; + align-items: center; + z-index: 1; + min-height: 20px; +} + +.base-model-wrapper { + display: flex; + align-items: center; + gap: 8px; + margin-left: 32px; +} + +.card-actions { + display: flex; + gap: 8px; +} + +.card-actions i { + cursor: pointer; + opacity: 0.8; + transition: opacity 0.2s ease; +} + +.card-actions i:hover { + opacity: 1; +} + +.card-footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent 15%, oklch(0% 0 0 / 0.75)); + backdrop-filter: blur(8px); + color: white; + padding: var(--space-1); + display: flex; + justify-content: space-between; + align-items: flex-start; + min-height: 32px; + gap: var(--space-1); +} + +.lora-count { + display: flex; + align-items: center; + gap: 4px; + background: rgba(255, 255, 255, 0.2); + padding: 2px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.85em; +} + +/* 响应式设计 */ +@media (max-width: 1400px) { + .recipe-grid { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + } + + .recipe-card { + max-width: 240px; + } +} + +@media (max-width: 768px) { + .recipe-grid { + grid-template-columns: minmax(260px, 1fr); + } + + .recipe-card { + max-width: 100%; + } +} \ No newline at end of file diff --git a/static/js/recipes.js b/static/js/recipes.js index 55bcd37b..11a0badc 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -118,39 +118,81 @@ class RecipeManager { card.dataset.filePath = recipe.file_path; card.dataset.title = recipe.title; card.dataset.created = recipe.created_date; + + console.log(recipe); - // Get base model from first lora if available - const baseModel = recipe.loras && recipe.loras.length > 0 - ? recipe.loras[0].baseModel - : ''; + // 获取 base model + const baseModel = recipe.base_model || ''; + // 确保 loras 数组存在 + const lorasCount = recipe.loras ? recipe.loras.length : 0; + + // Ensure file_url exists, fallback to file_path if needed + const imageUrl = recipe.file_url || + (recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` : + '/loras_static/images/no-preview.png'); + card.innerHTML = `
R
- ${recipe.title} + ${recipe.title}
- ${baseModel ? `${baseModel}` : ''} +
+ ${baseModel ? `${baseModel}` : ''} +
+
+ + + +
`; - // Recipe card click event - will be implemented later + // Recipe card click event card.addEventListener('click', () => { - console.log('Recipe clicked:', recipe); - // For future implementation: showRecipeDetails(recipe); + this.showRecipeDetails(recipe); + }); + + // Share button click event - prevent propagation to card + card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { + e.stopPropagation(); + // TODO: Implement share functionality + showToast('Share functionality will be implemented later', 'info'); + }); + + // Copy button click event - prevent propagation to card + card.querySelector('.fa-copy')?.addEventListener('click', (e) => { + e.stopPropagation(); + // TODO: Implement copy functionality + showToast('Copy functionality will be implemented later', 'info'); + }); + + // Delete button click event - prevent propagation to card + card.querySelector('.fa-trash')?.addEventListener('click', (e) => { + e.stopPropagation(); + // TODO: Implement delete functionality + showToast('Delete functionality will be implemented later', 'info'); }); return card; } + // Add a placeholder for recipe details method + showRecipeDetails(recipe) { + // TODO: Implement recipe details view + console.log('Recipe details:', recipe); + showToast(`Viewing ${recipe.title}`, 'info'); + } + // Will be implemented later: // - Recipe details view // - Recipe tag filtering diff --git a/templates/recipes.html b/templates/recipes.html index 3a24a9d7..be6a7959 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -4,6 +4,7 @@ LoRA Recipes + @@ -236,11 +237,18 @@
{{ recipe.title }}
- {% if recipe.base_model %} - - {{ recipe.base_model }} - - {% endif %} +
+ {% if recipe.base_model %} + + {{ recipe.base_model }} + + {% endif %} +
+
+ + + +