Compare commits

..

4 Commits

Author SHA1 Message Date
d3a71a9866 Fix caméra : gvfsd-gphoto2, race condition viewfinder, exif_transpose
- kiosk-session.sh : tue gvfsd-gphoto2 au démarrage (démon GNOME qui
  monopolisait le DSLR sur USB et bloquait gphoto2)
- camera.py : viewfinder activé AVANT mode="gphoto2" → le thread preview
  ne peut plus appeler capture_preview() avant que LiveView soit prêt
- _activer_viewfinder_init() sans lock pour l'init (thread pas encore actif)
- exif_transpose sur les captures DSLR → corrige l'orientation et les
  bandes blanches dues aux photos mal orientées

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:33:31 +02:00
0b4c5f9430 Fix strip (K60 coupe auto), diagnostic preview, nettoyage
Strip 10x15 :
- Envoie la strip simple avec format 10x15-2up (w288h432-div2)
  le K60 duplique et coupe automatiquement → plus de papier non coupé
- Supprime creer_impression_strip et le 2-up manuel avec marges blanches
- api/imprimer accepte format_papier depuis le frontend
- Frontend stocke formatImpression et le passe à l'impression

Diagnostic preview :
- GET /api/camera/debug : teste capture_preview(), liste les widgets
  config gphoto2, état du thread → permet de voir l'erreur exacte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:52:13 +02:00
1364b62297 Fix preview + miroir : thread continu, grace period, lock gphoto2
- _thread_preview tourne TOUJOURS quand DSLR connecté (plus de condition
  _preview_actif) → capture_preview() appelé en continu → miroir reste levé
- Grace period 15 frames (~500ms) avant de signaler camera_erreur : évite
  la race condition qui tuait le preview avant la 1ère frame
- _preview_gphoto2 protégé par _gp_lock (accès concurrent safe)
- surveiller_dslr ne tourne plus quand le DSLR est en mode gphoto2 connecté

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 23:01:25 +02:00
0a918666b3 Camera : viewfinder permanent + preview live pendant compte à rebours
- activer_viewfinder() à la connexion DSLR (miroir levé dès le démarrage)
- activer_viewfinder() après chaque capture (certains Canon le désactivent)
- Suppression desactiver_viewfinder() — miroir ne redescend plus jamais
- lancerPreview() au début du compte à rebours, arreterPreview() juste avant la capture
- Preview live fonctionnel : l'utilisateur se voit pendant tout le compte à rebours

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 22:32:46 +02:00
5 changed files with 92 additions and 33 deletions

View File

@@ -82,9 +82,11 @@ class Camera:
try:
self.camera = gp.Camera()
self.camera.init()
# Viewfinder activé AVANT de rendre la caméra visible au thread preview
self._activer_viewfinder_init()
self.connectee = True
self.mode = "gphoto2"
log.info("Camera DSLR connectee")
log.info("Camera DSLR connectee (LiveView actif)")
return True
except gp.GPhoto2Error as e:
log.warning(f"Pas de DSLR : {e}")
@@ -157,7 +159,9 @@ class Camera:
)
tmp_path = str(chemin_dest) + ".tmp"
fichier_camera.save(tmp_path)
img = PILImage.open(tmp_path)
self.activer_viewfinder() # Miroir relevé immédiatement après la prise
from PIL import ImageOps as PILImageOps
img = PILImageOps.exif_transpose(PILImage.open(tmp_path))
if img.width > 4000:
ratio = 4000 / img.width
img = img.resize((4000, int(img.height * ratio)), PILImage.LANCZOS)
@@ -198,11 +202,23 @@ class Camera:
else:
return None # Pas de simulation ni webcam en dehors du mode dedie
def _activer_viewfinder_init(self):
"""Active le LiveView pendant l'init (pas de lock, appelé avant que le thread démarre)."""
try:
cfg = self.camera.get_config()
vf = cfg.get_child_by_name("viewfinder")
vf.set_value(1)
self.camera.set_config(cfg)
log.info("Viewfinder activé (init)")
except Exception as e:
log.warning(f"Viewfinder non supporté : {e}")
def activer_viewfinder(self):
"""Active le LiveView sur les Canon."""
"""Active le LiveView (miroir levé) depuis le thread — thread-safe."""
if self.mode != "gphoto2" or not self.connectee:
return
try:
with self._gp_lock:
cfg = self.camera.get_config()
vf = cfg.get_child_by_name("viewfinder")
vf.set_value(1)
@@ -211,21 +227,9 @@ class Camera:
except Exception as e:
log.warning(f"Viewfinder non supporté : {e}")
def desactiver_viewfinder(self):
"""Désactive le LiveView."""
if self.mode != "gphoto2" or not self.connectee:
return
try:
cfg = self.camera.get_config()
vf = cfg.get_child_by_name("viewfinder")
vf.set_value(0)
self.camera.set_config(cfg)
log.info("Viewfinder désactivé")
except Exception as e:
log.warning(f"Viewfinder non supporté : {e}")
def _preview_gphoto2(self) -> bytes | None:
try:
with self._gp_lock:
fichier = self.camera.capture_preview()
donnees = bytes(fichier.get_data_and_size())
img = cv2.imdecode(np.frombuffer(donnees, dtype=np.uint8), cv2.IMREAD_COLOR)

