""" image_utils.py ============== Shared image processing utilities. """ from __future__ import annotations import io import logging from pathlib import Path try: from PIL import Image as _PILImage _HAS_PIL = True except ImportError: _HAS_PIL = False logger = logging.getLogger(__name__) DISCORD_MAX_BYTES = 8 * 1024 * 1024 # 8 MiB Discord free-tier upload limit def compress_to_discord_limit(data: bytes, filename: str) -> tuple[bytes, str]: """ Compress image bytes to fit within Discord's 8 MiB upload limit. Tries quality reduction first, then progressive downsizing. Converts to JPEG if the source format (e.g. PNG) cannot be quality-compressed. Returns (data, filename) — filename may change if format conversion is needed. No-op if data is already within the limit. """ if len(data) <= DISCORD_MAX_BYTES: logger.info(f"Image size is less than {DISCORD_MAX_BYTES}") return data, filename if not _HAS_PIL: logger.warning("Pillow not installed — cannot compress %s (%d bytes), uploading as-is", filename, len(data)) return data, filename suffix = Path(filename).suffix.lower() stem = Path(filename).stem img = _PILImage.open(io.BytesIO(data)) # PNG/GIF/BMP don't support lossy quality — convert to JPEG logger.info("Checking file extension for convert to jpeg") if suffix in (".jpg", ".jpeg"): fmt, out_name = "JPEG", filename elif suffix == ".webp": fmt, out_name = "WEBP", filename else: fmt, out_name = "JPEG", stem + ".jpg" if img.mode in ("RGBA", "P", "LA"): img = img.convert("RGB") logger.info("File extension checked") # Round 1: quality reduction only logger.info("# Round 1: quality reduction only") for quality in (85, 70, 55, 40, 25, 15): logger.info(f"# Round 1: Trying quality: {quality}") buf = io.BytesIO() img.save(buf, format=fmt, quality=quality) if buf.tell() <= DISCORD_MAX_BYTES: logger.info("Compressed %s at quality=%d: %d → %d bytes", filename, quality, len(data), buf.tell()) return buf.getvalue(), out_name # Round 2: resize + low quality logger.info("# Round 2: resize + low quality") for scale in (0.75, 0.5, 0.35, 0.25): w, h = img.size resized = img.resize((int(w * scale), int(h * scale)), _PILImage.LANCZOS) logger.info(f"# Round 2: Trying to resize: {resized.size}") buf = io.BytesIO() resized.save(buf, format=fmt, quality=15) if buf.tell() <= DISCORD_MAX_BYTES: logger.info("Compressed %s at scale=%.2f: %d → %d bytes", filename, scale, len(data), buf.tell()) return buf.getvalue(), out_name logger.warning("Could not compress %s under %d bytes — uploading best effort", filename, DISCORD_MAX_BYTES) buf = io.BytesIO() img.save(buf, format=fmt, quality=10) return buf.getvalue(), out_name