76 lines
2.5 KiB
Python
76 lines
2.5 KiB
Python
"""Image thumbnail generation and disk cache (roadmap #108-C).
|
|
|
|
Thumbnails are generated on demand with Pillow and cached under
|
|
``<OBSIGATE_DATA_DIR>/.obsigate-cache/thumbs/<sha1>.webp``. The cache key
|
|
embeds the source path, mtime (ns) and size, so an edited image naturally
|
|
invalidates its stale thumbnail without any explicit cleanup.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
|
|
DEFAULT_THUMB_SIZE = 256
|
|
|
|
# Extensions Pillow cannot decode without extra native libraries: served as-is.
|
|
_UNDECODABLE = {".svg"}
|
|
|
|
|
|
def thumbs_cache_dir() -> Path:
|
|
"""Return (and create) the thumbnail cache directory."""
|
|
base = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / ".obsigate-cache" / "thumbs"
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
return base
|
|
|
|
|
|
def thumb_cache_path(file_path: Path, size: int) -> Path:
|
|
"""Compute the deterministic cache path for *file_path* at *size*."""
|
|
try:
|
|
st = file_path.stat()
|
|
stamp = f"{st.st_mtime_ns}:{st.st_size}"
|
|
except OSError:
|
|
stamp = "0:0"
|
|
# Clé de cache miniature (pas un usage sécurité).
|
|
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest() # nosec B324
|
|
return thumbs_cache_dir() / f"{key}.webp"
|
|
|
|
|
|
def is_decodable(file_path: Path) -> bool:
|
|
"""True when Pillow can be expected to decode *file_path*."""
|
|
return file_path.suffix.lower() not in _UNDECODABLE
|
|
|
|
|
|
def generate_thumbnail(file_path: Path, size: int = DEFAULT_THUMB_SIZE) -> Path | None:
|
|
"""Generate (or reuse) a WebP thumbnail and return its path.
|
|
|
|
Returns ``None`` when the file cannot be decoded (e.g. SVG) or Pillow is
|
|
unavailable, so the caller can fall back to serving the original.
|
|
"""
|
|
cache_path = thumb_cache_path(file_path, size)
|
|
if cache_path.exists():
|
|
return cache_path
|
|
|
|
try:
|
|
from PIL import Image, ImageOps
|
|
except Exception: # pragma: no cover - Pillow is an optional runtime dep
|
|
return None
|
|
|
|
try:
|
|
with Image.open(file_path) as opened:
|
|
# Animated formats: keep only the first frame.
|
|
if getattr(opened, "is_animated", False):
|
|
opened.seek(0)
|
|
img = ImageOps.exif_transpose(opened) or opened
|
|
if img.mode not in ("RGB", "RGBA"):
|
|
img = img.convert("RGBA")
|
|
img.thumbnail((size, size))
|
|
|
|
tmp = cache_path.with_suffix(".tmp")
|
|
img.save(tmp, "WEBP", quality=80)
|
|
os.replace(tmp, cache_path)
|
|
return cache_path
|
|
except Exception:
|
|
return None
|