View File

@@ -25,7 +25,7 @@ except ImportError:
gp = None
from backend.gallery import lister_photos, compter_photos, supprimer_photo, vider_galerie
from backend.effects import appliquer_filtre, appliquer_overlay, chroma_key, lister_overlays, lister_fonds, lister_cadres, FILTRES
from backend.collage import creer_strip, creer_collage, creer_impression_strip
from backend.collage import creer_strip, creer_collage
from backend.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur, recuperer_booth_password
from backend.printer import lister_imprimantes, imprimer
from backend.mailer import envoyer_photo
@@ -48,11 +48,16 @@ _preview_lock = threading.Lock()
_loop_principal: asyncio.AbstractEventLoop | None = None # loop asyncio principal
_preview_none_count = 0
_PREVIEW_GRACE_FRAMES = 15 # ~500ms avant de declarer une erreur
def _thread_preview():
"""Thread de fond : capture les frames DSLR en continu et les stocke."""
global _derniere_frame_preview, _preview_actif
"""Thread de fond : capture les frames DSLR en continu.
Tourne TOUJOURS quand le DSLR est connecte pour maintenir le miroir leve."""
global _derniere_frame_preview
while True:
if not _preview_actif or capture_en_cours:
if capture_en_cours or camera.mode != "gphoto2" or not camera.connectee:
time.sleep(0.05)
continue
try:
@@ -67,20 +72,26 @@ def _thread_preview():
async def _pusher_preview():
"""Tache asyncio : pousse la derniere frame a tous les clients WS abonnes."""
global _preview_actif
global _preview_actif, _preview_none_count
while True:
await asyncio.sleep(0.05) # 20 fps max envoye aux clients
if not _preview_actif:
_preview_none_count = 0
continue
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:
_preview_none_count += 1
# Grace period : attendre plusieurs frames avant de signaler l'erreur
if camera.mode == "gphoto2" and _preview_none_count > _PREVIEW_GRACE_FRAMES:
await diffuser_ws({"type": "camera_erreur", "message": "DSLR ne repond pas au preview"})
_preview_actif = False
_preview_none_count = 0
continue
_preview_none_count = 0
loop = asyncio.get_event_loop()
b64 = await loop.run_in_executor(None, lambda d=donnees: base64.b64encode(d).decode("ascii"))
await diffuser_ws({"type": "preview", "image": f"data:image/jpeg;base64,{b64}"})
@@ -90,7 +101,8 @@ async def surveiller_dslr():
global _dslr_erreurs
while True:
await asyncio.sleep(5)
if capture_en_cours or _preview_actif:
# Ne pas interférer pendant que le thread preview utilise la camera
if capture_en_cours or (camera.mode == "gphoto2" and camera.connectee):
continue
try:
if GPHOTO2_DISPONIBLE:
@@ -384,6 +396,38 @@ async def api_camera_statut():
}
@app.get("/api/camera/debug")
async def api_camera_debug():
"""Diagnostic complet de la caméra (preview, viewfinder, erreurs gphoto2)."""
info = {
"mode": camera.mode,
"connectee": camera.connectee,
"preview_actif": _preview_actif,
"derniere_frame_ok": _derniere_frame_preview is not None,
"preview_none_count": _preview_none_count,
"capture_en_cours": capture_en_cours,
}
if camera.mode == "gphoto2" and camera.connectee:
loop = asyncio.get_event_loop()
try:
frame = await loop.run_in_executor(None, camera._preview_gphoto2)
info["test_preview"] = "ok" if frame else "retourne None"
info["test_preview_taille"] = len(frame) if frame else 0
except Exception as e:
info["test_preview"] = f"ERREUR : {e}"
try:
cfg_names = []
with camera._gp_lock:
cfg = camera.camera.get_config()
for i in range(cfg.count_children()):
child = cfg.get_child(i)
cfg_names.append(child.get_name())
info["config_widgets_top"] = cfg_names
except Exception as e:
info["config_widgets_top"] = f"ERREUR : {e}"
return info
@app.post("/api/camera/reconnecter")
async def api_camera_reconnecter(body: dict = {}):
camera.deconnecter()
@@ -475,13 +519,13 @@ async def api_strip(donnees: dict):
return JSONResponse({"erreur": f"Photo introuvable : {n}"}, status_code=404)
chemins.append(c)
chemin_strip = creer_strip(chemins)
# Creer aussi la page d'impression (2 bandes sur 10x15 paysage)
chemin_print = creer_impression_strip(chemin_strip)
return {
"nom": chemin_strip.name,
"chemin": f"/data/exports/{chemin_strip.name}",
"impression": chemin_print.name,
"chemin_impression": f"/data/exports/{chemin_print.name}",
# La strip elle-même est envoyée avec format 10x15-2up :
# le K60 duplique et coupe automatiquement via -div2
"impression": chemin_strip.name,
"format_impression": "10x15-2up",
}
@@ -640,8 +684,9 @@ async def api_imprimer(donnees: dict):
chemin = DOSSIER_PHOTOS / nom
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
cadre_override = donnees.get("cadre") # cadre choisi en session, prioritaire sur le config
resultat = imprimer(chemin, copies=copies, cadre_override=cadre_override)
cadre_override = donnees.get("cadre")
format_papier = donnees.get("format_papier") or None
resultat = imprimer(chemin, copies=copies, cadre_override=cadre_override, format_papier=format_papier)
if resultat.get("succes"):
distribuer_photo(chemin, imprimee=True)
return resultat

