from __future__ import annotations import io import logging from PIL import Image, ImageColor, ImageFilter, ImageOps from .coordinator import MediaItem _LOGGER = logging.getLogger(__name__) # Re-export fill mode constants so callers can import from here. FILL_COVER = "cover" FILL_CONTAIN = "contain" FILL_BLUR = "blur" # Absolute pixel ceiling. A 20000x20000 JPEG decodes to ~1.2 GB of RGB; Pillow # raises DecompressionBombError above MAX_IMAGE_PIXELS. We set this high enough # that 4K+ sources still decode, but reject anything absurd to protect # low-memory devices like the Home Assistant Green. _MAX_IMAGE_PIXELS = 80_000_000 # ~8K x 10K Image.MAX_IMAGE_PIXELS = _MAX_IMAGE_PIXELS def open_image( data: bytes, target_size: tuple[int, int] | None = None, ) -> Image.Image: """Open image bytes, apply EXIF orientation, normalise to RGB/RGBA. If ``target_size`` is given, uses PIL's ``draft`` mode so libjpeg decodes at a reduced scale. Big speed/memory win on low-power devices when the source is much larger than the output canvas. """ img = Image.open(io.BytesIO(data)) if target_size is not None and img.format == "JPEG": try: img.draft("RGB", target_size) except Exception: pass img = ImageOps.exif_transpose(img) # Force pixel data into memory; BytesIO must stay reachable until here. img.load() if img.mode not in ("RGB", "RGBA"): img = img.convert("RGB") return img def safe_close(img: Image.Image | None) -> None: """Close a PIL image without raising. No-op on None.""" if img is None: return try: img.close() except Exception: pass def is_portrait_img(img: Image.Image) -> bool: try: w, h = img.size return h >= w except Exception: return False def is_portrait_item(item: MediaItem, img: Image.Image | None = None) -> bool: by_meta = _is_portrait_dims(item.width, item.height) if by_meta is not None: return by_meta if img is not None: return is_portrait_img(img) return False def is_portrait_item_by_metadata(item: MediaItem) -> bool | None: """Return portrait/landscape from item metadata only, or None if unknown.""" return _is_portrait_dims(item.width, item.height) def resolve_output_size( req_w: int | None, req_h: int | None, ratio: str, max_short_edge: int | None = None, ) -> tuple[int, int]: ratio_w, ratio_h = _parse_aspect_ratio(ratio) target = ratio_w / ratio_h if req_w is None and req_h is None: if ratio_w >= ratio_h: width = 3840 height = max(1, int(round(width / target))) else: height = 3840 width = max(1, int(round(height * target))) elif req_w is None: height = max(1, int(req_h or 2160)) width = max(1, int(round(height * target))) elif req_h is None: width = max(1, int(req_w or 3840)) height = max(1, int(round(width / target))) else: req_w = max(1, int(req_w)) req_h = max(1, int(req_h)) if (req_w / req_h) >= target: height = req_h width = max(1, int(round(height * target))) else: width = req_w height = max(1, int(round(width / target))) if max_short_edge is not None: short = min(width, height) if short > max_short_edge: scale = max_short_edge / short width = max(1, int(round(width * scale))) height = max(1, int(round(height * scale))) return (width, height) def render_image(img: Image.Image, fill_mode: str, width: int, height: int) -> Image.Image: """Render img into a (width x height) canvas using the given fill mode.""" if fill_mode == FILL_CONTAIN: return _resize_contain(img, width, height) if fill_mode == FILL_BLUR: return _blur_fill(img, width, height) return _resize_cover(img, width, height) def pair_images( img1: Image.Image, img2: Image.Image, target_w: int, target_h: int, fill_mode: str, portrait_canvas: bool, divider: int, divider_fill: tuple[int, int, int] | tuple[int, int, int, int], transparent_divider: bool, ) -> Image.Image: canvas_mode = "RGBA" if transparent_divider else "RGB" canvas = Image.new(canvas_mode, (target_w, target_h), divider_fill) if portrait_canvas: top_h = max(1, (target_h - divider) // 2) bottom_h = max(1, target_h - divider - top_h) top_img = render_image(img1, fill_mode, target_w, top_h) bottom_img = render_image(img2, fill_mode, target_w, bottom_h) canvas.paste(top_img.convert(canvas_mode), (0, 0)) canvas.paste(bottom_img.convert(canvas_mode), (0, top_h + divider)) safe_close(top_img) safe_close(bottom_img) return canvas left_w = max(1, (target_w - divider) // 2) right_w = max(1, target_w - divider - left_w) left_img = render_image(img1, fill_mode, left_w, target_h) right_img = render_image(img2, fill_mode, right_w, target_h) canvas.paste(left_img.convert(canvas_mode), (0, 0)) canvas.paste(right_img.convert(canvas_mode), (left_w + divider, 0)) safe_close(left_img) safe_close(right_img) return canvas def encode_image(img: Image.Image) -> bytes: """Encode a PIL image to a client-compatible JPEG or PNG. JPEGs are written as baseline (non-progressive) with 4:2:0 subsampling and without EXIF, which maximises compatibility with Android WebView and older clients. RGBA images are encoded as PNG to preserve alpha. """ out = io.BytesIO() if "A" in img.getbands(): img.save(out, format="PNG", optimize=True) return out.getvalue() rgb = img if img.mode == "RGB" else img.convert("RGB") rgb.save( out, format="JPEG", quality=88, optimize=True, progressive=False, subsampling=2, ) if rgb is not img: safe_close(rgb) return out.getvalue() def parse_divider_color(color: str) -> tuple[tuple[int, int, int] | tuple[int, int, int, int], bool]: raw = (color or "").strip().lower() compact = raw.replace(" ", "") if compact in ("transparent", "transperant", "none", "clear", "rgba(0,0,0,0)"): return (0, 0, 0, 0), True try: return ImageColor.getrgb(color), False except Exception: return (255, 255, 255), False # -- Private helpers --------------------------------------------------------- def _is_portrait_dims(width: int | None, height: int | None) -> bool | None: if not width or not height: return None try: w, h = int(width), int(height) if w <= 0 or h <= 0: return None return h >= w except Exception: return None def _parse_aspect_ratio(ratio: str) -> tuple[int, int]: try: left, right = ratio.split(":", maxsplit=1) w, h = int(left), int(right) if w > 0 and h > 0: return (w, h) except Exception: pass return (16, 9) def _resize_cover(img: Image.Image, target_w: int, target_h: int) -> Image.Image: src_w, src_h = img.size if src_w <= 0 or src_h <= 0: return img.resize((target_w, target_h)) scale = max(target_w / src_w, target_h / src_h) new_w = max(1, int(round(src_w * scale))) new_h = max(1, int(round(src_h * scale))) resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS) left = max(0, int(round((new_w - target_w) / 2))) top = max(0, int(round((new_h - target_h) / 2))) cropped = resized.crop((left, top, left + target_w, top + target_h)) if cropped is not resized: safe_close(resized) return cropped def _resize_contain(img: Image.Image, target_w: int, target_h: int, bg=(0, 0, 0)) -> Image.Image: src_w, src_h = img.size if src_w <= 0 or src_h <= 0: return img.resize((target_w, target_h)) scale = min(target_w / src_w, target_h / src_h) new_w = max(1, int(src_w * scale)) new_h = max(1, int(src_h * scale)) resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS) canvas = Image.new("RGB", (target_w, target_h), bg) rgb_resized = resized if resized.mode == "RGB" else resized.convert("RGB") canvas.paste(rgb_resized, ((target_w - new_w) // 2, (target_h - new_h) // 2)) if rgb_resized is not resized: safe_close(rgb_resized) safe_close(resized) return canvas def _blur_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image: bg = _resize_cover(img, target_w, target_h).filter(ImageFilter.GaussianBlur(radius=24)) src_w, src_h = img.size if src_w <= 0 or src_h <= 0: return bg scale = min(target_w / src_w, target_h / src_h) new_w = max(1, int(src_w * scale)) new_h = max(1, int(src_h * scale)) fg = img.resize((new_w, new_h), Image.Resampling.LANCZOS) rgb_fg = fg if fg.mode == "RGB" else fg.convert("RGB") bg.paste(rgb_fg, ((target_w - new_w) // 2, (target_h - new_h) // 2)) if rgb_fg is not fg: safe_close(rgb_fg) safe_close(fg) return bg