Some checks failed
CI / backend-tests (pull_request) Failing after 9m11s
CI / backend-tests (push) Failing after 9m22s
CI / openapi-codegen-check (push) Successful in 2m14s
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m13s
CI / frontend-tests (push) Successful in 1m2s
CI / openapi-codegen-check (pull_request) Successful in 1m47s
5 conflicts resolved: - redaction.py: merged INN checksum + _PHONE_LOCAL_RE + _Repl alias from main - recommendation.py: kept commercial_sell_through_pct rename (#1635) - report_pdf.py: kept _scenario_deficit_cell + int|None return type (#1590) - parcel.py: kept main's detailed docstring (functionally identical) - alembic/env.py: kept HEAD (app.models import in __init__ already covers all)
77 lines
2.8 KiB
Python
77 lines
2.8 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, 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
|