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]);
@@ -80,9 +118,26 @@ const secondSmallCover = computed(() => memoizedCovers.value.small[1]);
"
>
-
+
-
+
+
+
+
+
+
+
+
@@ -96,6 +151,18 @@ const secondSmallCover = computed(() => memoizedCovers.value.small[1]);
:aspect-ratio="1 / 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"
>
@@ -344,18 +358,11 @@ onBeforeUnmount(() => {
/>
-
-
-
@@ -367,6 +374,30 @@ onBeforeUnmount(() => {
+
+
+
+
+
+
+
+
+
+