mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-06 10:25:18 -05:00
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:
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user