"""On-demand web image resizing with disk cache (srcset helpers).""" from __future__ import annotations import logging import tempfile from pathlib import Path from urllib.parse import quote from PIL import Image from . import config logger = logging.getLogger("livef12.images") # Allowed ?w= values for /jobs/.../files/... — keep in sync with templates. SRCSET_WIDTHS: tuple[int, ...] = (320, 640, 960, 1280) # Default sizes for the 1/2/4-column job + variant grids. GRID_SIZES = "(min-width: 960px) 25vw, (min-width: 640px) 50vw, 100vw" # Original/rembg pair is always two columns. PAIR_SIZES = "(min-width: 640px) 50vw, 50vw" def file_url(job_id: str, filename: str, width: int | None = None) -> str: base = f"/jobs/{quote(job_id, safe='')}/files/{quote(filename, safe='')}" if width is None: return base return f"{base}?w={width}" def srcset_for(job_id: str, filename: str) -> str: return ", ".join(f"{file_url(job_id, filename, w)} {w}w" for w in SRCSET_WIDTHS) def img_attrs(job_id: str, filename: str, *, sizes: str = GRID_SIZES) -> dict[str, str]: """Attrs for responsive : src, srcset, sizes, full_src (lightbox).""" return { "src": file_url(job_id, filename, 640), "srcset": srcset_for(job_id, filename), "sizes": sizes, # Lightbox: large webp, not the multi-MB master PNG/JPEG. "full_src": file_url(job_id, filename, SRCSET_WIDTHS[-1]), } def get_or_create_resized(source: Path, width: int) -> Path: """Return a cached WebP whose longest edge is at most `width` (no upscale).""" if width not in SRCSET_WIDTHS: raise ValueError(f"unsupported width: {width}") if not source.is_file(): raise FileNotFoundError(source) config.WEB_CACHE_DIR.mkdir(parents=True, exist_ok=True) cache_path = config.WEB_CACHE_DIR / f"{source.name}.w{width}.webp" try: if cache_path.is_file() and cache_path.stat().st_mtime >= source.stat().st_mtime: return cache_path except OSError: pass _write_resized(source, cache_path, width) return cache_path def _write_resized(source: Path, dest: Path, width: int) -> None: with Image.open(source) as img: img.load() if img.mode == "P": img = img.convert("RGBA" if "transparency" in img.info else "RGB") elif img.mode not in ("RGB", "RGBA"): img = img.convert("RGB") if img.width > width: new_h = max(1, round(img.height * (width / img.width))) img = img.resize((width, new_h), Image.Resampling.LANCZOS) dest.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( dir=dest.parent, suffix=".webp", delete=False ) as tmp: tmp_path = Path(tmp.name) try: img.save(tmp_path, format="WEBP", quality=78, method=4) tmp_path.replace(dest) except Exception: tmp_path.unlink(missing_ok=True) raise logger.debug("cached %s -> %s (%dx%d)", source.name, dest.name, img.width, img.height)