View File

@@ -85,6 +85,7 @@ wsOnMessage('camera_ok', () => {
async function lancerCapture() {
photosSession = [];
photoImpression = null;
formatImpression = null;
document.getElementById('photo-resultat').src = '';
document.getElementById('photo-partage').src = '';
@@ -111,12 +112,12 @@ async function lancerCapture() {
compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`;
}
// Compte a rebours SANS preview (libere la camera)
lancerPreview(); // Miroir live pendant le compte à rebours
await compteARebours();
arreterPreview(); // Libère le bus USB gphoto2 pour la capture
afficherFlash();
// Preview arrete -> capture propre sans conflit gphoto2
const promesseCapture = apiPost('/api/capturer').catch(() => null);
let resultat = await promesseCapture;
let erreurReseau = false;
@@ -249,7 +250,8 @@ function afficherFlash() {
setTimeout(() => flash.classList.add('cache'), 400);
}
let photoImpression = null; // Nom du fichier d'impression (2 bandes sur 10x15)
let photoImpression = null; // Fichier à imprimer (strip ou photo finale)
let formatImpression = null; // Format CUPS à utiliser (ex: "10x15-2up")
async function traiterCapture() {
if (photosSession.length === 0) {
@@ -258,6 +260,7 @@ async function traiterCapture() {
}
photoImpression = null;
formatImpression = null;
try {
if (modeActuel === 'multi') {
@@ -270,6 +273,7 @@ async function traiterCapture() {
return;
}
if (resultat.impression) photoImpression = resultat.impression;
if (resultat.format_impression) formatImpression = resultat.format_impression;
} else {
resultat = await apiPost('/api/collage', { photos: photosSession });
if (resultat.erreur) {

View File

@@ -29,6 +29,7 @@ async function lancerImpression() {
photo: fichierImpression,
copies: nbExemplaires,
cadre: cadreChoisi || undefined,
format_papier: formatImpression || undefined,
});
if (resultat.succes) {
afficherStatut(`${nbExemplaires} exemplaire(s) envoye(s) a l'imprimante !`, 'succes');

View File

@@ -16,6 +16,11 @@ xset s noblank
# Automount USB (pour la photostation)
udiskie --no-notify --no-appindicator &
# Libérer le DSLR : gvfsd-gphoto2 (GNOME) le monopolise sinon
pkill -f gvfsd-gphoto2 2>/dev/null || true
pkill -f gvfs-gphoto2-volume-monitor 2>/dev/null || true
sleep 1
# Backend en boucle : redémarre automatiquement s'il plante
_backend_loop() {
while true; do