diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index 32dc1976..cd9fa6bc 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -409,12 +409,20 @@ class RecipeRoutes: import uuid recipe_id = str(uuid.uuid4()) - # Save the image - image_ext = ".jpg" - image_filename = f"{recipe_id}{image_ext}" + # Optimize the image (resize and convert to WebP) + optimized_image, extension = ExifUtils.optimize_image( + image_data=image, + target_width=250, + format='webp', + quality=85, + preserve_metadata=True + ) + + # Save the optimized image + image_filename = f"{recipe_id}{extension}" image_path = os.path.join(recipes_dir, image_filename) with open(image_path, 'wb') as f: - f.write(image) + f.write(optimized_image) # Create the recipe JSON current_time = time.time() @@ -638,7 +646,6 @@ class RecipeRoutes: shutil.copy2(image_path, temp_path) # Add recipe metadata to the image - from ..utils.exif_utils import ExifUtils processed_path = ExifUtils.append_recipe_metadata(temp_path, recipe) # Create a URL for the processed image diff --git a/py/utils/exif_utils.py b/py/utils/exif_utils.py index 23a9325c..ce7e73f0 100644 --- a/py/utils/exif_utils.py +++ b/py/utils/exif_utils.py @@ -3,6 +3,7 @@ import json import logging from typing import Dict, Optional, Any from io import BytesIO +import os from PIL import Image import re @@ -17,8 +18,8 @@ class ExifUtils: try: # First try to open as image to check format with Image.open(image_path) as img: - if img.format not in ['JPEG', 'TIFF']: - # For non-JPEG/TIFF images, try to get EXIF through PIL + if img.format not in ['JPEG', 'TIFF', 'WEBP']: + # For non-JPEG/TIFF/WEBP images, try to get EXIF through PIL exif = img._getexif() if exif and piexif.ExifIFD.UserComment in exif: user_comment = exif[piexif.ExifIFD.UserComment] @@ -29,7 +30,7 @@ class ExifUtils: return user_comment return None - # For JPEG/TIFF, use piexif + # For JPEG/TIFF/WEBP, use piexif exif_dict = piexif.load(image_path) if piexif.ExifIFD.UserComment in exif_dict.get('Exif', {}): @@ -52,7 +53,25 @@ class ExifUtils: try: # Load the image and its EXIF data with Image.open(image_path) as img: - exif_dict = piexif.load(img.info.get('exif', b'')) + # Get original format + img_format = img.format + + # For WebP format, we need a different approach + if img_format == 'WEBP': + # WebP doesn't support standard EXIF through piexif + # We'll use PIL's exif parameter directly + exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + user_comment.encode('utf-16be')}} + exif_bytes = piexif.dump(exif_dict) + + # Save with the exif data + img.save(image_path, format='WEBP', exif=exif_bytes, quality=85) + return image_path + + # For other formats, use the standard approach + try: + exif_dict = piexif.load(img.info.get('exif', b'')) + except: + exif_dict = {'0th':{}, 'Exif':{}, 'GPS':{}, 'Interop':{}, '1st':{}} # If no Exif dictionary exists, create one if 'Exif' not in exif_dict: @@ -143,4 +162,116 @@ class ExifUtils: return user_comment[:recipe_marker_index].rstrip() else: # Metadata is in the middle of the string - return user_comment[:recipe_marker_index] + user_comment[next_line_index:] \ No newline at end of file + return user_comment[:recipe_marker_index] + user_comment[next_line_index:] + + @staticmethod + def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=True): + """ + Optimize an image by resizing and converting to WebP format + + Args: + image_data: Binary image data or path to image file + target_width: Width to resize the image to (preserves aspect ratio) + format: Output format (default: webp) + quality: Output quality (0-100) + preserve_metadata: Whether to preserve EXIF metadata + + Returns: + Tuple of (optimized_image_data, extension) + """ + try: + # Extract metadata if needed + user_comment = None + if preserve_metadata: + if isinstance(image_data, str) and os.path.exists(image_data): + # It's a file path + user_comment = ExifUtils.extract_user_comment(image_data) + img = Image.open(image_data) + else: + # It's binary data + temp_img = BytesIO(image_data) + img = Image.open(temp_img) + # Save to a temporary file to extract metadata + import tempfile + with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as temp_file: + temp_path = temp_file.name + temp_file.write(image_data) + user_comment = ExifUtils.extract_user_comment(temp_path) + os.unlink(temp_path) + else: + # Just open the image without extracting metadata + if isinstance(image_data, str) and os.path.exists(image_data): + img = Image.open(image_data) + else: + img = Image.open(BytesIO(image_data)) + + # Calculate new height to maintain aspect ratio + width, height = img.size + new_height = int(height * (target_width / width)) + + # Resize the image + resized_img = img.resize((target_width, new_height), Image.LANCZOS) + + # Save to BytesIO in the specified format + output = BytesIO() + + # WebP format + if format.lower() == 'webp': + resized_img.save(output, format='WEBP', quality=quality) + extension = '.webp' + # JPEG format + elif format.lower() in ('jpg', 'jpeg'): + resized_img.save(output, format='JPEG', quality=quality) + extension = '.jpg' + # PNG format + elif format.lower() == 'png': + resized_img.save(output, format='PNG', optimize=True) + extension = '.png' + else: + # Default to WebP + resized_img.save(output, format='WEBP', quality=quality) + extension = '.webp' + + # Get the optimized image data + optimized_data = output.getvalue() + + # If we need to preserve metadata, write it to a temporary file + if preserve_metadata and user_comment: + # For WebP format, we'll directly save with metadata + if format.lower() == 'webp': + # Create a new BytesIO with metadata + output_with_metadata = BytesIO() + + # Create EXIF data with user comment + exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + user_comment.encode('utf-16be')}} + exif_bytes = piexif.dump(exif_dict) + + # Save with metadata + resized_img.save(output_with_metadata, format='WEBP', exif=exif_bytes, quality=quality) + optimized_data = output_with_metadata.getvalue() + else: + # For other formats, use the temporary file approach + import tempfile + with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_file: + temp_path = temp_file.name + temp_file.write(optimized_data) + + # Add the metadata back + ExifUtils.update_user_comment(temp_path, user_comment) + + # Read the file with metadata + with open(temp_path, 'rb') as f: + optimized_data = f.read() + + # Clean up + os.unlink(temp_path) + + return optimized_data, extension + + except Exception as e: + logger.error(f"Error optimizing image: {e}", exc_info=True) + # Return original data if optimization fails + if isinstance(image_data, str) and os.path.exists(image_data): + with open(image_data, 'rb') as f: + return f.read(), os.path.splitext(image_data)[1] + return image_data, '.jpg' \ No newline at end of file diff --git a/py/utils/recipe_parsers.py b/py/utils/recipe_parsers.py index 8b53c693..0b017220 100644 --- a/py/utils/recipe_parsers.py +++ b/py/utils/recipe_parsers.py @@ -74,8 +74,6 @@ class RecipeFormatParser(RecipeMetadataParser): if not recipe_metadata: return {"error": "No recipe metadata found", "loras": []} - logger.info("Found existing recipe metadata in image") - # Process the recipe metadata loras = [] for lora in recipe_metadata.get('loras', []): @@ -96,7 +94,7 @@ class RecipeFormatParser(RecipeMetadataParser): exists_locally = lora_scanner.has_lora_hash(lora['hash']) if exists_locally: lora_cache = await lora_scanner.get_cached_data() - lora_item = next((item for item in lora_cache.raw_data if item['sha256'] == lora['hash']), None) + lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None) if lora_item: lora_entry['existsLocally'] = True lora_entry['localPath'] = lora_item['file_path'] diff --git a/static/css/components/import-modal.css b/static/css/components/import-modal.css index c49af6ae..76711dc4 100644 --- a/static/css/components/import-modal.css +++ b/static/css/components/import-modal.css @@ -4,14 +4,6 @@ transition: none !important; /* Disable any transitions that might affect display */ } -/* Ensure error messages maintain proper height and visibility */ -.import-section .error-message { - min-height: 1.2em; - visibility: visible !important; - opacity: 1 !important; - display: block !important; -} - /* Import Mode Toggle */ .import-mode-toggle { display: flex; @@ -681,3 +673,12 @@ margin-right: 4px; font-size: 0.9em; } + +/* Error message styling */ +.error-message { + color: var(--lora-error); + font-size: 0.9em; + margin-top: 8px; + min-height: 20px; /* Ensure there's always space for the error message */ + font-weight: 500; +}