diff --git a/frontend/components/Domain/Recipe/RecipeCard.vue b/frontend/components/Domain/Recipe/RecipeCard.vue index 8a91ebc4e..66262ad25 100644 --- a/frontend/components/Domain/Recipe/RecipeCard.vue +++ b/frontend/components/Domain/Recipe/RecipeCard.vue @@ -15,11 +15,11 @@ @click.self="$emit('click')" > diff --git a/frontend/components/Domain/Recipe/RecipeCardMobile.vue b/frontend/components/Domain/Recipe/RecipeCardMobile.vue index de2113643..35e1ee235 100644 --- a/frontend/components/Domain/Recipe/RecipeCardMobile.vue +++ b/frontend/components/Domain/Recipe/RecipeCardMobile.vue @@ -19,10 +19,10 @@ cover > @@ -41,11 +41,11 @@ name="avatar" > diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue index 9b592c4f9..26700c2a6 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue @@ -28,7 +28,7 @@ const props = withDefaults(defineProps(), { }); const display = useDisplay(); -const { recipeImage } = useStaticRoutes(); +const { recipeImage, recipeSmallImage } = useStaticRoutes(); const { imageKey } = usePageState(props.recipe.slug); const { user } = usePageUser(); @@ -46,7 +46,9 @@ const imageHeight = computed(() => { }); const recipeImageUrl = computed(() => { - return recipeImage(props.recipe.id, props.recipe.image, imageKey.value); + return display.smAndDown.value + ? recipeSmallImage(props.recipe.id, props.recipe.image, imageKey.value) + : recipeImage(props.recipe.id, props.recipe.image, imageKey.value); }); watch( diff --git a/frontend/components/Domain/Recipe/RecipeTimelineItem.vue b/frontend/components/Domain/Recipe/RecipeTimelineItem.vue index b94cf58d2..7088f453d 100644 --- a/frontend/components/Domain/Recipe/RecipeTimelineItem.vue +++ b/frontend/components/Domain/Recipe/RecipeTimelineItem.vue @@ -119,7 +119,7 @@ defineEmits<{ const { $globals } = useNuxtApp(); const display = useDisplay(); -const { recipeTimelineEventImage } = useStaticRoutes(); +const { recipeTimelineEventSmallImage } = useStaticRoutes(); const { eventTypeOptions } = useTimelineEventTypes(); const { user: currentUser } = useMealieAuth(); @@ -173,7 +173,7 @@ const eventImageUrl = computed(() => { return ""; } - return recipeTimelineEventImage(props.event.recipeId, props.event.id); + return recipeTimelineEventSmallImage(props.event.recipeId, props.event.id); }); diff --git a/mealie/pkgs/img/minify.py b/mealie/pkgs/img/minify.py index d86a9f7f8..c528543c2 100644 --- a/mealie/pkgs/img/minify.py +++ b/mealie/pkgs/img/minify.py @@ -22,11 +22,6 @@ WEBP = ImageFormat(".webp", "WEBP", ["RGB", "RGBA"]) IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".avif"} -def get_format(image: Path) -> str: - img = Image.open(image) - return img.format - - def sizeof_fmt(file_path: Path, decimal_places=2): if not file_path.exists(): return "(File Not Found)" @@ -57,7 +52,7 @@ class ABCMinifier(ABC): ) @abstractmethod - def minify(self, image: Path, force=True): ... + def minify(self, image_path: Path, force=True): ... def purge(self, image: Path): if not self._purge: @@ -71,82 +66,141 @@ class ABCMinifier(ABC): class PillowMinifier(ABCMinifier): @staticmethod def _convert_image( - image_file: Path, image_format: ImageFormat, dest: Path | None = None, quality: int = 100 + image_file: Path | None = None, + image_format: ImageFormat = WEBP, + dest: Path | None = None, + quality: int = 100, + img: Image.Image | None = None, ) -> Path: """ Converts an image to the specified format in-place. The original image is not removed. By default, the quality is set to 100. """ + if img is None: + if image_file is None: + raise ValueError("Must provide either image_file or img.") + img = Image.open(image_file) - img = Image.open(image_file) - img = ImageOps.exif_transpose(img) if img.mode not in image_format.modes: img = img.convert(image_format.modes[0]) - dest = dest or image_file.with_suffix(image_format.suffix) + img = ImageOps.exif_transpose(img) + + if dest is None: + if image_file is None: + raise ValueError("If dest is not provided, image_file must be.") + dest = image_file.with_suffix(image_format.suffix) + img.save(dest, image_format.format, quality=quality) return dest @staticmethod - def to_jpg(image_file: Path, dest: Path | None = None, quality: int = 100) -> Path: - return PillowMinifier._convert_image(image_file, JPG, dest, quality) - - @staticmethod - def to_webp(image_file: Path, dest: Path | None = None, quality: int = 100) -> Path: - return PillowMinifier._convert_image(image_file, WEBP, dest, quality) - - @staticmethod - def crop_center(pil_img: Image, crop_width=300, crop_height=300): - img_width, img_height = pil_img.size - return pil_img.crop( - ( - (img_width - crop_width) // 2, - (img_height - crop_height) // 2, - (img_width + crop_width) // 2, - (img_height + crop_height) // 2, - ) + def to_jpg( + image_file_path: Path | None = None, + dest: Path | None = None, + quality: int = 100, + img: Image.Image | None = None, + ) -> Path: + return PillowMinifier._convert_image( + image_file=image_file_path, image_format=JPG, dest=dest, quality=quality, img=img ) - def minify(self, image_file: Path, force=True): - if not image_file.exists(): - raise FileNotFoundError(f"{image_file.name} does not exist") + @staticmethod + def to_webp( + image_file_path: Path | None = None, + dest: Path | None = None, + quality: int = 100, + img: Image.Image | None = None, + ) -> Path: + return PillowMinifier._convert_image( + image_file=image_file_path, image_format=WEBP, dest=dest, quality=quality, img=img + ) - org_dest = image_file.parent.joinpath("original.webp") - min_dest = image_file.parent.joinpath("min-original.webp") - tiny_dest = image_file.parent.joinpath("tiny-original.webp") + @staticmethod + def crop_center(img: Image.Image, size=(300, 300), high_res: bool = True) -> Image.Image: + img = img.copy() + target_width, target_height = size + + # For retina displays, double the target size + if high_res: + target_width *= 2 + target_height *= 2 + + img_ratio = img.width / img.height + target_ratio = target_width / target_height + + # If original image smaller than target, do not upscale + if img.width < size[0] or img.height < size[1]: + return img + + # Resize first to fill area while preserving aspect ratio + if img_ratio > target_ratio: + # Wider than target + scale_height = target_height + scale_width = int(scale_height * img_ratio) + else: + # Taller than target + scale_width = target_width + scale_height = int(scale_width / img_ratio) + + img = img.resize((scale_width, scale_height), Image.LANCZOS) + + # Crop center of the resized image + left = (img.width - target_width) // 2 + top = (img.height - target_height) // 2 + right = left + target_width + bottom = top + target_height + return img.crop((left, top, right, bottom)) + + def minify(self, image_path: Path, force=True): + if not image_path.exists(): + raise FileNotFoundError(f"{image_path.name} does not exist") + + org_dest = image_path.parent.joinpath("original.webp") + min_dest = image_path.parent.joinpath("min-original.webp") + tiny_dest = image_path.parent.joinpath("tiny-original.webp") if not force and min_dest.exists() and tiny_dest.exists() and org_dest.exists(): - self._logger.info(f"{image_file.name} already minified") + self._logger.info(f"{image_path.name} already exists in all formats") return success = False - if self._opts.original: - if not force and org_dest.exists(): - self._logger.info(f"{image_file.name} already minified") - else: - PillowMinifier.to_webp(image_file, org_dest, quality=70) - success = True + try: + with Image.open(image_path) as img: + if self._opts.original: + if not force and org_dest.exists(): + self._logger.info(f"{org_dest} already exists") + else: + original = img.copy() + original.thumbnail((2048, 2048), Image.LANCZOS) + result_path = PillowMinifier.to_webp(dest=org_dest, quality=80, img=original) + self._logger.info(f"{result_path} created") + success = True - if self._opts.miniature: - if not force and min_dest.exists(): - self._logger.info(f"{image_file.name} already minified") - else: - PillowMinifier.to_webp(image_file, min_dest, quality=70) - self._logger.info(f"{image_file.name} minified") - success = True + if self._opts.miniature: + if not force and min_dest.exists(): + self._logger.info(f"{min_dest} already exists") + else: + mini = img.copy() + mini.thumbnail((1024, 1024), Image.LANCZOS) + result_path = PillowMinifier.to_webp(dest=min_dest, quality=80, img=mini) + self._logger.info(f"{result_path} created") + success = True - if self._opts.tiny: - if not force and tiny_dest.exists(): - self._logger.info(f"{image_file.name} already minified") - else: - img = Image.open(image_file) - img = ImageOps.exif_transpose(img) - tiny_image = PillowMinifier.crop_center(img) - tiny_image.save(tiny_dest, WEBP.format, quality=70) - self._logger.info("Tiny image saved") - success = True + if self._opts.tiny: + if not force and tiny_dest.exists(): + self._logger.info(f"{tiny_dest} already exists") + else: + tiny = PillowMinifier.crop_center(img.copy(), size=(300, 300)) + result_path = PillowMinifier.to_webp(dest=tiny_dest, quality=80, img=tiny) + self._logger.info(f"{result_path} created") + success = True + + except Exception as e: + self._logger.error(f"[ERROR] Failed to minify {image_path.name}. Error: {e}") + raise if self._purge and success: - self.purge(image_file) + self.purge(image_path)