diff --git a/backend/endpoints/tasks.py b/backend/endpoints/tasks.py index e27b74014..dc1c3eb5d 100644 --- a/backend/endpoints/tasks.py +++ b/backend/endpoints/tasks.py @@ -13,6 +13,7 @@ from handler.auth.constants import Scope from handler.redis_handler import low_prio_queue from rq.job import Job from tasks.manual.cleanup_orphaned_resources import cleanup_orphaned_resources_task +from tasks.scheduled.convert_images_to_webp import convert_images_to_webp_task from tasks.scheduled.scan_library import scan_library_task from tasks.scheduled.update_launchbox_metadata import update_launchbox_metadata_task from tasks.scheduled.update_switch_titledb import update_switch_titledb_task @@ -28,6 +29,7 @@ scheduled_tasks: dict[str, Task] = { "scan_library": scan_library_task, "update_launchbox_metadata": update_launchbox_metadata_task, "update_switch_titledb": update_switch_titledb_task, + "convert_images_to_webp": convert_images_to_webp_task, } manual_tasks: dict[str, Task] = { diff --git a/backend/handler/filesystem/resources_handler.py b/backend/handler/filesystem/resources_handler.py index 7511f8485..7e063ebe6 100644 --- a/backend/handler/filesystem/resources_handler.py +++ b/backend/handler/filesystem/resources_handler.py @@ -50,6 +50,48 @@ class FSResourcesHandler(FSHandler): small_img.save(save_path) + def _create_webp_version(self, image_path: Path, quality: int = 85) -> Path | None: + """Create a WebP version of the given image file. + Args: + image_path: Path to the original image file + quality: WebP quality (0-100, default 85) + Returns: + Path to the created WebP file + """ + webp_path = image_path.with_suffix(".webp") + + try: + with Image.open(image_path) as img: + # Convert to RGB if necessary (WebP doesn't support RGBA) + if img.mode in ("RGBA", "LA", "P"): + # Create white background for transparent images + background = Image.new("RGB", img.size, (255, 255, 255)) + if img.mode == "P": + img = img.convert("RGBA") + background.paste( + img, mask=img.split()[-1] if img.mode == "RGBA" else None + ) + img = background + elif img.mode != "RGB": + img = img.convert("RGB") + + img.save(webp_path, "WEBP", quality=quality, optimize=True) + log.info(f"Created WebP version: {webp_path}") + return webp_path + except Exception as exc: + log.error(f"Failed to create WebP version of {image_path}: {str(exc)}") + return None + + def _get_webp_path(self, original_path: Path) -> Path | None: + """Get the WebP version path for a given image file. + Args: + original_path: Path to the original image file + Returns: + Path to WebP file if it exists, None otherwise + """ + webp_path = original_path.with_suffix(".webp") + return webp_path if webp_path.exists() else None + async def _store_cover( self, entity: Rom | Collection, url_cover: str, size: CoverSize ) -> None: diff --git a/backend/tasks/scheduled/convert_images_to_webp.py b/backend/tasks/scheduled/convert_images_to_webp.py new file mode 100644 index 000000000..dc3307eac --- /dev/null +++ b/backend/tasks/scheduled/convert_images_to_webp.py @@ -0,0 +1,215 @@ +"""Background task to convert existing images to WebP format.""" + +import asyncio +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List + +from config import RESOURCES_BASE_PATH +from logger.logger import log +from PIL import Image, UnidentifiedImageError +from tasks.tasks import Task + + +@dataclass +class ConversionResult: + """Result of image conversion operation.""" + + success: bool + processed_count: int + error_count: int + total_files: int + errors: List[str] + + +class ImageConverter: + """Handles image format conversion to WebP.""" + + # Supported image formats + SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".gif"} + + # Image mode conversion mapping + MODE_CONVERSIONS = { + "P": "RGBA", # Palette-based to RGBA (preserves transparency) + "LA": "RGBA", # Grayscale with alpha to RGBA + "L": "RGB", # Grayscale to RGB + "CMYK": "RGB", # CMYK to RGB + "YCbCr": "RGB", # YCbCr to RGB + } + + def __init__(self, quality: int = 90): + self.quality = quality + + def _convert_image_mode(self, img: Image.Image) -> Image.Image: + """Convert image to appropriate mode for WebP conversion. + Args: + img: PIL Image object + Returns: + Converted PIL Image object + """ + if img.mode in ("RGB", "RGBA"): + return img + + target_mode = self.MODE_CONVERSIONS.get(img.mode, "RGB") + return img.convert(target_mode) + + def convert_to_webp(self, image_path: Path) -> bool: + """Convert a single image to WebP format. + Args: + image_path: Path to the source image + Returns: + True if conversion was successful, False otherwise + """ + webp_path = image_path.with_suffix(".webp") + + # Skip if WebP already exists + if webp_path.exists(): + log.debug(f"WebP already exists for {image_path}") + return True + + try: + with Image.open(image_path) as img: + # Convert image mode if necessary + img = self._convert_image_mode(img) + + # Save as WebP + img.save(webp_path, "WEBP", quality=self.quality, optimize=True) + log.info(f"Created WebP version: {webp_path}") + return True + + except Exception as exc: + log.error(f"Failed to create WebP version of {image_path}: {str(exc)}") + return False + + +class ConvertImagesToWebPTask(Task): + """Task to convert existing images to WebP format.""" + + def __init__(self): + super().__init__( + title="Convert images to WebP", + description="Convert existing image files (PNG, JPG, BMP, TIFF, GIF) to WebP format for better performance", + enabled=True, + manual_run=True, + cron_string=None, + ) + self.resources_path = Path(RESOURCES_BASE_PATH) + self.converter = ImageConverter() + self._reset_counters() + + def _reset_counters(self) -> None: + """Reset processing counters.""" + self.processed_count = 0 + self.error_count = 0 + self.errors: list[str] = [] + + def _find_convertible_images(self) -> List[Path]: + """Find all convertible images in the resources directory. + Returns: + List of paths to image files that can be converted to WebP + """ + if not self.resources_path.exists(): + log.warning(f"Resources path does not exist: {self.resources_path}") + return [] + + image_files: list[Path] = [] + for ext in ImageConverter.SUPPORTED_EXTENSIONS: + # Only convert cover images + image_files.extend(self.resources_path.rglob(f"**/cover/*{ext}")) + + return sorted(image_files) # Sort for consistent processing order + + def _process_single_image(self, image_path: Path) -> bool: + """Process a single image file. + Args: + image_path: Path to the image file + Returns: + True if processing was successful, False otherwise + """ + try: + # Validate image file first + with Image.open(image_path) as img: + img.verify() + + # Convert to WebP + if self.converter.convert_to_webp(image_path): + self.processed_count += 1 + return True + else: + self.error_count += 1 + self.errors.append(f"Conversion failed: {image_path}") + return False + + except (UnidentifiedImageError, OSError) as exc: + log.warning(f"Skipping invalid image file {image_path}: {str(exc)}") + self.error_count += 1 + self.errors.append(f"Invalid image file: {image_path} - {str(exc)}") + return False + except Exception as exc: + log.error(f"Unexpected error processing {image_path}: {str(exc)}") + self.error_count += 1 + self.errors.append(f"Unexpected error: {image_path} - {str(exc)}") + return False + + def _get_progress_message(self) -> str: + """Get current progress message.""" + return f"Processed: {self.processed_count}, Errors: {self.error_count}" + + async def run(self) -> Dict[str, Any]: + """Run the image conversion task. + Returns: + Dictionary with task results + """ + log.info("Starting image to WebP conversion task") + + # Find all convertible images + image_files = self._find_convertible_images() + total_files = len(image_files) + + if total_files == 0: + log.info("No convertible images found") + return self._create_result_dict(total_files) + + log.info(f"Found {total_files} image files to process") + + # Reset counters + self._reset_counters() + + # Process images + for i, image_file in enumerate(image_files, 1): + self._process_single_image(image_file) + + # Log progress periodically + if i % 50 == 0 or i == total_files: + log.info( + f"Progress: {i}/{total_files} - {self._get_progress_message()}" + ) + + # Yield control to prevent blocking + if i % 10 == 0: + await asyncio.sleep(0.1) + + # Log final results + log.info(f"Image to WebP conversion completed. {self._get_progress_message()}") + + return self._create_result_dict(total_files) + + def _create_result_dict(self, total_files: int) -> Dict[str, Any]: + """Create the result dictionary. + Args: + total_files: Total number of files found + Returns: + Dictionary with task results + """ + return { + "task": "convert_images_to_webp", + "status": "completed", + "processed_count": self.processed_count, + "error_count": self.error_count, + "total_files": total_files, + "errors": self.errors[:10], # Limit error list to prevent huge responses + } + + +# Task instance +convert_images_to_webp_task = ConvertImagesToWebPTask() diff --git a/frontend/src/components/common/Collection/Card.vue b/frontend/src/components/common/Collection/Card.vue index 14865c326..ea52065bd 100644 --- a/frontend/src/components/common/Collection/Card.vue +++ b/frontend/src/components/common/Collection/Card.vue @@ -16,7 +16,6 @@ const props = withDefaults( showRomCount?: boolean; withLink?: boolean; enable3DTilt?: boolean; - src?: string; }>(), { transformScale: false, @@ -25,7 +24,6 @@ const props = withDefaults( showRomCount: false, withLink: false, enable3DTilt: false, - src: "", }, ); @@ -35,6 +33,8 @@ const galleryViewStore = storeGalleryView(); const memoizedCovers = ref({ large: ["", ""], small: ["", ""], + largeWebp: ["", ""], + smallWebp: ["", ""], }); const collectionCoverImage = computed(() => @@ -44,14 +44,6 @@ const collectionCoverImage = computed(() => ); watchEffect(() => { - if (props.src) { - memoizedCovers.value = { - large: [props.src, props.src], - small: [props.src, props.src], - }; - return; - } - // Check if it's a regular collection with covers or a smart collection with covers const isRegularOrSmartWithCovers = (!("is_virtual" in props.collection) || !props.collection.is_virtual) && @@ -68,6 +60,18 @@ watchEffect(() => { props.collection.path_cover_small || "", props.collection.path_cover_small || "", ], + largeWebp: [ + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + ], + smallWebp: [ + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + ], }; return; } @@ -86,6 +90,18 @@ watchEffect(() => { memoizedCovers.value = { large: [collectionCoverImage.value, collectionCoverImage.value], small: [collectionCoverImage.value, collectionCoverImage.value], + largeWebp: [ + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + ], + smallWebp: [ + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + ], }; return; } @@ -96,13 +112,25 @@ watchEffect(() => { memoizedCovers.value = { large: [shuffledLarge[0], shuffledLarge[1]], small: [shuffledSmall[0], shuffledSmall[1]], + largeWebp: [ + shuffledLarge[0].split(".").slice(0, -1).join(".") + ".webp" || "", + shuffledLarge[1].split(".").slice(0, -1).join(".") + ".webp" || "", + ], + smallWebp: [ + shuffledSmall[0].split(".").slice(0, -1).join(".") + ".webp" || "", + shuffledSmall[1].split(".").slice(0, -1).join(".") + ".webp" || "", + ], }; }); -const firstCover = computed(() => memoizedCovers.value.large[0]); -const secondCover = computed(() => memoizedCovers.value.large[1]); +const firstLargeCover = computed(() => memoizedCovers.value.large[0]); +const secondLargeCover = computed(() => memoizedCovers.value.large[1]); const firstSmallCover = computed(() => memoizedCovers.value.small[0]); const secondSmallCover = computed(() => memoizedCovers.value.small[1]); +const firstLargeWebpCover = computed(() => memoizedCovers.value.largeWebp[0]); +const secondLargeWebpCover = computed(() => memoizedCovers.value.largeWebp[1]); +const firstSmallWebpCover = computed(() => memoizedCovers.value.smallWebp[0]); +const secondSmallWebpCover = computed(() => memoizedCovers.value.smallWebp[1]); // Tilt 3D effect logic interface TiltHTMLElement extends HTMLElement { @@ -202,28 +230,40 @@ onBeforeUnmount(() => { diff --git a/frontend/src/components/common/Collection/RAvatar.vue b/frontend/src/components/common/Collection/RAvatar.vue index a67c6c30d..c09173c82 100644 --- a/frontend/src/components/common/Collection/RAvatar.vue +++ b/frontend/src/components/common/Collection/RAvatar.vue @@ -16,6 +16,8 @@ const props = withDefaults( const memoizedCovers = ref({ large: ["", ""], small: ["", ""], + largeWebp: ["", ""], + smallWebp: ["", ""], }); const collectionCoverImage = computed(() => @@ -39,6 +41,18 @@ watchEffect(() => { props.collection.path_cover_small, props.collection.path_cover_small, ], + largeWebp: [ + props.collection.path_cover_large?.split(".").slice(0, -1).join(".") + + ".webp" || "", + props.collection.path_cover_large?.split(".").slice(0, -1).join(".") + + ".webp" || "", + ], + smallWebp: [ + props.collection.path_cover_small?.split(".").slice(0, -1).join(".") + + ".webp" || "", + props.collection.path_cover_small?.split(".").slice(0, -1).join(".") + + ".webp" || "", + ], }; return; } @@ -50,6 +64,18 @@ watchEffect(() => { memoizedCovers.value = { large: [collectionCoverImage.value, collectionCoverImage.value], small: [collectionCoverImage.value, collectionCoverImage.value], + largeWebp: [ + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + ], + smallWebp: [ + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + collectionCoverImage.value.split(".").slice(0, -1).join(".") + + ".webp" || "", + ], }; return; } @@ -60,6 +86,14 @@ watchEffect(() => { memoizedCovers.value = { large: [shuffledLarge[0], shuffledLarge[1]], small: [shuffledSmall[0], shuffledSmall[1]], + largeWebp: [ + shuffledLarge[0].split(".").slice(0, -1).join(".") + ".webp" || "", + shuffledLarge[1].split(".").slice(0, -1).join(".") + ".webp" || "", + ], + smallWebp: [ + shuffledSmall[0].split(".").slice(0, -1).join(".") + ".webp" || "", + shuffledSmall[1].split(".").slice(0, -1).join(".") + ".webp" || "", + ], }; }); @@ -67,6 +101,10 @@ const firstLargeCover = computed(() => memoizedCovers.value.large[0]); const secondLargeCover = computed(() => memoizedCovers.value.large[1]); const firstSmallCover = computed(() => memoizedCovers.value.small[0]); const secondSmallCover = computed(() => memoizedCovers.value.small[1]); +const firstLargeWebpCover = computed(() => memoizedCovers.value.largeWebp[0]); +const secondLargeWebpCover = computed(() => memoizedCovers.value.largeWebp[1]); +const firstSmallWebpCover = computed(() => memoizedCovers.value.smallWebp[0]); +const secondSmallWebpCover = computed(() => memoizedCovers.value.smallWebp[1]); + @@ -109,6 +176,22 @@ const secondSmallCover = computed(() => memoizedCovers.value.small[1]); :aspect-ratio="1 / 1" /> + diff --git a/frontend/src/components/common/Game/Card/Base.vue b/frontend/src/components/common/Game/Card/Base.vue index a3cd5c573..6f10a7ea3 100644 --- a/frontend/src/components/common/Game/Card/Base.vue +++ b/frontend/src/components/common/Game/Card/Base.vue @@ -142,6 +142,20 @@ const largeCover = computed(() => const smallCover = computed(() => romsStore.isSimpleRom(props.rom) ? props.rom.path_cover_small : "", ); +const largeWebpCover = computed(() => + romsStore.isSimpleRom(props.rom) + ? props.rom.path_cover_large + ? props.rom.path_cover_large.split(".").slice(0, -1).join(".") + ".webp" + : "" + : "", +); +const smallWebpCover = computed(() => + romsStore.isSimpleRom(props.rom) + ? props.rom.path_cover_small + ? props.rom.path_cover_small.split(".").slice(0, -1).join(".") + ".webp" + : "" + : "", +); const showNoteDialog = (event: MouseEvent | KeyboardEvent) => { event.preventDefault(); @@ -220,7 +234,7 @@ onBeforeUnmount(() => { content-class="d-flex flex-column justify-space-between" :class="{ pointer: pointerOnHover }" :key="romsStore.isSimpleRom(rom) ? rom.updated_at : ''" - :src="largeCover || fallbackCoverImage" + :src="largeWebpCover || fallbackCoverImage" :aspect-ratio="computedAspectRatio" >