File size: 2,491 Bytes
6d8fa62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
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)