elemes/services/image_cache.py

164 lines
5.5 KiB
Python

"""
Cache varian ringan (WebP) untuk asset gambar raster.
Arsitektur (Fitur D — optimasi kecepatan load gambar):
- **Upload menyimpan file original apa adanya.** Tidak ada konversi saat
upload — original tetap utuh di storage dan bisa diakses kapan saja.
- **Optimasi terjadi saat serving.** Saat browser meminta `/assets/<path>`,
server mengirim versi ringan: resize (maks dimensi) + encode WebP, yang
di-generate on-the-fly lalu di-cache di disk.
- **Semua asset lama otomatis ikut teroptimasi** — tanpa migrasi, tanpa
tooling konversi, tanpa menyentuh konten markdown (path referensi tidak
berubah).
Batas keras: cache WAJIB berada di luar `ASSETS_DIR` (lihat `config.CACHE_DIR`).
`export_bundle` men-zip seluruh isi `ASSETS_DIR` tanpa filter — kalau cache
berada di dalam, file turunan ikut ter-zip lalu ter-import ulang sebagai asset
biasa. Dengan cache di luar, export tetap 100% lossless.
Invalidasi: berdasarkan `mtime` + `size` file original. File diganti →
mtime berubah → key cache berubah → entry lama menjadi yatim (dibiarkan,
dibersihkan lazy).
"""
from __future__ import annotations
import hashlib
import io
import logging
import os
from config import (
ASSET_VARIANT_MAX_DIM,
ASSET_VARIANT_QUALITY,
CACHE_DIR,
)
logger = logging.getLogger(__name__)
# Ekstensi gambar raster yang boleh di-generate varian WebP-nya.
RASTER_EXTS = frozenset({'.png', '.jpg', '.jpeg', '.webp', '.gif'})
# Ekstensi yang selalu di-serve apa adanya (vektor / format khusus).
PASSTHROUGH_EXTS = frozenset({'.svg'})
def is_raster(rel_path: str) -> bool:
"""True bila path adalah gambar raster yang boleh di-generate variannya."""
return os.path.splitext(rel_path.lower())[1] in RASTER_EXTS
def is_passthrough(rel_path: str) -> bool:
"""True bila file harus selalu di-serve original (mis. SVG)."""
return os.path.splitext(rel_path.lower())[1] in PASSTHROUGH_EXTS
def cache_key(rel_path: str, mtime_ns: int, size: int, width: int | None) -> str:
"""Kunci cache deterministik: path + mtime + ukuran + lebar varian.
mtime membuat cache otomatis invalidasi saat file asli diganti.
"""
raw = f'{rel_path}|{mtime_ns}|{size}|{width or 0}'.encode('utf-8')
return hashlib.sha1(raw).hexdigest()
def _cache_file_for(key: str) -> str:
return os.path.join(CACHE_DIR, f'{key}.webp')
def _build_webp(abs_path: str, width: int | None) -> bytes | None:
"""Buka original, resize (bila perlu), encode WebP.
Return None bila file tidak bisa diproses (corrupt, format tak didukung,
GIF animasi, dsb) — pemanggil lalu fallback serve original.
"""
try:
from PIL import Image, ImageOps
except ImportError: # pragma: no cover - Pillow adalah dependency wajib
logger.warning("Pillow tidak tersedia — varian WebP dilewati")
return None
try:
with Image.open(abs_path) as im:
# GIF/WebP animasi: konversi WebP animasi kompleks & berisiko —
# biarkan apa adanya (pass-through).
if getattr(im, 'is_animated', False):
return None
# Hormati orientasi EXIF (foto HP) sebelum resize.
try:
im = ImageOps.exif_transpose(im)
except Exception: # pragma: no cover - EXIF rusak tidak fatal
pass
if im.mode not in ('RGB', 'RGBA'):
im = im.convert('RGBA' if 'transparency' in im.info else 'RGB')
target = width or ASSET_VARIANT_MAX_DIM
longest = max(im.size)
if longest > target:
scale = target / longest
new_size = (
max(1, round(im.width * scale)),
max(1, round(im.height * scale)),
)
im = im.resize(new_size, Image.LANCZOS)
buf = io.BytesIO()
im.save(buf, format='WEBP', quality=ASSET_VARIANT_QUALITY, method=4)
return buf.getvalue()
except Exception as exc:
logger.warning("Gagal membuat varian WebP untuk %s: %r", abs_path, exc)
return None
def get_variant(
abs_path: str,
rel_path: str,
width: int | None = None,
) -> tuple[bytes, str] | None:
"""Ambil (atau buat) varian WebP untuk asset gambar raster.
Return `(bytes, etag)` bila varian berhasil dibuat/diambil,
atau `None` bila file tidak boleh/berhasil di-variant-kan
(SVG, GIF animasi, file korrupt, Pillow gagal) — pemanggil harus
fallback ke original.
"""
if not is_raster(rel_path):
return None
try:
st = os.stat(abs_path)
except OSError:
return None
key = cache_key(rel_path, st.st_mtime_ns, st.st_size, width)
cache_file = _cache_file_for(key)
try:
with open(cache_file, 'rb') as fh:
data = fh.read()
if data:
return data, key
except FileNotFoundError:
pass
except OSError as exc: # pragma: no cover - I/O error tak terduga
logger.warning("Gagal baca cache %s: %r", cache_file, exc)
data = _build_webp(abs_path, width)
if not data:
return None
# Tulis atomik supaya request paralel tidak membaca file setengah jadi.
try:
os.makedirs(CACHE_DIR, exist_ok=True)
tmp_file = f'{cache_file}.{os.getpid()}.tmp'
with open(tmp_file, 'wb') as fh:
fh.write(data)
os.replace(tmp_file, cache_file)
except OSError as exc: # pragma: no cover - mis. cache dir tidak writable
logger.warning("Gagal menyimpan cache %s: %r", cache_file, exc)
return data, key