from __future__ import annotations import logging import time from dataclasses import dataclass from pathlib import Path from typing import Optional from PIL import Image from config import Config logger = logging.getLogger("image_processor") class OptimizationError(Exception): """Raised when saving an optimized image fails.""" @dataclass class OptimizationResult: output_path: Path final_width: int final_height: int file_size_bytes: int elapsed_seconds: float class Optimizer: """Resize (optional) and write JPEG with metadata stripped.""" def __init__(self, config: Config) -> None: self.config = config def optimize_and_save(self, image: Image.Image, output_path: Path) -> OptimizationResult: start = time.perf_counter() resized = self._maybe_resize(image) save_kwargs: dict[str, object] = { "format": "JPEG", "quality": int(self.config.jpeg_quality), "optimize": True, "progressive": True, } if not self.config.strip_metadata: exif = image.info.get("exif") if exif: save_kwargs["exif"] = exif try: output_path.parent.mkdir(parents=True, exist_ok=True) resized.save(output_path, **save_kwargs) except PermissionError as exc: raise OptimizationError(f"Permission denied writing {output_path}: {exc}") from exc except OSError as exc: raise OptimizationError(f"Failed to save {output_path}: {exc}") from exc elapsed = time.perf_counter() - start return OptimizationResult( output_path=output_path, final_width=resized.width, final_height=resized.height, file_size_bytes=output_path.stat().st_size, elapsed_seconds=elapsed, ) def _maybe_resize(self, image: Image.Image) -> Image.Image: max_w = self.config.max_width max_h = self.config.max_height if not max_w and not max_h: return image width, height = image.size scale = 1.0 if max_w and width > max_w: scale = min(scale, max_w / width) if max_h and height > max_h: scale = min(scale, max_h / height) if scale >= 1.0: return image new_size = (max(1, int(width * scale)), max(1, int(height * scale))) return image.resize(new_size, Image.Resampling.LANCZOS)