import io import re import uuid from pathlib import Path from fastapi import UploadFile from PIL import Image from app.storage import get_storage ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} MAX_BYTES = 8 * 1024 * 1024 VARIANTS = { "lg": 2400, "md": 1200, "thumb": 400, } def public_url_for_path(file_path: str) -> str: normalized = file_path.replace("\\", "/").lstrip("/") return get_storage().public_url(normalized) def thumb_path_for(file_path: str) -> str | None: normalized = file_path.replace("\\", "/") if "_lg." in normalized: return re.sub(r"_lg\.(\w+)$", r"_thumb.\1", normalized) base, ext = normalized.rsplit(".", 1) if "." in normalized else (normalized, "webp") return f"{base}_thumb.{ext}" def thumb_url_for_path(file_path: str) -> str | None: thumb = thumb_path_for(file_path) if not thumb: return None return get_storage().public_url(thumb) def _encode_variant(img: Image.Image, max_edge: int) -> bytes: work = img.convert("RGB") if img.mode in ("RGBA", "P") else img w, h = work.size if max(w, h) > max_edge: ratio = max_edge / max(w, h) work = work.resize((int(w * ratio), int(h * ratio)), Image.Resampling.LANCZOS) buf = io.BytesIO() work.save(buf, format="WEBP", quality=85 if max_edge >= 1200 else 80) return buf.getvalue() def save_image(upload: UploadFile) -> str: suffix = Path(upload.filename or "").suffix.lower() if suffix not in ALLOWED_EXTENSIONS: raise ValueError("Only JPG, PNG, and WebP images are allowed") raw = upload.file.read() if len(raw) > MAX_BYTES: raise ValueError("Image must be under 8 MB") storage = get_storage() base = uuid.uuid4().hex primary_key = f"{base}_lg.webp" with Image.open(io.BytesIO(raw)) as img: for variant, edge in VARIANTS.items(): key = f"{base}_{variant}.webp" storage.save(key, _encode_variant(img, edge), "image/webp") return primary_key