This commit is contained in:
Tempest 2025-08-20 08:08:06 -05:00 committed by GitHub
commit 86174cdb7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -57,7 +57,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 +71,142 @@ 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 = 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)
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)
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_path, JPG, dest, quality, img)
@staticmethod
def to_webp(image_file: Path, dest: Path | None = None, quality: int = 100) -> Path:
return PillowMinifier._convert_image(image_file, WEBP, dest, quality)
def to_webp(
image_file_path: Path | None = None,
dest_path: Path | None = None,
quality: int = 100,
img: Image.Image | None = None,
) -> Path:
return PillowMinifier._convert_image(image_file_path, WEBP, dest_path, quality, img)
@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 crop_center(img: Image.Image, size=(300, 300), high_res: bool = True) -> Image.Image:
img = img.copy()
target_width, target_height = size
def minify(self, image_file: Path, force=True):
if not image_file.exists():
raise FileNotFoundError(f"{image_file.name} does not exist")
# For retina displays, double the target size
if high_res:
target_width *= 2
target_height *= 2
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")
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
try:
with Image.open(image_path) as img:
img = ImageOps.exif_transpose(img)
if self._opts.original:
if not force and org_dest.exists():
self._logger.info(f"{image_file.name} already minified")
self._logger.info(f"{org_dest} already minified")
else:
PillowMinifier.to_webp(image_file, org_dest, quality=70)
result_path = PillowMinifier.to_webp(
dest_path=org_dest, quality=80, img=PillowMinifier.crop_center(img, size=(810, 400))
)
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")
self._logger.info(f"{min_dest} already minified")
else:
PillowMinifier.to_webp(image_file, min_dest, quality=70)
self._logger.info(f"{image_file.name} minified")
result_path = PillowMinifier.to_webp(
dest_path=min_dest,
quality=70,
img=PillowMinifier.crop_center(img, size=(414, 200)),
)
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")
self._logger.info(f"{tiny_dest} 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")
result_path = PillowMinifier.to_webp(
dest_path=tiny_dest, quality=70, img=PillowMinifier.crop_center(img, size=(150, 150))
)
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)