"""Validation and processing of uploaded photos. Uploads are decoded with Pillow (HEIC via pillow-heif), auto-rotated, stripped of all metadata (EXIF/GPS) by re-encoding, downscaled and stored as WebP under MEDIA_ROOT. The original upload is never stored. """ import io import threading import uuid from pathlib import Path import pillow_heif from django.conf import settings from PIL import Image, ImageOps, UnidentifiedImageError pillow_heif.register_heif_opener() ALLOWED_FORMATS = {"JPEG", "MPO", "PNG", "WEBP", "HEIF"} MAX_PIXELS = 50_000_000 LARGE_SIZE = 1600 THUMB_SIZE = 480 AI_SIZE = 2000 Image.MAX_IMAGE_PIXELS = MAX_PIXELS # Decoding a 12 MP photo needs ~100 MB; one at a time per process keeps memory predictable. _processing = threading.BoundedSemaphore(1) class ImageError(ValueError): pass def max_upload_bytes(): return settings.IMAGE_MAX_UPLOAD_MB * 1024 * 1024 def _open(fileobj): size = getattr(fileobj, "size", None) if size is not None and size > max_upload_bytes(): raise ImageError(f"Das Bild ist zu groß (max. {settings.IMAGE_MAX_UPLOAD_MB} MB).") try: fileobj.seek(0) img = Image.open(fileobj) except (UnidentifiedImageError, OSError, Image.DecompressionBombError): raise ImageError("Die Datei ist kein unterstütztes Bild (JPEG, PNG, WebP oder HEIC).") from None if img.format not in ALLOWED_FORMATS: raise ImageError("Die Datei ist kein unterstütztes Bild (JPEG, PNG, WebP oder HEIC).") if img.width * img.height > MAX_PIXELS: raise ImageError("Das Bild hat zu viele Pixel.") return img def validate(fileobj): """Cheap check (header only) used during form validation.""" _open(fileobj) fileobj.seek(0) def _decode(fileobj, target): img = _open(fileobj) if img.format == "JPEG": img.draft("RGB", (target * 2, target * 2)) # decode at reduced size: much less memory try: img.load() except (OSError, Image.DecompressionBombError): raise ImageError("Das Bild ist beschädigt oder unvollständig.") from None img = ImageOps.exif_transpose(img) if img.mode in ("RGBA", "LA", "P"): img = img.convert("RGBA") background = Image.new("RGB", img.size, (255, 255, 255)) background.paste(img, mask=img.getchannel("A")) img = background elif img.mode != "RGB": img = img.convert("RGB") return img def _media_path(relative): path = Path(settings.MEDIA_ROOT) / relative path.parent.mkdir(parents=True, exist_ok=True) return path def process_upload(fileobj): """Store a large and a thumbnail WebP. Returns dict with file, thumb, width, height.""" with _processing: img = _decode(fileobj, LARGE_SIZE) name = uuid.uuid4().hex large = img.copy() large.thumbnail((LARGE_SIZE, LARGE_SIZE), Image.LANCZOS) thumb = img.copy() thumb.thumbnail((THUMB_SIZE, THUMB_SIZE), Image.LANCZOS) result = {"file": f"recipes/{name}.webp", "thumb": f"recipes/{name}_t.webp", "width": large.width, "height": large.height} large.save(_media_path(result["file"]), "WEBP", quality=82, method=4) thumb.save(_media_path(result["thumb"]), "WEBP", quality=78, method=4) return result def prepare_for_ai(fileobj): """Downscaled JPEG bytes (no metadata) for sending to a vision model.""" with _processing: img = _decode(fileobj, AI_SIZE) img.thumbnail((AI_SIZE, AI_SIZE), Image.LANCZOS) out = io.BytesIO() img.save(out, "JPEG", quality=85) return out.getvalue() def delete_files(*relative_paths): for relative in relative_paths: if relative: (Path(settings.MEDIA_ROOT) / relative).unlink(missing_ok=True) def file_path(relative): """Absolute path of a stored file; refuses anything outside MEDIA_ROOT.""" root = Path(settings.MEDIA_ROOT).resolve() path = (root / relative).resolve() if root not in path.parents: raise ImageError("Ungültiger Pfad.") return path