Files
kottgard-testing/scripts/optimize-site-images.py
T

118 lines
3.4 KiB
Python

#!/usr/bin/env python3
"""Compress site images to <= 100 KB while preserving original colors."""
from __future__ import annotations
import argparse
import io
import shutil
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
SITE = ROOT / "public" / "images" / "site"
BACKUP = SITE / "_originals"
SKIP = {"logo.jpeg", "README.txt"}
SKIP_PATTERNS = (" copy", "-1.jpg")
def should_skip(path: Path) -> bool:
if path.name in SKIP or path.name.startswith("_"):
return True
return any(p in path.name for p in SKIP_PATTERNS)
def encode_jpeg(img: Image.Image, quality: int) -> bytes:
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality, optimize=True, subsampling=2)
return buf.getvalue()
def compress_to_target(img: Image.Image, max_bytes: int) -> tuple[Image.Image, int]:
working = img.convert("RGB")
scale = 1.0
while scale >= 0.35:
if scale < 1.0:
w, h = working.size
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
candidate = working.resize((nw, nh), Image.Resampling.LANCZOS)
else:
candidate = working
lo, hi = 20, 92
best_q = lo
best_data = encode_jpeg(candidate, lo)
while lo <= hi:
mid = (lo + hi) // 2
data = encode_jpeg(candidate, mid)
if len(data) <= max_bytes:
best_q = mid
best_data = data
lo = mid + 1
else:
hi = mid - 1
if len(best_data) <= max_bytes:
return Image.open(io.BytesIO(best_data)).convert("RGB"), best_q
scale *= 0.88
data = encode_jpeg(candidate, 20)
return Image.open(io.BytesIO(data)).convert("RGB"), 20
def optimize_file(path: Path, max_bytes: int, dry_run: bool) -> tuple[int, int]:
before = path.stat().st_size
if before <= max_bytes:
return before, before
with Image.open(path) as img:
out, quality = compress_to_target(img, max_bytes)
if dry_run:
buf = io.BytesIO()
out.save(buf, format="JPEG", quality=quality, optimize=True, subsampling=2)
return before, len(buf.getvalue())
out.save(path, format="JPEG", quality=quality, optimize=True, subsampling=2)
return before, path.stat().st_size
def main() -> None:
parser = argparse.ArgumentParser(description="Optimize site images to <= 100 KB")
parser.add_argument("--max-kb", type=int, default=100)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
max_bytes = args.max_kb * 1024
BACKUP.mkdir(exist_ok=True)
files = sorted(SITE.glob("*.jpg")) + sorted(SITE.glob("*.jpeg"))
changed = 0
for path in files:
if should_skip(path):
continue
backup = BACKUP / path.name
if not backup.exists() and not args.dry_run:
shutil.copy2(path, backup)
before, after = optimize_file(path, max_bytes, args.dry_run)
if after < before or before > max_bytes:
changed += 1
print(
f"{'~' if args.dry_run else ''} {path.name}: "
f"{before // 1024} KB → {after // 1024} KB"
)
else:
print(f"{path.name}: {before // 1024} KB (ok)")
print(f"\nDone — {changed} optimized, originals in {BACKUP}")
if __name__ == "__main__":
main()