images.py
Rama master · réplica de lectura del repositorio original.
"""Image re-encoding helpers for destination size caps.
Destinations enforce their own image size limits and the posters'
degrade-to-text-only contract means an oversized image silently produces a
text-only post (2026-09-09 glass.photo report: ~2.9 MB source JPEGs vs
Bluesky's blob cap — every glass photo dropped on Bluesky). Instead of
dropping, oversized images are downscaled/re-encoded to fit, mirroring what
Bluesky's own client does before upload.
Pillow is a lazy import inside the functions: a broken or missing install
degrades to "no downscale" (the old skip behavior) rather than breaking
dispatch, and keeps `import images` cheap on paths that never touch images.
"""
import logging
logger = logging.getLogger("feedecho.images")
# Target ceiling after re-encode. Sits at or below Bluesky's 2 MB blob limit
# so one quality pass has headroom to converge without looping.
DOWNSCALE_TARGET_BYTES = 2_000_000
# Presets tried in order; first one that fits wins. Tuned for photos:
# a 3072px source typically lands under the target by the 2048px/q85 step.
# thumbnail() scales down preserving aspect ratio and never upscales.
_REENCODE_PRESETS: list[tuple[int, int]] = [
(3072, 90),
(2048, 85),
(1600, 82),
(1280, 80),
(1024, 78),
]
# Formats safe to re-encode (JPEG sources dominate feed imagery; PNG and
# WebP sources are re-encoded to JPEG to reliably fit under the blob limit).
JPEG_TYPES = {"image/jpeg"}
PILLOW_TYPES = {"image/jpeg", "image/png", "image/webp"}
class ImageToolUnavailable(Exception):
"""Pillow is not importable — callers fall back to skip behavior."""
def needs_downscale(img_bytes: bytes, max_bytes: int) -> bool:
return len(img_bytes) > max_bytes
def downscale_image(
img_bytes: bytes,
content_type: str,
max_bytes: int = DOWNSCALE_TARGET_BYTES,
) -> tuple[bytes, str] | None:
"""Return (bytes, content_type) fitting max_bytes, or None if impossible.
Tries, in order:
1. full-resolution recompress with quality steps
2. progressively smaller aspect-preserving downscale + re-encode to JPEG
Returns None when Pillow is unavailable, the format is unsupported, or
even the smallest preset exceeds max_bytes. Callers must keep their
existing skip-on-None path — this helper never raises for bad input.
"""
if len(img_bytes) <= max_bytes:
return img_bytes, content_type
if content_type not in PILLOW_TYPES:
return None
try:
from PIL import Image, ImageOps
except Exception:
logger.warning("Pillow unavailable; oversized image cannot be re-encoded")
return None
import io
try:
with Image.open(io.BytesIO(img_bytes)) as im:
im.load()
# EXIF orientation must be baked in before resize or rotated
# photos come out sideways.
try:
im = ImageOps.exif_transpose(im)
except Exception:
pass
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
# Step 1: full-resolution quality reduction (re-encode to JPEG).
for quality in (90, 85, 80, 75):
buf = io.BytesIO()
im.save(buf, format="JPEG", quality=quality, optimize=True)
if buf.tell() <= max_bytes:
return buf.getvalue(), "image/jpeg"
# Step 2: progressive aspect-preserving resize + re-encode to JPEG.
for box_size, quality in _REENCODE_PRESETS:
fitted = im.copy()
fitted.thumbnail((box_size, box_size), Image.Resampling.LANCZOS)
buf = io.BytesIO()
fitted.save(buf, format="JPEG", quality=quality, optimize=True)
if buf.tell() <= max_bytes:
return buf.getvalue(), "image/jpeg"
except Exception:
logger.warning("Image re-encode failed; falling back to skip", exc_info=True)
return None
logger.info(
"Image could not be re-encoded under %d bytes even at smallest preset",
max_bytes,
)
return None