164 lines
5.5 KiB
Python
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
|