fix(agent): capture JPEG+downscale (allege CPU/disque, frequence intacte) + robustesse chemin _background/shots

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-07-01 12:36:47 +02:00
parent 2a1b1ed80e
commit 144a5c288a
3 changed files with 465 additions and 12 deletions

View File

@@ -0,0 +1,110 @@
"""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'
~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")