"""Politique de sauvegarde des captures — réduction du poids disque. Constat : tous les shots étaient sauvés en PNG plein écran lossless (``img.save(path, "PNG", quality=...)`` — PNG ignore ``quality``), d'où ~90 Go pour 13 sessions. La majorité de ce poids n'a aucune valeur de grounding (full + full_blurred en doublon, heartbeats plein écran). Cette politique distingue le **type** de shot et écrit le format adapté : - ``crop`` → PNG lossless. C'est la cible de grounding qwen3-vl ; on préserve chaque pixel (perte JPEG = bruit sur de petites icônes). Le crop fait 80×80 → poids négligeable, aucun intérêt à le dégrader. - ``full`` / ``window`` / ``context`` → JPEG ``quality=SCREENSHOT_QUALITY, optimize=True``. Ce sont des vues contextuelles / humaines : la compression JPEG (~5-10x) est sans impact fonctionnel. - ``heartbeat`` → JPEG **downscalé** (largeur max ``HEARTBEAT_MAX_WIDTH``, ratio préservé). C'est de la *liveness* (le serveur vérifie juste qu'un écran a changé), pas du grounding → la pleine résolution est du gaspillage. ``save_capture`` retourne le chemin RÉELLEMENT écrit, extension ajustée selon le format. L'appelant doit utiliser ce retour (et non un chemin ``.png`` présumé) pour streamer / référencer le bon fichier. ⚠️ Contrat avec le serveur : l'extension du crop NE DOIT PAS changer (le serveur retrouve le crop par basename via ``vision_info.crop`` — voir ``stream_processor._extract_crop_b64`` stratégie 1). C'est pourquoi ``crop`` reste PNG. Les full/window/context/heartbeat sont retrouvés par ``screenshot_id`` avec extension ``.png`` hardcodée côté serveur, mais le serveur réécrit toujours l'upload sous ``{shot_id}.png`` (le suffixe envoyé sur le fil est ignoré) → changer l'extension LOCALE de ces types est sûr. """ from __future__ import annotations import os from typing import Iterable from PIL import Image from ..config import SCREENSHOT_QUALITY # Types sauvés en JPEG (vue contextuelle / humaine, pas de grounding pixel). _JPEG_KINDS: frozenset = frozenset({"full", "window", "context"}) # Largeur max d'un heartbeat downscalé. 1280 px suffit largement pour de la # liveness (détecter qu'un écran a changé) ; on divise le poids d'un 2560 px # par ~4 (surface) avant compression JPEG. HEARTBEAT_MAX_WIDTH = 1280 def _ensure_jpeg_ready(img: Image.Image) -> Image.Image: """Convertit en RGB si nécessaire (JPEG ne supporte ni alpha ni palette).""" if img.mode in ("RGBA", "LA", "P"): return img.convert("RGB") return img def _downscale_to_width(img: Image.Image, max_width: int) -> Image.Image: """Réduit l'image à ``max_width`` en préservant le ratio (no-op si plus petite).""" if img.width <= max_width: return img new_height = max(1, round(img.height * max_width / img.width)) return img.resize((max_width, new_height), Image.LANCZOS) def save_capture(img: Image.Image, path_base: str, kind: str) -> str: """Sauve ``img`` selon la politique du ``kind`` et retourne le chemin écrit. Args: img: image PIL à sauvegarder. path_base: chemin SANS extension (ex. ``.../shots/shot_0001_full``). L'extension finale (``.png`` ou ``.jpg``) est ajoutée par la politique. kind: type de shot — ``"crop"`` | ``"full"`` | ``"window"`` | ``"context"`` | ``"heartbeat"``. Returns: Le chemin RÉELLEMENT écrit, avec la bonne extension. Raises: ValueError: si ``kind`` n'est pas reconnu (fail-closed : on refuse d'écrire un fichier dont la politique est indéterminée). """ if kind == "crop": out_path = f"{path_base}.png" img.save(out_path, "PNG") return out_path if kind in _JPEG_KINDS: out_path = f"{path_base}.jpg" _ensure_jpeg_ready(img).save( out_path, "JPEG", quality=SCREENSHOT_QUALITY, optimize=True ) return out_path if kind == "heartbeat": out_path = f"{path_base}.jpg" small = _downscale_to_width(_ensure_jpeg_ready(img), HEARTBEAT_MAX_WIDTH) small.save(out_path, "JPEG", quality=SCREENSHOT_QUALITY) return out_path raise ValueError( f"kind de capture inconnu : {kind!r} " f"(attendu: crop, full, window, context, heartbeat)" ) def known_kinds() -> Iterable[str]: """Retourne les ``kind`` supportés (utile pour la validation appelant).""" return ("crop", *sorted(_JPEG_KINDS), "heartbeat")