feat: Improve Image Minification Logic and Efficiency (#5883)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
This commit is contained in:
Tempest
2025-12-05 15:45:53 -06:00
committed by GitHub
parent ae8b489f97
commit ed806b9fec
5 changed files with 121 additions and 65 deletions

View File

@@ -15,11 +15,11 @@
@click.self="$emit('click')" @click.self="$emit('click')"
> >
<RecipeCardImage <RecipeCardImage
small
:icon-size="imageHeight" :icon-size="imageHeight"
:height="imageHeight" :height="imageHeight"
:slug="slug" :slug="slug"
:recipe-id="recipeId" :recipe-id="recipeId"
size="small"
:image-version="image" :image-version="image"
> >
<v-expand-transition v-if="description"> <v-expand-transition v-if="description">

View File

@@ -19,10 +19,10 @@
cover cover
> >
<RecipeCardImage <RecipeCardImage
tiny
:icon-size="100" :icon-size="100"
:slug="slug" :slug="slug"
:recipe-id="recipeId" :recipe-id="recipeId"
size="small"
:image-version="image" :image-version="image"
:height="height" :height="height"
/> />
@@ -41,11 +41,11 @@
name="avatar" name="avatar"
> >
<RecipeCardImage <RecipeCardImage
tiny
:icon-size="100" :icon-size="100"
:slug="slug" :slug="slug"
:recipe-id="recipeId" :recipe-id="recipeId"
:image-version="image" :image-version="image"
size="small"
width="125" width="125"
:height="height" :height="height"
/> />

View File

@@ -28,7 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
}); });
const display = useDisplay(); const display = useDisplay();
const { recipeImage } = useStaticRoutes(); const { recipeImage, recipeSmallImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug); const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser(); const { user } = usePageUser();
@@ -46,7 +46,9 @@ const imageHeight = computed(() => {
}); });
const recipeImageUrl = 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( watch(

View File

@@ -119,7 +119,7 @@ defineEmits<{
const { $globals } = useNuxtApp(); const { $globals } = useNuxtApp();
const display = useDisplay(); const display = useDisplay();
const { recipeTimelineEventImage } = useStaticRoutes(); const { recipeTimelineEventSmallImage } = useStaticRoutes();
const { eventTypeOptions } = useTimelineEventTypes(); const { eventTypeOptions } = useTimelineEventTypes();
const { user: currentUser } = useMealieAuth(); const { user: currentUser } = useMealieAuth();
@@ -173,7 +173,7 @@ const eventImageUrl = computed<string>(() => {
return ""; return "";
} }
return recipeTimelineEventImage(props.event.recipeId, props.event.id); return recipeTimelineEventSmallImage(props.event.recipeId, props.event.id);
}); });
</script> </script>

View File

@@ -22,11 +22,6 @@ WEBP = ImageFormat(".webp", "WEBP", ["RGB", "RGBA"])
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".avif"} 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): def sizeof_fmt(file_path: Path, decimal_places=2):
if not file_path.exists(): if not file_path.exists():
return "(File Not Found)" return "(File Not Found)"
@@ -57,7 +52,7 @@ class ABCMinifier(ABC):
) )
@abstractmethod @abstractmethod
def minify(self, image: Path, force=True): ... def minify(self, image_path: Path, force=True): ...
def purge(self, image: Path): def purge(self, image: Path):
if not self._purge: if not self._purge:
@@ -71,82 +66,141 @@ class ABCMinifier(ABC):
class PillowMinifier(ABCMinifier): class PillowMinifier(ABCMinifier):
@staticmethod @staticmethod
def _convert_image( 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: ) -> Path:
""" """
Converts an image to the specified format in-place. The original image is not Converts an image to the specified format in-place. The original image is not
removed. By default, the quality is set to 100. 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: if img.mode not in image_format.modes:
img = img.convert(image_format.modes[0]) 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) img.save(dest, image_format.format, quality=quality)
return dest return dest
@staticmethod @staticmethod
def to_jpg(image_file: Path, dest: Path | None = None, quality: int = 100) -> Path: def to_jpg(
return PillowMinifier._convert_image(image_file, JPG, dest, quality) image_file_path: Path | None = None,
dest: Path | None = None,
@staticmethod quality: int = 100,
def to_webp(image_file: Path, dest: Path | None = None, quality: int = 100) -> Path: img: Image.Image | None = None,
return PillowMinifier._convert_image(image_file, WEBP, dest, quality) ) -> Path:
return PillowMinifier._convert_image(
@staticmethod image_file=image_file_path, image_format=JPG, dest=dest, quality=quality, img=img
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 minify(self, image_file: Path, force=True): @staticmethod
if not image_file.exists(): def to_webp(
raise FileNotFoundError(f"{image_file.name} does not exist") 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") @staticmethod
min_dest = image_file.parent.joinpath("min-original.webp") def crop_center(img: Image.Image, size=(300, 300), high_res: bool = True) -> Image.Image:
tiny_dest = image_file.parent.joinpath("tiny-original.webp") 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(): 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 return
success = False success = False
if self._opts.original: try:
if not force and org_dest.exists(): with Image.open(image_path) as img:
self._logger.info(f"{image_file.name} already minified") if self._opts.original:
else: if not force and org_dest.exists():
PillowMinifier.to_webp(image_file, org_dest, quality=70) self._logger.info(f"{org_dest} already exists")
success = True 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 self._opts.miniature:
if not force and min_dest.exists(): if not force and min_dest.exists():
self._logger.info(f"{image_file.name} already minified") self._logger.info(f"{min_dest} already exists")
else: else:
PillowMinifier.to_webp(image_file, min_dest, quality=70) mini = img.copy()
self._logger.info(f"{image_file.name} minified") mini.thumbnail((1024, 1024), Image.LANCZOS)
success = True 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 self._opts.tiny:
if not force and tiny_dest.exists(): if not force and tiny_dest.exists():
self._logger.info(f"{image_file.name} already minified") self._logger.info(f"{tiny_dest} already exists")
else: else:
img = Image.open(image_file) tiny = PillowMinifier.crop_center(img.copy(), size=(300, 300))
img = ImageOps.exif_transpose(img) result_path = PillowMinifier.to_webp(dest=tiny_dest, quality=80, img=tiny)
tiny_image = PillowMinifier.crop_center(img) self._logger.info(f"{result_path} created")
tiny_image.save(tiny_dest, WEBP.format, quality=70) success = True
self._logger.info("Tiny image saved")
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: if self._purge and success:
self.purge(image_file) self.purge(image_path)