Craete image to webp task

This commit is contained in:
Georges-Antoine Assi
2025-08-27 22:12:12 -04:00
parent 8f7efc0bb4
commit 586ce0ef30
6 changed files with 442 additions and 29 deletions

View File

@@ -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] = {

View File

@@ -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:

View File

@@ -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()

View File

@@ -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(() => {
<template
v-if="
('is_virtual' in collection && collection.is_virtual) ||
!collection.path_cover_large
!collection.path_cover_large ||
!collection.path_cover_small
"
>
<div class="split-image first-image">
<v-img
cover
:src="firstCover"
:lazy-src="firstSmallWebpCover"
:src="firstLargeWebpCover"
:aspect-ratio="galleryViewStore.defaultAspectRatioCollection"
/>
>
<template #error>
<v-img :lazy-src="firstSmallCover" :src="firstLargeCover" />
</template>
</v-img>
</div>
<div class="split-image second-image">
<v-img
cover
:src="secondCover"
:lazy-src="secondSmallWebpCover"
:src="secondLargeWebpCover"
:aspect-ratio="galleryViewStore.defaultAspectRatioCollection"
/>
>
<template #error>
<v-img :lazy-src="secondSmallCover" :src="secondLargeCover" />
</template>
</v-img>
</div>
</template>
<template v-else>
<v-img
cover
:src="src || collection.path_cover_large"
:lazy-src="firstSmallWebpCover"
:src="firstLargeWebpCover"
:aspect-ratio="galleryViewStore.defaultAspectRatioCollection"
/>
</template>

View File

@@ -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]);
</script>
<template>
@@ -80,9 +118,26 @@ const secondSmallCover = computed(() => memoizedCovers.value.small[1]);
"
>
<div class="split-image first-image">
<v-img cover :src="firstLargeCover" :aspect-ratio="1 / 1">
<v-img cover :src="firstLargeWebpCover" :aspect-ratio="1 / 1">
<template #placeholder>
<v-img cover eager :src="firstSmallCover" :aspect-ratio="1 / 1" />
<v-img
cover
eager
:src="firstSmallWebpCover"
:aspect-ratio="1 / 1"
/>
</template>
<template #error>
<v-img cover :src="firstLargeCover" :aspect-ratio="1 / 1">
<template #placeholder>
<v-img
cover
eager
:src="firstSmallCover"
:aspect-ratio="1 / 1"
/>
</template>
</v-img>
</template>
</v-img>
</div>
@@ -96,6 +151,18 @@ const secondSmallCover = computed(() => memoizedCovers.value.small[1]);
:aspect-ratio="1 / 1"
/>
</template>
<template #error>
<v-img cover :src="secondLargeCover" :aspect-ratio="1 / 1">
<template #placeholder>
<v-img
cover
eager
:src="secondSmallCover"
:aspect-ratio="1 / 1"
/>
</template>
</v-img>
</template>
</v-img>
</div>
</template>
@@ -109,6 +176,22 @@ const secondSmallCover = computed(() => memoizedCovers.value.small[1]);
:aspect-ratio="1 / 1"
/>
</template>
<template #error>
<v-img
cover
:src="collection.path_cover_large"
:aspect-ratio="1 / 1"
>
<template #placeholder>
<v-img
cover
eager
:src="collection.path_cover_small"
:aspect-ratio="1 / 1"
/>
</template>
</v-img>
</template>
</v-img>
</template>
</div>

View File

@@ -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"
>
<template v-bind="props" v-if="titleOnHover">
@@ -344,18 +358,11 @@ onBeforeUnmount(() => {
/>
</v-expand-transition>
</div>
<template #error>
<v-img
cover
:src="fallbackCoverImage"
:aspect-ratio="computedAspectRatio"
></v-img>
</template>
<template #placeholder>
<v-img
cover
eager
:src="smallCover || fallbackCoverImage"
:src="smallWebpCover || fallbackCoverImage"
:aspect-ratio="computedAspectRatio"
>
<template #placeholder>
@@ -367,6 +374,30 @@ onBeforeUnmount(() => {
</template>
</v-img>
</template>
<template #error>
<v-img
cover
:src="largeCover || fallbackCoverImage"
:aspect-ratio="computedAspectRatio"
>
<template #placeholder>
<v-img
cover
eager
:src="smallCover || fallbackCoverImage"
:aspect-ratio="computedAspectRatio"
/>
</template>
<template #error>
<v-img
cover
eager
:src="fallbackCoverImage"
:aspect-ratio="computedAspectRatio"
/>
</template>
</v-img>
</template>
</v-img>
</v-hover>
</v-card-text>