Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
67 lines
2.2 KiB
Python
67 lines
2.2 KiB
Python
"""Thumbnail generation for DOM.РФ photos.
|
||
|
||
Strategy: original PNG/JPG ~1-3 MB downloaded by scraper to
|
||
data/raw/domrf_photos/<obj_id>/<file_id>.{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/<obj_id>/<file_id>?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 and overwrite=False. 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:
|
||
return dst
|
||
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
|