"""Thumbnail generation for DOM.РФ photos. Strategy: original PNG/JPG ~1-3 MB downloaded by scraper to data/raw/domrf_photos//.{png,jpg}. We additionally write a WebP cover-thumbnail at 320×240 (~10-30 KB) next to it, suffix `_thumb.webp`. The frontend grid uses /api/v1/photos//?size=thumb. Lightbox opens the original DOM.РФ URL (we don't need to mirror originals). """ from __future__ import annotations import logging import os from pathlib import Path from PIL import Image, ImageOps logger = logging.getLogger(__name__) THUMB_SIZE: tuple[int, int] = (320, 240) THUMB_QUALITY: int = 70 THUMB_SUFFIX: str = "_thumb.webp" def thumb_path_for(src: Path) -> Path: """Return canonical thumbnail path next to the source file.""" return src.with_name(src.stem + THUMB_SUFFIX) def make_thumbnail( src: Path, *, size: tuple[int, int] = THUMB_SIZE, quality: int = THUMB_QUALITY, overwrite: bool = False, ) -> Path | None: """Generate a WebP cover-thumbnail next to src. Returns thumb path on success. Skips if thumb already exists, is not stale (dst mtime >= src mtime) and overwrite=False. A stale thumb (src rewritten after thumb, e.g. original re-downloaded on size mismatch) is regenerated. Returns None on any error (logged) so callers can keep going with the next file. """ if not src.exists(): return None dst = thumb_path_for(src) if dst.exists() and not overwrite: # Existence alone is not freshness: if src was rewritten after dst was # generated, the thumb is stale and must be regenerated. Only skip when # the thumb is at least as new as the source. try: if dst.stat().st_mtime >= src.stat().st_mtime: return dst except OSError as e: # stat failed (race / removed) — fall through and try to regenerate. logger.warning("thumbnail freshness check %s failed: %s", src, e) try: with Image.open(src) as im: im = ImageOps.exif_transpose(im) im = im.convert("RGB") im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS) dst.parent.mkdir(parents=True, exist_ok=True) # Atomic write: encode to a sibling temp file, then os.replace() so a # crash/OOM mid-encode never leaves a truncated .webp at the canonical # path (which dst.exists() would later treat as a valid cached thumb). tmp = dst.with_name(f".{dst.name}.tmp") try: im.save(tmp, format="WEBP", quality=quality, method=4) os.replace(tmp, dst) except BaseException: tmp.unlink(missing_ok=True) raise return dst except Exception as e: logger.warning("thumbnail %s failed: %s", src, e) return None