Ctrl K
ComunidadGuías…

images.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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