Preview push 30fps : thread de fond + diffusion WS sans polling

Avant : frontend poll WS toutes les 100ms -> gphoto2 bloquant -> lag
Apres : thread dedie capture en continu a 30fps, pusher asyncio
        diffuse a 20fps a tous les clients, frontend dit juste
        preview_start / preview_stop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 22:21:29 +02:00
parent 9a6ede02f6
commit b871fc4c3b
2 changed files with 62 additions and 22 deletions

View File

@@ -2,6 +2,8 @@ import asyncio
import base64
import json
import logging
import threading
import time
from pathlib import Path
from contextlib import asynccontextmanager
@@ -37,7 +39,49 @@ clients_ws: list[WebSocket] = []
capture_en_cours = False
_dslr_erreurs = 0
_dernier_envoi_erreur_preview = 0.0 # timestamp du dernier camera_erreur envoye depuis le preview
# --- Preview push : thread de fond qui capture en continu ---
_derniere_frame_preview: bytes | None = None # derniere frame JPEG capturee
_preview_actif = False # True quand au moins un client veut le preview
_preview_lock = threading.Lock()
_loop_principal: asyncio.AbstractEventLoop | None = None # loop asyncio principal
def _thread_preview():
"""Thread de fond : capture les frames DSLR en continu et les stocke."""
global _derniere_frame_preview, _preview_actif
while True:
if not _preview_actif or capture_en_cours:
time.sleep(0.05)
continue
try:
donnees = camera.preview()
with _preview_lock:
_derniere_frame_preview = donnees
except Exception:
with _preview_lock:
_derniere_frame_preview = None
time.sleep(0.033) # ~30 fps max
async def _pusher_preview():
"""Tache asyncio : pousse la derniere frame a tous les clients WS abonnes."""
global _preview_actif
while True:
await asyncio.sleep(0.05) # 20 fps max envoye aux clients
if not clients_ws:
_preview_actif = False
continue
with _preview_lock:
donnees = _derniere_frame_preview
if donnees is None:
# Signaler l'erreur si le DSLR est sense etre connecte
if camera.mode == "gphoto2" and _preview_actif:
await diffuser_ws({"type": "camera_erreur", "message": "DSLR ne repond pas au preview"})
_preview_actif = False
continue
b64 = base64.b64encode(donnees).decode("ascii")
await diffuser_ws({"type": "preview", "image": f"data:image/jpeg;base64,{b64}"})
async def surveiller_dslr():
"""Verifie periodiquement si un DSLR est branche et bascule dessus."""
@@ -78,11 +122,18 @@ async def surveiller_dslr():
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Demarrage et arret de l'application."""
global _loop_principal
log.info("Demarrage du photobooth")
_loop_principal = asyncio.get_event_loop()
camera.connecter()
task = asyncio.create_task(surveiller_dslr())
# Thread de capture preview (bloquant, tourne en parallele)
t = threading.Thread(target=_thread_preview, daemon=True)
t.start()
task_dslr = asyncio.create_task(surveiller_dslr())
task_push = asyncio.create_task(_pusher_preview())
yield
task.cancel()
task_dslr.cancel()
task_push.cancel()
log.info("Arret du photobooth")
camera.deconnecter()
@@ -632,19 +683,11 @@ async def traiter_message_ws(msg: dict, ws: WebSocket):
if type_msg == "ping":
await ws.send_json({"type": "pong"})
elif type_msg == "preview":
import time as _time
global _dernier_envoi_erreur_preview
donnees = camera.preview()
if donnees:
b64 = base64.b64encode(donnees).decode("ascii")
await ws.send_json({"type": "preview", "image": f"data:image/jpeg;base64,{b64}"})
# Si le DSLR est cense etre connecte mais le preview echoue, signaler l'erreur
if camera.mode == "gphoto2" and not camera.preview_dslr_ok:
now = _time.monotonic()
if now - _dernier_envoi_erreur_preview > 5.0:
_dernier_envoi_erreur_preview = now
await ws.send_json({"type": "camera_erreur", "message": "Le DSLR ne repond pas au preview"})
elif type_msg == "preview_start":
global _preview_actif
_preview_actif = True
elif type_msg == "preview_stop":
_preview_actif = False
async def diffuser_ws(message: dict):

View File

@@ -1,7 +1,6 @@
/* Module capture photo - Preview live, compte a rebours, declenchement */
let previewActif = false;
let previewInterval = null;
let previewTimeoutId = null;
const PREVIEW_TIMEOUT_MS = 3000;
@@ -45,16 +44,14 @@ function lancerPreview() {
if (previewActif) return;
previewActif = true;
afficherErreurPreview(false);
previewInterval = setInterval(() => {
if (previewActif) wsEnvoyer({ type: 'preview' });
}, 100);
wsEnvoyer({ type: 'preview_start' });
resetPreviewTimeout();
}
function arreterPreview() {
if (!previewActif) return;
previewActif = false;
wsEnvoyer({ type: 'preview_stop' });
if (previewInterval) {
clearInterval(previewInterval);
previewInterval = null;