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 base64
import json import json
import logging import logging
import threading
import time
from pathlib import Path from pathlib import Path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -37,7 +39,49 @@ clients_ws: list[WebSocket] = []
capture_en_cours = False capture_en_cours = False
_dslr_erreurs = 0 _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(): async def surveiller_dslr():
"""Verifie periodiquement si un DSLR est branche et bascule dessus.""" """Verifie periodiquement si un DSLR est branche et bascule dessus."""
@@ -78,11 +122,18 @@ async def surveiller_dslr():
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Demarrage et arret de l'application.""" """Demarrage et arret de l'application."""
global _loop_principal
log.info("Demarrage du photobooth") log.info("Demarrage du photobooth")
_loop_principal = asyncio.get_event_loop()
camera.connecter() 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 yield
task.cancel() task_dslr.cancel()
task_push.cancel()
log.info("Arret du photobooth") log.info("Arret du photobooth")
camera.deconnecter() camera.deconnecter()
@@ -632,19 +683,11 @@ async def traiter_message_ws(msg: dict, ws: WebSocket):
if type_msg == "ping": if type_msg == "ping":
await ws.send_json({"type": "pong"}) await ws.send_json({"type": "pong"})
elif type_msg == "preview": elif type_msg == "preview_start":
import time as _time global _preview_actif
global _dernier_envoi_erreur_preview _preview_actif = True
donnees = camera.preview() elif type_msg == "preview_stop":
if donnees: _preview_actif = False
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"})
async def diffuser_ws(message: dict): async def diffuser_ws(message: dict):

View File

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