Compare commits

...

179 Commits

Author SHA1 Message Date
abc5d78575 Fix compteur : default papier/ruban capacite 320 (pas 400)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-25 11:19:11 +02:00
d845c2606e Impression : affiche photos restantes sur le rouleau avant validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-25 10:55:36 +02:00
6d58fe5d57 Consommables : capacite defaut 320, 15x20 compte double (papier + ruban)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-25 10:51:08 +02:00
e6a0b268ff Endpoint /api/imprimer-fichier : impression par upload (pour galerie en ligne)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-25 00:24:47 +02:00
2f32cf2f38 Cadres impression : fallback cross-format (15x20 utilise le 10x15 si pas de cadre dédié)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-24 13:29:13 +02:00
61b9a72f10 UX partage : masquer Recommencer pendant impression/email, detail pellicule, reset copies
- Ecran partage : boutons bas masqués quand formulaire impression ou email ouvert
- "Terminer" remplacé par "Recommencer" (retour mode, pas accueil)
- Reset photoImpression/formatImpression/nbExemplaires dans recommencer()
- Bouton Pellicule affiche "N poses = 2 tirages" pour expliquer le strip dupliqué
- CSS .mode-detail pour sous-titre discret sur bouton mode
- Bump cache versions (style v18, app v27, share v11)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-20 20:32:42 +02:00
4931a29d45 Notifier galerie des impressions + luminosite par defaut +15%
- notifier_booth_impression() : appelle /gallery/{event}/photo/{name}/imprimee
  apres chaque impression pour mettre a jour le statut dans la galerie en ligne
- Luminosite impression par defaut +15% (corrige photos sombres a l'impression)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-20 10:59:22 +02:00
5e8cff7951 Mini menu client + luminosite impression + endpoints systeme
- Menu client coin haut-gauche : WiFi, luminosite, relancer, redemarrer, eteindre
- Popup WiFi : scan, statut, connexion (reutilise les endpoints existants)
- Popup luminosite : slider -50% a +50%, ajuste la luminosite avant impression
  (ImageEnhance.Brightness dans _preparer_image)
- Endpoints systeme : /api/systeme/eteindre, redemarrer, redemarrer-app
- Endpoint exposition Canon : /api/camera/exposition (GET choix + POST valeur)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-20 10:22:00 +02:00
bb49c2c82d Fix flow surprise : preview reste live, flash masque la transition
- Preview live continue pendant la surprise (overlay par-dessus)
- Capture DSLR lancee pendant la surprise (1s de reaction)
- Flash blanc couvre tout PUIS on masque surprise + preview derriere
- Plus de frame flou visible entre surprise et photo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 14:46:14 +02:00
812de8ae53 Surprise + capture en parallele : DSLR declenche pendant l'image surprise
- Preview stoppe AVANT la surprise (plus de frame flou)
- Capture DSLR lancee en meme temps que la surprise (apiPost en parallele)
- 800ms de reaction pendant que le DSLR travaille
- Flash + masquer surprise apres le declenchement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 14:44:21 +02:00
5f1a96ab8a Watchdog WiFi + reconnexion auto + fix timing surprise capture
- Watchdog connectivite toutes les 60s (check TCP 1.1.1.1:53)
- Si offline : scan WiFi et reconnexion auto aux reseaux enregistres
- Si internet restaure : flush immediat des spools (email + galerie)
- Surprise : capture declenchee 500ms apres affichage (au lieu de 1.7s apres)
  pour capturer la reaction naturelle des gens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 12:32:31 +02:00
d21d87347d Retry periodique email + spool galerie en cas de coupure internet
- Spool galerie booth : si upload echoue, photo stockee dans
  data/booth_spool.json et retentee toutes les 5 min
- Email spool : retry periodique (5 min) au lieu de juste au demarrage
- Les deux spools traites dans la meme boucle async
- Message frontend spool : "sera envoyee des que possible"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 10:05:21 +02:00
5c774babbb Log impressions (JSONL) + sleep 3s entre copies multi
- _log_impression() ecrit chaque demande dans data/impressions.jsonl
  (timestamp, photo, copies, format, succes, event_id, detail CUPS)
- Delai entre copies passe de 1s a 3s pour fiabiliser multi-copies Mitsubishi

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 09:57:26 +02:00
6c45faecdc Reveil Canon fiabilise : poll USB au lieu de sleep fixe, retry gphoto2, frontend patient
Backend: reveiller_canon() poll USB toutes les 1s (max 20s) au lieu de sleep(15+25),
3 tentatives gphoto2, temps total 10-30s au lieu de 40s fixe.
Frontend: attend 40s meme si reveil_en_cours=false (reconnexion auto),
messages plus clairs ("Touchez l'ecran et reessayez").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 09:46:29 +02:00
be838a7a48 Surprise : desactiver en mode strip/multi (photo simple uniquement)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 22:45:43 +02:00
d9c8d9b779 Retirer data/config.json du tracking git (etat runtime)
Ce fichier contient SMTP, relais, surprise, evenement actif —
il ne doit jamais etre ecrase par un git pull.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 19:11:13 +02:00
05f78f731d Surprise : precharger image + animation fade in/out + attente chargement
Evite le freeze percu : image prechargee au boot, fade 200ms,
attente img.onload si pas encore prete, re-precharge apres affichage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 19:09:53 +02:00
6b2928c9af Ajout frontend/sounds/.gitkeep pour eviter crash StaticFiles
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 18:33:49 +02:00
0e1b951ed4 API creer_evenement : accepte formats_actifs depuis le deploy LXC
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 18:19:57 +02:00
f1299fd6a7 Bulletproof: messages novices, kiosk port auto, reset compteur, sync booth event_id
- Messages erreur camera/impression: texte novice ("Oups!", "Reessayez") au lieu de jargon technique
- kiosk-session.sh: lit le port depuis config.json (plus de http://localhost en dur), healthcheck /api/config, supprime git pull auto au boot
- Activer evenement: reset automatique du compteur photos
- Activer evenement: sync booth.event_id quand galerie live active
- Port par defaut backend: 80 → 8080

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 09:17:26 +02:00
96464ccca1 Canon réveil: frontend attend connexion + fix taille countdown CSS
- /api/camera/statut expose reveil_en_cours pour que le frontend sache
- lancerCapture() poll toutes les 2s (max 60s) si Canon en réveil au lieu de fail immédiat
- Fix CSS countdown: .compte-a-rebours span était à 14rem (écrasait .anim-texte par spécificité)
  → chiffres clamp(2rem, 8vw, 5rem), texte clamp(0.8rem, 3vw, 1.5rem)
- Bump cache versions (style v17, camera v18)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 15:32:27 +02:00
583b89f0bd Canon: fiabilite veille/reveil + detection PTP freeze + UX countdown + fix copies impression
- Canon PTP freeze: 5 keepalive FAIL consecutifs → deconnexion forcee
- Canon veille: reset timestamps reconnexion (pas de boucle de mort)
- Canon reveil: guard _reveil_en_cours dans thread preview + surveiller_dslr
- Canon capture: attente auto reveil (60s max) au lieu de 503 immediat
- Canon reconnexion quand _echecs_connexion == 0
- UX: sequence "Attention!" → "Preparez-vous!" → 3 → 2 → 1
- UX: pre-messages uniquement photo simple + 1ere photo pellicule
- UX: pas de compteur "Photo 1/1" en mode simple
- UX: pas de cadre propose pour pellicule
- UX: taille texte countdown reduite pour Surface (scale-factor 3)
- Impression: N jobs lp separes au lieu de lp -n (Mitsubishi ignore -n)
- Bouton Terminer grise pendant impression

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 18:54:10 +02:00
4e267492c2 Fiabilite Canon : veille/reveil relais, sequence boot, stats diagnostic, fin evenement, compteur consommables
- Preview thread : poll uniquement quand ecran capture actif, keepalive 30s sinon
- Sequence boot Canon : power cycle relais si absent USB au demarrage
- Veille auto 30min : Canon coupe par relais apres inactivite, reveil au preview_start
- Stats diagnostic /api/canon/stats : keepalive, preview, captures, connexions
- Escalade reconnexion adoucie : pas d'USB reset les 2 premieres tentatives
- Hard reset relais 10s (resistance decharge 990ohm sur dummy battery 8V)
- Gestion fin evenement : date_fin + bouton terminer + guard 403 impression
- Compteur base sur min(papier, ruban) avec cap evenement optionnel
- Pre-eclairage countdown : projecteurs ON 3s avant capture pour mesure expo
- Canon _configurer_init() : ISO 800, drivemode Single, viewfinder, EXIF preserve
- Admin : detail evenement, compteur consommables, galerie impression

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyN9xFp84P9VaWT3Qe2DXu
2026-08-14 19:33:28 +02:00
a6766f529e Module relais USB HID : pilotage projecteurs + alim Canon + eclairage auto
Pilote backend/relais.py (hidapi/ctypes) pour module LCUS 5131:2007.
Relais 1-2 = projecteurs (NO), relais 3 = alim Canon (NF).
API : status, on/off par nom, power-cycle, config seuils.
Eclairage auto : analyse luminosite visage → allume projecteurs selon seuils.
Admin : onglet eclairage enrichi avec boutons relais + seuils configurables.
Note : power-cycle Canon par relais ne redémarre pas le DSLR (contact capot batterie).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-02 15:45:03 +02:00
3aa3068127 Videos didactiques multi-etapes : wizard operateur + admin dynamique
Backend : 8 endpoints API (upload/delete/reorder/toggle/label/stream/creer etape),
stockage data/videos/{situation}/etape_XX.mp4, config.json toggle+labels,
6 situations par defaut, migration auto anciens fichiers flat.
Admin : panneau dynamique avec toggle, etapes reordonnables, labels editables.
Wizard booth : overlay plein ecran, video loop par etape, bouton validation sequentielle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 20:41:05 +02:00
5e482a89e7 Consommables : ajout compteur poses perdues + ratio corrige 0.5 (10/20)
- Nouvelle carte 'Poses perdues' dans le panneau consommables
- Le ratio ruban optimal pour 10x15 est 0.5 (10cm sur 20cm de panneau)
- Diagnostic enrichi : alerte epuisement, ruban avant papier, % gaspille
- Reset poses perdues avec le ruban

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 16:41:30 +02:00
3b6f376ded Suivi consommables : compteur papier, ruban et photos avec diagnostic ratio
- Onglet Consommables dans General : barres de progression papier/ruban, compteur photos
- Capacites configurables (feuilles par bobine / poses par bobine)
- Reset individuel papier/ruban (quand on change la bobine)
- Diagnostic ratio ruban/papier pour verifier que le rembobinage fonctionne
- Tracking automatique a chaque impression (format-aware)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 15:23:17 +02:00
44a9992557 Admin backoffice : rubriques General/Evenement + videos situation + surprise + rembobinage ruban
- Reorganisation menu admin en 2 rubriques (General / Evenement)
- Destinations deplace dans Evenement
- Nouveau panneau Videos : upload par situation (montage, depannage, bourrage, rechargement)
- Nouveau panneau Surprise : photo/video affichee ~1s avant capture pour provoquer sourire
- Toggle rembobinage ruban Mitsubishi (StpiDecklist) pour economiser le ruban sur petits formats
- Historique email cloisonne par evenement
- Memoire projet consolidee dans memoire.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-09 12:31:27 +02:00
e5e9f763ab Bump cache versions (style v12, camera v17, gallery v4)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-30 13:25:22 +02:00
8bc854b5a7 Admin galerie + compteur copies + flash capture + erreur imprimante toast
- Galerie admin : onglet avec sélection photos + impression par lot
- Compteur : compte les copies (×N) et non plus 1 par impression
- Flash blanc immédiat après countdown pour masquer le délai AF
- Erreur imprimante : petit toast discret au lieu de bannière plein écran

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-30 13:24:55 +02:00
e9143f7a72 Remettre viewfinder off avant capture pour activer l'AF phase-detect
Sans ça le Canon reste en LiveView et utilise le contrast-detect AF
(lent et imprécis). Le sleep(0.2) reste supprimé.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-28 11:07:29 +02:00
d31c2cc811 Accélérer la capture : viewfinder warmup en arrière-plan
- Supprime sleep(0.2) avant capture et viewfinder off inutile
- Déplace activer_viewfinder + chauffer_liveview dans un thread
  daemon après le save, pour retourner la photo immédiatement
- Supprime pause(150ms) frontend entre fin countdown et capture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 15:54:17 +02:00
5614be8c6e Supprimer le timeout auto-retour du menu admin
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 15:46:38 +02:00
4487af8399 Masquer popup QR à chaque changement d'écran
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 15:21:40 +02:00
c7bd92edea Fix QR après photo : corriger share.js qui écrasait afficherQR()
share.js était chargé après app.js et redéfinissait afficherQR()
avec l'ancienne version (mauvaise URL, mauvais éléments DOM).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 15:20:21 +02:00
e81daa7305 Cache-bust JS/CSS v16 + QR debug logging
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 15:19:08 +02:00
18ca4a8d5f Fix impression strip : ne pas pivoter/rotation_180 les pellicules
Le strip est généré en portrait 600x1800 par design. L'orientation
"paysage" configurée pour 10x15 le faisait pivoter, et rotation_180
l'inversait. Résultat : bandes imprimées à l'envers avec une seule
photo visible par bande après coupe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 14:59:53 +02:00
0407244554 QR code par photo : popup QR dynamique + page vue photo sur booth
- afficherQR() génère un QR vers /g/{event}/view/{photo}?p=
- Page photo-view.html : plein écran + bouton télécharger + galerie
- QR accueil reste la galerie complète, QR après capture = la photo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 14:14:28 +02:00
09c8ea75fa Fix QR code URL : utiliser /g/ au lieu de /admin/gallery/ (routes publiques)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 14:04:36 +02:00
1589754a92 Fix QR code URL : ajout /admin/gallery/ prefix + priorité evenement.event_id
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 13:49:20 +02:00
3c3dbd6c8b Watchdog systemd + fix stabilité kiosk/backend
- Watchdog (30s) : vérifie backend, caméra, chromium, tue les orphelins,
  reboot auto après 10 échecs consécutifs (~5min sans service)
- kiosk-session.sh : tue les backends orphelins au démarrage, trap EXIT
  pour cleanup quand le kiosk est tué
- camera.py : force release USB avec timeout 2s quand connecter() échoue
- main.py : uvicorn.run(app) au lieu de string pour éviter double import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 13:21:02 +02:00
29e65ab1bd Keepalive Canon anti-veille : get_config() toutes les 10s quand preview KO
Quand capture_preview() echoue (I/O error, miroir bloque), le thread
preview n'envoyait plus de commande PTP au Canon → l'appareil finissait
par s'eteindre via auto power off. Ajoute un keepalive de secours via
get_config() qui declenche ptp_canon_eos_keepdeviceon cote libgphoto2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 00:21:59 +02:00
5a81907a0e Kiosk : flag maintenance + anti-doublon backend
- Check /tmp/photobooth-maintenance pour bloquer le restart loop
- Vérifie qu'aucun backend ne tourne avant d'en lancer un nouveau

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-26 19:49:51 +02:00
8dabba65bb Admin WiFi : scan réseaux, connexion, mots de passe mémorisés
- Module backend/wifi.py : scan nmcli, connexion, mots de passe persistés dans data/wifi_passwords.json
- Gère la locale FR (oui/non), déduplique les AP multiples, prend le meilleur signal
- Onglet WiFi dans le menu admin avec statut, scan, clavier virtuel AZERTY complet
- Réseaux enregistrés avec bouton connecter/oublier et affichage temporaire du mdp
- API: /api/wifi/status, /api/wifi/scan, /api/wifi/connect, /api/wifi/saved, /api/wifi/password/{ssid}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-26 19:29:12 +02:00
4e431507fc Resilience : ecran pause, reconnexion WS auto, watchdog systemd, WiFi monitor
- WebSocket : reconnexion avec backoff exponentiel au lieu de reload page
- Ecran pause avec tasse de cafe quand le backend est injoignable
- Endpoint /api/health pour monitoring externe
- Watchdog systemd (sd_notify READY=1 + WATCHDOG=1 toutes les 10s)
- Service systemd durci (Type=notify, WatchdogSec=30, KillMode, limites)
- WiFi watchdog : script + timer systemd, verifie la gateway toutes les 60s
- Destinations : fallback url_tunnel (WireGuard) en priorite

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-26 19:16:39 +02:00
48f54ab70a Robustesse DSLR + éclairage visage + fix pellicule cadre imposé
- camera.py: fix fuite FD USB sur échec reconnexion (gp.Camera non libéré),
  cleanup PTP session avant init, catch toutes exceptions preview/capture,
  deconnecter() avec timeout thread + force del pour libérer le port USB
- main.py: thread preview anti-crash (outer try/except), reconnexion DSLR
  même si preview actif, notification camera_erreur après échec capture,
  intégration module éclairage (analyse luminosité visage via WS)
- printer.py: mode pellicule (-2up) ignoré par le cadre imposé événement
- eclairage.py: nouveau module analyse luminosité visage (Haar + HSV)
- camera.js: indicateur éclairage temps réel (score/100 + visages)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-25 19:11:10 +02:00
182042350d Calibration impression : offsets coupe (mm) + rotation 180° configurable
- impression.offsets_coupe_mm : {haut, bas, gauche, droite} compense la coupe asymétrique
- impression.rotation_180 : retourne l'image si le driver inverse le sens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 09:29:40 +02:00
e83d5c8d58 Fix impression paysage : inverser largeur/hauteur quand orientation=paysage
Les dimensions FORMATS sont en portrait — sans inversion, une photo paysage
était crop en portrait pour l'impression.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 09:14:36 +02:00
e6e7c5a9f2 Fix cadre overlay centré sur preview live (top/left/width/height écrasés par .preview-live img)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 09:00:40 +02:00
6dafe61417 Escalade DSLR recovery : uhubctl power cycle + backoff 60s après 10 échecs
Niveaux : ioctl reset (2) -> unbind/rebind (3-5) -> uhubctl 5V cut (6+) -> backoff 60s (10+)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 08:44:12 +02:00
e2f65b1ba7 USB rebind via sudo tee (permission sysfs requiert root)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 08:37:41 +02:00
29a7db028e USB rebind Canon : unbind/rebind sysfs pour recovery hard DSLR PTP freeze
Escalade progressive : ioctl reset (tentative 2), unbind/rebind USB (tentative 3+)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 08:35:03 +02:00
97e3f6508d DSLR auto-recovery USB reset + cadre overlay non-mirroré + lien galerie événement
- surveiller_dslr: reset USB ioctl après 2 échecs, double reset après 3
- deconnecter(): timeout threading 3s pour éviter blocage PTP
- CSS: .preview-live .cadre-overlay sans scaleX(-1)
- Admin événement: lien direct vers galerie en ligne

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 08:30:15 +02:00
6bbcf85e52 Formats activables par evenement + cadre impose exclut strips
- Toggles formats (10x15, 15x20, strip) par evenement
- Strip desactive masque le bouton pellicule sur l'accueil
- Cadre impose/defaut s'applique uniquement sur 10x15 et 15x20 (pas strip)
- Upload cadre evenement le copie dans les deux formats automatiquement
- Section cadres admin simplifiee (un seul bloc au lieu de 3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 00:45:47 +02:00
77b1127816 Cadre impose visible en overlay sur live + preview + partage
- Overlay cadre sur les 3 ecrans : live (capture), preview (filtres), partage
- chargerCadreImpose() au debut de chaque capture : affiche le cadre event
- Photos sauvegardees sans cadre, cadre applique uniquement a l'impression
- afficherCadreOverlay cherche d'abord dans les cadres evenement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 00:36:57 +02:00
e5c73d4855 Theme Viking Celte : bois sculpte, bronze, entrelacs
Palette bois fonce (#1a0f08/#2b1810), bronze (#b08d57), parchemin (#f0e6d2).
Polices MedievalSharp + Cinzel. Boutons style bois sculpte avec bordures bronze.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 00:31:51 +02:00
9ce8fc0e55 Gestion evenements + cadres par event + fix cadre paysage + dossier non-imprimees
- CRUD evenements : creer, activer, editer, supprimer dans panneau admin
- Cadres specifiques par evenement (aucun/disponible/defaut/impose)
- Fix cadre paysage deformé : rotation auto si orientation ne correspond pas
- Photos non imprimees vont dans sous-dossier "non_imprimees" (USB/FTP)
  au lieu d'etre ignorees ; booth recoit toujours toutes les photos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 00:16:35 +02:00
545d8cd6ad Admin : galerie en ligne dans Destinations + booth envoie toutes les photos
- Remplace le toggle "Site internet" par "Galerie en ligne" dans le panneau Destinations
- Supprime la section dupliquée "Galerie Live" du panneau Fonctions
- Le booth reçoit toutes les photos (imprimées ou non), le filtre sauvegarder_tout ne s'applique qu'à USB/FTP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 00:05:47 +02:00
98c738cf79 Galerie booth : QR avec auto-login, event_id aleatoire, flyer imprimable
- QR code inclut le mot de passe (?p=XXXX) pour acces direct sans saisie
- Supprime le code texte visible sur l'ecran d'accueil, QR plus petit
- Bouton admin 'Imprimer flyer QR galerie' genere un 10x15 avec
  nom evenement, QR code, texte invitation, design assorti
- Auto-login ajoute cote serveur booth (booth.copydev.fr)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 20:07:52 +02:00
4d2d3e7a93 Bump cache-busting versions (css v5, app v5, camera v11, share v6)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 19:46:24 +02:00
85f32c35bb Rapport email a contact@copydev.fr apres vidage du spool
Au demarrage, si le spool contient des emails en attente, les envoie
puis envoie un rapport recapitulatif (nb envoyes + liste destinataires)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 19:42:25 +02:00
9bdad23cf5 Spool email : envoi au demarrage uniquement, message 'dans la semaine'
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 19:34:26 +02:00
e47ff04933 Email spool : file d'attente quand envoi echoue, retry toutes les 60s
- Si l'envoi SMTP echoue (pas de connexion, config incomplete, etc.),
  l'email est stocke dans data/email_spool.json
- Tache asyncio periodique (60s) tente de vider le spool
- L'adresse est quand meme ajoutee a l'historique de suggestions
- Frontend affiche "Email en file d'attente" au lieu de "Erreur"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 19:17:58 +02:00
158f1b509f Email : fermer formulaire avant envoi + raccourcis domaines FR
- Ferme le formulaire email immediatement au clic Envoyer (le toast
  etait cache derriere l'overlay fullscreen)
- Ajout domaines : @outlook, @yahoo, @icloud, @orange, @free, @sfr, @laposte

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 19:13:37 +02:00
0b7b55f57b Email plein ecran avec suggestions d'adresses precedentes
- Formulaire email : overlay fullscreen au lieu d'inline (debordait de l'ecran)
- Historique emails : stocke chaque adresse dans data/emails_history.json
- Suggestions : affiche les emails deja saisis en pills cliquables,
  filtrees au fur et a mesure de la saisie
- API GET /api/emails/historique + DELETE pour reset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 18:57:28 +02:00
e730b6bc8b Cadres : preview coin haut-gauche + compteur bloque impression pas capture
- Thumbnails cadres : positionnement coin haut-gauche au lieu du centre
  (le centre du cadre PNG est transparent = invisible)
- Compteur photos : ne bloque plus la capture quand limite atteinte,
  masque le bouton imprimer sur l'ecran partage a la place

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 09:03:26 +02:00
f49d5511ef Pellicule : marge de coupe plus large + warmup LiveView apres capture
La coupe physique du K60 n'est jamais parfaitement centree, donc une marge
noire trop fine fait perdre la pellicule d'un cote du strip dedouble. Marge
de coupe (perforations) elargie de 34 a 55px (~4.6mm) de chaque cote.

Ajoute aussi un warmup LiveView (_chauffer_liveview) juste apres la remontee
du miroir post-capture : consomme les ~1-2s de stabilisation pendant le
spinner de traitement plutot que de laisser le preview du countdown suivant
figé sur l'ancienne photo.
2026-06-21 23:47:06 +02:00
640715282c Fix impression pellicule : la feuille -div2 doit etre envoyee a taille pleine
La PageSize CUPS w288h432-div2 declare la meme taille de page que w288h432
(confirme par le PPD Mitsubishi) : le K60 coupe la feuille pleine apres
impression, il ne duplique pas une image fournie a taille reduite. On envoyait
la bande seule (600x1800) sous cette PageSize, donc CUPS l'etirait pour
remplir la feuille 1200x1800 puis le K60 la coupait en 2 — d'ou la photo
etiree et coupee au lieu de 2 bandes identiques.

Ajoute aussi le miroir horizontal sur le preview live (selfie) et un flou de
chargement pendant le bref delai de remontee du miroir DSLR apres capture,
pour eviter l'impression d'image figee sur la photo precedente.
2026-06-21 23:32:50 +02:00
a79762197d Fix impression pellicule : strip genere en paysage au lieu du portrait attendu
creer_strip() produisait une bande paysage (1800x595) alors que printer.py
('10x15-2up' -> PageSize ...-div2) et tous les cadres existants (assets/cadres/strip/*.png)
attendent une bande portrait fixe 600x1800 (5x15cm), dupliquee et coupee
automatiquement par l'imprimante. Le mismatch faisait pivoter/etirer
l'image a l'impression (StpiShrinkOutput=Crop sur un ratio incompatible),
d'ou le rendu deforme coupe en 2 mauvais morceaux au lieu de 2 pellicules
identiques.

- creer_strip() : bande 600x1800 portrait, photos empilees verticalement,
  perforations sur les cotes (au lieu de haut/bas)
- Cadre de visee live (camera.js) : formule de ratio mise a jour en
  consequence pour rester fidele au resultat final
2026-06-21 23:00:48 +02:00
0df68da41a Cadre de visee live correspondant au recadrage final pellicule/collage
L'apercu live montrait le plein cadre camera (ratio ~3:2) alors que le
recadrage final de chaque case (creer_strip/creer_collage) est plus carre
(~1.11:1 pour 3 photos en pellicule) : le cadrage visualise pendant le
compte a rebours ne correspondait pas au resultat imprime.

Ajoute un cadre pointille superpose au live preview, dimensionne en
reproduisant cote JS la meme formule de recadrage que backend/collage.py,
visible uniquement en mode multi-shot (strip ou collage).
2026-06-21 22:52:05 +02:00
b8f3bef852 DSLR : empecher la mise en veille (autopoweroff Canon + udev no-autosuspend)
Le boitier se mettait en veille apres quelques minutes d'inactivite et
apparaissait deconnecte. Deux causes possibles traitees :
- autopoweroff Canon desactive a la connexion (cote firmware)
- USB autosuspend du host desactive pour les peripheriques Canon (cote noyau)
2026-06-21 22:15:56 +02:00
66847dccf8 Ecran partage : photo miniature flexible pour toujours rentrer dans l'ecran
Le bloc partage (photo + boutons + form impression) pouvait depasser la
hauteur de l'ecran quand le choix du nombre d'exemplaires s'affichait.
La miniature absorbe maintenant l'espace restant (flex + min-height:0)
au lieu d'une hauteur fixe en vh, les autres elements gardent leur taille.
2026-06-21 22:13:26 +02:00
fe0a559c85 Flash Canon configurable, choix de cadre a l'impression, fixes capture/reconnexion DSLR
- Flash integre Canon activable/desactivable depuis l'admin (configurer_flash)
- Popup de choix de cadre juste avant impression, layout sans defilement (flex nowrap + aspect-ratio)
- Capture : ignore camera_erreur pendant une capture en cours (laisse l'API trancher)
- surveiller_dslr : grace period preview augmentee a 2s, reapplique la config camera apres reconnexion
- start.sh simplifie (Chromium gere par kiosk-session.sh), remote debugging port 9222 active
2026-06-21 21:59:36 +02:00
c7225cc9f6 surveiller_dslr : retirer le reset USB automatique (contre-productif)
Les resets USB ioctl répétés en boucle aggravent l'état des appareils Canon
au lieu de les débloquer. La fonction _usb_reset_canon est conservée pour
usage manuel mais n'est plus appelée automatiquement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:33:14 +02:00
f66f08da4e surveiller_dslr : pause 12s après reset USB Canon pour ré-énumération
Le reset USB ioctl prend ~10s sur le Canon EOS 6D pour que l'appareil
se ré-énumère sur le bus. Avec 3s on retombait en boucle de resets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:04:23 +02:00
e4e5ee05d4 surveiller_dslr : reset USB automatique après 3 échecs de connexion Canon
Quand gphoto2 détecte le DSLR mais que camera.init() timeout répétitivement
(USB stall après kill -9 ou déconnexion brutale), on tente un reset USB ioctl
(USBDEVFS_RESET sur /dev/bus/usb/<bus>/<dev>) toutes les 3 tentatives (~15s).
Évite de rester bloqué indéfiniment sur "Timeout reading from or writing to the port".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:02:29 +02:00
4f12f77106 Capture : viewfinder=0 avant prise (AF phase-détection), timeout 20s, spinner UI
- camera.py : désactiver LiveView avant capture (viewfinder=0) → miroir abaissé →
  AF phase-détection Canon actif. Remplace _declencher_af_liveview (autofocusdrive=1
  pouvait bloquer sur certains corps Canon). activer_viewfinder() après la prise.
- main.py : fix bug global _preview_actif dans api_capturer (était variable locale,
  le global restait True). Timeout asyncio 20s sur run_in_executor pour éviter freeze
  permanent si capture bloque. Logs INFO avant/après capture.
- camera.js + index.html + style.css : spinner "Capture en cours..." entre le flash
  et l'affichage du résultat. Cacher le spinner dans afficherErreurCapture aussi.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:51:51 +02:00
d02cc90fee Bullet-proof caméra : reconnexion auto + fix crash capture + AF LiveView
backend:
- surveiller_dslr : fix condition qui court-circuitait le check santé
  quand mode==gphoto2 — watchdog surveille maintenant via preview_dslr_ok
  et reconnecte automatiquement après 15s d'échec (3x5s)
- _pusher_preview : ne pas envoyer camera_erreur pendant capture_en_cours
  (race condition HTTP/WS : le POST /capturer peut arriver avant preview_stop)
- api_capturer : force _preview_actif=False dès le début de la capture
- _capture_gphoto2 : déclenche autofocusdrive avant la prise en LiveView

frontend:
- websocket.js : support multi-callbacks par type (wsOnMessage ne remplace
  plus le callback précédent, tous s'exécutent)
- app.js : camera_erreur affiche bannière + reload dernier recours à 2min
  (plus 10s), camera_ok cache la bannière automatiquement
- camera.js : camera_erreur abort capture + arreterPreview(); camera_ok
  reset captureAbortee

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:11:33 +02:00
7e239ea0f9 Camera déconnectée : retour accueil automatique + abort capture
- camera_erreur pendant la capture : abort immédiat + afficherErreurCapture()
  → retour accueil en 4s (au lieu de rester bloqué sur "Reconnexion en cours...")
- camera_ok : cache l'erreur et reset le flag captureAbortee
- captureAbortee flag : permet à lancerCapture() de sortir proprement
  sans déclencher de capture fantôme après une déconnexion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 11:53:30 +02:00
a51d235faf Fix preview live : position:absolute sur img (height:100% cassé dans flex avec align-items:center)
La balise <img> avec height:100% ne se propage pas quand le parent est un
flex item positionné avec align-self:center au lieu de stretch. Le fix :
position:absolute top:0 left:0 width/height 100% sur .preview-live img.

Retiré aussi --remote-debugging-port=9222 (diagnostic temporaire).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 11:16:19 +02:00
831c64f4f7 Debug: active remote debugging port 9222 sur Chromium 2026-06-10 10:48:24 +02:00
652d0424e3 Retirer le .deb du repo, ajouter .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 09:57:02 +02:00
f977189a93 Kiosk : git pull auto + diagnostic caméra dans admin
- kiosk-session.sh : git pull à chaque démarrage du backend
  (les correctifs Gitea sont maintenant déployés automatiquement)
- Log backend dans /tmp/photobooth-backend.log (lisible via admin)
- Admin > Infos : bouton "Diagnostic camera" → appelle /api/camera/debug
  et affiche mode, connectee, erreur capture_preview, widgets gphoto2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 09:56:55 +02:00
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
0477831679 Imprimante : bouton Couper papier (micro-job blanc déclenche le cutter K60)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:19:27 +02:00
843762b6d7 Imprimante : bandeau erreur tous écrans + reset USB + statut détaillé
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:17:27 +02:00
2b204ddb5e Admin : statut détaillé imprimante + message erreur CUPS traduit
- GET /api/imprimante/statut-detail : statut + dernière erreur log
- Bouton évacuer retourne le vrai message (format papier, bourrage...)
- Bloc d'erreur affiché dans l'onglet Matériel si problème détecté

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:38:48 +02:00
3cd5525981 detecter_usb : 2 niveaux /media/<user>/<label> + filtre fstype 2026-06-04 13:53:05 +02:00
c013c9e69e Ajout udiskie automount USB dans kiosk-session 2026-06-04 13:48:28 +02:00
4de88877c7 Fix clignotement : verrou instance unique kiosk + meta notranslate HTML
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 13:06:12 +02:00
a85cc100f2 Fix translate bar (--lang=fr + prefs FR) + popup photostation popup-overlay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:58:09 +02:00
0c00011de0 Fix popup photostation (hors ecran-accueil) + z-index boutons + profil Chromium sans translate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:50:41 +02:00
8db27bf77d Fix : clic btn-photostation ne déclenche plus la navigation vers mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:42:26 +02:00
d66a9c2fa9 Fix : bit exécutable scripts kiosk-session.sh et install-photostation.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:37:13 +02:00
babd89425c Intégration station d'impression photo (photostation)
- Icône discrète coin bas-gauche, visible seulement si photostation installée
- Popup PIN (4 chiffres) avant lancement
- Backend : /api/photostation/disponible + /api/photostation/lancer
- kiosk-session.sh : Chromium attend le flag /tmp/photostation_running avant redémarrage
- Script install-photostation.sh pour déploiement sur la Surface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:20:48 +02:00
fe71921d1a Cadres choix : zoom 2.5x + fond photo simulé, bordures bien visibles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:14:40 +02:00
ba2b119e1f Kiosk : boucle infinie backend + Chromium, plus d'écran login
Si Chromium ou le backend plantent, ils redémarrent automatiquement.
La session LightDM ne se termine jamais → plus d'écran de login intempestif.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:40:23 +02:00
6af9d7c073 Fix : desactiver Translate dans Chromium kiosk
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:39:18 +02:00
e3d1d9cc07 Impression Mitsubishi K60 + cadres + interface admin distante
- Intégration selphy_print (backend CUPS dyesub Mitsubishi CP-K60DW-S)
- Formats impression : 15x20, 10x15, 10x15 2-strips avec cadre et orientation portrait/paysage
- Cadres d'impression par format (strip/10x15/15x20) : upload, sélection, aperçu live
- Cadres démo générés : pellicule noir/vintage/couleurs + bordures classique/doré/rose gold
- Écran de choix de cadre avant capture (clic direct, aperçu grand format)
- Filtres réduits à 3 (Couleur, N&B, Sépia) et appliqués sur tous les strips
- Port 80 via authbind, route /admin avec redirection auto depuis poste distant
- Autologin LightDM corrigé (pam-autologin-service)
- Bouton évacuer bourrage papier dans admin
- Fix : _gp_lock manquant dans Camera.__init__

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:38:31 +02:00
6451f5774c Mise a jour theme GRUB + logo copydev + test pygame
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:43:21 +02:00
1120513309 Switch trigger_capture() : eosremoterelease ne genere FILE_ADDED qu'une fois
eosremoterelease='Immediate' envoyait GP_EVENT_FILE_ADDED uniquement
pour la 1ere pose (bug Canon/gphoto2 connu). trigger_capture() genere
l'evenement pour CHAQUE pose. En liveview actif (miroir deja leve),
trigger_capture() ne bouge pas le miroir — uniquement l'obturateur.
Timeout reduit 15s -> 12s, poll 500ms -> 200ms (reaction plus rapide).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 16:06:30 +02:00
46752bf29b Fix capture multi-pose : supp eosviewfinder (cause principale timeout)
- Retire eosviewfinder=1 avant/apres chaque prise : relancait le liveview
  (4-6s de blocage) → timeout 15s → ch=None → photos=[] → S_ACCUEIL
- Le miroir est deja leve par capture_preview() continu, l'obturateur
  seul se declenche via eosremoterelease=Immediate
- Logs detailles : chaque etape imprime son resultat pour debug
- Apres capture echouee : _prev_thumbs.append(None) pour garder index cohérent
- S_PREV 2.5s → 1.5s (plus reactif entre les poses)
- Apres capture : revient en S_PREV 0.6s pour montrer la vignette
- Print "Séquence terminée N/M photos" pour suivi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 15:22:30 +02:00
eb0634fab4 Fix captures 2/3 perdues + miroir + stabilite camera post-prise
- post_capture_grace=10 : ignore les erreurs -52 transitoires juste
  apres une prise (Canon EOS a besoin ~1s pour stabiliser le capteur)
- Threshold abandon capture : 3 -> 8 erreurs consecutives
- Threshold reconnexion : 5 -> 10 erreurs consecutives
- Avant chaque prise : eosviewfinder=1 pour garantir miroir leve
- Apres prise : viewfinder relance + sleep 1s + drain 8 evenements
- Sim auto-detect : pas de USB reset (evite boucle -52), verification
  fonctionnelle avec un capture_preview() avant de valider la camera
- Sim poll : 15s au lieu de 10s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 15:02:51 +02:00
529cb41581 Fix saccades LV + distorsion pellicule + draw_capture plein ecran
- Supprime sleep(0.3) apres chaque capture_preview() reussi
  (limitait le live view a ~3fps, cause principale des saccades)
- Fixe crash silencieux thread camera : _nb_retry non initialise
  pour errs 1-4 (NameError) -> restructure en for/else propre
- Pellicule live view : _pfit() au lieu de transform.scale() -> plus de distorsion
- draw_capture() : mode pellicule affiché au lieu de fullscreen black

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 11:15:46 +02:00
9c57c8f5f2 Bouton Quitter admin + boucle reconnexion caméra améliorée
- Admin onglet Système : bouton Quitter (gris) pour fermer l'appli
- Reconnexion caméra : max 10 tentatives (~30s) puis bascule en mode
  simulation ; le loop sim reprend la détection automatique toutes les 10s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:14:51 +02:00
d4ad1dec17 Fix déformation photos + miroir Canon EOS
- _pfit() : helper scale+crop centré pygame sans déformer
- ImageOps.fit() partout : vignettes, galerie, composite (recadrage centré
  au lieu de resize qui écrase)
- eosremoterelease 'Immediate' : déclenche l'obturateur Canon en restant
  en LV, évite la levée/baisse de miroir à chaque prise

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:49:46 +02:00
32e83250d2 USB reset aussi dans le loop de reconnexion automatique 2026-04-11 17:44:17 +02:00
5fc2f11bd4 Pellicule galerie + délai S_PREV + USB unbind/bind
- draw_galerie() : rendu pellicule (bande film avec perforations) quand le
  mode est actif
- _load_gal() : charge les surfs à la taille des cadres pellicule si actif
- S_PREV : 1.5s → 2.5s pour laisser le DSLR revenir en live view
- _usb_reset_done() : utilise unbind/bind sysfs au lieu de usbreset
  (unbind/bind = seule méthode qui libère réellement le claim USB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:43:10 +02:00
0a90ae1de6 Auto-détection DSLR + reset USB au démarrage et toutes les 10s
- _usb_reset_done() : reset USB via usbreset pour libérer tout claim
  obsolète avant d'initialiser libgphoto2 (fix erreur -53)
- Appelé au démarrage et dans _cam_loop() toutes les 10s en simulation
- _cam_loop() : log les erreurs d'auto-detect pour diagnostic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:40:10 +02:00
59370a4965 Fix pellicule compte à rebours + auto-détection DSLR
- draw_compte() pellicule : suppression de l'overlay global qui masquait la
  bande film pendant le compte à rebours
- _cam_loop() : ne quitte plus en mode simulation, tente une reconnexion
  automatique toutes les 8s si le DSLR est branché après démarrage
- _reconnect_cam() : fonctionne aussi depuis le mode simulation (bouton admin)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:34:05 +02:00
99ae7c785c Fix toggle pellicule_mode : premier clic activait en sens inverse
Le handler utilisait True comme défaut pour tous les toggles, ce qui faisait
que not f.get("pellicule_mode", True) = False au premier clic (toggle semblait
bloqué). Ajout d'un ensemble _false_defs pour les features à défaut False.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:24:49 +02:00
34f66bde1c Mode pellicule : bande style film 35mm activable dans l'admin
- Nouveau toggle "Mode pellicule" dans l'onglet Fonctions de l'admin
- _draw_pellicule() : bande noire cinéma avec perforations haut/bas,
  cadres numérotés (01A, 02A...) ; le live view est rendu dans le cadre
  courant, les photos prises occupent leur case, les futures sont en gris
- draw_preview() / draw_compte() : utilisent _draw_pellicule() quand activé
- _make_composite() : génère une bande simple (non doublée) en mode pellicule
- Thumbnails chargés à _PELL_FH (748px) pour la qualité native en pellicule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:17:43 +02:00
1c1f0323cc Multipose: composite upload + strip preview layout
- _make_composite(): crée une image bande (2 rangées) ou grille 2x2 à partir
  des photos prises, sauvée en /tmp/photobooth_composite.jpg
- _upload_booth(): upload le composite unique en mode multi au lieu des photos
  individuelles
- _draw_multi_strip(): overlay bas avec les cases numérotées — thumbnails pour
  les prises faites, cadre pulsant pour la case courante, placeholder gris pour
  les suivantes
- draw_preview() / draw_compte(): utilisent _draw_multi_strip() en mode multi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:08:17 +02:00
8ae42e1bc6 Backend: galerie session /s/{code} + upload API
Ajoute deux routes FastAPI :
- GET /s/{code} → galerie HTML mobile avec auto-refresh 15s
- POST /api/sessions/{code}/upload → stockage photo dans session_{code}/

Corrige layout admin : titre sm au lieu de lg, compteur sans chevauchement.
Wizard step 2 : code affiché en lg (96px) au lieu de xxl (300px).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:33:35 +02:00
a1a0ce0503 Scripts test liveview : pygame, pyqt6, opencv, tkinter - mesure FPS et premiere image 2026-04-09 12:42:50 +02:00
19d1998704 Suppression preview pendant capture : plus de conflit gphoto2, capture propre 2026-04-09 11:25:17 +02:00
a3c7d6e4e4 Capture en parallele du compte a rebours : photo prise pendant le 3 2 1 2026-04-09 11:21:19 +02:00
c31cab8dc0 Script bench camera : mesure temps preview et capture gphoto2 2026-04-09 11:14:06 +02:00
0d24c8af26 Kiosk PyQt6 WebEngine : alternative navigateur, process unique, auto-reload 2026-04-09 07:46:39 +02:00
b4ec4dbf3f Fix race condition gphoto2 : lock non-bloquant preview cede la place a la capture 2026-04-09 07:28:33 +02:00
57ac16d366 Retrait viewfinder : priorite a la capture fonctionnelle 2026-04-09 02:05:32 +02:00
95f1a597e6 Fix critique : camera.capturer() dans run_in_executor pour ne pas bloquer asyncio 2026-04-09 02:03:58 +02:00
718d17a49c Auto-reload page si erreur camera (10s) ou WS deconnecte (5s) 2026-04-09 02:01:35 +02:00
db751ca48c Retrait verrou camera et threading : revert approche simple sans deadlock 2026-04-09 02:00:11 +02:00
03102c803e Fix blocage asyncio viewfinder + bouton retour sur erreur camera 2026-04-09 01:54:46 +02:00
4273caf1ee Fix race condition gphoto2 : verrou camera sur preview et capture 2026-04-09 01:49:48 +02:00
076e6d0030 Canon LiveView : activer viewfinder gphoto2 avant capture_preview 2026-04-09 01:44:46 +02:00
a59f0ef766 Fix freeze liveview : bloquer surveiller_dslr pendant preview (conflit gphoto2) 2026-04-09 01:38:59 +02:00
94e3d68dd7 Preview DSLR : resize 960px + base64 hors asyncio pour fluidifier liveview 2026-04-09 01:36:20 +02:00
8e7fb61e90 Logo SikaPics : width forcee a 32vw (double taille naturelle 310px) 2026-04-09 01:27:16 +02:00
f4662807d4 Logo SikaPics 200% de la taille precedente (90vh->180vh) 2026-04-09 01:15:46 +02:00
1926e8fcf7 Logo SikaPics 200% (45vh->90vh) + QR code 150% (180->270px)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:46:26 +02:00
b871fc4c3b 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>
2026-04-08 22:21:29 +02:00
9a6ede02f6 Supprimer tout fallback webcam/simulation quand le DSLR est absent
- Preview gphoto2 echoue -> None direct, pas de webcam ni simulation
- Connexion sans DSLR -> mode erreur, camera non connectee
- Suppression _preview_webcam_fallback et _init_preview_fallback
- WS preview : appel direct camera.preview(), plus de logique speciale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:17:20 +02:00
deca890b34 Admin: onglet Infos systeme (reseau, RAM, disque, temp, camera)
- Endpoint /api/systeme/info : IPs par interface, hostname, uptime,
  disque, RAM, temperature CPU, version Python, statut camera
- Onglet Infos dans le backoffice avec affichage visuel (barres, couleurs)
- Bouton Actualiser pour rafraichir les donnees en temps reel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:55:34 +02:00
cf41f66367 Gestion erreurs capture : messages clairs a chaque etape
- Verification etat camera AVANT le lancement (pas de DSLR = erreur immediate)
- Detection erreur camera PENDANT le compte a rebours (pas de capture aveugle)
- Capture : erreur reseau catchee, retry x3, message explicite si echec
- Limite de photos : message + detail au lieu de retour accueil silencieux
- Strip/collage : verification resultat, message si generation echoue
- traiterCapture : try/catch global, plus de retour accueil silencieux
- Overlay erreur : titre + detail lisible, 4s avant retour accueil

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:47:58 +02:00
f9fd4c4f22 Fix erreur DSLR: detection immediate + message clair a l'ecran
- Backend: detecte quand le preview DSLR echoue (fallback silencieux)
  et envoie camera_erreur au frontend (debounce 5s)
- Frontend: overlay d'erreur avec message si preview DSLR indisponible
- Frontend: si capture echoue, affiche le motif 3s avant retour accueil
  au lieu de retourner silencieusement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:46:46 +02:00
ef6679dc97 Preview: overlay erreur si DSLR deconnecte pendant la capture
- Overlay semi-transparent sur le preview quand camera_erreur recue
- Timeout 3s : si aucune frame recue, affiche l'erreur automatiquement
- Disparait des que les frames reprennent (camera_ok ou nouvelle frame)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:42:05 +02:00
c27b7152d6 Theme Plymouth: chat photographe anime au boot
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:27:52 +02:00
5de771b9f9 Erreur camera: overlay plein ecran + pas de fallback webcam
Page splash au boot pour eviter l'erreur Firefox

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:07:09 +02:00
d719f4c100 Fix surveillance DSLR: detecte perte de connexion et reconnecte
- Teste get_summary() pour verifier que le DSLR repond
- Reconnecte apres 2 echecs consecutifs
- Fallback webcam si DSLR plus disponible

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:05:20 +02:00
cb102c9e4b Preview webcam pendant capture DSLR (pas de freeze entre les poses)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:37:38 +02:00
7df190b5bc Preview: fallback webcam quand le DSLR ne supporte pas le live view
Le Canon 6D ne supporte pas capture_preview() via gphoto2.
Utilise la webcam embarquee pour le preview tout en gardant le DSLR pour la capture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:33:59 +02:00
eababa9ec8 Photos DSLR: 4000px max, JPEG 92% (~1.5-2 Mo)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:30:44 +02:00
61b9749ab7 Reduire resolution max photos DSLR a 2000px (plus rapide)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:30:15 +02:00
820dd40545 Logo 45vh + boutons photo/pellicule beaucoup plus gros (8rem icones, padding 3/5rem)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:28:37 +02:00
8975b0392f Fix multi-shot: countdown entre chaque photo + vider apercu precedent
- Preview relancee avant chaque countdown (l'utilisateur se voit)
- Countdown 3-2-1 entre chaque photo de la serie
- Apercu precedent vide au debut d'une nouvelle session
- Surveillance DSLR pausee pendant les captures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:06:52 +02:00
c2b6b01f4f Auto-detection DSLR: bascule automatique quand branche/rebranche
- Verification toutes les 5s si un DSLR est disponible
- Bascule auto de webcam/simulation vers DSLR
- Reconnexion auto si le DSLR est deconnecte

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:05:24 +02:00
0f83c7a6b7 Retry capture frontend (3 tentatives) + ne pas abandonner sur erreur I/O
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:03:32 +02:00
3c308085af Redimensionner photos DSLR (max 3000px, JPEG 92%) pour affichage rapide
Les RAW du Canon 6D font 24Mo, trop lent pour le navigateur.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:29:31 +02:00
79b0084178 Fix capture DSLR: pause avant capture + retry si I/O in progress
- Frontend: arrete le preview 300ms avant la capture
- Backend: 3 tentatives avec 500ms entre chaque si DSLR occupe

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:27:54 +02:00
053e5dc7b4 Countdown transparent par dessus la preview live + texte blanc avec ombre
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:23:08 +02:00
9208c00d3e Fix API gphoto2 pour python-gphoto2 v2.5+ (suppression contexte)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:18:16 +02:00
4451c81ddd Fix liste appareils admin (objets source/nom au lieu de strings)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:14:59 +02:00
60cc5dc1d0 Logo 80vw/30vh
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:07:53 +02:00
6ec2e79d20 Logo accueil agrandi (500x200px max)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:07:27 +02:00
2c5431f9b2 Fix logo SikaPics (vrai PNG 310x201)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:03:55 +02:00
4b216d2692 Desactiver le cache navigateur pour les assets
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:58:56 +02:00
ec220079e0 Logo SikaPics via balise img (fix ::before invisible)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:53:10 +02:00
8b7a00b27e Logo SikaPics local + QR 180px + clavier virtuel email + QR conditionnel booth
- Logo SikaPics servi localement (/assets/logos/sikapics.png)
- QR code accueil agrandi a 180px
- Clavier AZERTY virtuel integre pour saisie email (tactile)
- Raccourcis @gmail, @hotmail, .com, .fr
- QR partage: affiche seulement si booth actif, lien vers galerie live
- Bouton QR visible si booth OU qr_code active

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:50:01 +02:00
d2ae366e2f Boutons Recharger + Quitter sur l'ecran de choix admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:42:16 +02:00
d49097a60d Fix: setupModes ne cible que les boutons de l'ecran mode (pas admin)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:38:07 +02:00
f8d83d8481 Fix clics qui traversent les ecrans (z-index + background opaque)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:29:56 +02:00
c691c4c191 Bouton quitter le navigateur dans admin > Systeme
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:28:26 +02:00
7d6e4624ca Ecran de choix admin : Nouvel evenement (wizard) ou Parametres
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:14:47 +02:00
229a388a1e Wizard evenement tactile + menu admin simplifie + bouton update + logo SikaPics
- Wizard: 6 etapes (nom, theme, modes, nb photos, partage, resume)
- Gros boutons tactiles, une question par ecran
- Parametres avances accessibles depuis le wizard
- Bouton "Mettre a jour l'affichage" dans Fonctions > Systeme
- Logo SikaPics dans le theme sikapics
- Menu admin reorganise: Materiel / Compteur / Destinations / Personnalisation / Evenement / Fonctions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:12:51 +02:00
626db8905a Systeme de themes : Base (sombre) + SikaPics (rose elegant)
- themes.css : variables et overrides par theme via data-theme
- SikaPics : palette rose/violet, fond clair, typo Montserrat/Dancing Script
- Selecteur de theme dans admin > Evenement
- Preview live du theme dans l'admin

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:06:59 +02:00
05e2c8318b QR code booth agrandi (120px)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:59:58 +02:00
19768edd43 Strip pellicule noir + bandes verticales noires entre photos
- Fond noir (pellicule)
- Separateurs noirs entre les photos
- Plus large (utilise toute la hauteur disponible)
- Perforations grises sur bord noir

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:59:14 +02:00
99d2dc8f56 Onglet admin Galerie Live (booth) + config booth sur la borne
- Onglet Galerie Live dans admin : activer, URL, API key, event ID
- Affichage du code invites + bouton regenerer
- QR code + code sur ecran d'accueil (si active)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:52:10 +02:00
f0584e8fdf Galerie live booth.copydev.fr + icones agrandies + pellicule
- booth/: serveur Node.js galerie photo live avec auth par code 4 chiffres
- Backend: envoi auto des photos vers booth, endpoint QR dynamique
- Frontend: code + QR booth sur ecran accueil, icone pellicule pour multi-shot
- Icones mode agrandies (5rem)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:27:05 +02:00
e583126f85 Strip pellicule horizontal 4 photos + impression 2 bandes 10x15 paysage
- Strip: 4 photos cote a cote style pellicule avec perforations
- Impression: 2 bandes identiques sur 10x15 paysage
- Apercu: montre une seule bande, imprime les 2
- Multi-shot: defaut 4 photos au lieu de 3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:55:12 +02:00
8d105638c3 Compteur photos positionne en bas d'ecran (hors du contenu centre)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:41:04 +02:00
85ab7b8153 Support webcam (OpenCV) + optimisation tactile
- Camera: support webcam v4l2 via OpenCV en plus de gphoto2
- API: selection de source camera (webcam:0, gphoto2, etc.)
- CSS: boutons agrandis pour usage tactile (min 48px)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:51:21 +02:00
8418d481a5 Pave numerique tactile dans la popup admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:11:08 +02:00
c919c420b7 Fix accolade manquante dans app.js (SyntaxError)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:04:34 +02:00
109 changed files with 14494 additions and 607 deletions

2
.gitignore vendored
View File

@@ -16,3 +16,5 @@ data/exports/*
!data/exports/.gitkeep
*.log
.DS_Store
printer-driver-*.deb
data/config.json

135
PROCEDURE_VPN_FIX.md Normal file
View File

@@ -0,0 +1,135 @@
---
name: booth-vpn-fix
description: Procédure urgente pour rétablir le tunnel WireGuard sur la Surface (booth terrain) et installer un watchdog
metadata:
node_type: memory
type: project
originSessionId: 6d8a34f4-db2f-4d5b-92e9-895e3925ccb5
---
## Problème
Le booth (Surface) est sur un WiFi externe, le tunnel WireGuard ne se connecte pas même après reboot.
Dernier handshake : 6+ jours. Aucun accès SSH ni VPN.
**Why:** Le service wg-quick@wg0 n'est peut-être pas enabled au boot, ou l'Endpoint dans la config WG pointe vers une IP locale (192.168.111.x) au lieu de l'IP publique.
## Infos serveur WG (LXC 111)
- IP LXC : 192.168.111.211
- Port WG : 51820 (UDP) — écoute OK
- Clé publique serveur : `g3s7Gh1Er+LIEI/W91HAVDNIo+gsk/KiajdtoDUQRVo=`
- Le port 51820 est redirigé depuis la box vers le LXC
## Étapes à faire sur la Surface (SSH ou clavier physique)
### 1. Diagnostic
```bash
sudo systemctl status wg-quick@wg0
sudo wg show
cat /etc/wireguard/wg0.conf
```
### 2. Vérifier l'Endpoint
Dans `/etc/wireguard/wg0.conf`, le `[Peer]` doit avoir :
```
Endpoint = <IP_PUBLIQUE_MAISON>:51820
```
**PAS** une IP locale type 192.168.111.x — ça ne marche que depuis le LAN.
Pour trouver l'IP publique depuis le PC fixe :
```bash
curl -s ifconfig.me
```
### 3. Activer au boot + relancer
```bash
sudo systemctl enable wg-quick@wg0
sudo systemctl restart wg-quick@wg0
sudo wg show
```
Vérifier que le handshake se fait (latest handshake < 1 minute).
### 4. Installer le watchdog WG (une fois le tunnel rétabli)
Créer `/usr/local/bin/wg-watchdog.sh` :
```bash
#!/bin/bash
IFACE=wg0
MAX_AGE=180 # 3 minutes sans handshake = relance
latest=$(sudo wg show $IFACE latest-handshakes 2>/dev/null | awk '{print $2}')
if [ -z "$latest" ] || [ "$latest" = "0" ]; then
systemctl restart wg-quick@$IFACE
exit 0
fi
now=$(date +%s)
age=$((now - latest))
if [ $age -gt $MAX_AGE ]; then
logger "wg-watchdog: handshake age ${age}s > ${MAX_AGE}s, restarting $IFACE"
systemctl restart wg-quick@$IFACE
fi
```
```bash
sudo chmod +x /usr/local/bin/wg-watchdog.sh
echo "* * * * * root /usr/local/bin/wg-watchdog.sh" | sudo tee /etc/cron.d/wg-watchdog
```
### 5. Installer reverse SSH en fallback
```bash
sudo apt install -y autossh
```
Créer `/etc/systemd/system/autossh-tunnel.service` :
```ini
[Unit]
Description=Reverse SSH tunnel fallback
After=network-online.target
Wants=network-online.target
[Service]
User=jules
ExecStart=/usr/bin/autossh -M 0 -N -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" -o "ExitOnForwardFailure yes" -R 2222:localhost:22 root@192.168.111.211
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable autossh-tunnel
sudo systemctl start autossh-tunnel
```
Ensuite depuis le LXC 111 : `ssh -p 2222 jules@localhost` pour joindre la Surface même si WG est mort.
**Prérequis :** la clé SSH de jules@surface doit être dans authorized_keys de root@192.168.111.211 (LXC).
### 6. Déployer les dernières modifs
Une fois le tunnel rétabli :
```bash
cd /home/jules/photobooth && git pull && sudo systemctl restart photobooth
```
Commits non déployés sur Surface :
- Flash blanc capture + printer toast CSS + admin galerie + compteur copies
- Cache versions bump
## Changements faits sur LXC 111 (cette session)
- Landing page vitrine complète (tarifs, formulaire résa, calendrier dispo)
- Endpoint `/api/contact` : crée événement + email admin (pas client)
- Endpoint `/api/contact/valider/{id}` : valide résa + envoie email client
- Endpoint `/api/disponibilites` : dates occupées pour calendrier
- ZIP streaming (fix 500 sur gros ZIP)
- Barre progression téléchargement ZIP
- Admin panel : section "Demandes en attente" avec bouton Valider

View File

@@ -1,10 +1,15 @@
import io
import logging
import subprocess
import threading
import time
from datetime import datetime
import numpy as np
from pathlib import Path
import cv2
from backend.config import DOSSIER_PHOTOS, charger_config
log = logging.getLogger("photobooth.camera")
@@ -15,46 +20,143 @@ try:
GPHOTO2_DISPONIBLE = True
except ImportError:
GPHOTO2_DISPONIBLE = False
log.warning("python-gphoto2 non installe, mode simulation active")
log.warning("python-gphoto2 non installe")
def lister_webcams() -> list[dict]:
"""Liste les webcams disponibles via v4l2."""
webcams = []
try:
result = subprocess.run(
["v4l2-ctl", "--list-devices"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
nom = ""
for line in lines:
if not line.startswith("\t"):
nom = line.strip().rstrip(":")
elif "/dev/video" in line:
dev = line.strip()
# Ne garder que les devices principaux (pas metadata)
idx = int(dev.replace("/dev/video", ""))
cap = cv2.VideoCapture(idx)
if cap.isOpened():
cap.release()
webcams.append({"nom": nom, "device": dev, "index": idx})
nom = "" # Eviter les doublons
except (FileNotFoundError, subprocess.TimeoutExpired):
# v4l2-ctl non installe, fallback
for i in range(4):
cap = cv2.VideoCapture(i)
if cap.isOpened():
cap.release()
webcams.append({"nom": f"Camera {i}", "device": f"/dev/video{i}", "index": i})
return webcams
class Camera:
"""Controle de l'appareil DSLR via gphoto2."""
"""Controle camera : DSLR (gphoto2), webcam (OpenCV) ou simulation."""
def __init__(self):
self.camera = None
self.contexte = None
self.connectee = False
self.mode = "simulation" # "gphoto2", "webcam", "simulation"
self.webcam = None
self.webcam_index = -1
self.preview_dslr_ok = True # True si le dernier preview DSLR a reussi
self._gp_lock = threading.Lock()
def connecter(self) -> bool:
"""Connecte l'appareil photo."""
if not GPHOTO2_DISPONIBLE:
log.info("Mode simulation : camera virtuelle connectee")
def connecter(self, source=None) -> bool:
"""Connecte la camera. source: 'gphoto2', 'webcam:0', 'webcam:2', etc."""
self.deconnecter()
if source and source.startswith("webcam:"):
idx = int(source.split(":")[1])
return self._connecter_webcam(idx)
if source == "gphoto2" or source is None:
if GPHOTO2_DISPONIBLE:
cam = None
try:
cam = gp.Camera()
try:
cam.exit()
except Exception:
pass
cam.init()
self.camera = cam
time.sleep(1)
self._configurer_init()
self.connectee = True
self.mode = "gphoto2"
self.preview_dslr_ok = True
self._log_reglages()
log.info("Camera DSLR connectee (LiveView actif)")
return True
except Exception as e:
log.warning(f"Pas de DSLR : {e}")
if cam is not None:
def _force_release(c):
try:
c.exit()
except Exception:
pass
t = threading.Thread(target=_force_release, args=(cam,), daemon=True)
t.start()
t.join(timeout=2.0)
del cam
self.camera = None
# Pas de DSLR disponible : mode erreur, pas de fallback
log.warning("Aucun DSLR disponible, camera non connectee")
self.connectee = False
self.mode = "erreur"
return False
def _connecter_webcam(self, index: int) -> bool:
"""Connecte une webcam via OpenCV."""
cap = cv2.VideoCapture(index)
if cap.isOpened():
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
self.webcam = cap
self.webcam_index = index
self.connectee = True
self.mode = "webcam"
log.info(f"Webcam connectee : /dev/video{index}")
return True
log.error(f"Impossible d'ouvrir /dev/video{index}")
return False
try:
self.contexte = gp.Context()
self.camera = gp.Camera()
self.camera.init(self.contexte)
self.connectee = True
log.info("Camera DSLR connectee")
return True
except gp.GPhoto2Error as e:
log.error(f"Erreur connexion camera : {e}")
self.connectee = False
return False
def deconnecter(self):
"""Deconnecte l'appareil photo."""
if self.camera and GPHOTO2_DISPONIBLE:
try:
self.camera.exit(self.contexte)
except gp.GPhoto2Error:
pass
def deconnecter(self, timeout: float = 3.0):
"""Deconnecte l'appareil photo avec timeout pour eviter le blocage PTP."""
cam_ref = self.camera
ctx_ref = self.contexte
if cam_ref and GPHOTO2_DISPONIBLE:
def _exit():
try:
cam_ref.exit()
except Exception:
pass
t = threading.Thread(target=_exit, daemon=True)
t.start()
t.join(timeout=timeout)
if t.is_alive():
log.warning("camera.exit() bloque — force del camera/contexte pour liberer le FD USB")
if self.webcam:
self.webcam.release()
self.camera = None
self.contexte = None
del cam_ref
del ctx_ref
self.webcam = None
self.webcam_index = -1
self.connectee = False
self.mode = "simulation"
self.preview_dslr_ok = False
log.info("Camera deconnectee")
def capturer(self) -> Path | None:
@@ -67,47 +169,274 @@ class Camera:
nom_fichier = f"photo_{horodatage}.jpg"
chemin_dest = DOSSIER_PHOTOS / nom_fichier
if not GPHOTO2_DISPONIBLE:
if self.mode == "webcam":
return self._capture_webcam(chemin_dest)
elif self.mode == "gphoto2":
return self._capture_gphoto2(chemin_dest)
else:
return self._capture_simulation(chemin_dest)
try:
chemin_camera = self.camera.capture(gp.GP_CAPTURE_IMAGE, self.contexte)
fichier_camera = self.camera.file_get(
chemin_camera.folder, chemin_camera.name, gp.GP_FILE_TYPE_NORMAL, self.contexte
)
fichier_camera.save(str(chemin_dest))
log.info(f"Photo capturee : {chemin_dest}")
return chemin_dest
except gp.GPhoto2Error as e:
log.error(f"Erreur capture : {e}")
def _capture_gphoto2(self, chemin_dest: Path) -> Path | None:
from PIL import Image as PILImage
for attempt in range(3):
try:
with self._gp_lock:
if not self.camera:
log.error("Camera None pendant capture")
self.preview_dslr_ok = False
return None
try:
cfg = self.camera.get_config()
vf = cfg.get_child_by_name("viewfinder")
vf.set_value(0)
self.camera.set_config(cfg)
time.sleep(0.8)
except Exception:
pass
log.info(f"Déclenchement capture DSLR (tentative {attempt+1}/3)")
t0 = time.time()
chemin_camera = self.camera.capture(gp.GP_CAPTURE_IMAGE)
t1 = time.time()
fichier_camera = gp.CameraFile()
self.camera.file_get(
chemin_camera.folder, chemin_camera.name, gp.GP_FILE_TYPE_NORMAL, fichier_camera
)
t2 = time.time()
tmp_path = str(chemin_dest) + ".tmp"
fichier_camera.save(tmp_path)
log.info(f"Capture: shutter={t1-t0:.1f}s download={t2-t1:.1f}s")
threading.Thread(target=self._post_capture_warmup, daemon=True).start()
from PIL import ImageOps as PILImageOps
t3 = time.time()
pil_img = PILImage.open(tmp_path)
exif_data = pil_img.info.get("exif")
img = PILImageOps.exif_transpose(pil_img)
if img.width > 4000:
ratio = 4000 / img.width
img = img.resize((4000, int(img.height * ratio)), PILImage.BILINEAR)
save_kwargs = {"quality": 92}
if exif_data:
save_kwargs["exif"] = exif_data
img.save(str(chemin_dest), "JPEG", **save_kwargs)
t4 = time.time()
log.info(f"Post-process: {t4-t3:.1f}s")
Path(tmp_path).unlink(missing_ok=True)
log.info(f"Photo capturee (DSLR) : {chemin_dest} ({img.width}x{img.height})")
return chemin_dest
except gp.GPhoto2Error as e:
if "I/O in progress" in str(e) and attempt < 2:
log.warning(f"DSLR occupe, retry {attempt + 1}/3...")
time.sleep(0.5)
continue
log.error(f"Erreur capture DSLR : {e}")
self.preview_dslr_ok = False
return None
except Exception as e:
log.error(f"Erreur fatale capture DSLR : {e}")
self.preview_dslr_ok = False
return None
def _post_capture_warmup(self):
self.activer_viewfinder()
self._chauffer_liveview()
def _chauffer_liveview(self, tentatives: int = 8, pause: float = 0.15):
"""Consomme les premieres frames LiveView juste apres la remontee du miroir
(souvent en erreur ~1-2s le temps que l'AF/capteur se stabilise), pendant
que le spinner de traitement est affiche — pour que le preview du prochain
countdown soit deja a jour au lieu de rester figé sur l'ancienne photo."""
for _ in range(tentatives):
try:
with self._gp_lock:
self.camera.capture_preview()
return
except Exception:
time.sleep(pause)
def _capture_webcam(self, chemin_dest: Path) -> Path | None:
if not self.webcam or not self.webcam.isOpened():
return None
ret, frame = self.webcam.read()
if ret:
cv2.imwrite(str(chemin_dest), frame, [cv2.IMWRITE_JPEG_QUALITY, 95])
log.info(f"Photo capturee (webcam) : {chemin_dest}")
return chemin_dest
log.error("Erreur capture webcam")
return None
def preview(self) -> bytes | None:
"""Capture un apercu live (preview) en JPEG."""
if not self.connectee:
return None
if not GPHOTO2_DISPONIBLE:
return self._preview_simulation()
if self.mode == "webcam":
return self._preview_webcam()
elif self.mode == "gphoto2":
data = self._preview_gphoto2()
self.preview_dslr_ok = data is not None
return data # None si le DSLR ne repond pas, pas de fallback
else:
return None # Pas de simulation ni webcam en dehors du mode dedie
def configurer_flash(self, actif: bool):
"""Active ou désactive le flash intégré Canon.
Essaie plusieurs noms de widgets selon le modèle."""
if not self.camera or self.mode != "gphoto2":
return
# strobofiring : "0"=inhibé(off), "2"=auto — Canon EOS 60D (widget radio, valeurs string)
# flashmode : "On"/"Off" — autres Canon
candidats = [
("strobofiring", "2", "0"), # (widget, valeur_on, valeur_off)
("flashmode", "On", "Off"),
]
try:
fichier = self.camera.capture_preview(self.contexte)
donnees = fichier.get_data_and_size()
return bytes(donnees)
except gp.GPhoto2Error as e:
log.error(f"Erreur preview : {e}")
with self._gp_lock:
cfg = self.camera.get_config()
for nom, val_on, val_off in candidats:
try:
widget = cfg.get_child_by_name(nom)
widget.set_value(val_on if actif else val_off)
self.camera.set_config(cfg)
log.info(f"Flash intégré ({nom}) : {'On' if actif else 'Off'}")
return
except Exception:
pass
log.debug("Aucun widget flash trouvé sur ce modèle Canon")
except Exception as e:
log.debug(f"configurer_flash : {e}")
def _configurer_init(self):
"""Configure le Canon : veille OFF, ISO, drivemode, viewfinder (appels séparés)."""
reglages = [
("autopoweroff", 0),
("viewfinder", 1),
("iso", "800"),
("aperture", "4"),
("drivemode", "Single"),
]
for nom, valeur in reglages:
for tentative in range(3):
try:
cfg = self.camera.get_config()
w = cfg.get_child_by_name(nom)
w.set_value(valeur)
self.camera.set_config(cfg)
log.info(f"Canon init: {nom} = {valeur}")
break
except gp.GPhoto2Error as e:
if tentative < 2:
time.sleep(0.5)
else:
log.warning(f"Canon init {nom}: {e}")
except Exception:
break
def _log_reglages(self):
"""Log les réglages Canon actuels pour diagnostic."""
try:
cfg = self.camera.get_config()
vals = {}
for nom in ["autoexposuremode", "iso", "shutterspeed", "aperture", "meteringmode"]:
try:
w = cfg.get_child_by_name(nom)
vals[nom] = w.get_value()
log.info(f"Canon actuel: {nom} = {vals[nom]}")
except Exception:
pass
mode = vals.get("autoexposuremode", "")
if mode in ("Flash Off", "Auto", "Night Portrait", "Landscape", "Portrait", "Sports"):
log.warning(f"Canon en mode scène '{mode}' — ISO/vitesse non modifiables. Tourner le dial sur M ou Av.")
except Exception:
pass
def activer_viewfinder(self):
"""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)
self.camera.set_config(cfg)
log.info("Viewfinder activé")
except Exception as e:
log.warning(f"Viewfinder non supporté : {e}")
def keepalive(self) -> bool:
"""Envoie une commande legere au Canon pour empecher l'auto power off.
Utilise get_config qui declenche internement ptp_canon_eos_keepdeviceon."""
if self.mode != "gphoto2" or not self.connectee or not self.camera:
return False
try:
with self._gp_lock:
self.camera.get_config()
return True
except Exception:
return False
def _preview_gphoto2(self) -> bytes | None:
try:
with self._gp_lock:
if not self.camera:
return None
fichier = self.camera.capture_preview()
donnees = bytes(fichier.get_data_and_size())
img = cv2.imdecode(np.frombuffer(donnees, dtype=np.uint8), cv2.IMREAD_COLOR)
if img is not None:
h, w = img.shape[:2]
if w > 960:
img = cv2.resize(img, (960, int(h * 960 / w)), interpolation=cv2.INTER_LINEAR)
_, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 75])
return buf.tobytes()
return donnees
except Exception as e:
log.error(f"Erreur preview DSLR : {e}")
return None
def lister_appareils(self) -> list[str]:
"""Liste les appareils photo detectes."""
if not GPHOTO2_DISPONIBLE:
return ["[Simulation] Camera virtuelle"]
def _preview_webcam(self) -> bytes | None:
if not self.webcam or not self.webcam.isOpened():
return None
ret, frame = self.webcam.read()
if ret:
_, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 70])
return buf.tobytes()
return None
try:
appareils = gp.Camera.autodetect(self.contexte or gp.Context())
return [f"{nom} ({port})" for nom, port in appareils]
except gp.GPhoto2Error:
return []
def lister_appareils(self) -> list[dict]:
"""Liste tous les appareils disponibles (DSLR + webcams)."""
appareils = []
# Webcams
for wc in lister_webcams():
appareils.append({
"nom": wc["nom"],
"source": f"webcam:{wc['index']}",
"type": "webcam"
})
# DSLR via gphoto2
if GPHOTO2_DISPONIBLE:
try:
dslrs = gp.Camera.autodetect()
for nom, port in dslrs:
appareils.append({
"nom": f"{nom} ({port})",
"source": "gphoto2",
"type": "dslr"
})
except gp.GPhoto2Error:
pass
if not appareils:
appareils.append({
"nom": "Simulation",
"source": "simulation",
"type": "simulation"
})
return appareils
def _capture_simulation(self, chemin: Path) -> Path:
"""Genere une image de test en mode simulation."""
@@ -117,7 +446,6 @@ class Camera:
draw = ImageDraw.Draw(img)
texte = f"SIMULATION\n{datetime.now().strftime('%H:%M:%S')}"
draw.text((960, 640), texte, fill=(255, 255, 255), anchor="mm")
# Dessiner un cadre
draw.rectangle([50, 50, 1870, 1230], outline=(233, 30, 99), width=4)
img.save(chemin, "JPEG", quality=95)
log.info(f"Photo simulation : {chemin}")

View File

@@ -4,72 +4,63 @@ from pathlib import Path
from PIL import Image, ImageDraw
from backend.config import DOSSIER_EXPORTS, charger_config
from backend.effects import appliquer_cadre_impression
log = logging.getLogger("photobooth.collage")
# Format 10x15cm a 300dpi
LARGEUR_10x15 = 1200
HAUTEUR_10x15 = 1800
# Format 10x15cm paysage
LARGEUR_10x15_L = 1800
HAUTEUR_10x15_L = 1200
def _crop_center(img: Image.Image, ratio_cible: float) -> Image.Image:
"""Recadre une image au centre pour correspondre au ratio cible."""
ratio_img = img.width / img.height
if ratio_img > ratio_cible:
# Trop large, couper les cotes
nouvelle_largeur = int(img.height * ratio_cible)
offset = (img.width - nouvelle_largeur) // 2
return img.crop((offset, 0, offset + nouvelle_largeur, img.height))
else:
# Trop haut, couper haut/bas
nouvelle_hauteur = int(img.width / ratio_cible)
offset = (img.height - nouvelle_hauteur) // 2
return img.crop((0, offset, img.width, offset + nouvelle_hauteur))
def creer_strip(photos: list[Path], marge: int = 30, couleur_fond: str = "#ffffff") -> Path:
"""Cree une bande verticale (strip) au format 10x15cm."""
def creer_strip(photos: list[Path], marge: int = 10, couleur_fond: str = "#000000",
cadre: str | None = None) -> Path:
"""Cree une bande portrait style pellicule (photos empilees de haut en bas).
Pellicule noire avec perforations sur les cotes, photos separees par des
bandes noires. Taille fixe 600x1800px (5x15cm a 300dpi) : c'est le format
attendu par l'imprimante en mode "10x15-2up" (PageSize ...-div2), qui
duplique et decoupe automatiquement la bande en 2 exemplaires sur une
feuille 10x15. Doit rester strictement portrait (largeur < hauteur) et a
cette taille exacte pour eviter toute rotation/etirement a l'impression.
"""
nb = len(photos)
images = [Image.open(p) for p in photos]
# Calculer la taille de chaque cellule dans le format 10x15
espace_disponible_h = HAUTEUR_10x15 - marge * (nb + 1)
hauteur_cellule = espace_disponible_h // nb
largeur_cellule = LARGEUR_10x15 - marge * 2
ratio_cellule = largeur_cellule / hauteur_cellule
# Dimensions de la bande : format fixe attendu par printer.py (FORMATS["10x15-2up"])
bande_largeur = 600
bande_hauteur = 1800
# Recadrer et redimensionner chaque photo
images_redim = []
for img in images:
img_crop = _crop_center(img, ratio_cellule)
img_redim = img_crop.resize((largeur_cellule, hauteur_cellule), Image.LANCZOS)
images_redim.append(img_redim)
# Zone perforations pellicule (gauche et droite). Cette zone noire est aussi
# la marge de securite pour la coupe physique du K60 : la feuille pleine
# (2 bandes cote a cote) est coupee au centre apres impression, et la coupe
# n'est jamais parfaitement precise. Une marge trop fine fait que la coupe
# tombe dans la photo d'un cote (pellicule absente d'un cote, visible de
# l'autre). Elargie a 55px (~4.6mm) pour absorber le jeu mecanique du cutter.
perf_largeur = 55
zone_photos_largeur = bande_largeur - perf_largeur * 2
# Assembler sur le canvas 10x15
strip = Image.new("RGB", (LARGEUR_10x15, HAUTEUR_10x15), couleur_fond)
y = marge
for img in images_redim:
strip.paste(img, (marge, y))
y += hauteur_cellule + marge
nom = f"strip_{photos[0].stem}.jpg"
chemin = DOSSIER_EXPORTS / nom
strip.save(chemin, "JPEG", quality=95, dpi=(300, 300))
log.info(f"Strip 10x15 cree : {chemin} ({nb} photos)")
return chemin
def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 25,
couleur_fond: str = "#ffffff") -> Path:
"""Cree un collage en grille au format 10x15cm."""
nb = len(photos)
images = [Image.open(p) for p in photos]
lignes = (nb + colonnes - 1) // colonnes
# Calculer la taille de chaque cellule
largeur_cellule = (LARGEUR_10x15 - marge * (colonnes + 1)) // colonnes
hauteur_cellule = (HAUTEUR_10x15 - marge * (lignes + 1)) // lignes
# Taille de chaque photo dans la bande (separees par bandes noires)
sep = 12 # separateur noir entre photos
espace_photos = bande_hauteur - marge * 2 - sep * (nb - 1)
hauteur_cellule = espace_photos // nb
largeur_cellule = zone_photos_largeur
ratio_cellule = largeur_cellule / hauteur_cellule
# Recadrer et redimensionner
@@ -79,7 +70,85 @@ def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 25,
img_redim = img_crop.resize((largeur_cellule, hauteur_cellule), Image.LANCZOS)
images_redim.append(img_redim)
# Assembler sur le canvas 10x15
# Creer la bande (fond noir = pellicule)
strip = Image.new("RGB", (bande_largeur, bande_hauteur), (20, 20, 20))
draw = ImageDraw.Draw(strip)
# Bords pellicule noirs (deja le fond)
# Perforations pellicule (gauche) - trous gris dans le bord noir, centrees
# dans la marge (laisse du noir plein des 2 cotes pour la marge de coupe)
trou_largeur = 26
trou_x = (perf_largeur - trou_largeur) // 2
for py in range(marge + 10, bande_hauteur - marge, 44):
draw.rounded_rectangle(
[trou_x, py, trou_x + trou_largeur, py + 22],
radius=5, fill=(60, 60, 60)
)
# Perforations pellicule (droite)
for py in range(marge + 10, bande_hauteur - marge, 44):
draw.rounded_rectangle(
[bande_largeur - perf_largeur + trou_x, py, bande_largeur - perf_largeur + trou_x + trou_largeur, py + 22],
radius=5, fill=(60, 60, 60)
)
# Coller les photos (separees par des bandes noires horizontales)
y = marge
for img in images_redim:
strip.paste(img, (perf_largeur, y))
y += hauteur_cellule + sep
if cadre:
strip = appliquer_cadre_impression(strip, "strip", cadre)
nom = f"strip_{photos[0].stem}.jpg"
chemin = DOSSIER_EXPORTS / nom
strip.save(chemin, "JPEG", quality=95, dpi=(300, 300))
log.info(f"Strip pellicule cree : {chemin} ({nb} photos)")
return chemin
def creer_impression_strip(chemin_strip: Path, marge: int = 20,
couleur_fond: str = "#ffffff") -> Path:
"""Cree un 10x15 paysage avec 2 bandes identiques superposees pour impression."""
strip = Image.open(chemin_strip)
# Canvas 10x15 paysage
page = Image.new("RGB", (LARGEUR_10x15_L, HAUTEUR_10x15_L), couleur_fond)
# Redimensionner la bande pour qu'elle tienne dans la moitie
bande_largeur = LARGEUR_10x15_L - marge * 2
bande_hauteur = (HAUTEUR_10x15_L - marge * 3) // 2
strip_redim = strip.resize((bande_largeur, bande_hauteur), Image.LANCZOS)
# Coller 2 fois
page.paste(strip_redim, (marge, marge))
page.paste(strip_redim, (marge, marge + bande_hauteur + marge))
nom = f"print_{chemin_strip.stem}.jpg"
chemin = DOSSIER_EXPORTS / nom
page.save(chemin, "JPEG", quality=95, dpi=(300, 300))
log.info(f"Page impression 10x15 creee : {chemin}")
return chemin
def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 25,
couleur_fond: str = "#ffffff", cadre: str | None = None) -> Path:
"""Cree un collage en grille au format 10x15cm."""
nb = len(photos)
images = [Image.open(p) for p in photos]
lignes = (nb + colonnes - 1) // colonnes
largeur_cellule = (LARGEUR_10x15 - marge * (colonnes + 1)) // colonnes
hauteur_cellule = (HAUTEUR_10x15 - marge * (lignes + 1)) // lignes
ratio_cellule = largeur_cellule / hauteur_cellule
images_redim = []
for img in images:
img_crop = _crop_center(img, ratio_cellule)
img_redim = img_crop.resize((largeur_cellule, hauteur_cellule), Image.LANCZOS)
images_redim.append(img_redim)
collage = Image.new("RGB", (LARGEUR_10x15, HAUTEUR_10x15), couleur_fond)
for i, img in enumerate(images_redim):
@@ -89,6 +158,9 @@ def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 25,
y = marge + lig * (hauteur_cellule + marge)
collage.paste(img, (x, y))
if cadre:
collage = appliquer_cadre_impression(collage, "10x15", cadre)
nom = f"collage_{photos[0].stem}.jpg"
chemin = DOSSIER_EXPORTS / nom
collage.save(chemin, "JPEG", quality=95, dpi=(300, 300))

View File

@@ -11,6 +11,8 @@ DOSSIER_EXPORTS = RACINE / "data" / "exports"
DOSSIER_OVERLAYS = RACINE / "frontend" / "assets" / "overlays"
DOSSIER_FONDS = RACINE / "frontend" / "assets" / "backgrounds"
DOSSIER_ANIMATIONS = RACINE / "frontend" / "assets" / "animations"
DOSSIER_CADRES = RACINE / "frontend" / "assets" / "cadres"
FORMATS_CADRES = ["strip", "10x15", "15x20"]
def charger_config() -> dict:
@@ -51,3 +53,5 @@ def _merge_profond(base: dict, modifications: dict):
# Initialisation des dossiers au chargement du module
for dossier in [DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS, DOSSIER_ANIMATIONS]:
dossier.mkdir(parents=True, exist_ok=True)
for fmt in FORMATS_CADRES:
(DOSSIER_CADRES / fmt).mkdir(parents=True, exist_ok=True)

View File

@@ -1,39 +1,64 @@
import ftplib
import json
import logging
import shutil
import threading
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError
from backend.config import charger_config, mettre_a_jour_config
from backend.config import charger_config, mettre_a_jour_config, RACINE
log = logging.getLogger("photobooth.destinations")
FICHIER_BOOTH_SPOOL = RACINE / "data" / "booth_spool.json"
_booth_spool_lock = threading.Lock()
def distribuer_photo(chemin_photo: Path, imprimee: bool = False):
def distribuer_photo(chemin_photo: Path, imprimee: bool = False, copies: int = 1, format_papier: str | None = None):
"""Copie la photo vers toutes les destinations activees."""
config = charger_config()
dest = config.get("destinations", {})
# Verifier si on sauvegarde tout ou seulement les imprimees
# Galerie live (booth) — toujours envoyer, imprimee ou non
booth = config.get("booth", {})
if booth.get("actif", False):
threading.Thread(
target=envoyer_booth,
args=(chemin_photo, booth),
daemon=True,
).start()
sous_dossier = None
if not dest.get("sauvegarder_tout", True) and not imprimee:
log.info(f"Photo non imprimee, pas de distribution : {chemin_photo.name}")
return
sous_dossier = "non_imprimees"
# Cle USB
if dest.get("cle_usb", False):
copier_usb(chemin_photo, dest.get("chemin_usb", "/media/usb"))
copier_usb(chemin_photo, dest.get("chemin_usb", "/media/usb"), sous_dossier)
# FTP
if dest.get("ftp", False):
envoyer_ftp(chemin_photo, dest)
envoyer_ftp(chemin_photo, dest, sous_dossier)
# Incrementer le compteur
compteur = config.get("compteur", {})
if compteur.get("actif", False):
compteur["photos_prises"] = compteur.get("photos_prises", 0) + 1
mettre_a_jour_config({"compteur": compteur})
# Notifier booth que la photo a ete imprimee
if imprimee and booth.get("actif", False):
threading.Thread(
target=notifier_booth_impression,
args=(chemin_photo.name, copies, booth, config),
daemon=True,
).start()
# Incrementer le compteur (feuilles 10x15 consommees)
if imprimee:
compteur = config.get("compteur", {})
if compteur.get("actif", False):
compteur["photos_prises"] = compteur.get("photos_prises", 0) + copies
mettre_a_jour_config({"compteur": compteur})
maj_consommables(config, copies, format_papier)
def copier_usb(chemin_photo: Path, chemin_usb: str):
def copier_usb(chemin_photo: Path, chemin_usb: str, sous_dossier: str | None = None):
"""Copie une photo sur la cle USB."""
dossier_usb = Path(chemin_usb)
if not dossier_usb.exists():
@@ -41,24 +66,28 @@ def copier_usb(chemin_photo: Path, chemin_usb: str):
return False
dossier_dest = dossier_usb / "photobooth"
dossier_dest.mkdir(exist_ok=True)
if sous_dossier:
dossier_dest = dossier_dest / sous_dossier
dossier_dest.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(chemin_photo, dossier_dest / chemin_photo.name)
log.info(f"Photo copiee sur USB : {chemin_photo.name}")
log.info(f"Photo copiee sur USB : {chemin_photo.name} ({sous_dossier or 'root'})")
return True
except OSError as e:
log.error(f"Erreur copie USB : {e}")
return False
def envoyer_ftp(chemin_photo: Path, config_dest: dict):
def envoyer_ftp(chemin_photo: Path, config_dest: dict, sous_dossier: str | None = None):
"""Envoie une photo par FTP."""
host = config_dest.get("ftp_host", "")
port = config_dest.get("ftp_port", 21)
user = config_dest.get("ftp_user", "")
password = config_dest.get("ftp_password", "")
chemin_distant = config_dest.get("ftp_chemin", "/photobooth")
if sous_dossier:
chemin_distant = f"{chemin_distant}/{sous_dossier}"
if not host:
log.warning("FTP non configure")
@@ -83,32 +112,321 @@ def envoyer_ftp(chemin_photo: Path, config_dest: dict):
return False
def _upload_booth(url: str, api_key: str, event_id: str, chemin_photo: Path) -> bool:
boundary = "----BoothUpload"
filename = chemin_photo.name
with open(chemin_photo, "rb") as f:
file_data = f.read()
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="photo"; filename="{filename}"\r\n'
f"Content-Type: image/jpeg\r\n\r\n"
).encode() + file_data + f"\r\n--{boundary}--\r\n".encode()
req = Request(f"{url}/admin/gallery/{event_id}/upload", data=body, method="POST")
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
req.add_header("X-Api-Key", api_key)
with urlopen(req, timeout=10) as resp:
return resp.status == 200
def envoyer_booth(chemin_photo: Path, config_booth: dict):
"""Envoie une photo vers la galerie live booth. En cas d'echec, met en spool."""
url = config_booth.get("url", "").rstrip("/")
url_tunnel = config_booth.get("url_tunnel", "").rstrip("/")
api_key = config_booth.get("api_key", "")
config = charger_config()
event_id = config.get("evenement", {}).get("event_id") or config_booth.get("event_id", "default")
if not url and not url_tunnel:
log.warning("Booth non configure (URL manquante)")
return False
for tentative_url in [u for u in (url_tunnel, url) if u]:
try:
if _upload_booth(tentative_url, api_key, event_id, chemin_photo):
log.info(f"Photo envoyee au booth via {tentative_url} : {chemin_photo.name}")
return True
log.error(f"Booth erreur HTTP via {tentative_url}")
except (URLError, OSError) as e:
log.warning(f"Echec envoi booth via {tentative_url} : {e}")
_ajouter_booth_spool(str(chemin_photo))
return False
def notifier_booth_impression(filename: str, copies: int, config_booth: dict, config: dict):
"""Notifie booth.copydev.fr qu'une photo a ete imprimee."""
url = config_booth.get("url", "").rstrip("/")
url_tunnel = config_booth.get("url_tunnel", "").rstrip("/")
api_key = config_booth.get("api_key", "")
event_id = config.get("evenement", {}).get("event_id") or config_booth.get("event_id", "default")
body = json.dumps({"copies": copies}).encode()
for tentative_url in [u for u in (url_tunnel, url) if u]:
try:
req = Request(
f"{tentative_url}/admin/gallery/{event_id}/photo/{filename}/imprimee",
data=body, method="POST",
)
req.add_header("Content-Type", "application/json")
req.add_header("X-Api-Key", api_key)
with urlopen(req, timeout=10) as resp:
if resp.status == 200:
log.info(f"Booth notifie impression: {filename} x{copies}")
return
except (URLError, OSError) as e:
log.warning(f"Echec notification impression booth: {e}")
# --- Spool galerie booth ---
def _ajouter_booth_spool(chemin: str):
with _booth_spool_lock:
spool = _charger_booth_spool()
if chemin not in spool:
spool.append(chemin)
_sauver_booth_spool(spool)
log.info(f"Photo mise en spool galerie ({len(spool)} en attente)")
def _charger_booth_spool() -> list:
if not FICHIER_BOOTH_SPOOL.exists():
return []
try:
with open(FICHIER_BOOTH_SPOOL, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return []
def _sauver_booth_spool(spool: list):
try:
with open(FICHIER_BOOTH_SPOOL, "w") as f:
json.dump(spool, f)
except OSError:
pass
def taille_booth_spool() -> int:
with _booth_spool_lock:
return len(_charger_booth_spool())
def traiter_booth_spool() -> int:
"""Retente l'envoi des photos en spool. Retourne le nombre envoyees."""
with _booth_spool_lock:
spool = _charger_booth_spool()
if not spool:
return 0
config = charger_config()
booth = config.get("booth", {})
if not booth.get("actif", False):
return 0
envoyes = 0
restants = []
for chemin_str in spool:
chemin = Path(chemin_str)
if not chemin.exists():
log.warning(f"Spool booth: photo introuvable {chemin}, ignoree")
continue
url = booth.get("url", "").rstrip("/")
url_tunnel = booth.get("url_tunnel", "").rstrip("/")
api_key = booth.get("api_key", "")
event_id = config.get("evenement", {}).get("event_id") or booth.get("event_id", "default")
ok = False
for tentative_url in [u for u in (url_tunnel, url) if u]:
try:
if _upload_booth(tentative_url, api_key, event_id, chemin):
log.info(f"Spool booth: {chemin.name} envoyee via {tentative_url}")
ok = True
break
except (URLError, OSError):
pass
if ok:
envoyes += 1
else:
restants.append(chemin_str)
break
if envoyes > 0 or len(restants) < len(spool):
idx = envoyes + len(restants)
restants.extend(spool[idx:])
with _booth_spool_lock:
_sauver_booth_spool(restants)
log.info(f"Spool booth: {envoyes} envoyee(s), {len(restants)} en attente")
return envoyes
def recuperer_booth_password() -> dict:
"""Recupere le mot de passe et l'info de la session booth."""
config = charger_config()
booth = config.get("booth", {})
url = booth.get("url", "").rstrip("/")
url_tunnel = booth.get("url_tunnel", "").rstrip("/")
api_key = booth.get("api_key", "")
event_id = config.get("evenement", {}).get("event_id") or booth.get("event_id", "default")
for tentative_url in [u for u in (url_tunnel, url) if u]:
try:
req = Request(f"{tentative_url}/admin/gallery/{event_id}/info")
req.add_header("X-Api-Key", api_key)
with urlopen(req, timeout=5) as resp:
import json
return json.loads(resp.read())
except (URLError, OSError) as e:
log.warning(f"Echec recuperation booth info via {tentative_url} : {e}")
return {"password": None}
def detecter_usb() -> list[str]:
"""Detecte les cles USB montees."""
chemins_possibles = [Path("/media"), Path("/mnt")]
"""Detecte les cles USB montees (cherche dans /media, /mnt et /media/<user>/)."""
usb_trouvees = []
for racine in chemins_possibles:
if racine.exists():
for sous_dossier in racine.iterdir():
if sous_dossier.is_mount() or (sous_dossier.is_dir() and any(sous_dossier.iterdir())):
usb_trouvees.append(str(sous_dossier))
candidats = []
for racine in [Path("/media"), Path("/mnt")]:
if not racine.exists():
continue
try:
for niveau1 in racine.iterdir():
if not niveau1.is_dir():
continue
if niveau1.is_mount():
candidats.append(niveau1)
else:
# /media/<user>/ → descendre encore (udiskie)
try:
for niveau2 in niveau1.iterdir():
if niveau2.is_dir() and niveau2.is_mount():
candidats.append(niveau2)
except PermissionError:
pass
except PermissionError:
pass
for chemin in candidats:
# Exclure les partitions système
try:
result = __import__("subprocess").run(
["findmnt", "-no", "FSTYPE", str(chemin)],
capture_output=True, text=True, timeout=3
)
fstype = result.stdout.strip()
if fstype in ("vfat", "exfat", "ntfs", "ext4", "ext3", "hfsplus"):
usb_trouvees.append(str(chemin))
except Exception:
usb_trouvees.append(str(chemin))
return usb_trouvees
def compteur_restant() -> dict:
"""Retourne l'etat du compteur."""
"""Retourne l'etat du compteur, basé sur les consommables restants."""
config = charger_config()
compteur = config.get("compteur", {})
limite = compteur.get("limite", 400)
conso = config.get("consommables", {})
prises = compteur.get("photos_prises", 0)
papier_rest = max(0, conso.get("papier_capacite", 320) - conso.get("papier_utilise", 0))
ruban_rest = max(0, round(conso.get("ruban_capacite", 320) - conso.get("ruban_utilise", 0), 1))
restantes = int(min(papier_rest, ruban_rest))
limite_event = compteur.get("limite", 0)
if compteur.get("actif") and limite_event > 0:
restantes = min(restantes, max(0, limite_event - prises))
papier_cap = conso.get("papier_capacite", 320)
ruban_cap = conso.get("ruban_capacite", 320)
capacite_conso = int(min(papier_cap, ruban_cap))
return {
"actif": compteur.get("actif", False),
"limite": limite,
"limite": limite_event,
"photos_prises": prises,
"restantes": max(0, limite - prises),
"restantes": restantes,
"capacite": capacite_conso,
"papier_restant": papier_rest,
"ruban_restant": int(ruban_rest),
}
def reset_compteur():
"""Remet le compteur a zero."""
mettre_a_jour_config({"compteur": {"photos_prises": 0}})
# --- Suivi consommables ---
RATIO_RUBAN_OPTIMAL = {
"10x15": 0.5,
"10x15-2up": 1.0,
"15x20": 1.0,
"15x20-2up": 2.0,
}
# Poids papier : combien d'unites 10x15 une feuille consomme
POIDS_PAPIER = {
"10x15": 1, "10x15-2up": 1,
"15x20": 2, "15x20-2up": 2,
"15x15": 2, "15x15-2up": 2,
}
def maj_consommables(config: dict, copies: int, format_papier: str | None):
"""Met a jour le suivi consommables (papier, ruban, photos)."""
conso = config.get("consommables", {})
fmt = format_papier or config.get("impression", {}).get("format", "15x20")
rembobinage = config.get("impression", {}).get("rembobinage_ruban", False)
is_2up = "-2up" in fmt
feuilles = copies if not is_2up else (copies + 1) // 2
poids = POIDS_PAPIER.get(fmt, 1)
conso["papier_utilise"] = conso.get("papier_utilise", 0) + feuilles * poids
conso["photos_imprimees"] = conso.get("photos_imprimees", 0) + copies
ratio_optimal = RATIO_RUBAN_OPTIMAL.get(fmt, 1.0)
ruban_par_feuille = float(poids)
if rembobinage:
ruban_consomme = feuilles * ratio_optimal
else:
ruban_consomme = feuilles * ruban_par_feuille
conso["ruban_utilise"] = round(conso.get("ruban_utilise", 0) + ruban_consomme, 1)
poses_perdues = feuilles * ruban_par_feuille - feuilles * ratio_optimal if not rembobinage else 0
conso["poses_perdues"] = round(conso.get("poses_perdues", 0) + poses_perdues, 1)
mettre_a_jour_config({"consommables": conso})
def consommables_etat() -> dict:
"""Retourne l'etat des consommables."""
config = charger_config()
conso = config.get("consommables", {})
papier_cap = conso.get("papier_capacite", 320)
ruban_cap = conso.get("ruban_capacite", 320)
papier_used = conso.get("papier_utilise", 0)
ruban_used = conso.get("ruban_utilise", 0)
photos = conso.get("photos_imprimees", 0)
perdues = conso.get("poses_perdues", 0)
return {
"papier_capacite": papier_cap,
"papier_utilise": papier_used,
"papier_restant": max(0, papier_cap - papier_used),
"ruban_capacite": ruban_cap,
"ruban_utilise": ruban_used,
"ruban_restant": max(0, round(ruban_cap - ruban_used, 1)),
"photos_imprimees": photos,
"poses_perdues": perdues,
}
def reset_consommables(quoi: str):
"""Reinitialise papier, ruban ou tout."""
config = charger_config()
conso = config.get("consommables", {})
if quoi in ("papier", "tout"):
conso["papier_utilise"] = 0
if quoi in ("ruban", "tout"):
conso["ruban_utilise"] = 0
conso["poses_perdues"] = 0
if quoi == "tout":
conso["photos_imprimees"] = 0
conso["poses_perdues"] = 0
mettre_a_jour_config({"consommables": conso})

114
backend/eclairage.py Normal file
View File

@@ -0,0 +1,114 @@
"""Analyse de luminosité sur les visages détectés dans le preview DSLR.
Produit une note d'éclairage (0-100) et une recommandation (allumer/ok/sombre).
Échantillonne toutes les ~3 secondes pour ne pas charger le CPU.
"""
import logging
import time
from pathlib import Path
import cv2
import numpy as np
log = logging.getLogger("photobooth.eclairage")
_CASCADE_CANDIDATES = [
"/usr/share/opencv4/haarcascades/haarcascade_frontalface_default.xml",
"/usr/share/opencv/haarcascades/haarcascade_frontalface_default.xml",
"/usr/local/share/opencv4/haarcascades/haarcascade_frontalface_default.xml",
]
try:
_CASCADE_PATH = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
except AttributeError:
_CASCADE_PATH = next((p for p in _CASCADE_CANDIDATES if Path(p).exists()), _CASCADE_CANDIDATES[0])
_face_cascade = cv2.CascadeClassifier(_CASCADE_PATH)
SEUIL_SOMBRE = 45
SEUIL_CORRECT = 65
_dernier_analyse: float = 0
_INTERVALLE = 1.0
_dernier_resultat: dict | None = None
def analyser_frame(jpeg_data: bytes) -> dict | None:
"""Analyse une frame JPEG du preview.
Retourne None si l'intervalle n'est pas écoulé (throttle).
Sinon retourne:
{
"score": int 0-100,
"action": "allumer" | "attention" | "ok",
"visages": int,
"detail": str,
}
"""
global _dernier_analyse, _dernier_resultat
now = time.monotonic()
if now - _dernier_analyse < _INTERVALLE:
return None
_dernier_analyse = now
try:
arr = np.frombuffer(jpeg_data, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
return None
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
faces = _face_cascade.detectMultiScale(
gray, scaleFactor=1.2, minNeighbors=4, minSize=(60, 60)
)
if len(faces) == 0:
_dernier_resultat = {
"score": -1,
"action": "no_face",
"visages": 0,
"detail": "Aucun visage détecté",
}
return _dernier_resultat
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
scores = []
for (x, y, w, h) in faces:
pad_x = int(w * 0.1)
pad_y = int(h * 0.1)
x1 = max(0, x + pad_x)
y1 = max(0, y + pad_y)
x2 = min(img.shape[1], x + w - pad_x)
y2 = min(img.shape[0], y + h - pad_y)
roi_v = hsv[y1:y2, x1:x2, 2]
score = int(np.mean(roi_v) / 2.55)
scores.append(score)
score_moyen = int(np.mean(scores))
if score_moyen < SEUIL_SOMBRE:
action = "allumer"
detail = f"Visages sombres ({score_moyen}/100)"
elif score_moyen < SEUIL_CORRECT:
action = "attention"
detail = f"Éclairage limite ({score_moyen}/100)"
else:
action = "ok"
detail = f"Éclairage correct ({score_moyen}/100)"
_dernier_resultat = {
"score": score_moyen,
"action": action,
"visages": len(faces),
"detail": detail,
}
return _dernier_resultat
except Exception as e:
log.debug(f"Erreur analyse éclairage : {e}")
return None
def dernier_resultat() -> dict | None:
return _dernier_resultat

View File

@@ -4,21 +4,15 @@ from pathlib import Path
from PIL import Image, ImageEnhance, ImageFilter, ImageOps
from backend.config import DOSSIER_OVERLAYS, DOSSIER_EXPORTS, DOSSIER_FONDS
from backend.config import DOSSIER_OVERLAYS, DOSSIER_EXPORTS, DOSSIER_FONDS, DOSSIER_CADRES
log = logging.getLogger("photobooth.effects")
# Filtres disponibles
FILTRES = {
"original": "Original",
"nb": "Noir & Blanc",
"sepia": "Sepia",
"vintage": "Vintage",
"contraste": "Contraste fort",
"lumineux": "Lumineux",
"chaud": "Tons chauds",
"froid": "Tons froids",
"flou_artistique": "Flou artistique",
"original": "Couleur",
"nb": "Noir & Blanc",
"sepia": "Sepia",
}
@@ -127,6 +121,58 @@ def chroma_key(chemin_photo: Path, nom_fond: str | None = None,
return chemin_export
def _chercher_cadre(format_papier: str, nom_cadre: str, event_id: str | None = None) -> Path | None:
"""Cherche le cadre dans le format demandé, sinon fallback autres formats."""
candidats = []
if event_id:
from backend.evenements import DOSSIER_EVENEMENTS
base_event = DOSSIER_EVENEMENTS / event_id / "cadres"
candidats.append(base_event / format_papier / nom_cadre)
for d in sorted(base_event.iterdir()) if base_event.exists() else []:
if d.is_dir() and d.name != format_papier:
candidats.append(d / nom_cadre)
candidats.append(DOSSIER_CADRES / format_papier / nom_cadre)
for d in sorted(DOSSIER_CADRES.iterdir()) if DOSSIER_CADRES.exists() else []:
if d.is_dir() and d.name != format_papier:
candidats.append(d / nom_cadre)
for c in candidats:
if c.exists():
return c
return None
def appliquer_cadre_impression(img: Image.Image, format_papier: str, nom_cadre: str, event_id: str | None = None) -> Image.Image:
"""Composite un cadre PNG par-dessus l'image assemblée.
Si le cadre n'existe pas dans le format demandé, utilise celui d'un autre format (resize)."""
chemin = _chercher_cadre(format_papier, nom_cadre, event_id)
if chemin is None:
log.warning(f"Cadre introuvable : {nom_cadre} pour {format_papier}")
return img
cadre = Image.open(chemin).convert("RGBA")
img_portrait = img.height > img.width
cadre_portrait = cadre.height > cadre.width
if img_portrait != cadre_portrait:
cadre = cadre.rotate(90, expand=True)
if cadre.size != img.size:
cadre = cadre.resize(img.size, Image.LANCZOS)
base = img.convert("RGBA")
composite = Image.alpha_composite(base, cadre)
return composite.convert("RGB")
def lister_cadres(format_papier: str) -> list[str]:
"""Liste les cadres disponibles pour un format. Inclut ceux d'autres formats en fallback."""
noms = set()
dossier = DOSSIER_CADRES / format_papier
if dossier.exists():
noms.update(f.name for f in dossier.iterdir() if f.suffix.lower() == ".png")
if not noms and DOSSIER_CADRES.exists():
for d in DOSSIER_CADRES.iterdir():
if d.is_dir():
noms.update(f.name for f in d.iterdir() if f.suffix.lower() == ".png")
return sorted(noms)
def lister_overlays() -> list[str]:
"""Liste les overlays disponibles."""
if not DOSSIER_OVERLAYS.exists():

193
backend/evenements.py Normal file
View File

@@ -0,0 +1,193 @@
import json
import logging
import shutil
import uuid
from datetime import date, datetime
from pathlib import Path
from backend.config import RACINE, DOSSIER_CADRES, FORMATS_CADRES, charger_config, mettre_a_jour_config
log = logging.getLogger("photobooth.evenements")
DOSSIER_EVENEMENTS = RACINE / "data" / "evenements"
DOSSIER_EVENEMENTS.mkdir(parents=True, exist_ok=True)
def _chemin_event(event_id: str) -> Path:
return DOSSIER_EVENEMENTS / f"{event_id}.json"
def _chemin_cadres_event(event_id: str) -> Path:
d = DOSSIER_EVENEMENTS / event_id / "cadres"
d.mkdir(parents=True, exist_ok=True)
return d
def lister_evenements() -> list[dict]:
events = []
config = charger_config()
actif_id = config.get("evenement", {}).get("event_id")
for f in sorted(DOSSIER_EVENEMENTS.glob("*.json")):
try:
data = json.loads(f.read_text("utf-8"))
data["actif"] = (data.get("id") == actif_id)
events.append(data)
except (json.JSONDecodeError, KeyError):
continue
return events
def creer_evenement(nom: str, **kwargs) -> dict:
event_id = kwargs.get("event_id") or uuid.uuid4().hex[:8]
event = {
"id": event_id,
"nom": nom,
"theme": kwargs.get("theme", "base"),
"couleur_primaire": kwargs.get("couleur_primaire", "#e91e63"),
"couleur_secondaire": kwargs.get("couleur_secondaire", "#ffffff"),
"media_accueil": kwargs.get("media_accueil"),
"formats_actifs": kwargs.get("formats_actifs", ["10x15", "15x20", "strip"]),
"date_fin": kwargs.get("date_fin"),
"termine": False,
"cadres": {},
}
_chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8")
log.info(f"Evenement cree : {nom} ({event_id})")
return event
def obtenir_evenement(event_id: str) -> dict | None:
chemin = _chemin_event(event_id)
if not chemin.exists():
return None
return json.loads(chemin.read_text("utf-8"))
def modifier_evenement(event_id: str, modifications: dict) -> dict | None:
event = obtenir_evenement(event_id)
if not event:
return None
for k, v in modifications.items():
if k != "id":
event[k] = v
_chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8")
return event
def supprimer_evenement(event_id: str) -> bool:
chemin = _chemin_event(event_id)
if not chemin.exists():
return False
chemin.unlink()
dossier_cadres = DOSSIER_EVENEMENTS / event_id
if dossier_cadres.exists():
shutil.rmtree(dossier_cadres)
config = charger_config()
if config.get("evenement", {}).get("event_id") == event_id:
mettre_a_jour_config({"evenement": {"event_id": None}})
log.info(f"Evenement supprime : {event_id}")
return True
def terminer_evenement(event_id: str) -> dict | None:
event = obtenir_evenement(event_id)
if not event:
return None
event["termine"] = True
_chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8")
log.info(f"Evenement termine : {event['nom']} ({event_id})")
return event
def evenement_est_termine() -> bool:
"""Verifie si l'evenement actif est termine (manuellement ou date_fin depassee)."""
config = charger_config()
event_id = config.get("evenement", {}).get("event_id")
if not event_id:
return False
event = obtenir_evenement(event_id)
if not event:
return False
if event.get("termine"):
return True
date_fin = event.get("date_fin")
if date_fin:
try:
fin = datetime.strptime(date_fin, "%Y-%m-%d").date()
if date.today() > fin:
return True
except ValueError:
pass
return False
def activer_evenement(event_id: str) -> dict | None:
event = obtenir_evenement(event_id)
if not event:
return None
config = charger_config()
booth = config.get("booth", {})
if booth.get("actif"):
booth["event_id"] = event_id
mettre_a_jour_config({
"evenement": {
"event_id": event_id,
"nom": event["nom"],
"theme": event.get("theme", "base"),
"couleur_primaire": event.get("couleur_primaire", "#e91e63"),
"couleur_secondaire": event.get("couleur_secondaire", "#ffffff"),
"media_accueil": event.get("media_accueil"),
"formats_actifs": event.get("formats_actifs", ["10x15", "15x20", "strip"]),
},
"booth": booth,
})
log.info(f"Evenement active : {event['nom']} ({event_id})")
return event
def lister_cadres_event(event_id: str, format_papier: str) -> list[str]:
dossier = _chemin_cadres_event(event_id) / format_papier
if not dossier.exists():
return []
return sorted(f.name for f in dossier.iterdir() if f.suffix.lower() == ".png")
def config_cadre_event(event_id: str) -> dict:
"""Retourne la config cadres de l'evenement : {format: {mode, cadre}}
mode: 'disponible' (choix user), 'impose' (force), 'defaut' (pre-selectionne)
"""
event = obtenir_evenement(event_id)
if not event:
return {}
return event.get("cadres", {})
def set_cadre_event(event_id: str, format_papier: str, mode: str, cadre: str | None) -> bool:
"""mode: 'aucun', 'disponible', 'impose', 'defaut'"""
event = obtenir_evenement(event_id)
if not event:
return False
if "cadres" not in event:
event["cadres"] = {}
event["cadres"][format_papier] = {"mode": mode, "cadre": cadre}
_chemin_event(event_id).write_text(json.dumps(event, ensure_ascii=False, indent=2), "utf-8")
return True
def cadre_actif_pour_impression(format_papier: str) -> tuple[str | None, str]:
"""Retourne (nom_cadre, mode) en tenant compte de l'evenement actif.
mode: 'aucun', 'disponible', 'impose', 'defaut'
Strips exclus du cadre impose/defaut.
"""
if format_papier == "strip":
return None, "aucun"
config = charger_config()
event_id = config.get("evenement", {}).get("event_id")
if not event_id:
return None, "aucun"
event = obtenir_evenement(event_id)
if not event:
return None, "aucun"
cadres = event.get("cadres", {})
fmt_conf = cadres.get(format_papier, {})
return fmt_conf.get("cadre"), fmt_conf.get("mode", "aucun")

View File

@@ -1,15 +1,21 @@
import asyncio
import json
import logging
import smtplib
import threading
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email import encoders
from pathlib import Path
from backend.config import charger_config
from backend.config import charger_config, RACINE
log = logging.getLogger("photobooth.mailer")
FICHIER_SPOOL = RACINE / "data" / "email_spool.json"
_spool_lock = threading.Lock()
def envoyer_photo(destinataire: str, chemin_photo: Path) -> bool:
"""Envoie une photo par email."""
@@ -58,7 +64,212 @@ def envoyer_photo(destinataire: str, chemin_photo: Path) -> bool:
serveur.login(smtp_user, smtp_password)
serveur.send_message(msg)
log.info(f"Email envoye a {destinataire}")
_sauvegarder_email_historique(destinataire)
return True
except (smtplib.SMTPException, OSError) as e:
log.error(f"Erreur envoi email : {e}")
return False
# --- Spool : file d'attente pour emails en echec ---
def ajouter_au_spool(destinataire: str, chemin_photo: str):
with _spool_lock:
spool = _charger_spool()
spool.append({"email": destinataire, "photo": chemin_photo})
_sauver_spool(spool)
log.info(f"Email pour {destinataire} mis en file d'attente ({len(spool)} en attente)")
def _charger_spool() -> list:
if not FICHIER_SPOOL.exists():
return []
try:
with open(FICHIER_SPOOL, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return []
def _sauver_spool(spool: list):
try:
with open(FICHIER_SPOOL, "w", encoding="utf-8") as f:
json.dump(spool, f, ensure_ascii=False)
except OSError:
pass
def taille_spool() -> int:
with _spool_lock:
return len(_charger_spool())
EMAIL_RAPPORT = "contact@copydev.fr"
def traiter_spool() -> tuple[int, list[str]]:
"""Tente d'envoyer les emails en attente. Retourne (nb envoyes, liste destinataires)."""
with _spool_lock:
spool = _charger_spool()
if not spool:
return 0, []
envoyes = 0
destinataires = []
restants = []
for entry in spool:
chemin = Path(entry["photo"])
if not chemin.exists():
log.warning(f"Spool: photo introuvable {chemin}, email ignore")
continue
ok = envoyer_photo(entry["email"], chemin)
if ok:
envoyes += 1
destinataires.append(entry["email"])
else:
restants.append(entry)
break
if envoyes > 0 or len(restants) < len(spool):
idx = envoyes + len(restants)
restants.extend(spool[idx:])
with _spool_lock:
_sauver_spool(restants)
log.info(f"Spool: {envoyes} envoye(s), {len(restants)} en attente")
return envoyes, destinataires
def envoyer_rapport_spool(nb_envoyes: int, destinataires: list[str], nb_echoues: int):
"""Envoie un rapport a contact@copydev.fr apres vidage du spool."""
config = charger_config()
conf_email = config.get("email", {})
conf_event = config.get("evenement", {})
nom_event = conf_event.get("nom", "Photobooth")
smtp_host = conf_email.get("smtp_host", "")
smtp_port = conf_email.get("smtp_port", 587)
smtp_user = conf_email.get("smtp_user", "")
smtp_password = conf_email.get("smtp_password", "")
expediteur = conf_email.get("expediteur", smtp_user)
if not smtp_host or not smtp_user:
return
liste = "\n".join(f" - {d}" for d in destinataires)
corps = f"{nb_envoyes} photo(s) envoyée(s) par email.\n\n"
corps += f"Destinataires :\n{liste}\n"
if nb_echoues > 0:
corps += f"\n{nb_echoues} email(s) en échec (restent en file d'attente).\n"
msg = MIMEMultipart()
msg["From"] = expediteur
msg["To"] = EMAIL_RAPPORT
msg["Subject"] = f"[{nom_event}] {nb_envoyes} photo(s) envoyée(s)"
msg.attach(MIMEText(corps, "plain", "utf-8"))
try:
with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as serveur:
serveur.starttls()
serveur.login(smtp_user, smtp_password)
serveur.send_message(msg)
log.info(f"Rapport spool envoye a {EMAIL_RAPPORT}")
except (smtplib.SMTPException, OSError) as e:
log.error(f"Erreur envoi rapport spool : {e}")
RETRY_INTERVAL = 300 # 5 minutes
WATCHDOG_INTERVAL = 60 # 1 minute
async def _flush_spools():
"""Vide les spools email + galerie."""
from backend.destinations import traiter_booth_spool, taille_booth_spool
loop = asyncio.get_event_loop()
total_mail = taille_spool()
if total_mail > 0:
log.info(f"Spool email: retry ({total_mail} en attente)")
nb, dests = await loop.run_in_executor(None, traiter_spool)
if nb > 0:
echoues = total_mail - nb
await loop.run_in_executor(None, envoyer_rapport_spool, nb, dests, echoues)
total_booth = taille_booth_spool()
if total_booth > 0:
log.info(f"Spool galerie: retry ({total_booth} en attente)")
await loop.run_in_executor(None, traiter_booth_spool)
async def tache_spool_periodique():
"""Watchdog connectivite (60s) + retry spool (5 min)."""
from backend.wifi import watchdog_tick
from backend.destinations import taille_booth_spool
await asyncio.sleep(15)
loop = asyncio.get_event_loop()
ticks_depuis_flush = 0
while True:
result = await loop.run_in_executor(None, watchdog_tick)
if result == "restored":
await _flush_spools()
ticks_depuis_flush = 0
else:
ticks_depuis_flush += 1
if ticks_depuis_flush >= RETRY_INTERVAL // WATCHDOG_INTERVAL:
has_spool = taille_spool() > 0 or taille_booth_spool() > 0
if has_spool:
await _flush_spools()
ticks_depuis_flush = 0
await asyncio.sleep(WATCHDOG_INTERVAL)
async def tache_spool_demarrage():
"""Alias pour compatibilite — lance la boucle periodique."""
await tache_spool_periodique()
FICHIER_EMAILS = RACINE / "data" / "emails_history.json"
def _event_id_actif() -> str:
return charger_config().get("evenement", {}).get("event_id") or "_sans_evenement"
def _charger_toutes_historiques() -> dict:
if not FICHIER_EMAILS.exists():
return {}
try:
with open(FICHIER_EMAILS, "r", encoding="utf-8") as f:
data = json.load(f)
# Compatibilite avec l'ancien format (liste globale non cloisonnee)
if isinstance(data, list):
return {"_sans_evenement": data}
return data
except (json.JSONDecodeError, OSError):
return {}
def _sauvegarder_email_historique(email: str):
toutes = _charger_toutes_historiques()
event_id = _event_id_actif()
historique = toutes.setdefault(event_id, [])
email_lower = email.lower().strip()
if email_lower not in historique:
historique.append(email_lower)
try:
with open(FICHIER_EMAILS, "w", encoding="utf-8") as f:
json.dump(toutes, f, ensure_ascii=False)
except OSError:
pass
def charger_emails_historique() -> list:
return _charger_toutes_historiques().get(_event_id_actif(), [])
def effacer_emails_historique():
toutes = _charger_toutes_historiques()
toutes.pop(_event_id_actif(), None)
try:
with open(FICHIER_EMAILS, "w", encoding="utf-8") as f:
json.dump(toutes, f, ensure_ascii=False)
except OSError:
pass

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +1,369 @@
import logging
import subprocess
import tempfile
import time
from pathlib import Path
from backend.config import charger_config
from backend.effects import appliquer_cadre_impression
log = logging.getLogger("photobooth.printer")
# Essayer d'importer cups
try:
import cups
CUPS_DISPONIBLE = True
except ImportError:
CUPS_DISPONIBLE = False
log.warning("pycups non installe, impression indisponible")
# PageSize CUPS + dimensions exactes à 300 DPI pour la Mitsubishi CP-K60DW-S
# Le resize vers ces dimensions exactes évite toute bordure blanche
FORMATS = {
"10x15": {"cups": "w288h432", "px": (1200, 1800)},
"15x15": {"cups": "w432h432", "px": (1800, 1800)},
"15x20": {"cups": "w432h576", "px": (1800, 2400)},
# "-div2" declare en CUPS la MEME taille de page que le format plein (le PPD le confirme :
# PaperDimension identique pour w288h432 et w288h432-div2). Le K60 ne duplique pas une
# image fournie a taille reduite : il imprime la feuille entiere telle qu'envoyee puis la
# coupe en 2 apres impression. "px" = taille d'une bande seule, "px_page" = feuille entiere
# a envoyer a lp (bande dupliquee cote a cote par _dupliquer_page).
"10x15-2up": {"cups": "w288h432-div2", "px": (600, 1800), "px_page": (1200, 1800)},
"15x20-2up": {"cups": "w432h576-div2", "px": (900, 2400), "px_page": (1800, 2400)},
}
ERREUR_FICHIER = "fichier_introuvable"
ERREUR_IMPRIMANTE = "imprimante_indisponible"
ERREUR_BOURRAGE = "bourrage_papier"
ERREUR_PAPIER = "plus_de_papier"
ERREUR_IMPRESSION = "erreur_impression"
def _run(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout, r.stderr
except subprocess.TimeoutExpired:
return -1, "", "timeout"
except FileNotFoundError:
return -1, "", f"commande introuvable : {cmd[0]}"
def preparer_apercu(chemin: Path, largeur: int, hauteur: int,
orientation: str = "paysage") -> Path:
"""
Génère un aperçu JPEG recadré aux bonnes dimensions (pour le frontend).
orientation : 'portrait' pivote le DSLR paysage de 90° avant recadrage.
"""
from PIL import Image, ImageOps
img = Image.open(chemin).convert("RGB")
img = ImageOps.exif_transpose(img)
if orientation == "paysage" and hauteur > largeur:
largeur, hauteur = hauteur, largeur
if orientation == "portrait" and img.width > img.height:
img = img.rotate(90, expand=True)
img = _crop_to(img, largeur, hauteur)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
img.save(tmp.name, "JPEG", quality=85)
return Path(tmp.name)
def _crop_to(img, largeur: int, hauteur: int):
"""Cover crop centre vers les dimensions exactes."""
from PIL import Image
iw, ih = img.size
ratio_cible = largeur / hauteur
ratio_src = iw / ih
if ratio_src > ratio_cible:
new_w = int(ih * ratio_cible)
left = (iw - new_w) // 2
img = img.crop((left, 0, left + new_w, ih))
elif ratio_src < ratio_cible:
new_h = int(iw / ratio_cible)
top = (ih - new_h) // 2
img = img.crop((0, top, iw, top + new_h))
return img.resize((largeur, hauteur), Image.LANCZOS)
def _preparer_image(chemin: Path, largeur: int, hauteur: int,
format_papier: str | None = None, cadre: str | None = None,
orientation: str = "paysage", event_id: str | None = None,
skip_rotate: bool = False) -> Path:
"""
Pivote si nécessaire, recadre aux dimensions exactes (cover crop),
applique le cadre, compense les marges de coupe, sauvegarde en temp.
"""
try:
from PIL import Image, ImageOps
except ImportError:
log.warning("PIL absent — image envoyée telle quelle, risque de bordures")
return chemin
img = Image.open(chemin).convert("RGB")
img = ImageOps.exif_transpose(img)
if not skip_rotate:
if orientation == "portrait" and img.width > img.height:
img = img.rotate(90, expand=True)
elif orientation == "paysage" and img.height > img.width:
img = img.rotate(-90, expand=True)
img = _crop_to(img, largeur, hauteur)
if cadre and format_papier:
img = appliquer_cadre_impression(img, format_papier, cadre, event_id=event_id)
img = _appliquer_offsets_coupe(img)
config_imp = charger_config().get("impression", {})
if config_imp.get("rotation_180", False) and not skip_rotate:
img = img.rotate(180)
luminosite = config_imp.get("luminosite_impression", 15)
if luminosite != 0:
from PIL import ImageEnhance
facteur = 1.0 + luminosite / 100.0
img = ImageEnhance.Brightness(img).enhance(facteur)
log.debug(f"Luminosité impression ajustée : {luminosite}% (facteur {facteur:.2f})")
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
img.save(tmp.name, "JPEG", quality=95, dpi=(300, 300))
log.debug(f"Image préparée : {orientation} {largeur}x{hauteur}px → {tmp.name}")
return Path(tmp.name)
def _appliquer_offsets_coupe(img):
"""Décale le contenu de l'image pour compenser les marges de coupe asymétriques.
Config impression.offsets_coupe_mm : {haut, bas, gauche, droite} en mm."""
from PIL import Image
config = charger_config()
offsets = config.get("impression", {}).get("offsets_coupe_mm", {})
if not offsets:
return img
DPI = 300
mm_px = DPI / 25.4
haut = int(offsets.get("haut", 0) * mm_px)
bas = int(offsets.get("bas", 0) * mm_px)
gauche = int(offsets.get("gauche", 0) * mm_px)
droite = int(offsets.get("droite", 0) * mm_px)
if haut == 0 and bas == 0 and gauche == 0 and droite == 0:
return img
w, h = img.size
nouvelle = Image.new("RGB", (w, h), (255, 255, 255))
# Décale : offset positif = l'imprimante coupe trop de ce côté, on décale le contenu vers l'opposé
# crop la source pour que le contenu tienne malgré le décalage
src_x = max(0, droite - gauche)
src_y = max(0, bas - haut)
dst_x = max(0, gauche - droite)
dst_y = max(0, haut - bas)
crop_w = w - abs(gauche - droite)
crop_h = h - abs(haut - bas)
region = img.crop((src_x, src_y, src_x + crop_w, src_y + crop_h))
nouvelle.paste(region, (dst_x, dst_y))
return nouvelle
def _dupliquer_page(chemin: Path, taille_page: tuple[int, int]) -> Path:
"""Colle la bande 2 fois cote a cote pour remplir la feuille entiere.
Necessaire pour les formats "-div2" : le K60 coupe la feuille imprimee en 2
apres impression, il ne duplique pas une image fournie a taille reduite.
"""
from PIL import Image
img = Image.open(chemin)
page = Image.new("RGB", taille_page, (255, 255, 255))
page.paste(img, (0, 0))
page.paste(img, (img.width, 0))
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
page.save(tmp.name, "JPEG", quality=95, dpi=(300, 300))
return Path(tmp.name)
def _statut(nom: str) -> str:
"""Retourne 'idle', 'printing', 'stopped' ou 'inconnue'."""
code, out, _ = _run(["lpstat", "-p", nom])
if code != 0:
return "inconnue"
out = out.lower()
if "disabled" in out or "stopped" in out:
return "stopped"
if "printing" in out or "processing" in out:
return "printing"
if "idle" in out or "inactive" in out:
return "idle"
return "inconnue"
def _reactiver(nom: str) -> bool:
"""Réactive l'imprimante via cupsenable (sudoers sans mot de passe requis)."""
code, _, err = _run(["sudo", "cupsenable", nom], timeout=5)
if code == 0:
log.info(f"Imprimante {nom} réactivée")
return True
log.error(f"Impossible de réactiver {nom} : {err}")
return False
def _detecter_erreur_physique(nom: str) -> str | None:
"""Interroge l'état physique via le backend dyesub."""
try:
r = subprocess.run(
["/usr/lib/cups/backend/gutenprint53+usb", "-s"],
capture_output=True, text=True, timeout=10,
)
combined = (r.stdout + r.stderr).lower()
except Exception:
return None
if "jam" in combined:
return ERREUR_BOURRAGE
if "media out" in combined or "no media" in combined or "remaining: 0/" in combined:
return ERREUR_PAPIER
return None
def lister_imprimantes() -> list[dict]:
"""Liste les imprimantes disponibles via CUPS."""
if not CUPS_DISPONIBLE:
"""Liste les imprimantes CUPS disponibles."""
code, out, _ = _run(["lpstat", "-p"])
if code != 0 or not out.strip():
return [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}]
try:
conn = cups.Connection()
imprimantes = conn.getPrinters()
return [
{
"nom": nom,
"statut": "prete" if info.get("printer-state") == 3 else "occupee",
"info": info.get("printer-info", ""),
}
for nom, info in imprimantes.items()
]
except cups.IPPError as e:
log.error(f"Erreur CUPS : {e}")
return []
imprimantes = []
for ligne in out.splitlines():
if not ligne.startswith("printer "):
continue
parties = ligne.split()
if len(parties) < 2:
continue
nom = parties[1]
arretee = "disabled" in ligne.lower() or "stopped" in ligne.lower()
imprimantes.append({
"nom": nom,
"statut": "arretee" if arretee else "prete",
})
return imprimantes or [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}]
def imprimer(chemin_photo: Path, imprimante: str | None = None, copies: int | None = None) -> bool:
"""Imprime une photo sur l'imprimante configuree."""
def imprimer(
chemin_photo: Path,
imprimante: str | None = None,
copies: int | None = None,
format_papier: str | None = None,
cadre_override: str | None = None,
) -> dict:
"""
Imprime une photo sans bordure blanche.
L'image est recadrée et redimensionnée aux dimensions exactes (300 DPI)
avant envoi à CUPS.
Retourne {"succes": True} ou {"succes": False, "erreur": CODE, "message": str}
"""
config = charger_config()
conf_imp = config.get("impression", {})
if imprimante is None:
imprimante = conf_imp.get("imprimante")
imprimante = conf_imp.get("imprimante") or "Mitsubishi"
if copies is None:
copies = conf_imp.get("copies", 1)
if format_papier is None:
format_papier = conf_imp.get("format", "15x20")
fmt = FORMATS.get(format_papier, FORMATS["15x20"])
page_size = fmt["cups"]
largeur, hauteur = fmt["px"]
fmt_base = format_papier.replace("-2up", "")
is_strip = "-2up" in format_papier
cadre = cadre_override if cadre_override is not None else (None if is_strip else conf_imp.get("cadres_actifs", {}).get(fmt_base))
orientation = "portrait" if is_strip else conf_imp.get("orientations", {}).get(fmt_base, "portrait")
if orientation == "paysage" and hauteur > largeur:
largeur, hauteur = hauteur, largeur
event_id = config.get("evenement", {}).get("event_id")
if event_id and cadre_override is None and not is_strip:
from backend.evenements import cadre_actif_pour_impression
ev_cadre, ev_mode = cadre_actif_pour_impression(fmt_base)
if ev_mode == "impose" and ev_cadre:
cadre = ev_cadre
elif ev_mode == "defaut" and ev_cadre and not cadre:
cadre = ev_cadre
if not chemin_photo.exists():
log.error(f"Fichier introuvable : {chemin_photo}")
return False
return {"succes": False, "erreur": ERREUR_FICHIER, "message": "Fichier photo introuvable"}
if not CUPS_DISPONIBLE:
log.info(f"[Simulation] Impression de {chemin_photo} x{copies}")
return True
chemin_print = _preparer_image(chemin_photo, largeur, hauteur, fmt_base, cadre, orientation, event_id=event_id, skip_rotate=is_strip)
tmp_a_supprimer = [chemin_print] if chemin_print != chemin_photo else []
px_page = fmt.get("px_page")
if px_page:
chemin_page = _dupliquer_page(chemin_print, px_page)
tmp_a_supprimer.append(chemin_page)
chemin_print = chemin_page
if _statut(imprimante) == "stopped":
log.warning(f"Imprimante {imprimante} arrêtée — réactivation")
if not _reactiver(imprimante):
return {
"succes": False,
"erreur": ERREUR_IMPRIMANTE,
"message": "Imprimante arrêtée, réactivation impossible (vérifiez sudo cupsenable)",
}
time.sleep(1)
rembobinage = conf_imp.get("rembobinage_ruban", False)
try:
conn = cups.Connection()
jobs = []
for copie in range(1, copies + 1):
job_ok = False
for tentative in range(1, 4):
cmd = [
"lp",
"-d", imprimante,
"-o", f"PageSize={page_size}",
"-o", "StpiShrinkOutput=Crop",
]
if rembobinage:
cmd.extend(["-o", "StpiDecklist=true"])
cmd.append(str(chemin_print))
if imprimante is None:
imprimante = conn.getDefault()
if imprimante is None:
imprimantes = conn.getPrinters()
if imprimantes:
imprimante = list(imprimantes.keys())[0]
else:
log.error("Aucune imprimante trouvee")
return False
code, out, err = _run(cmd, timeout=30)
options = {
"copies": str(copies),
"media": conf_imp.get("format", "10x15"),
"fit-to-page": "true",
}
if code == 0:
job = out.strip()
log.info(f"Impression copie {copie}/{copies} lancée : {job} ({format_papier} {largeur}x{hauteur}px)")
jobs.append(job)
job_ok = True
break
job_id = conn.printFile(imprimante, str(chemin_photo), "Photobooth", options)
log.info(f"Impression lancee : job #{job_id} sur {imprimante}")
return True
log.warning(f"Impression copie {copie}/{copies} échouée (tentative {tentative}/3) : {err.strip()}")
except cups.IPPError as e:
log.error(f"Erreur impression : {e}")
return False
if tentative < 3:
if _statut(imprimante) == "stopped":
erreur = _detecter_erreur_physique(imprimante)
if erreur == ERREUR_BOURRAGE:
return {
"succes": False,
"erreur": ERREUR_BOURRAGE,
"message": "Bourrage papier — retirez le papier coincé puis réessayez",
}
if erreur == ERREUR_PAPIER:
return {
"succes": False,
"erreur": ERREUR_PAPIER,
"message": "Plus de papier — rechargez le rouleau",
}
_reactiver(imprimante)
time.sleep(2)
if not job_ok:
return {
"succes": False,
"erreur": ERREUR_IMPRESSION,
"message": f"Impression copie {copie}/{copies} échouée après 3 tentatives",
}
if copie < copies:
time.sleep(3)
log.info(f"Impression terminée : {copies} copie(s), jobs={jobs}")
return {"succes": True, "job": ", ".join(jobs)}
finally:
for tmp in tmp_a_supprimer:
tmp.unlink(missing_ok=True)

186
backend/relais.py Normal file
View File

@@ -0,0 +1,186 @@
"""Pilotage module relais USB HID (LCUS 5131:2007).
4 canaux, protocole LCUS :
ON : 0x00 0xA0 <ch> 0x01 <checksum> 0x00 0x00 0x00 0x00
OFF : 0x00 0xA0 <ch> 0x00 <checksum> 0x00 0x00 0x00 0x00
Status : 0x00 0xD1 ...
Assignation :
1 = Projecteur gauche (NO)
2 = Projecteur droit (NO)
3 = Canon alim (NF — OFF=alimenté, ON=coupé)
4 = Libre
"""
import ctypes
import ctypes.util
import logging
import threading
import time
log = logging.getLogger("photobooth.relais")
VID = 0x5131
PID = 0x2007
CANAUX = {
"projecteur_gauche": {"relay": 1, "mode": "NO"},
"projecteur_droit": {"relay": 2, "mode": "NO"},
"canon": {"relay": 3, "mode": "NF"}, # dummy battery
"canon_usb": {"relay": 4, "mode": "NF"}, # VBUS USB
}
_lib = None
_dev = None
_lock = threading.Lock()
def _charger_hidapi():
global _lib
if _lib is not None:
return _lib
for name in ("hidapi-hidraw", "hidapi-libusb", "hidapi"):
path = ctypes.util.find_library(name)
if path:
_lib = ctypes.CDLL(path)
_lib.hid_init()
_lib.hid_open.restype = ctypes.c_void_p
_lib.hid_read_timeout.restype = ctypes.c_int
log.info(f"hidapi chargé : {path}")
return _lib
try:
_lib = ctypes.CDLL("libhidapi-hidraw.so.0")
_lib.hid_init()
_lib.hid_open.restype = ctypes.c_void_p
_lib.hid_read_timeout.restype = ctypes.c_int
return _lib
except OSError:
log.warning("hidapi introuvable — relais désactivés")
return None
def connecter():
global _dev
lib = _charger_hidapi()
if not lib:
return False
with _lock:
if _dev:
return True
_dev = lib.hid_open(VID, PID, None)
if not _dev:
log.warning("Module relais non trouvé (5131:2007)")
_dev = None
return False
log.info("Module relais connecté")
return True
def deconnecter():
global _dev
with _lock:
if _dev and _lib:
_lib.hid_close(_dev)
_dev = None
def est_connecte():
return _dev is not None
def _ecrire(data: list[int]):
if not _dev or not _lib:
return False
buf = (ctypes.c_ubyte * 9)(*data)
with _lock:
res = _lib.hid_write(_dev, buf, 9)
return res > 0
def _relay_set(num: int, on: bool):
state = 0x01 if on else 0x00
checksum = (0xA0 + num + state) & 0xFF
ok = _ecrire([0x00, 0xA0, num, state, checksum, 0x00, 0x00, 0x00, 0x00])
if ok:
log.info(f"Relais {num} → {'ON' if on else 'OFF'}")
else:
log.error(f"Échec écriture relais {num}")
return ok
def status():
if not _dev or not _lib:
return None
_ecrire([0x00, 0xD1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
time.sleep(0.1)
rbuf = (ctypes.c_ubyte * 64)()
with _lock:
n = _lib.hid_read_timeout(_dev, rbuf, 64, 500)
if n < 5:
return None
return {
"relay_1": rbuf[1] == 1,
"relay_2": rbuf[2] == 1,
"relay_3": rbuf[3] == 1,
"relay_4": rbuf[4] == 1,
}
def activer(nom: str):
cfg = CANAUX.get(nom)
if not cfg:
return False
return _relay_set(cfg["relay"], True)
def desactiver(nom: str):
cfg = CANAUX.get(nom)
if not cfg:
return False
return _relay_set(cfg["relay"], False)
def projecteurs(on: bool):
_relay_set(1, on)
_relay_set(2, on)
log.info(f"Projecteurs {'allumés' if on else 'éteints'}")
def power_cycle_canon(duree: float = 5.0):
"""Cycle complet R3+R4 : coupe dummy battery + VBUS, reboot Canon.
Résistance de décharge 470Ω sur le 8V → condensateurs vidés en ~3s."""
log.info(f"Power-cycle Canon R3+R4 ({duree}s)")
_relay_set(3, True) # R3 ON = coupe dummy battery (NF)
_relay_set(4, True) # R4 ON = coupe VBUS USB (NF)
time.sleep(duree) # résistance vide les caps en ~3s
_relay_set(3, False) # R3 OFF = dummy battery → Canon boot
time.sleep(15) # attente boot Canon
_relay_set(4, False) # R4 OFF = VBUS → USB enumeration
log.info("Canon ré-alimenté — attente USB enumeration")
def hard_reset_canon(duree: float = 10.0):
"""Reset prolongé pour freeze PTP sévère.
Résistance de décharge 470Ω → 5s suffisent, mais on garde une marge."""
log.warning(f"Hard reset Canon — coupure R3+R4 ({duree}s)")
_relay_set(3, True) # R3 ON = coupe dummy battery
_relay_set(4, True) # R4 ON = coupe VBUS
time.sleep(duree) # vidange complète avec résistance
_relay_set(3, False) # R3 OFF = dummy battery → Canon boot
time.sleep(15) # attente boot Canon
_relay_set(4, False) # R4 OFF = VBUS → USB enumeration
log.info("Hard reset Canon terminé — attente démarrage")
def etat_complet():
raw = status()
if raw is None:
return {"connecte": False}
result = {"connecte": True}
for nom, cfg in CANAUX.items():
relay_on = raw.get(f"relay_{cfg['relay']}", False)
if cfg["mode"] == "NF":
result[nom] = not relay_on
else:
result[nom] = relay_on
return result

262
backend/wifi.py Normal file
View File

@@ -0,0 +1,262 @@
"""Gestion WiFi via nmcli — scan, connexion, réseaux enregistrés."""
import json
import logging
import subprocess
from pathlib import Path
log = logging.getLogger("photobooth.wifi")
WIFI_PASSWORDS_FILE = Path(__file__).parent.parent / "data" / "wifi_passwords.json"
def _nmcli_split(line):
"""Split nmcli -t output, gère les ':' échappés par '\\'."""
parts = []
cur = []
i = 0
while i < len(line):
if line[i] == '\\' and i + 1 < len(line):
cur.append(line[i + 1])
i += 2
elif line[i] == ':':
parts.append(''.join(cur))
cur = []
i += 1
else:
cur.append(line[i])
i += 1
parts.append(''.join(cur))
return parts
def _charger_mdp() -> dict:
if WIFI_PASSWORDS_FILE.exists():
try:
return json.loads(WIFI_PASSWORDS_FILE.read_text())
except Exception:
return {}
return {}
def _sauvegarder_mdp(data: dict):
WIFI_PASSWORDS_FILE.parent.mkdir(parents=True, exist_ok=True)
WIFI_PASSWORDS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
def wifi_status() -> dict:
result = {"connecte": False, "ssid": None, "signal": None, "ip": None, "interface": None}
try:
r = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"],
capture_output=True, text=True, timeout=10,
)
for line in r.stdout.strip().split("\n"):
p = _nmcli_split(line)
if len(p) >= 4 and p[1] == "wifi" and p[2] == "connected":
result.update(connecte=True, interface=p[0], ssid=p[3])
break
if result["connecte"] and result["interface"]:
r2 = subprocess.run(
["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", result["interface"]],
capture_output=True, text=True, timeout=5,
)
for line in r2.stdout.strip().split("\n"):
if "IP4.ADDRESS" in line:
result["ip"] = line.split(":", 1)[1].strip().split("/")[0]
break
r3 = subprocess.run(
["nmcli", "-t", "-f", "ACTIVE,SIGNAL", "dev", "wifi", "list"],
capture_output=True, text=True, timeout=5,
)
for line in r3.stdout.strip().split("\n"):
p = _nmcli_split(line)
if len(p) >= 2 and p[0] in ("yes", "oui") and p[1].isdigit():
result["signal"] = int(p[1])
break
except Exception as e:
log.warning(f"wifi_status: {e}")
return result
def wifi_scan() -> list:
saved_ssids = {n["ssid"] for n in wifi_saved_list()}
networks = []
try:
r = subprocess.run(
["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,ACTIVE", "dev", "wifi", "list", "--rescan", "yes"],
capture_output=True, text=True, timeout=20,
)
merged = {}
for line in r.stdout.strip().split("\n"):
if not line.strip():
continue
p = _nmcli_split(line)
if len(p) < 4:
continue
ssid = p[0]
if not ssid:
continue
sig = int(p[1]) if p[1].isdigit() else 0
is_active = p[3] in ("yes", "oui")
if ssid in merged:
if sig > merged[ssid]["signal"]:
merged[ssid]["signal"] = sig
if is_active:
merged[ssid]["actif"] = True
else:
merged[ssid] = {
"ssid": ssid,
"signal": sig,
"securise": bool(p[2] and p[2] not in ("--", "")),
"securite": p[2] if p[2] not in ("--", "") else "",
"actif": is_active,
"enregistre": ssid in saved_ssids,
}
networks = list(merged.values())
networks.sort(key=lambda n: (-n["actif"], -n["signal"]))
except Exception as e:
log.warning(f"wifi_scan: {e}")
return networks
def wifi_connect(ssid: str, password: str | None = None) -> dict:
mdp = _charger_mdp()
if not password and ssid in mdp:
password = mdp[ssid]
# Réseau enregistré sans nouveau mot de passe → activer la connexion existante
if not password:
try:
r = subprocess.run(
["nmcli", "connection", "up", ssid],
capture_output=True, text=True, timeout=30,
)
if r.returncode == 0:
return {"succes": True, "message": f"Connecté à {ssid}"}
except Exception:
pass
if password:
try:
# Supprimer l'ancienne connexion pour éviter les doublons
subprocess.run(["nmcli", "connection", "delete", ssid],
capture_output=True, text=True, timeout=5)
r = subprocess.run(
["nmcli", "dev", "wifi", "connect", ssid, "password", password],
capture_output=True, text=True, timeout=30,
)
if r.returncode == 0:
mdp[ssid] = password
_sauvegarder_mdp(mdp)
return {"succes": True, "message": f"Connecté à {ssid}"}
err = r.stderr.strip() or r.stdout.strip()
if "secret" in err.lower() or "no suitable" in err.lower():
return {"succes": False, "message": "Mot de passe incorrect"}
return {"succes": False, "message": err}
except subprocess.TimeoutExpired:
return {"succes": False, "message": "Timeout — le réseau ne répond pas"}
except Exception as e:
return {"succes": False, "message": str(e)}
return {"succes": False, "message": "Mot de passe requis"}
def wifi_saved_list() -> list:
saved = []
mdp = _charger_mdp()
try:
r = subprocess.run(
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
capture_output=True, text=True, timeout=5,
)
for line in r.stdout.strip().split("\n"):
p = _nmcli_split(line)
if len(p) >= 2 and p[1] == "802-11-wireless":
saved.append({"ssid": p[0], "mdp_connu": p[0] in mdp})
except Exception as e:
log.warning(f"wifi_saved_list: {e}")
return saved
def wifi_forget(ssid: str) -> dict:
mdp = _charger_mdp()
try:
r = subprocess.run(
["nmcli", "connection", "delete", ssid],
capture_output=True, text=True, timeout=10,
)
if ssid in mdp:
del mdp[ssid]
_sauvegarder_mdp(mdp)
if r.returncode == 0:
return {"succes": True}
return {"succes": False, "message": r.stderr.strip()}
except Exception as e:
return {"succes": False, "message": str(e)}
def wifi_get_password(ssid: str) -> str | None:
return _charger_mdp().get(ssid)
# --- Watchdog connectivité ---
_internet_ok = False
def check_internet(timeout: int = 5) -> bool:
"""Teste la connectivité internet (DNS + HTTP rapide)."""
import socket
try:
socket.create_connection(("1.1.1.1", 53), timeout=timeout).close()
return True
except OSError:
return False
def watchdog_tick() -> str | None:
"""Vérifie internet. Retourne 'restored' si passage offline→online, None sinon."""
global _internet_ok
now_ok = check_internet()
if now_ok and not _internet_ok:
_internet_ok = True
log.info("Internet restauré — flush spool")
return "restored"
_internet_ok = now_ok
if not now_ok:
_tenter_reconnexion_wifi()
return None
def _tenter_reconnexion_wifi():
"""Si déconnecté du WiFi, tente de se reconnecter à un réseau enregistré visible."""
status = wifi_status()
if status["connecte"]:
return
log.info("Pas de WiFi — scan des réseaux enregistrés")
saved = {n["ssid"] for n in wifi_saved_list()}
if not saved:
return
try:
r = subprocess.run(
["nmcli", "-t", "-f", "SSID,SIGNAL", "dev", "wifi", "list", "--rescan", "yes"],
capture_output=True, text=True, timeout=15,
)
candidates = []
for line in r.stdout.strip().split("\n"):
p = _nmcli_split(line)
if len(p) >= 2 and p[0] in saved and p[1].isdigit():
candidates.append((p[0], int(p[1])))
candidates.sort(key=lambda x: -x[1])
for ssid, sig in candidates:
log.info(f"Tentative reconnexion WiFi: {ssid} (signal {sig}%)")
result = wifi_connect(ssid)
if result.get("succes"):
log.info(f"Reconnecté à {ssid}")
return
except Exception as e:
log.warning(f"Reconnexion WiFi échouée: {e}")

16
booth/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "booth-gallery",
"version": "1.0.0",
"description": "Galerie photo live pour photobooth",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"express": "^4.21.0",
"ws": "^8.18.0",
"multer": "^1.4.5-lts.1",
"sharp": "^0.33.5"
}
}

432
booth/public/index.html Normal file
View File

@@ -0,0 +1,432 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Galerie Photo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a0f;
--card: #14141f;
--accent: #e91e63;
--text: #ffffff;
--text2: #8888aa;
--radius: 16px;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* --- Ecran de login --- */
#login-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
#login-screen.hidden { display: none; }
#gallery-screen.hidden { display: none; }
.login-box {
background: var(--card);
border-radius: var(--radius);
padding: 3rem;
max-width: 400px;
width: 100%;
text-align: center;
}
.login-box h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.login-box p {
color: var(--text2);
margin-bottom: 2rem;
}
.login-box .icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.code-input {
display: flex;
gap: 12px;
justify-content: center;
margin-bottom: 1.5rem;
}
.code-input input {
width: 60px;
height: 70px;
text-align: center;
font-size: 2rem;
font-weight: bold;
border: 2px solid #333;
border-radius: 12px;
background: var(--bg);
color: var(--text);
outline: none;
-webkit-appearance: none;
}
.code-input input:focus {
border-color: var(--accent);
}
.login-error {
color: #f44336;
font-size: 0.9rem;
margin-top: 0.5rem;
display: none;
}
.login-error.visible { display: block; }
/* --- Galerie --- */
#gallery-screen {
padding: 1rem;
}
.gallery-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
margin-bottom: 1rem;
}
.gallery-header h1 {
font-size: 1.5rem;
}
.photo-count {
color: var(--text2);
font-size: 0.9rem;
}
.live-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(233, 30, 99, 0.2);
color: var(--accent);
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.live-dot {
width: 8px;
height: 8px;
background: var(--accent);
border-radius: 50%;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
padding: 0 1rem 2rem;
}
.photo-card {
position: relative;
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
aspect-ratio: 4/3;
background: var(--card);
animation: fadeIn 0.4s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.photo-card img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.photo-card:active img {
transform: scale(1.05);
}
.photo-time {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0,0,0,0.6);
color: white;
padding: 2px 8px;
border-radius: 8px;
font-size: 0.75rem;
}
/* --- Lightbox --- */
.lightbox {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0,0,0,0.95);
z-index: 100;
align-items: center;
justify-content: center;
flex-direction: column;
}
.lightbox.active { display: flex; }
.lightbox img {
max-width: 95%;
max-height: 85vh;
border-radius: 8px;
object-fit: contain;
}
.lightbox-close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: white;
font-size: 2.5rem;
cursor: pointer;
padding: 0.5rem;
}
.lightbox-download {
margin-top: 1rem;
background: var(--accent);
color: white;
border: none;
padding: 12px 24px;
border-radius: 12px;
font-size: 1rem;
cursor: pointer;
}
/* --- Responsive --- */
@media (max-width: 600px) {
.photo-grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.login-box { padding: 2rem; }
.code-input input { width: 50px; height: 60px; font-size: 1.5rem; }
}
/* --- Empty state --- */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text2);
}
.empty-state .icon { font-size: 4rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<!-- Ecran de login -->
<div id="login-screen">
<div class="login-box">
<div class="icon">&#128247;</div>
<h1>Galerie Photo</h1>
<p>Saisissez le code affiche sur la borne</p>
<div class="code-input">
<input type="tel" maxlength="1" data-index="0" inputmode="numeric" autocomplete="off">
<input type="tel" maxlength="1" data-index="1" inputmode="numeric" autocomplete="off">
<input type="tel" maxlength="1" data-index="2" inputmode="numeric" autocomplete="off">
<input type="tel" maxlength="1" data-index="3" inputmode="numeric" autocomplete="off">
</div>
<div id="login-error" class="login-error">Code incorrect</div>
</div>
</div>
<!-- Ecran galerie -->
<div id="gallery-screen" class="hidden">
<div class="gallery-header">
<div>
<h1>Galerie Photo</h1>
<span class="photo-count" id="photo-count">0 photos</span>
</div>
<span class="live-badge"><span class="live-dot"></span> LIVE</span>
</div>
<div class="photo-grid" id="photo-grid"></div>
<div class="empty-state" id="empty-state">
<div class="icon">&#128248;</div>
<p>Les photos apparaitront ici en temps reel</p>
</div>
</div>
<!-- Lightbox -->
<div class="lightbox" id="lightbox">
<button class="lightbox-close" onclick="closeLightbox()">&times;</button>
<img id="lightbox-img" src="">
<button class="lightbox-download" id="lightbox-dl" onclick="downloadPhoto()">Telecharger</button>
</div>
<script>
const eventId = window.location.pathname.replace(/^\//, '') || 'default';
let token = null;
let ws = null;
let photos = [];
// --- Login ---
const codeInputs = document.querySelectorAll('.code-input input');
codeInputs.forEach((input, i) => {
input.addEventListener('input', (e) => {
const val = e.target.value;
if (val && i < 3) codeInputs[i + 1].focus();
if (val && i === 3) tryLogin();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !e.target.value && i > 0) {
codeInputs[i - 1].focus();
}
});
});
codeInputs[0].focus();
async function tryLogin() {
const code = Array.from(codeInputs).map(i => i.value).join('');
if (code.length !== 4) return;
try {
const res = await fetch(`/api/${eventId}/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: code }),
});
if (res.ok) {
const data = await res.json();
token = data.token;
document.getElementById('login-error').classList.remove('visible');
showGallery();
} else {
document.getElementById('login-error').classList.add('visible');
codeInputs.forEach(i => { i.value = ''; });
codeInputs[0].focus();
}
} catch (err) {
console.error(err);
}
}
// --- Galerie ---
async function showGallery() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('gallery-screen').classList.remove('hidden');
// Charger les photos existantes
const res = await fetch(`/api/${eventId}/photos`, {
headers: { 'X-Token': token },
});
const data = await res.json();
photos = data.photos || [];
renderPhotos();
// Connexion WebSocket pour les nouvelles photos
connectWS();
}
function renderPhotos() {
const grid = document.getElementById('photo-grid');
const empty = document.getElementById('empty-state');
const count = document.getElementById('photo-count');
count.textContent = `${photos.length} photo${photos.length > 1 ? 's' : ''}`;
if (photos.length === 0) {
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
// Afficher les photos les plus recentes en premier
const sorted = [...photos].reverse();
grid.innerHTML = sorted.map(p => `
<div class="photo-card" onclick="openLightbox('${p.name}')">
<img src="/thumbs/${eventId}/${p.name}?token=${token}" loading="lazy" alt="">
<span class="photo-time">${formatTime(p.timestamp)}</span>
</div>
`).join('');
}
function formatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
// --- WebSocket live ---
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}?event=${eventId}&token=${token}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'new_photo') {
photos.push(msg.photo);
renderPhotos();
}
};
ws.onclose = () => {
setTimeout(connectWS, 3000);
};
}
// --- Lightbox ---
let currentPhoto = null;
function openLightbox(name) {
currentPhoto = name;
document.getElementById('lightbox-img').src = `/photos/${eventId}/${name}?token=${token}`;
document.getElementById('lightbox').classList.add('active');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('active');
}
document.getElementById('lightbox').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeLightbox();
});
function downloadPhoto() {
if (!currentPhoto) return;
const a = document.createElement('a');
a.href = `/photos/${eventId}/${currentPhoto}?token=${token}`;
a.download = currentPhoto;
a.click();
}
</script>
</body>
</html>

224
booth/server.js Normal file
View File

@@ -0,0 +1,224 @@
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const multer = require('multer');
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// --- Configuration ---
const PORT = process.env.PORT || 3000;
const PHOTOS_DIR = path.join(__dirname, 'data', 'photos');
const THUMBS_DIR = path.join(__dirname, 'data', 'thumbs');
// Creer les dossiers
fs.mkdirSync(PHOTOS_DIR, { recursive: true });
fs.mkdirSync(THUMBS_DIR, { recursive: true });
// --- Sessions evenement ---
// { eventId: { password, photos: [{name, timestamp}], createdAt } }
const sessions = new Map();
function genererPassword() {
return crypto.randomInt(1000, 9999).toString();
}
function getSession(eventId) {
if (!sessions.has(eventId)) {
sessions.set(eventId, {
password: genererPassword(),
photos: [],
createdAt: Date.now(),
});
// Creer le dossier
fs.mkdirSync(path.join(PHOTOS_DIR, eventId), { recursive: true });
fs.mkdirSync(path.join(THUMBS_DIR, eventId), { recursive: true });
}
return sessions.get(eventId);
}
// --- Upload photos depuis la borne ---
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 20 * 1024 * 1024 }, // 20 Mo max
});
// API Key pour l'upload (simple)
const API_KEY = process.env.API_KEY || 'booth-secret-key';
function verifierApiKey(req, res, next) {
const key = req.headers['x-api-key'] || req.query.key;
if (key !== API_KEY) {
return res.status(401).json({ error: 'Cle API invalide' });
}
next();
}
// --- Routes API ---
// Upload photo depuis la borne
app.post('/api/:eventId/upload', verifierApiKey, upload.single('photo'), async (req, res) => {
try {
const { eventId } = req.params;
const session = getSession(eventId);
const timestamp = Date.now();
const filename = `${timestamp}.jpg`;
// Sauvegarder la photo originale
const photoPath = path.join(PHOTOS_DIR, eventId, filename);
await sharp(req.file.buffer)
.jpeg({ quality: 90 })
.toFile(photoPath);
// Creer la miniature
const thumbPath = path.join(THUMBS_DIR, eventId, filename);
await sharp(req.file.buffer)
.resize(400, 400, { fit: 'cover' })
.jpeg({ quality: 75 })
.toFile(thumbPath);
// Ajouter a la session
const photoInfo = { name: filename, timestamp };
session.photos.push(photoInfo);
// Notifier les clients WebSocket
broadcast(eventId, {
type: 'new_photo',
photo: photoInfo,
});
res.json({ ok: true, filename });
} catch (err) {
console.error('Erreur upload:', err);
res.status(500).json({ error: 'Erreur upload' });
}
});
// Info session (mot de passe pour la borne)
app.get('/api/:eventId/info', verifierApiKey, (req, res) => {
const session = getSession(req.params.eventId);
res.json({
password: session.password,
photoCount: session.photos.length,
});
});
// Regenerer le mot de passe
app.post('/api/:eventId/reset-password', verifierApiKey, (req, res) => {
const session = getSession(req.params.eventId);
session.password = genererPassword();
res.json({ password: session.password });
});
// --- Routes publiques (invites) ---
// Verifier le mot de passe
app.post('/api/:eventId/auth', express.json(), (req, res) => {
const session = sessions.get(req.params.eventId);
if (!session) {
return res.status(404).json({ error: 'Evenement introuvable' });
}
if (req.body.password !== session.password) {
return res.status(403).json({ error: 'Mot de passe incorrect' });
}
// Generer un token simple
const token = crypto.randomBytes(16).toString('hex');
session.token = session.token || new Set();
session.token.add(token);
res.json({ ok: true, token, photoCount: session.photos.length });
});
// Middleware pour verifier le token invite
function verifierToken(req, res, next) {
const token = req.headers['x-token'] || req.query.token;
const session = sessions.get(req.params.eventId);
if (!session || !session.token || !session.token.has(token)) {
return res.status(403).json({ error: 'Acces refuse' });
}
req.session = session;
next();
}
// Liste des photos
app.get('/api/:eventId/photos', verifierToken, (req, res) => {
res.json({ photos: req.session.photos });
});
// Servir les photos
app.get('/photos/:eventId/:filename', (req, res) => {
const token = req.query.token;
const session = sessions.get(req.params.eventId);
if (!session || !session.token || !session.token.has(token)) {
return res.status(403).send('Acces refuse');
}
const filePath = path.join(PHOTOS_DIR, req.params.eventId, req.params.filename);
if (!fs.existsSync(filePath)) return res.status(404).send('Not found');
res.sendFile(filePath);
});
// Servir les miniatures
app.get('/thumbs/:eventId/:filename', (req, res) => {
const token = req.query.token;
const session = sessions.get(req.params.eventId);
if (!session || !session.token || !session.token.has(token)) {
return res.status(403).send('Acces refuse');
}
const filePath = path.join(THUMBS_DIR, req.params.eventId, req.params.filename);
if (!fs.existsSync(filePath)) return res.status(404).send('Not found');
res.sendFile(filePath);
});
// --- WebSocket ---
wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
const eventId = url.searchParams.get('event');
const token = url.searchParams.get('token');
const session = sessions.get(eventId);
if (!session || !session.token || !session.token.has(token)) {
ws.close(4001, 'Acces refuse');
return;
}
ws.eventId = eventId;
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('error', () => {});
});
function broadcast(eventId, data) {
const msg = JSON.stringify(data);
wss.clients.forEach(client => {
if (client.eventId === eventId && client.readyState === WebSocket.OPEN) {
client.send(msg);
}
});
}
// Ping pour garder les connexions
setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
// --- Frontend statique ---
app.use(express.static(path.join(__dirname, 'public')));
// SPA fallback
app.get('/:eventId', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// --- Demarrage ---
server.listen(PORT, () => {
console.log(`Booth gallery running on port ${PORT}`);
});

View File

@@ -1,85 +0,0 @@
{
"evenement": {
"nom": "Mon Evenement",
"logo": null,
"couleur_primaire": "#e91e63",
"couleur_secondaire": "#ffffff",
"media_accueil": null
},
"fonctionnalites": {
"photo_simple": true,
"multi_shot": true,
"filtres": true,
"overlays": true,
"chroma_key": false,
"impression": true,
"email": true,
"qr_code": true,
"galerie": true
},
"camera": {
"appareil": null,
"iso": "auto",
"balance_blancs": "auto",
"compte_a_rebours": 3,
"animation_compte_a_rebours": "classique",
"animations_css_actives": ["classique"]
},
"animations_custom": {
"actives": []
},
"compteur": {
"actif": true,
"limite": 400,
"photos_prises": 0
},
"destinations": {
"memoire_interne": true,
"cle_usb": false,
"chemin_usb": "/media/usb",
"ftp": false,
"ftp_host": "",
"ftp_port": 21,
"ftp_user": "",
"ftp_password": "",
"ftp_chemin": "/photobooth",
"site_web": false,
"site_web_url": "",
"email_auto": false,
"sauvegarder_tout": true
},
"impression": {
"imprimante": null,
"copies_max": 5,
"format": "10x15"
},
"cadres": {
"actifs": []
},
"email": {
"smtp_host": "",
"smtp_port": 587,
"smtp_user": "",
"smtp_password": "",
"expediteur": "",
"sujet": "Votre photo - {evenement}",
"message": "Voici votre photo prise lors de {evenement} ! Merci et a bientot."
},
"qr_code": {
"url_galerie": ""
},
"multi_shot": {
"nombre_photos": 3,
"mode": "strip",
"delai_entre_photos": 2
},
"chroma_key": {
"couleur": "#00ff00",
"tolerance": 40,
"fond_par_defaut": null
},
"serveur": {
"host": "0.0.0.0",
"port": 8080
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

File diff suppressed because it is too large Load Diff

381
frontend/css/themes.css Normal file
View File

@@ -0,0 +1,381 @@
/* === Themes Photobooth === */
/* --- Theme Base (defaut, sombre moderne) --- */
[data-theme="base"] {
--primaire: #e91e63;
--secondaire: #ffffff;
--fond: #1a1a2e;
--fond-carte: #16213e;
--texte: #ffffff;
--texte-secondaire: #a0a0b0;
--danger: #f44336;
--succes: #4caf50;
--rayon: 16px;
--ombre: 0 8px 32px rgba(0,0,0,0.3);
--accent-gradient: linear-gradient(135deg, #e91e63, #ff5252);
--pellicule-fond: #141414;
--pellicule-perf: #3a3a3a;
}
/* --- Theme SikaPics (rose elegant, style photographe) --- */
[data-theme="sikapics"] {
--primaire: #c31f48;
--secondaire: #240439;
--fond: #fbe7e8;
--fond-carte: #ffffff;
--texte: #240439;
--texte-secondaire: #666666;
--danger: #c31f48;
--succes: #4caf50;
--rayon: 20px;
--ombre: 0 4px 24px rgba(195, 31, 72, 0.12);
--accent-gradient: linear-gradient(135deg, #c31f48, #f33362);
--pellicule-fond: #240439;
--pellicule-perf: #4a2060;
}
[data-theme="sikapics"] body,
[data-theme="sikapics"] {
font-family: 'Montserrat', 'Segoe UI', system-ui, sans-serif;
}
[data-theme="sikapics"] .ecran {
background: var(--fond);
}
[data-theme="sikapics"] #ecran-accueil {
background: linear-gradient(160deg, #fbe7e8 0%, #fff0f1 40%, #ffffff 100%);
}
[data-theme="sikapics"] #nom-evenement {
font-family: 'Dancing Script', 'Kalnia', cursive, serif;
font-size: 4.5rem;
color: var(--primaire);
font-weight: 600;
}
/* Logo SikaPics gere via JS (appliquerConfig) */
[data-theme="sikapics"] .accueil-instruction {
color: var(--texte-secondaire);
font-weight: 300;
letter-spacing: 2px;
text-transform: uppercase;
font-size: 1rem;
}
[data-theme="sikapics"] .cercle-pulse {
border-color: var(--primaire);
background: rgba(195, 31, 72, 0.1);
}
[data-theme="sikapics"] .btn-mode {
background: #ffffff;
border: 2px solid #eee;
color: var(--texte);
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
[data-theme="sikapics"] .btn-mode:active {
border-color: var(--primaire);
box-shadow: 0 2px 16px rgba(195, 31, 72, 0.2);
}
[data-theme="sikapics"] .btn-action {
background: var(--accent-gradient);
border: none;
color: #fff;
font-weight: 600;
letter-spacing: 1px;
}
[data-theme="sikapics"] .btn-secondaire {
background: transparent;
border: 2px solid var(--primaire);
color: var(--primaire);
}
[data-theme="sikapics"] .btn-retour {
color: var(--texte-secondaire);
border-color: #ccc;
}
[data-theme="sikapics"] .btn-partage {
background: #ffffff;
border: 2px solid #eee;
color: var(--texte);
}
[data-theme="sikapics"] .btn-filtre,
[data-theme="sikapics"] .btn-overlay {
background: #ffffff;
border: 2px solid #eee;
color: var(--texte);
}
[data-theme="sikapics"] .btn-filtre.actif,
[data-theme="sikapics"] .btn-overlay.actif {
border-color: var(--primaire);
background: rgba(195, 31, 72, 0.08);
}
[data-theme="sikapics"] .popup-box {
background: #ffffff;
color: var(--texte);
}
[data-theme="sikapics"] .popup-box input {
background: #f8f0f1;
border-color: #e0c0c5;
color: var(--texte);
}
[data-theme="sikapics"] .pave-touche {
background: rgba(195, 31, 72, 0.08);
color: var(--texte);
}
[data-theme="sikapics"] .pave-touche:active {
background: rgba(195, 31, 72, 0.2);
}
[data-theme="sikapics"] .pave-effacer {
background: rgba(195, 31, 72, 0.15);
}
[data-theme="sikapics"] .pave-valider {
background: rgba(76, 175, 80, 0.15);
}
[data-theme="sikapics"] .admin-contenu {
background: #ffffff;
}
[data-theme="sikapics"] .admin-onglets {
background: #f8f0f1;
}
[data-theme="sikapics"] .onglet {
color: var(--texte-secondaire);
}
[data-theme="sikapics"] .onglet.actif {
color: var(--primaire);
background: #ffffff;
}
[data-theme="sikapics"] .compte-a-rebours {
color: var(--primaire);
text-shadow: 0 4px 20px rgba(195, 31, 72, 0.3);
}
[data-theme="sikapics"] .flash-blanc {
background: rgba(251, 231, 232, 0.9);
}
[data-theme="sikapics"] .booth-info {
background: rgba(36, 4, 57, 0.7);
color: #fff;
}
[data-theme="sikapics"] .booth-code {
color: #ffffff;
}
[data-theme="sikapics"] .compteur-accueil {
background: rgba(36, 4, 57, 0.5);
color: #ffffff;
}
[data-theme="sikapics"] .btn-admin {
color: rgba(36, 4, 57, 0.2);
}
[data-theme="sikapics"] .btn-admin:active {
color: rgba(36, 4, 57, 0.5);
}
/* --- Theme Viking Celte (bois sculpte, bronze, entrelacs) --- */
[data-theme="viking"] {
--primaire: #b08d57;
--secondaire: #f0e6d2;
--fond: #1a0f08;
--fond-carte: #2b1810;
--texte: #f0e6d2;
--texte-secondaire: #a08a6e;
--danger: #8b2500;
--succes: #5a7a3a;
--rayon: 8px;
--ombre: 0 8px 32px rgba(0,0,0,0.5);
--accent-gradient: linear-gradient(135deg, #b08d57, #d4a843);
--pellicule-fond: #1a0f08;
--pellicule-perf: #4a3520;
}
[data-theme="viking"] body,
[data-theme="viking"] {
font-family: 'MedievalSharp', 'Cinzel', 'Georgia', serif;
}
[data-theme="viking"] .ecran {
background: var(--fond);
}
[data-theme="viking"] #ecran-accueil {
background:
radial-gradient(ellipse at center, rgba(176,141,87,0.08) 0%, transparent 70%),
linear-gradient(180deg, #1a0f08 0%, #2b1810 50%, #1a0f08 100%);
}
[data-theme="viking"] #nom-evenement {
font-family: 'MedievalSharp', 'Cinzel', serif;
font-size: 4rem;
color: var(--primaire);
font-weight: 700;
text-shadow: 0 2px 8px rgba(0,0,0,0.6), 0 0 30px rgba(176,141,87,0.2);
letter-spacing: 3px;
}
[data-theme="viking"] .accueil-instruction {
color: var(--texte-secondaire);
font-weight: 400;
letter-spacing: 3px;
text-transform: uppercase;
font-size: 0.95rem;
}
[data-theme="viking"] .cercle-pulse {
border-color: var(--primaire);
background: rgba(176,141,87,0.1);
box-shadow: 0 0 40px rgba(176,141,87,0.15);
}
[data-theme="viking"] .btn-mode {
background: linear-gradient(145deg, #2b1810, #3a2418);
border: 2px solid #5a3d28;
color: var(--texte);
box-shadow: 0 2px 12px rgba(0,0,0,0.3), inset 0 1px 0 rgba(176,141,87,0.1);
}
[data-theme="viking"] .btn-mode:active {
border-color: var(--primaire);
box-shadow: 0 2px 16px rgba(176,141,87,0.3);
}
[data-theme="viking"] .btn-action {
background: var(--accent-gradient);
border: none;
color: #1a0f08;
font-weight: 700;
letter-spacing: 2px;
text-shadow: 0 1px 0 rgba(255,255,255,0.2);
}
[data-theme="viking"] .btn-secondaire {
background: transparent;
border: 2px solid var(--primaire);
color: var(--primaire);
}
[data-theme="viking"] .btn-retour {
color: var(--texte-secondaire);
border-color: #5a3d28;
}
[data-theme="viking"] .btn-partage {
background: linear-gradient(145deg, #2b1810, #3a2418);
border: 2px solid #5a3d28;
color: var(--texte);
}
[data-theme="viking"] .btn-filtre,
[data-theme="viking"] .btn-overlay {
background: linear-gradient(145deg, #2b1810, #3a2418);
border: 2px solid #5a3d28;
color: var(--texte);
}
[data-theme="viking"] .btn-filtre.actif,
[data-theme="viking"] .btn-overlay.actif {
border-color: var(--primaire);
background: rgba(176,141,87,0.15);
}
[data-theme="viking"] .popup-box {
background: linear-gradient(145deg, #2b1810, #1a0f08);
color: var(--texte);
border: 1px solid #5a3d28;
}
[data-theme="viking"] .popup-box input {
background: #1a0f08;
border-color: #5a3d28;
color: var(--texte);
}
[data-theme="viking"] .pave-touche {
background: rgba(176,141,87,0.1);
color: var(--texte);
border: 1px solid #5a3d28;
}
[data-theme="viking"] .pave-touche:active {
background: rgba(176,141,87,0.25);
}
[data-theme="viking"] .pave-effacer {
background: rgba(139,37,0,0.2);
}
[data-theme="viking"] .pave-valider {
background: rgba(90,122,58,0.2);
}
[data-theme="viking"] .admin-contenu {
background: #2b1810;
}
[data-theme="viking"] .admin-onglets {
background: #1a0f08;
}
[data-theme="viking"] .onglet {
color: var(--texte-secondaire);
}
[data-theme="viking"] .onglet.actif {
color: var(--primaire);
background: #2b1810;
}
[data-theme="viking"] .compte-a-rebours {
color: var(--primaire);
text-shadow: 0 4px 20px rgba(176,141,87,0.4), 0 0 60px rgba(176,141,87,0.15);
}
[data-theme="viking"] .flash-blanc {
background: rgba(240,230,210,0.85);
}
[data-theme="viking"] .booth-info {
background: rgba(26,15,8,0.8);
border: 1px solid #5a3d28;
color: var(--texte);
}
[data-theme="viking"] .compteur-accueil {
background: rgba(26,15,8,0.6);
border: 1px solid rgba(176,141,87,0.3);
color: var(--texte);
}
[data-theme="viking"] .btn-admin {
color: rgba(176,141,87,0.15);
}
[data-theme="viking"] .btn-admin:active {
color: rgba(176,141,87,0.4);
}
/* Import Google Fonts pour SikaPics + Viking */
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Dancing+Script:wght@400;600;700&family=MedievalSharp&family=Montserrat:wght@300;400;500;600;700&display=swap');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,11 @@ let modeActuel = 'simple';
let photosSession = []; // Photos de la session en cours
let photoFinale = null; // Photo finale (avec effets)
let ecranActuel = 'accueil';
let _veilleTimer = null;
let _enVeille = false;
let _spotsAllumes = false;
const _shutterSound = new Audio('/sounds/shutter.wav');
_shutterSound.volume = 0.8;
// --- Initialisation ---
@@ -14,28 +19,54 @@ async function init() {
setupEcranAccueil();
setupModes();
setupOnglets();
chargerBoothInfo();
verifierPhotostationDisponible();
}
function appliquerConfig() {
const event = config.evenement || {};
const fonc = config.fonctionnalites || {};
// Couleurs
document.documentElement.style.setProperty('--primaire', event.couleur_primaire || '#e91e63');
document.documentElement.style.setProperty('--secondaire', event.couleur_secondaire || '#ffffff');
// Theme
const theme = event.theme || 'base';
document.documentElement.setAttribute('data-theme', theme);
// Couleurs (seulement si theme base, sinon le theme CSS gere)
if (theme === 'base') {
document.documentElement.style.setProperty('--primaire', event.couleur_primaire || '#e91e63');
document.documentElement.style.setProperty('--secondaire', event.couleur_secondaire || '#ffffff');
}
// Logo selon le theme
const logo = document.getElementById('accueil-logo');
if (logo) {
const logos = { sikapics: '/assets/logos/sikapics.png' };
if (logos[theme]) {
logo.src = logos[theme];
logo.classList.remove('cache');
} else {
logo.classList.add('cache');
}
}
// Nom evenement
const h1 = document.getElementById('nom-evenement');
if (h1) h1.textContent = event.nom || 'Photobooth';
// Visibilite des modes
toggleVisible('btn-multi', fonc.multi_shot);
// Visibilite des modes (restreint par formats evenement)
const fmtsEvent = event.formats_actifs;
const stripDispo = !fmtsEvent || fmtsEvent.includes('strip');
toggleVisible('btn-multi', fonc.multi_shot && stripDispo);
toggleVisible('btn-imprimer', fonc.impression);
toggleVisible('btn-email', fonc.email);
toggleVisible('btn-qr', fonc.qr_code);
const boothActif = config.booth?.actif;
toggleVisible('btn-qr', fonc.qr_code || boothActif);
// Media d'accueil (video/gif en boucle)
appliquerMediaAccueil(event.media_accueil);
// Precharger image surprise
if (typeof prechargerSurprise === 'function') prechargerSurprise();
}
function appliquerMediaAccueil(media) {
@@ -67,6 +98,7 @@ function appliquerMediaAccueil(media) {
video.classList.remove('cache');
video.play().catch(() => {});
}
}
// --- Navigation ---
@@ -80,24 +112,44 @@ function allerA(ecran) {
ecranActuel = ecran;
}
// Masquer le popup QR a chaque changement d'ecran
fermerQR();
// Actions au changement d'ecran
if (ecran === 'accueil') {
photosSession = [];
photoFinale = null;
arreterPreview();
majCompteurAccueil();
} else if (ecran === 'capture') {
eteindreSpots();
lancerTimerVeille();
} else {
arreterTimerVeille();
reveillerEclairage();
}
if (ecran === 'capture') {
lancerCapture();
} else if (ecran === 'partage') {
majBoutonImprimerCompteur();
} else if (ecran === 'galerie') {
chargerGalerie();
} else if (ecran === 'admin') {
chargerAdmin();
demarrerTimeoutAdmin();
} else {
arreterTimeoutAdmin();
}
}
function demarrerTimeoutAdmin() {}
function arreterTimeoutAdmin() {}
function recommencer() {
photosSession = [];
photoFinale = null;
photoImpression = null;
formatImpression = null;
nbExemplaires = 1;
allerA('mode');
}
@@ -109,6 +161,8 @@ function setupEcranAccueil() {
// Toucher pour commencer (ignorer le bouton admin et le popup)
accueil.addEventListener('click', (e) => {
if (e.target.closest('.btn-admin')) return;
if (e.target.closest('.btn-photostation')) return;
if (_enVeille) reveillerEclairage();
allerA('mode');
});
}
@@ -116,6 +170,8 @@ function setupEcranAccueil() {
// --- Admin : icone + mot de passe ---
const MDP_ADMIN = '1234';
const PIN_PHOTOSTATION = '1234';
let _pinPhotostationSaisi = '';
function ouvrirAdmin() {
const popup = document.getElementById('popup-mdp');
@@ -123,7 +179,6 @@ function ouvrirAdmin() {
popup.classList.remove('cache');
input.value = '';
document.getElementById('mdp-erreur').classList.add('cache');
setTimeout(() => input.focus(), 100);
}
function fermerPopupMdp() {
@@ -131,18 +186,85 @@ function fermerPopupMdp() {
document.getElementById('input-mdp-admin').value = '';
}
function paveTouche(chiffre) {
const input = document.getElementById('input-mdp-admin');
input.value += chiffre;
}
function paveEffacer() {
const input = document.getElementById('input-mdp-admin');
input.value = input.value.slice(0, -1);
}
// --- Photostation ---
async function verifierPhotostationDisponible() {
const data = await apiGet('/api/photostation/disponible').catch(() => null);
if (data?.disponible) {
document.getElementById('btn-photostation').classList.remove('cache');
}
}
function ouvrirPhotostation() {
_pinPhotostationSaisi = '';
_majAffichagePin();
document.getElementById('pin-photostation-erreur').classList.add('cache');
document.getElementById('popup-pin-photostation').classList.remove('cache');
}
function fermerPopupPhotostation() {
document.getElementById('popup-pin-photostation').classList.add('cache');
_pinPhotostationSaisi = '';
}
function pinPhotostationTouche(c) {
if (_pinPhotostationSaisi.length >= 4) return;
_pinPhotostationSaisi += c;
_majAffichagePin();
if (_pinPhotostationSaisi.length === 4) {
setTimeout(_validerPinPhotostation, 150);
}
}
function pinPhotostationEffacer() {
_pinPhotostationSaisi = _pinPhotostationSaisi.slice(0, -1);
_majAffichagePin();
}
function _majAffichagePin() {
const n = _pinPhotostationSaisi.length;
const el = document.getElementById('pin-photostation-affichage');
el.textContent = '●'.repeat(n) + '○'.repeat(4 - n);
}
async function _validerPinPhotostation() {
if (_pinPhotostationSaisi !== PIN_PHOTOSTATION) {
document.getElementById('pin-photostation-erreur').classList.remove('cache');
_pinPhotostationSaisi = '';
_majAffichagePin();
return;
}
fermerPopupPhotostation();
afficherStatut('Lancement de la station d\'impression...', 'succes');
await apiPost('/api/photostation/lancer', {});
}
function validerMdpAdmin() {
const input = document.getElementById('input-mdp-admin');
if (input.value === MDP_ADMIN) {
fermerPopupMdp();
allerA('admin');
allerA('admin-choix');
} else {
document.getElementById('mdp-erreur').classList.remove('cache');
input.value = '';
input.focus();
}
}
function ouvrirWizard() {
allerA('wizard');
wizardInit();
}
// Valider avec Entree
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !document.getElementById('popup-mdp').classList.contains('cache')) {
@@ -152,21 +274,78 @@ document.addEventListener('keydown', (e) => {
// --- Modes ---
let cadreChoisi = null; // cadre sélectionné par l'utilisateur avant capture
function setupModes() {
document.querySelectorAll('.btn-mode').forEach(btn => {
document.querySelectorAll('#ecran-mode .btn-mode').forEach(btn => {
btn.addEventListener('click', () => {
modeActuel = btn.dataset.mode;
cadreChoisi = null;
allerA('capture');
});
});
majDetailPellicule();
}
// --- Onglets admin ---
function majDetailPellicule() {
const el = document.getElementById('pellicule-detail');
if (!el) return;
const nb = config.multi_shot?.nombre_photos || 4;
el.textContent = `${nb} poses = 2 tirages`;
}
async function chargerCadresChoix() {
const cfg = await apiGet('/api/config');
const fmt = (cfg.impression?.format || '15x20').replace('-2up', '');
const data = await apiGet(`/api/cadres-impression/${fmt}`).catch(() => null);
if (!data || data.disponibles.length === 0) return false;
const grille = document.getElementById('grille-cadres-choix');
// Conserver "Sans cadre" en premier, supprimer les anciens cadres
const aucun = grille.querySelector('.cadre-choix-aucun');
grille.innerHTML = '';
if (aucun) grille.appendChild(aucun);
for (const nom of data.disponibles) {
const div = document.createElement('div');
div.className = 'cadre-choix-item';
div.innerHTML = `<div class="cadre-thumb-wrap"><img src="/assets/cadres/${fmt}/${nom}" alt="${nom}"></div><span>${nom.replace('.png','')}</span>`;
div.onclick = () => {
cadreChoisi = nom;
allerA('capture');
};
grille.appendChild(div);
}
return true;
}
function lancerSansCadre() {
cadreChoisi = null;
allerA('capture');
}
// --- Rubriques + Onglets admin ---
let rubriqueActive = 'general';
function changerRubrique(nom) {
rubriqueActive = nom;
document.querySelectorAll('.rubrique').forEach(r => r.classList.toggle('actif', r.dataset.rubrique === nom));
document.querySelectorAll('.admin-onglets').forEach(g => g.classList.toggle('cache', g.id !== 'onglets-' + nom));
document.querySelectorAll('.admin-panneau').forEach(p => p.classList.remove('actif'));
document.querySelectorAll('.onglet').forEach(o => o.classList.remove('actif'));
const groupe = document.getElementById('onglets-' + nom);
if (groupe) {
const premier = groupe.querySelector('.onglet');
if (premier) { premier.click(); }
}
}
function setupOnglets() {
document.querySelectorAll('.onglet').forEach(onglet => {
onglet.addEventListener('click', () => {
document.querySelectorAll('.onglet').forEach(o => o.classList.remove('actif'));
const groupe = onglet.closest('.admin-onglets');
if (groupe) groupe.querySelectorAll('.onglet').forEach(o => o.classList.remove('actif'));
document.querySelectorAll('.admin-panneau').forEach(p => p.classList.remove('actif'));
onglet.classList.add('actif');
const panneau = document.getElementById('panneau-' + onglet.dataset.onglet);
@@ -199,6 +378,30 @@ function toggleVisible(id, visible) {
}
}
async function chargerBoothInfo() {
const booth = config.booth || {};
const el = document.getElementById('booth-info');
if (!booth.actif || !booth.url) {
el.classList.add('cache');
return;
}
try {
const info = await apiGet('/api/booth/info');
if (info.password) {
const eventId = (config.evenement || {}).event_id || booth.event_id || 'default';
const qrUrl = `${booth.url}/g/${eventId}?p=${info.password}`;
document.getElementById('booth-qr').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`;
el.classList.remove('cache');
}
} catch (e) {
el.classList.add('cache');
}
}
function fermerQR() {
document.getElementById('popup-qr').classList.add('cache');
}
function afficherStatut(message, type = 'succes') {
const el = document.getElementById('statut-partage');
el.textContent = message;
@@ -211,7 +414,400 @@ function afficherStatut(message, type = 'succes') {
wsOnMessage('config_maj', (msg) => {
config = msg.config;
appliquerConfig();
if (ecranActuel === 'accueil') lancerTimerVeille();
});
// === Erreur equipement : overlay intelligent avec escalade ===
let _erreurCameraTimer = null;
let _erreurCameraCount = 0;
let _erreurCameraPhase = 0; // 0=auto-fix, 1=patience, 2=operator, 3=restart
function _showErreurEquipement(icon, titre, msg, phase) {
const el = document.getElementById('erreur-equipement');
document.getElementById('erreur-equip-icon').textContent = icon;
document.getElementById('erreur-equip-titre').textContent = titre;
document.getElementById('erreur-equip-msg').textContent = msg;
const spinner = document.getElementById('erreur-equip-spinner');
const steps = document.getElementById('erreur-equip-steps');
const btnRetry = document.getElementById('erreur-equip-btn-retry');
const btnVideo = document.getElementById('erreur-equip-btn-video');
const btnRestart = document.getElementById('erreur-equip-btn-restart');
spinner.classList.toggle('cache', phase >= 2);
steps.classList.toggle('cache', phase < 2);
btnRetry.classList.toggle('cache', phase < 1);
btnVideo.classList.toggle('cache', phase < 2);
btnRestart.classList.toggle('cache', phase < 2);
if (phase >= 2) {
steps.innerHTML = '<ol>' +
'<li>Verifiez que l\'appareil photo est allume (bouton ON)</li>' +
'<li>Verifiez le cable USB entre l\'appareil et la borne</li>' +
'<li>Eteignez et rallumez l\'appareil photo</li>' +
'<li>Si le probleme persiste, redemarrez la borne</li>' +
'</ol>';
}
el.classList.remove('cache');
}
function fermerErreurEquipement() {
document.getElementById('erreur-equipement').classList.add('cache');
_erreurCameraCount = 0;
_erreurCameraPhase = 0;
allerA('accueil');
}
function erreurEquipRetry() {
document.getElementById('erreur-equip-titre').textContent = 'Verification en cours...';
document.getElementById('erreur-equip-msg').textContent = 'Un instant...';
document.getElementById('erreur-equip-spinner').classList.remove('cache');
document.getElementById('erreur-equip-steps').classList.add('cache');
document.getElementById('erreur-equip-btn-retry').classList.add('cache');
if (_erreurEquipType === 'imprimante') {
fetch('/api/imprimante/reactiver', { method: 'POST' }).catch(() => {});
} else {
fetch('/api/camera/reconnecter', { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}' }).catch(() => {});
}
}
let _erreurEquipType = 'camera';
function erreurEquipVideo() {
document.getElementById('erreur-equipement').classList.add('cache');
const situationMap = { camera: 'camera_hs', imprimante: window._imprimanteErreurSituation || 'depannage_imprimante' };
const situation = situationMap[_erreurEquipType] || 'camera_hs';
if (typeof lancerWizardSituation === 'function') {
lancerWizardSituation(situation, _erreurEquipType);
}
}
function erreurEquipRestart() {
_showErreurEquipement('⏳', 'Redemarrage en cours', 'La borne redemarre, patientez 30 secondes...', 0);
document.getElementById('erreur-equip-btn-accueil').classList.add('cache');
fetch('/api/systeme/redemarrer', { method: 'POST' }).catch(() => {});
}
wsOnMessage('camera_erreur', (data) => {
if (window.location.pathname === '/admin') return;
_erreurCameraCount++;
_erreurEquipType = 'camera';
if (_erreurCameraCount <= 2) {
_erreurCameraPhase = 0;
_showErreurEquipement('📷', 'Preparation de l\'appareil photo', 'Un instant, reconnexion automatique...', 0);
} else if (_erreurCameraCount <= 5) {
_erreurCameraPhase = 1;
_showErreurEquipement('📷', 'L\'appareil photo ne repond pas', 'Le systeme essaie de le reconnecter. Vous pouvez aussi reessayer manuellement.', 1);
} else {
_erreurCameraPhase = 2;
_showErreurEquipement('📷', 'Appareil photo injoignable', 'Suivez ces etapes pour le remettre en marche :', 2);
}
if (_erreurCameraTimer) clearTimeout(_erreurCameraTimer);
_erreurCameraTimer = setTimeout(() => { location.reload(); }, 180000);
});
wsOnMessage('shutter', () => {
_shutterSound.currentTime = 0;
_shutterSound.play().catch(() => {});
});
wsOnMessage('camera_ok', () => {
document.getElementById('erreur-equipement').classList.add('cache');
_erreurCameraCount = 0;
_erreurCameraPhase = 0;
if (_erreurCameraTimer) { clearTimeout(_erreurCameraTimer); _erreurCameraTimer = null; }
});
wsOnMessage('imprimante_erreur', (data) => {
if (window.location.pathname === '/admin') return;
_erreurEquipType = 'imprimante';
const titre = data.titre || 'Probleme imprimante';
const msg = data.message || 'L\'imprimante ne fonctionne pas correctement.';
const etapes = data.etapes || [];
const situation = data.situation || 'depannage_imprimante';
_showErreurEquipement('🖨', titre, msg, 2);
const steps = document.getElementById('erreur-equip-steps');
if (etapes.length) {
steps.innerHTML = '<ol>' + etapes.map(e => '<li>' + e + '</li>').join('') + '</ol>';
steps.classList.remove('cache');
}
document.getElementById('erreur-equip-btn-retry').classList.remove('cache');
document.getElementById('erreur-equip-btn-retry').textContent = 'Verifier';
document.getElementById('erreur-equip-btn-retry').onclick = function() {
fetch('/api/imprimante/reactiver', { method: 'POST' }).catch(() => {});
_showErreurEquipement('🖨', 'Verification en cours...', 'Un instant...', 0);
};
document.getElementById('erreur-equip-btn-video').classList.remove('cache');
document.getElementById('erreur-equip-btn-video').onclick = function() {
document.getElementById('erreur-equipement').classList.add('cache');
if (typeof lancerWizardSituation === 'function') lancerWizardSituation(situation, 'imprimante');
};
window._imprimanteErreurSituation = situation;
});
wsOnMessage('imprimante_ok', () => {
if (_erreurEquipType === 'imprimante') {
document.getElementById('erreur-equipement').classList.add('cache');
}
document.getElementById('printer-erreur').classList.add('cache');
});
function _afficherErreurImprimante(msg) {
const el = document.getElementById('printer-erreur');
document.getElementById('printer-erreur-msg').textContent = msg;
el.classList.remove('cache');
}
async function _pollStatutImprimante() {
try {
const r = await apiGet('/api/imprimante/statut-detail');
const el = document.getElementById('printer-erreur');
if (r.statut && !r.statut.includes('stopped') && !r.statut.includes('disabled')) {
el.classList.add('cache');
if (_erreurEquipType === 'imprimante') {
document.getElementById('erreur-equipement').classList.add('cache');
}
}
} catch(e) {}
}
setInterval(_pollStatutImprimante, 30000);
// === WIZARD ===
let wizardStep = 1;
const WIZARD_TOTAL = 6;
let wizardData = {
nom: '',
theme: 'base',
modes: { simple: true, multi: true },
nbPhotos: 4,
fonctions: {},
};
function wizardInit() {
wizardStep = 1;
wizardData.nom = config.evenement?.nom || '';
wizardData.theme = config.evenement?.theme || 'base';
wizardData.nbPhotos = config.multi_shot?.nombre_photos || 4;
document.getElementById('wiz-nom').value = wizardData.nom;
document.getElementById('wiz-nb-photos').textContent = wizardData.nbPhotos;
wizardAfficherStep();
}
function wizardAfficherStep() {
document.querySelectorAll('.wizard-step').forEach(s => s.classList.remove('actif'));
const step = document.querySelector(`.wizard-step[data-step="${wizardStep}"]`);
if (step) step.classList.add('actif');
// Progress dots
const prog = document.getElementById('wizard-progress');
prog.innerHTML = '';
for (let i = 1; i <= WIZARD_TOTAL; i++) {
const dot = document.createElement('div');
dot.className = 'wizard-dot' + (i === wizardStep ? ' actif' : '') + (i < wizardStep ? ' fait' : '');
prog.appendChild(dot);
}
}
function wizardSuivant() {
// Sauvegarder l'etape courante
if (wizardStep === 1) wizardData.nom = document.getElementById('wiz-nom').value;
if (wizardStep === 4) wizardData.nbPhotos = parseInt(document.getElementById('wiz-nb-photos').textContent);
wizardStep++;
if (wizardStep > WIZARD_TOTAL) wizardStep = WIZARD_TOTAL;
// Resume
if (wizardStep === WIZARD_TOTAL) {
const modes = Object.keys(wizardData.modes).filter(k => wizardData.modes[k]);
const foncs = Object.keys(wizardData.fonctions).filter(k => wizardData.fonctions[k]);
document.getElementById('wiz-resume').innerHTML = `
<div><strong>Evenement :</strong> ${wizardData.nom || 'Photobooth'}</div>
<div><strong>Theme :</strong> ${wizardData.theme}</div>
<div><strong>Modes :</strong> ${modes.join(', ') || 'photo'}</div>
<div><strong>Photos/pellicule :</strong> ${wizardData.nbPhotos}</div>
<div><strong>Partage :</strong> ${foncs.join(', ') || 'aucun'}</div>
`;
}
wizardAfficherStep();
}
function wizardChoisir(btn, key) {
btn.closest('.wizard-choix').querySelectorAll('.wizard-card').forEach(c => c.classList.remove('actif'));
btn.classList.add('actif');
wizardData[key] = btn.dataset.val;
}
function wizardToggle(btn) {
btn.classList.toggle('actif');
const val = btn.dataset.val;
const isActive = btn.classList.contains('actif');
if (['simple', 'multi'].includes(val)) {
wizardData.modes[val] = isActive;
} else {
wizardData.fonctions[val] = isActive;
}
}
function wizardNbPhotos(delta) {
const el = document.getElementById('wiz-nb-photos');
let n = parseInt(el.textContent) + delta;
n = Math.max(2, Math.min(8, n));
el.textContent = n;
wizardData.nbPhotos = n;
}
async function wizardTerminer() {
// Sauvegarder toute la config
await apiPost('/api/config', {
evenement: {
nom: wizardData.nom || 'Photobooth',
theme: wizardData.theme,
},
fonctionnalites: {
photo_simple: wizardData.modes.simple !== false,
multi_shot: wizardData.modes.multi !== false,
impression: !!wizardData.fonctions.impression,
email: !!wizardData.fonctions.email,
qr_code: !!wizardData.fonctions.qr,
},
multi_shot: {
nombre_photos: wizardData.nbPhotos,
mode: 'strip',
},
booth: {
actif: !!wizardData.fonctions.galerie_live,
},
});
config = await apiGet('/api/config');
appliquerConfig();
chargerBoothInfo();
allerA('accueil');
}
// --- Veille eclairage ---
function lancerTimerVeille() {
arreterTimerVeille();
const delai = (config.relais?.veille_delai || 0) * 60000;
if (delai <= 0) return;
_veilleTimer = setTimeout(() => {
_enVeille = true;
_spotsAllumes = false;
fetch('/api/relais/veille', { method: 'POST' }).catch(() => {});
}, delai);
}
function arreterTimerVeille() {
if (_veilleTimer) { clearTimeout(_veilleTimer); _veilleTimer = null; }
}
function reveillerEclairage() {
if (!_enVeille && _spotsAllumes) return;
_enVeille = false;
_spotsAllumes = true;
arreterTimerVeille();
fetch('/api/relais/reveil', { method: 'POST' }).catch(() => {});
}
function eteindreSpots() {
_spotsAllumes = false;
fetch('/api/relais/veille', { method: 'POST' }).catch(() => {});
}
// --- Mini menu client ---
function toggleMenuClient() {
document.getElementById('menu-client').classList.toggle('cache');
}
async function menuClientAction(action) {
document.getElementById('menu-client').classList.add('cache');
if (action === 'wifi') {
document.getElementById('popup-wifi').classList.remove('cache');
scanWifi();
} else if (action === 'exposition') {
document.getElementById('popup-exposition').classList.remove('cache');
chargerExposition();
} else if (action === 'shutdown') {
if (confirm('Eteindre la borne ?')) apiPost('/api/systeme/eteindre');
} else if (action === 'reboot') {
if (confirm('Redemarrer la borne ?')) apiPost('/api/systeme/redemarrer');
} else if (action === 'restart-app') {
apiPost('/api/systeme/redemarrer-app');
setTimeout(() => location.reload(), 3000);
}
}
async function scanWifi() {
const list = document.getElementById('wifi-list');
const status = document.getElementById('wifi-status');
list.innerHTML = '<div style="text-align:center;padding:16px;color:var(--text2)">Scan en cours...</div>';
try {
const s = await apiGet('/api/wifi/status');
status.innerHTML = s.connecte
? `<div style="padding:8px;color:#4caf50">Connecte a <b>${s.ssid}</b> (${s.signal || '?'}%)</div>`
: '<div style="padding:8px;color:#f44336">Non connecte</div>';
const nets = await apiGet('/api/wifi/scan');
if (!nets.length) { list.innerHTML = '<div style="padding:12px;color:var(--text2)">Aucun reseau</div>'; return; }
list.innerHTML = nets.map(n => `
<div class="wifi-item${n.actif ? ' actif' : ''}" onclick="connecterWifi('${n.ssid.replace(/'/g,"\\'")}', ${n.enregistre})">
<div>
<div style="font-weight:600">${n.ssid}</div>
<div style="font-size:.75rem;color:var(--text2)">${n.signal}% ${n.securise ? '&#128274;' : ''} ${n.enregistre ? '(enregistre)' : ''}</div>
</div>
${n.actif ? '<span style="color:#4caf50;font-weight:700">&#10003;</span>' : ''}
</div>
`).join('');
} catch(e) { list.innerHTML = '<div style="padding:12px;color:#f44336">Erreur: ' + e + '</div>'; }
}
async function connecterWifi(ssid, enregistre) {
if (enregistre) {
const r = await apiPost('/api/wifi/connect', {ssid});
alert(r.message || (r.succes ? 'Connecte' : 'Erreur'));
scanWifi();
return;
}
const mdp = prompt('Mot de passe WiFi pour ' + ssid + ' :');
if (mdp === null) return;
const r = await apiPost('/api/wifi/connect', {ssid, password: mdp});
alert(r.message || (r.succes ? 'Connecte' : 'Erreur'));
scanWifi();
}
async function chargerExposition() {
const cfg = charger_config ? charger_config() : config;
const luminosite = (cfg || config).impression?.luminosite_impression || 0;
document.getElementById('expo-slider').value = luminosite;
document.getElementById('expo-slider').min = -50;
document.getElementById('expo-slider').max = 50;
document.getElementById('expo-slider').step = 5;
updateExpoLabel(luminosite);
}
function updateExpoLabel(val) {
const signe = val > 0 ? '+' : '';
document.getElementById('expo-label').textContent = `${signe}${val}%`;
document.getElementById('expo-status').textContent = val == 0 ? 'Normal' : `${signe}${val}%`;
}
async function appliquerExpo() {
const val = parseInt(document.getElementById('expo-slider').value);
await apiPost('/api/config', {impression: {luminosite_impression: val}});
document.getElementById('popup-exposition').classList.add('cache');
afficherStatut('Luminosite impression : ' + (val > 0 ? '+' : '') + val + '%', 'succes');
config = await apiGet('/api/config');
}
// Demarrage
document.addEventListener('DOMContentLoaded', init);
document.addEventListener('DOMContentLoaded', () => {
init().then(() => {
if (window.location.pathname === '/admin') {
allerA('admin-choix');
}
});
});

View File

@@ -1,78 +1,376 @@
/* Module capture photo - Preview live, compte a rebours, declenchement */
let previewActif = false;
let previewTimeoutId = null;
let previewInterval = null;
const PREVIEW_TIMEOUT_MS = 3000;
let captureAbortee = false;
let captureEnCours = false; // true pendant tout le flux lancerCapture()
const EMOJI_CAR = { 3: '🤪', 2: '😱', 1: '🔥' };
// --- Surprise avant capture ---
let _surprisePreloaded = null;
function prechargerSurprise() {
const surprise = (config || {}).surprise;
if (!surprise || !surprise.actif || !surprise.fichier) return;
if (surprise.type === 'video') return;
const img = new Image();
img.src = '/api/surprise/media?t=' + Date.now();
_surprisePreloaded = img;
}
function montrerSurprise() {
const surprise = (config || {}).surprise;
if (!surprise || !surprise.actif || !surprise.fichier) return false;
const overlay = document.getElementById('surprise-overlay');
if (!overlay) return false;
if (surprise.type === 'video') {
overlay.innerHTML = '<video src="/api/surprise/media" autoplay muted playsinline style="max-width:100%;max-height:100%;object-fit:contain"></video>';
} else {
if (_surprisePreloaded && _surprisePreloaded.complete) {
_surprisePreloaded.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain';
overlay.innerHTML = '';
overlay.appendChild(_surprisePreloaded);
} else {
overlay.innerHTML = '<img src="/api/surprise/media" style="max-width:100%;max-height:100%;object-fit:contain">';
}
}
overlay.style.opacity = '0';
overlay.classList.remove('cache');
overlay.style.transition = 'opacity 0.15s';
overlay.style.opacity = '1';
return true;
}
function cacherSurprise() {
const overlay = document.getElementById('surprise-overlay');
if (!overlay) return;
overlay.style.opacity = '0';
setTimeout(() => {
overlay.classList.add('cache');
overlay.style.transition = '';
overlay.style.opacity = '';
}, 200);
}
// --- Preview live ---
function afficherErreurCapture(titre, detail = '') {
arreterPreview();
document.getElementById('capture-en-cours')?.classList.add('cache');
const el = document.getElementById('preview-erreur-camera');
if (!el) { allerA('accueil'); return; }
el.querySelector('p').textContent = titre;
el.querySelector('span').textContent = detail || 'Retour dans 4 secondes...';
el.classList.remove('cache');
setTimeout(() => {
el.classList.add('cache');
allerA('accueil');
}, 4000);
}
function afficherErreurPreview(visible) {
const el = document.getElementById('preview-erreur-camera');
if (!el) return;
if (visible) {
el.querySelector('p').textContent = 'Un instant...';
el.querySelector('span').textContent = 'L\'appareil photo se reconnecte';
el.classList.remove('cache');
} else {
el.classList.add('cache');
}
}
function resetPreviewTimeout() {
if (previewTimeoutId) clearTimeout(previewTimeoutId);
previewTimeoutId = setTimeout(() => {
if (previewActif) afficherErreurPreview(true);
}, PREVIEW_TIMEOUT_MS);
}
function lancerPreview() {
if (previewActif) return;
previewActif = true;
afficherErreurPreview(false);
document.getElementById('img-preview')?.classList.add('preview-chargement');
wsEnvoyer({ type: 'preview_start' });
resetPreviewTimeout();
}
previewInterval = setInterval(() => {
if (previewActif) wsEnvoyer({ type: 'preview' });
}, 100);
function demarrerEclairageLive() {
if (!previewActif) {
previewActif = true;
wsEnvoyer({ type: 'preview_start' });
resetPreviewTimeout();
}
}
function arreterPreview() {
if (!previewActif) return;
previewActif = false;
wsEnvoyer({ type: 'preview_stop' });
if (previewInterval) {
clearInterval(previewInterval);
previewInterval = null;
}
if (previewTimeoutId) {
clearTimeout(previewTimeoutId);
previewTimeoutId = null;
}
afficherErreurPreview(false);
document.getElementById('cadre-guide-recadrage')?.classList.add('cache');
}
wsOnMessage('preview', (msg) => {
if (!previewActif) return;
const img = document.getElementById('img-preview');
if (img) img.src = msg.image;
if (img) {
img.onload = () => {
img.classList.remove('preview-chargement');
positionnerGuideRecadrage();
};
img.src = msg.image;
}
const adminPrev = document.getElementById('eclairage-preview');
if (adminPrev && document.getElementById('panneau-eclairage')?.classList.contains('actif')) {
adminPrev.src = msg.image;
}
afficherErreurPreview(false);
resetPreviewTimeout();
});
// --- Cadre de visee : montre en live la zone qui sera gardee apres recadrage ---
// (la pellicule/le collage recadrent chaque photo au centre selon un ratio different du plein cadre camera)
function getRatioCelluleAttendu() {
if (modeActuel !== 'multi') return null;
const nb = getNombrePhotos();
const mode = config.multi_shot?.mode || 'strip';
if (mode === 'strip') {
// Reproduit creer_strip() dans backend/collage.py (bande portrait 600x1800, photos empilees)
const bandeLargeur = 600, bandeHauteur = 1800, marge = 10, sep = 12, perfLargeur = 55;
const zonePhotosLargeur = bandeLargeur - perfLargeur * 2;
const espacePhotos = bandeHauteur - marge * 2 - sep * (nb - 1);
const hauteurCellule = Math.floor(espacePhotos / nb);
return zonePhotosLargeur / hauteurCellule;
} else {
// Reproduit creer_collage() dans backend/collage.py
const colonnes = 2;
const largeurPage = 1200, hauteurPage = 1800, margeC = 25;
const lignes = Math.ceil(nb / colonnes);
const largeurCellule = Math.floor((largeurPage - margeC * (colonnes + 1)) / colonnes);
const hauteurCellule = Math.floor((hauteurPage - margeC * (lignes + 1)) / lignes);
return largeurCellule / hauteurCellule;
}
}
function positionnerGuideRecadrage() {
const guide = document.getElementById('cadre-guide-recadrage');
const img = document.getElementById('img-preview');
const container = document.getElementById('preview-live');
if (!guide || !img || !container) return;
const ratioCible = getRatioCelluleAttendu();
if (!ratioCible || !img.naturalWidth || !img.naturalHeight) {
guide.classList.add('cache');
return;
}
const cw = container.clientWidth, ch = container.clientHeight;
const ratioImg = img.naturalWidth / img.naturalHeight;
const ratioContainer = cw / ch;
// Boite affichee de l'image dans le conteneur (object-fit: contain)
let imgW, imgH, imgTop, imgLeft;
if (ratioImg > ratioContainer) {
imgW = cw; imgH = cw / ratioImg;
imgLeft = 0; imgTop = (ch - imgH) / 2;
} else {
imgH = ch; imgW = ch * ratioImg;
imgTop = 0; imgLeft = (cw - imgW) / 2;
}
// Boite du recadrage final, centree dans l'image affichee (meme logique que _crop_center cote backend)
let guideW, guideH;
if (ratioImg > ratioCible) {
guideH = imgH;
guideW = imgH * ratioCible;
} else {
guideW = imgW;
guideH = imgW / ratioCible;
}
guide.style.left = `${imgLeft + (imgW - guideW) / 2}px`;
guide.style.top = `${imgTop + (imgH - guideH) / 2}px`;
guide.style.width = `${guideW}px`;
guide.style.height = `${guideH}px`;
guide.classList.remove('cache');
}
window.addEventListener('resize', positionnerGuideRecadrage);
// Erreur camera pendant preview idle : retour accueil
// Si on est en cours de capture (compte à rebours ou attente API), on ignore :
// l'API /api/capturer retournera elle-même une erreur si le DSLR est vraiment mort.
wsOnMessage('camera_erreur', () => {
arreterPreview();
if (!captureEnCours) {
captureAbortee = true;
if (ecranActuel === 'capture') {
afficherErreurCapture('Un instant...', 'L\'appareil photo se prepare');
}
}
});
wsOnMessage('camera_ok', () => {
captureAbortee = false;
afficherErreurPreview(false);
});
wsOnMessage('eclairage', (msg) => {
if (captureEnCours) return;
// Admin panel eclairage
const scoreBox = document.getElementById('eclairage-score-box');
const actionEl = document.getElementById('eclairage-action');
const visagesEl = document.getElementById('eclairage-visages');
if (scoreBox) {
const score = msg.score >= 0 ? msg.score : '--';
scoreBox.textContent = score;
if (msg.action === 'allumer') {
scoreBox.style.background = '#dc2626'; scoreBox.style.color = '#fff';
actionEl.textContent = 'ALLUMER LA LUMIERE';
actionEl.style.color = '#f87171';
} else if (msg.action === 'attention') {
scoreBox.style.background = '#d97706'; scoreBox.style.color = '#fff';
actionEl.textContent = 'Eclairage faible';
actionEl.style.color = '#fbbf24';
} else if (msg.action === 'ok') {
scoreBox.style.background = '#16a34a'; scoreBox.style.color = '#fff';
actionEl.textContent = 'Eclairage OK';
actionEl.style.color = '#4ade80';
} else {
scoreBox.style.background = '#222'; scoreBox.style.color = '#888';
actionEl.textContent = 'Aucun visage';
actionEl.style.color = '#888';
}
visagesEl.textContent = msg.visages > 0 ? msg.visages + ' visage' + (msg.visages > 1 ? 's' : '') + ' detecte' + (msg.visages > 1 ? 's' : '') : '';
}
});
// --- Capture ---
async function lancerCapture() {
captureEnCours = true;
captureAbortee = false;
photosSession = [];
lancerPreview();
photoImpression = null;
formatImpression = null;
document.getElementById('photo-resultat').src = '';
document.getElementById('photo-partage').src = '';
await chargerCadreImpose();
majCompteurAccueil();
// Verifier l'etat de la camera — attendre si pas prete ou reveil en cours
try {
let statut = await apiGet('/api/camera/statut');
if (!statut.connectee || statut.mode === 'erreur') {
afficherStatut('Preparation de l\'appareil photo...', 'succes');
// Attendre jusqu'a 40s que le Canon soit pret (reveil ou reconnexion)
for (let att = 0; att < 20; att++) {
await pause(2000);
statut = await apiGet('/api/camera/statut');
if (statut.connectee && statut.mode !== 'erreur') break;
}
afficherStatut('', '');
}
if (!statut.connectee || statut.mode === 'erreur') {
captureEnCours = false;
afficherErreurCapture('Appareil photo indisponible', 'Touchez l\'ecran et reessayez');
return;
}
} catch (e) {
captureEnCours = false;
afficherErreurCapture('Preparation en cours', 'Touchez l\'ecran et reessayez');
return;
}
const nbPhotos = getNombrePhotos();
const compteurEl = document.getElementById('capture-compteur');
for (let i = 0; i < nbPhotos; i++) {
if (nbPhotos > 1) {
compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`;
} else {
compteurEl.textContent = '';
}
await compteARebours();
afficherFlash();
lancerPreview(); // Miroir live pendant le compte à rebours
wsEnvoyer({type: 'prepare_capture'});
const avecPreMessages = (i === 0);
await compteARebours(avecPreMessages);
// Surprise : seulement en photo simple (pas strip/multi)
// S'affiche PAR-DESSUS le preview live (pas besoin de le stopper)
const aSurprise = (modeActuel === 'simple') && montrerSurprise();
// Lancer la capture DSLR pendant que la surprise est visible
const capturePromise = apiPost('/api/capturer').catch(() => null);
// Laisser la surprise visible le temps que le DSLR declenche
if (aSurprise) await pause(1000);
// Flash blanc couvre tout (surprise + preview dessous)
const flash = document.getElementById('flash-blanc');
flash.classList.remove('cache');
flash.style.animation = 'none';
flash.offsetHeight;
flash.style.animation = '';
// Pendant que le flash couvre l'ecran, on nettoie derriere
arreterPreview();
const resultat = await apiPost('/api/capturer');
if (aSurprise) { cacherSurprise(); prechargerSurprise(); }
if (resultat.erreur) {
// Limite atteinte ou erreur
allerA('accueil');
let resultat = await capturePromise;
if (!resultat && !resultat?.erreur) {
flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache');
captureEnCours = false;
afficherErreurCapture('Oups !', 'La photo n\'a pas pu etre prise, reessayez');
return;
}
if (resultat?.erreur) {
flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache');
captureEnCours = false;
const msg = resultat.erreur;
afficherErreurCapture('Oups !', 'La photo n\'a pas pu etre prise, reessayez');
return;
}
flash.classList.add('cache');
document.getElementById('capture-en-cours').classList.add('cache');
if (resultat.nom) {
photosSession.push(resultat.nom);
}
if (i < nbPhotos - 1) {
lancerPreview();
await pause(500);
}
}
captureEnCours = false;
await traiterCapture();
}
function getNombrePhotos() {
if (modeActuel === 'simple') return 1;
if (modeActuel === 'multi') return config.multi_shot?.nombre_photos || 3;
if (modeActuel === 'multi') return config.multi_shot?.nombre_photos || 4;
return 1;
}
@@ -90,7 +388,7 @@ function choisirAnimationAleatoire() {
return pool[Math.floor(Math.random() * pool.length)];
}
async function compteARebours() {
async function compteARebours(avecPreMessages = true) {
const conteneur = document.getElementById('compte-a-rebours');
const chiffre = document.getElementById('chiffre-car');
const duree = config.camera?.compte_a_rebours || 3;
@@ -106,6 +404,17 @@ async function compteARebours() {
// Animation CSS classique
conteneur.classList.remove('cache');
if (avecPreMessages) {
const preMessages = ['Attention !', 'Préparez-vous !'];
for (const msg of preMessages) {
chiffre.textContent = msg;
chiffre.className = 'anim-chiffre anim-texte';
void chiffre.offsetWidth;
chiffre.classList.add('anim-' + anim.id);
await pause(1000);
}
}
for (let i = duree; i > 0; i--) {
if (anim.id === 'emoji') {
chiffre.textContent = EMOJI_CAR[i] || i;
@@ -172,27 +481,50 @@ function afficherFlash() {
setTimeout(() => flash.classList.add('cache'), 400);
}
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) {
allerA('accueil');
afficherErreurCapture('Oups !', 'La photo n\'a pas pu etre prise, reessayez');
return;
}
if (modeActuel === 'multi') {
const mode = config.multi_shot?.mode || 'strip';
let resultat;
if (mode === 'strip') {
resultat = await apiPost('/api/strip', { photos: photosSession });
} else {
resultat = await apiPost('/api/collage', { photos: photosSession });
}
if (resultat.nom) {
photoImpression = null;
formatImpression = null;
try {
if (modeActuel === 'multi') {
const mode = config.multi_shot?.mode || 'strip';
let resultat;
if (mode === 'strip') {
resultat = await apiPost('/api/strip', { photos: photosSession });
if (resultat.erreur) {
afficherErreurCapture('Oups !', 'La planche n\'a pas pu etre creee, reessayez');
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) {
afficherErreurCapture('Oups !', 'Le collage n\'a pas pu etre cree, reessayez');
return;
}
}
if (!resultat.nom) {
afficherErreurCapture('Oups !', 'Un probleme est survenu, reessayez');
return;
}
photoFinale = resultat.nom;
afficherPreviewPhoto('/data/exports/' + resultat.nom);
} else {
photoFinale = photosSession[0];
afficherPreviewPhoto('/data/photos/' + photosSession[0]);
}
} else {
photoFinale = photosSession[0];
afficherPreviewPhoto('/data/photos/' + photosSession[0]);
} catch (e) {
afficherErreurCapture('Erreur reseau', 'Impossible de traiter les photos');
return;
}
allerA('preview');
@@ -201,8 +533,9 @@ async function traiterCapture() {
function afficherPreviewPhoto(chemin) {
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
afficherCadreOverlay(cadreChoisi);
if (modeActuel === 'simple' && config.fonctionnalites?.filtres) {
if (config.fonctionnalites?.filtres) {
chargerFiltres();
}
if (config.fonctionnalites?.overlays) {
@@ -218,12 +551,37 @@ function afficherPreviewPhoto(chemin) {
async function majCompteurAccueil() {
const etat = await apiGet('/api/compteur');
const el = document.getElementById('compteur-accueil');
if (etat.actif) {
el.classList.remove('cache');
document.getElementById('compteur-restant').textContent = etat.restantes;
document.getElementById('compteur-limite').textContent = etat.limite;
el.classList.remove('cache');
document.getElementById('compteur-restant').textContent = etat.restantes;
document.getElementById('compteur-limite').textContent =
(etat.actif && etat.limite > 0) ? etat.limite : etat.capacite;
}
async function majBoutonImprimerCompteur() {
const etat = await apiGet('/api/compteur');
const btn = document.getElementById('btn-imprimer');
if (!btn) return;
if (etat.restantes <= 0) {
btn.classList.add('cache');
}
}
async function chargerCadreImpose() {
if (modeActuel === 'multi') {
afficherCadreOverlay(null);
return;
}
const fmt = (config.impression?.format || '15x20').replace('-2up', '');
const data = await apiGet(`/api/cadres-impression/${fmt}`).catch(() => null);
if (!data) { afficherCadreOverlay(null); return; }
if (data.event_mode === 'impose' && data.event_cadre) {
cadreChoisi = data.event_cadre;
afficherCadreOverlay(cadreChoisi);
} else if (data.event_mode === 'defaut' && data.event_cadre) {
cadreChoisi = data.event_cadre;
afficherCadreOverlay(cadreChoisi);
} else {
el.classList.add('cache');
afficherCadreOverlay(null);
}
}

View File

@@ -23,11 +23,18 @@ async function appliquerFiltreUI(filtre, btn) {
btn.classList.add('actif');
filtreActuel = filtre;
if (modeActuel === 'multi') {
// En mode multi : ré-assembler le strip avec le filtre appliqué sur chaque photo
await appliquerFiltreStrip(filtre);
return;
}
if (filtre === 'original') {
const chemin = '/data/photos/' + photosSession[0];
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
photoFinale = photosSession[0];
afficherCadreOverlay(cadreChoisi);
return;
}
@@ -40,9 +47,66 @@ async function appliquerFiltreUI(filtre, btn) {
photoFinale = resultat.nom;
document.getElementById('photo-resultat').src = '/data/exports/' + resultat.nom;
document.getElementById('photo-partage').src = '/data/exports/' + resultat.nom;
afficherCadreOverlay(cadreChoisi);
}
}
async function appliquerFiltreStrip(filtre) {
let photosAAssembler = photosSession;
let source = 'photos'; // les originaux sont dans data/photos/
if (filtre !== 'original') {
const promises = photosSession.map(p => apiPost('/api/filtre', { photo: p, filtre }));
const resultats = await Promise.all(promises);
photosAAssembler = resultats.filter(r => r.nom).map(r => r.nom);
source = 'exports'; // les filtrés sont dans data/exports/
if (photosAAssembler.length === 0) return;
}
const mode = config.multi_shot?.mode || 'strip';
const endpoint = mode === 'strip' ? '/api/strip' : '/api/collage';
const resultat = await apiPost(endpoint, { photos: photosAAssembler, source });
if (resultat.nom) {
photoFinale = resultat.nom;
if (resultat.impression) photoImpression = resultat.impression;
const src = '/data/exports/' + resultat.nom;
document.getElementById('photo-resultat').src = src;
document.getElementById('photo-partage').src = src;
afficherCadreOverlay(cadreChoisi);
}
}
function afficherCadreOverlay(nom) {
const ids = ['cadre-overlay-preview', 'cadre-overlay-live', 'cadre-overlay-partage'];
if (!nom) {
ids.forEach(id => {
const el = document.getElementById(id);
if (el) { el.classList.add('cache'); el.src = ''; }
});
return;
}
const config_imp = config.impression || {};
const fmt = (config_imp.format || '15x20').replace('-2up', '');
const event_id = (config.evenement || {}).event_id;
let src = `/assets/cadres/${fmt}/${nom}`;
if (event_id) {
const eventSrc = `/data/evenements/${event_id}/cadres/${fmt}/${nom}`;
src = eventSrc;
}
ids.forEach(id => {
const el = document.getElementById(id);
if (el) {
el.src = src;
el.onerror = () => {
el.src = `/assets/cadres/${fmt}/${nom}`;
el.onerror = null;
};
el.classList.remove('cache');
}
});
}
async function chargerOverlays() {
const overlays = await apiGet('/api/overlays');
const barre = document.getElementById('barre-overlays');

View File

@@ -25,3 +25,98 @@ function voirPhoto(photo) {
document.getElementById('photo-partage').src = '/' + photo.chemin;
allerA('partage');
}
/* --- Admin gallery --- */
let adminGaleriePhotos = [];
let adminGalerieSelection = new Set();
async function chargerAdminGalerie() {
const photos = await apiGet('/api/galerie');
adminGaleriePhotos = photos;
adminGalerieSelection.clear();
const grille = document.getElementById('admin-galerie-grille');
grille.innerHTML = '';
if (photos.length === 0) {
grille.innerHTML = '<p style="color:var(--texte-secondaire);grid-column:1/-1;text-align:center">Aucune photo</p>';
majAdminGalerieSelection();
return;
}
for (const photo of photos) {
const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;cursor:pointer';
wrap.dataset.nom = photo.nom;
const img = document.createElement('img');
img.src = '/' + photo.chemin;
img.alt = photo.nom;
img.loading = 'lazy';
img.style.cssText = 'width:100%;border-radius:8px;display:block';
const check = document.createElement('div');
check.className = 'admin-galerie-check';
check.textContent = '✓';
wrap.appendChild(img);
wrap.appendChild(check);
wrap.addEventListener('click', () => toggleAdminGaleriePhoto(photo.nom, wrap));
grille.appendChild(wrap);
}
majAdminGalerieSelection();
}
function toggleAdminGaleriePhoto(nom, wrap) {
if (adminGalerieSelection.has(nom)) {
adminGalerieSelection.delete(nom);
wrap.querySelector('.admin-galerie-check').classList.remove('visible');
} else {
adminGalerieSelection.add(nom);
wrap.querySelector('.admin-galerie-check').classList.add('visible');
}
majAdminGalerieSelection();
}
function adminGalerieSelectAll() {
const grille = document.getElementById('admin-galerie-grille');
for (const photo of adminGaleriePhotos) {
adminGalerieSelection.add(photo.nom);
}
grille.querySelectorAll('.admin-galerie-check').forEach(c => c.classList.add('visible'));
majAdminGalerieSelection();
}
function adminGalerieDeselectAll() {
adminGalerieSelection.clear();
const grille = document.getElementById('admin-galerie-grille');
grille.querySelectorAll('.admin-galerie-check').forEach(c => c.classList.remove('visible'));
majAdminGalerieSelection();
}
function majAdminGalerieSelection() {
const span = document.getElementById('admin-galerie-selection');
const n = adminGalerieSelection.size;
span.textContent = n > 0 ? n + ' selectionnee' + (n > 1 ? 's' : '') : '';
}
async function adminGalerieImprimer() {
const noms = [...adminGalerieSelection];
if (noms.length === 0) return;
if (!confirm('Imprimer ' + noms.length + ' photo' + (noms.length > 1 ? 's' : '') + ' ?')) return;
let ok = 0, fail = 0, termine = false;
for (const nom of noms) {
try {
const r = await apiPost('/api/imprimer', { photo: nom });
if (r && r.succes) ok++;
else if (r && r.erreur === 'evenement_termine') { termine = true; break; }
else fail++;
} catch { fail++; }
}
if (termine) {
alert('Evenement termine — impression desactivee. Les photos ne seront pas imprimees.');
} else {
alert('Impression : ' + ok + ' OK' + (fail > 0 ? ', ' + fail + ' echec' : ''));
}
}

View File

@@ -1,71 +1,236 @@
/* Module partage - Impression, email, QR code */
let nbExemplaires = 1;
let copiesMax = 5;
let copiesMax = 2;
let _restantActuel = null;
function ouvrirImpression() {
copiesMax = config.impression?.copies_max || 5;
function _masquerBoutonsBas(masquer) {
const bas = document.querySelector('#ecran-partage .partage-bas');
if (bas) bas.style.display = masquer ? 'none' : '';
}
async function _majRestantImpression() {
const el = document.getElementById('impression-restant');
if (!el) return;
try {
const etat = await apiGet('/api/compteur');
_restantActuel = etat.restantes;
const apres = Math.max(0, _restantActuel - nbExemplaires);
el.textContent = `${apres} photo(s) restante(s) sur le rouleau`;
} catch (e) { el.textContent = ''; }
}
async function ouvrirImpression() {
copiesMax = config.impression?.copies_max || 2;
nbExemplaires = 1;
document.getElementById('nb-exemplaires').textContent = nbExemplaires;
document.getElementById('form-impression').classList.remove('cache');
cadreChoisi = null;
_masquerBoutonsBas(true);
_majRestantImpression();
if (modeActuel === 'multi') {
document.getElementById('form-impression').classList.remove('cache');
return;
}
const hasCadres = await chargerCadresChoixImpression();
if (hasCadres) {
document.getElementById('popup-cadre-impression').classList.remove('cache');
} else {
document.getElementById('form-impression').classList.remove('cache');
}
}
function fermerImpression() {
document.getElementById('form-impression').classList.add('cache');
cadreChoisi = null;
_masquerBoutonsBas(false);
}
function confirmerCadreImpression() {
document.getElementById('popup-cadre-impression').classList.add('cache');
document.getElementById('form-impression').classList.remove('cache');
}
function annulerCadreImpression() {
document.getElementById('popup-cadre-impression').classList.add('cache');
cadreChoisi = null;
_masquerBoutonsBas(false);
}
async function chargerCadresChoixImpression() {
const fmt = (config.impression?.format || '15x20').replace('-2up', '');
const data = await apiGet(`/api/cadres-impression/${fmt}`).catch(() => null);
if (!data) return false;
// Mode impose : pas de choix, cadre force
if (data.event_mode === 'impose' && data.event_cadre) {
cadreChoisi = data.event_cadre;
return false;
}
const tousLesGlobaux = data.disponibles || [];
const tousLesEvent = data.event_cadres || [];
if (tousLesGlobaux.length === 0 && tousLesEvent.length === 0) return false;
const grille = document.getElementById('grille-cadres-impression');
grille.innerHTML = '';
const eventCadreDefaut = (data.event_mode === 'defaut') ? data.event_cadre : null;
const divAucun = document.createElement('div');
divAucun.className = 'cadre-choix-item cadre-choix-aucun' + (!eventCadreDefaut ? ' cadre-selectionne' : '');
divAucun.innerHTML = '<div class="cadre-choix-vide"></div><span>Sans cadre</span>';
divAucun.onclick = () => {
cadreChoisi = null;
grille.querySelectorAll('.cadre-choix-item').forEach(el => el.classList.remove('cadre-selectionne'));
divAucun.classList.add('cadre-selectionne');
};
grille.appendChild(divAucun);
// Cadres evenement
for (const nom of tousLesEvent) {
const isDefaut = (nom === eventCadreDefaut);
const div = document.createElement('div');
div.className = 'cadre-choix-item' + (isDefaut ? ' cadre-selectionne' : '');
div.innerHTML = `<div class="cadre-thumb-wrap"><img src="/data/evenements/${data.event_id}/cadres/${fmt}/${nom}" alt="${nom}"></div><span>${nom.replace('.png','')}</span>`;
div.onclick = () => {
cadreChoisi = nom;
grille.querySelectorAll('.cadre-choix-item').forEach(el => el.classList.remove('cadre-selectionne'));
div.classList.add('cadre-selectionne');
};
grille.appendChild(div);
if (isDefaut) cadreChoisi = nom;
}
// Cadres globaux
for (const nom of tousLesGlobaux) {
const div = document.createElement('div');
div.className = 'cadre-choix-item';
div.innerHTML = `<div class="cadre-thumb-wrap"><img src="/assets/cadres/${fmt}/${nom}" alt="${nom}"></div><span>${nom.replace('.png','')}</span>`;
div.onclick = () => {
cadreChoisi = nom;
grille.querySelectorAll('.cadre-choix-item').forEach(el => el.classList.remove('cadre-selectionne'));
div.classList.add('cadre-selectionne');
};
grille.appendChild(div);
}
return true;
}
function changerExemplaires(delta) {
nbExemplaires = Math.max(1, Math.min(copiesMax, nbExemplaires + delta));
document.getElementById('nb-exemplaires').textContent = nbExemplaires;
if (_restantActuel !== null) {
const apres = Math.max(0, _restantActuel - nbExemplaires);
document.getElementById('impression-restant').textContent = `${apres} photo(s) restante(s) sur le rouleau`;
}
}
async function lancerImpression() {
if (!photoFinale) return;
fermerImpression();
document.getElementById('form-impression').classList.add('cache');
cadreChoisi = null;
const btnTerminer = document.getElementById('btn-terminer');
if (btnTerminer) { btnTerminer.disabled = true; btnTerminer.style.opacity = '0.4'; }
// Pour les strips, imprimer la version 2 bandes sur 10x15
const fichierImpression = photoImpression || photoFinale;
afficherStatut(`Impression de ${nbExemplaires} exemplaire(s)...`, 'succes');
const resultat = await apiPost('/api/imprimer', { photo: photoFinale, copies: nbExemplaires });
if (resultat.succes) {
const resultat = await apiPost('/api/imprimer', {
photo: fichierImpression,
copies: nbExemplaires,
cadre: cadreChoisi || undefined,
format_papier: formatImpression || undefined,
});
if (btnTerminer) { btnTerminer.disabled = false; btnTerminer.style.opacity = ''; }
_masquerBoutonsBas(false);
if (resultat.attente) {
afficherStatutEco('⏳', resultat.message || 'Votre photo sortira avec la suivante !', 'attente-eco');
} else if (resultat.jumeau && resultat.succes) {
afficherStatutEco('🎁', 'Vous recevez 2 tirages identiques — gardez-en un, offrez l\'autre !', 'jumeau-eco');
} else if (resultat.succes) {
afficherStatut(`${nbExemplaires} exemplaire(s) envoye(s) a l'imprimante !`, 'succes');
} else if (resultat.erreur === 'evenement_termine') {
afficherStatut('L\'impression n\'est plus disponible pour cet evenement', 'erreur');
} else {
afficherStatut('Erreur d\'impression', 'erreur');
afficherStatut('L\'impression a rencontre un probleme, reessayez', 'erreur');
}
}
function ouvrirEmail() {
async function ouvrirEmail() {
document.getElementById('form-email').classList.remove('cache');
document.getElementById('input-email').focus();
document.getElementById('input-email').value = '';
_masquerBoutonsBas(true);
await chargerSuggestionsEmail();
}
function fermerEmail() {
document.getElementById('form-email').classList.add('cache');
document.getElementById('input-email').value = '';
_masquerBoutonsBas(false);
}
async function chargerSuggestionsEmail() {
const container = document.getElementById('email-suggestions');
container.innerHTML = '';
const data = await apiGet('/api/emails/historique').catch(() => null);
if (!data || !data.emails || data.emails.length === 0) return;
for (const email of data.emails) {
const btn = document.createElement('button');
btn.textContent = email;
btn.onclick = () => {
document.getElementById('input-email').value = email;
};
container.appendChild(btn);
}
}
function clavierTape(car) {
const input = document.getElementById('input-email');
input.value += car;
filtrerSuggestionsEmail();
}
function clavierEffacer() {
const input = document.getElementById('input-email');
input.value = input.value.slice(0, -1);
filtrerSuggestionsEmail();
}
function filtrerSuggestionsEmail() {
const val = document.getElementById('input-email').value.toLowerCase();
const btns = document.querySelectorAll('#email-suggestions button');
btns.forEach(btn => {
btn.style.display = (!val || btn.textContent.includes(val)) ? '' : 'none';
});
}
async function envoyerEmail() {
const email = document.getElementById('input-email').value.trim();
if (!email || !photoFinale) return;
fermerEmail();
afficherStatut('Envoi en cours...', 'succes');
const resultat = await apiPost('/api/email', { email, photo: photoFinale });
if (resultat.succes) {
afficherStatut('Email envoye !', 'succes');
fermerEmail();
if (resultat.spool) {
afficherStatut('Votre photo sera envoyee par email des que possible', 'succes');
} else if (resultat.succes) {
afficherStatut('Email envoyé !', 'succes');
} else {
afficherStatut('Erreur d\'envoi email', 'erreur');
afficherStatut('L\'email n\'a pas pu etre envoye, reessayez', 'erreur');
}
}
async function afficherQR() {
const zone = document.getElementById('zone-qr');
zone.classList.toggle('cache');
if (!zone.classList.contains('cache')) {
const resultat = await apiGet('/api/qr/galerie');
if (resultat.chemin) {
document.getElementById('img-qr').src = resultat.chemin;
} else {
afficherStatut('QR Code non configure', 'erreur');
zone.classList.add('cache');
}
}
const booth = config.booth || {};
if (!booth.actif || !booth.url || !photoFinale) return;
try {
const info = await apiGet('/api/booth/info');
if (!info.password) return;
const eventId = (config.evenement || {}).event_id || booth.event_id || 'default';
const qrUrl = `${booth.url}/g/${eventId}/view/${photoFinale}?p=${info.password}`;
document.getElementById('qr-photo').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`;
document.getElementById('popup-qr').classList.remove('cache');
} catch (e) { console.error('afficherQR error:', e); }
}

View File

@@ -1,32 +1,38 @@
/* Communication WebSocket avec le backend */
/* Communication WebSocket avec le backend — reconnexion résiliente */
let ws = null;
let wsReconnectTimer = null;
let _wsReconnectDelay = 1000;
let _wsWasConnected = false;
const wsCallbacks = {};
function wsConnecter() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
try { ws = new WebSocket(`${proto}//${location.host}/ws`); } catch(e) { _wsScheduleReconnect(); return; }
ws.onopen = () => {
console.log('WebSocket connecte');
if (wsReconnectTimer) {
clearInterval(wsReconnectTimer);
wsReconnectTimer = null;
_wsReconnectDelay = 1000;
if (_wsWasConnected) {
_masquerPause();
// Recharger la config pour resynchroniser l'état
if (typeof chargerConfig === 'function') chargerConfig();
}
_wsWasConnected = true;
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
const cb = wsCallbacks[msg.type];
if (cb) cb(msg);
try {
const msg = JSON.parse(event.data);
const cbs = wsCallbacks[msg.type];
if (cbs) cbs.forEach(cb => cb(msg));
} catch(e) {}
};
ws.onclose = () => {
console.log('WebSocket deconnecte, reconnexion dans 3s...');
if (!wsReconnectTimer) {
wsReconnectTimer = setInterval(wsConnecter, 3000);
}
console.log('WebSocket deconnecte');
ws = null;
_afficherPause();
_wsScheduleReconnect();
};
ws.onerror = () => {
@@ -34,6 +40,26 @@ function wsConnecter() {
};
}
function _wsScheduleReconnect() {
const delay = Math.min(_wsReconnectDelay, 10000);
_wsReconnectDelay = Math.min(_wsReconnectDelay * 1.5, 10000);
setTimeout(wsConnecter, delay);
}
function _afficherPause() {
const el = document.getElementById('ecran-pause');
if (el) el.style.display = 'flex';
}
function _masquerPause() {
const el = document.getElementById('ecran-pause');
if (el) { el.style.display = 'none'; }
// Relancer le preview seulement si on est sur un ecran de prise de vue (pas l'accueil)
if (typeof lancerPreview === 'function' && !document.getElementById('ecran-accueil')?.classList.contains('actif')) {
lancerPreview();
}
}
function wsEnvoyer(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
@@ -41,7 +67,8 @@ function wsEnvoyer(msg) {
}
function wsOnMessage(type, callback) {
wsCallbacks[type] = callback;
if (!wsCallbacks[type]) wsCallbacks[type] = [];
wsCallbacks[type].push(callback);
}
// Connexion au demarrage

0
frontend/sounds/.gitkeep Normal file
View File

View File

@@ -2,7 +2,7 @@
## Description
Photobooth professionnel pour location evenementielle.
RPi4 + ecran tactile + DSLR (gphoto2) + imprimante sublimation.
Surface 6 + ecran tactile + DSLR Canon (gphoto2) + imprimante sublimation Mitsubishi.
## Architecture
- **Backend** : Python FastAPI + WebSocket
@@ -33,3 +33,268 @@ Phase 4 : scripts production (install.sh, start.sh, systemd).
- Projet cree le 2026-03-21
- Mode simulation camera si gphoto2 non installe (dev sans DSLR)
- Config persistante dans data/config.json
---
## Infra & réseau
### Surface (booth terrain)
- IP LAN : 192.168.111.39 (DHCP, sujette à changement)
- IP VPN : 10.10.0.2
- Accès SSH : `ssh surface` (alias ~/.ssh/config, clé ed25519)
- User : `jules`, repo : `~/photobooth`
- **Pas de clavier physique** — toute intervention SSH uniquement
- Déploiement : pas de systemd (photobooth.service disabled, plante exec 203). Le run = `scripts/kiosk-session.sh` lancé au login LightDM autologin : boucle relance backend + boucle Chromium kiosk. Pour déployer : `git push` depuis dev, puis `kill <pid backend.main>` + `pkill -f 'chromium '` → relance auto avec git pull.
- Remote debugging Chromium : port 9222
- Kernel linux-surface avec iptsd (tactile), scale-factor=3
- `data/config.json` = état runtime (compteur, imprimante) — ne jamais écraser par commit/push depuis dev
### LXC 111 (booth-manager / galerie)
- IP : 192.168.111.211
- Accès : `ssh root@192.168.111.10` puis `pct exec 111 -- <cmd>`
- Code : `/opt/photobooth/backend/booth_admin.py`
- Service : `systemctl restart photobooth`
- Héberge : booth.copydev.fr (landing, galerie, admin)
### WireGuard tunnel
- Serveur (LXC 111) : wg0, 10.10.0.1/24, port 51820/UDP, enabled
- Client (Surface) : 10.10.0.2/24
- Clé publique serveur : `g3s7Gh1Er+LIEI/W91HAVDNIo+gsk/KiajdtoDUQRVo=`
- Clé privée client : `uH9lesPtezigCDlB6cSCYy87QFxrtLFWcnwi93/YgXM=`
- Clé publique client : `p+yIc+slDkKXnogvvuohbWlF8iapbI4LxJPH1RCaUig=`
- Port forward routeur : UDP 51820 → 192.168.111.211
### Proxmox PVE
- IP : 192.168.111.10, accès `ssh root@192.168.111.10`
### NPM (Nginx Proxy Manager)
- IP : 192.168.111.5, port 81
- Cert wildcard *.copydev.fr ID #2
---
## Règles de conduite Claude
- **Pas de clavier sur la borne** : ne jamais suggérer Ctrl+R, F5, ou actions clavier. Forcer reload via SSH (port 9222) ou restart lightdm/chromium.
- **Pas de confirmation sur la Surface** : PC dédié, aucun risque, exécuter directement les commandes SSH.
- **Pas de SSH sauvage** : ne jamais SSH sur des serveurs non demandés sans instruction explicite.
---
## Matériel
### Imprimante sublimation Mitsubishi
- Reçue le 2026-05-28
- Erreur 03/01/01 sur K60 = papier mal chargé (pas fin de rouleau)
### Module relais USB HID
- Commandé le 2026-06-23, livraison estimée ~2026-07-01
- Pour piloter alim secteur Canon (power-cycle PTP freeze) + éclairage booth
- Intégration via /dev/hidraw* dans le backend Python
---
## UX dépannage novices
Utilisateurs finaux = novices (événementiel). Erreurs imprimante : prévoir guides imagés (popup plein écran, étapes numérotées, icônes grandes, pas de codes techniques). Cas : cassette mal insérée, bourrage, fin consommable, câble USB.
---
## VPN booth (2026-07-04)
Tunnel WG a eu une panne le 04/07. Cause probable : wg-quick@wg0 pas enabled au boot, ou Endpoint IP locale.
### Procédure fix (clavier USB temporaire)
1. `sudo systemctl status wg-quick@wg0 && sudo wg show && cat /etc/wireguard/wg0.conf`
2. Vérifier Endpoint = `<IP_PUBLIQUE>:51820` (pas 192.168.111.x). IP publique : `curl -s ifconfig.me`
3. `sudo systemctl enable wg-quick@wg0 && sudo systemctl restart wg-quick@wg0`
4. Installer watchdog + reverse SSH — voir PROCEDURE_VPN_FIX.md
---
## LXC 111 — booth.copydev.fr (état au 2026-07-04)
Code sur le serveur : `/opt/photobooth/` (PAS dans le repo git local).
Backup local des fichiers modifiés : `lxc111_backup/` dans ce dossier projet.
### Fichiers modifiés sur LXC 111
- `backend/main.py` (1126 lignes) — endpoints galerie + réservation
- `backend/booth_admin.py` (808 lignes) — admin panel API + ZIP streaming
- `frontend/landing.html` (900 lignes) — page vitrine complète
- `frontend/admin.html` (930 lignes) — panel admin avec validation résa
- `frontend/gallery.html` (414 lignes) — galerie publique + modal ZIP progress
### Endpoints ajoutés
- `GET /api/disponibilites` → retourne dates réservées (statut "valide" ou "demande")
- `POST /api/contact` → crée événement + envoie email admin (pas client). Champs : nom, email, telephone, nom_evenement, type, date, formule, theme, nb_invites, impressions, opt_livraison, message
- `POST /api/contact/valider/{event_id}` → passe statut "valide" + envoie email confirmation au client avec URL galerie + code accès
### Landing page (frontend/landing.html)
- Vitrine commerciale booth.copydev.fr, couleur indigo #5c6bc0
- 6 cartes prestations (Canon 18Mpx, Mitsubishi sublimation, galerie live, personnalisation, écran tactile, partage)
- 3 tarifs : Pack 300 (300€), Pack 600 (450€), Livraison & installation (sur devis)
- Formulaire réservation → crée événement auto → email admin
- Calendrier dispo : WE = sam+dim (un bloc), semaine = lun→ven (un bloc), même prix. Dates réservées bloquées.
- Modal accès galerie (code 4 digits)
- Lien admin caché (coin bas-droit, opacity:0 → hover 0.3)
### Admin panel (frontend/admin.html)
- Section "Demandes en attente" (orange) avec boutons Valider/Refuser
- Section "Galeries en ligne" séparée
- `validerReservation()` → `POST /api/contact/valider/{id}` avec `credentials: 'include'`
### ZIP galerie
- Corrigé crash mémoire (648 Mo in-memory → temp file + streaming 1 Mo chunks)
- Barre de progression dans modal (ReadableStream, affiche Mo reçus + %)
- Restreint admin only (pas guest)
### SMTP
- Corrigé pour port 587 : STARTTLS (pas SMTP_SSL qui est pour 465)
- Config OVH : ssl0.ovh.net port 587
### Compteur photos
- `distribuer_photo()` accepte paramètre `copies` et incrémente par ce nombre (pas toujours +1)
---
## Historique développement (juin-juillet 2026)
### Kiosk & production (3-4 juin)
- Kiosk boucle infinie : backend + Chromium relancés automatiquement, plus d'écran login
- Impression Mitsubishi K60 + cadres + interface admin distante
- Chromium : désactivation Translate (--lang=fr + prefs FR), meta notranslate
- Verrou instance unique kiosk (anti-clignotement)
- Intégration station d'impression photo (photostation) — popup accueil
- USB automount avec udiskie dans kiosk-session
- Détection USB 2 niveaux /media/<user>/<label> + filtre fstype
- Cadres : choix avec zoom 2.5x + fond photo simulé
### Caméra & preview (5-10 juin)
- Viewfinder permanent + preview live pendant compte à rebours
- Fix preview + miroir : thread continu, grace period, lock gphoto2
- Fix caméra : kill gvfsd-gphoto2, race condition viewfinder, exif_transpose
- Fix strip K60 coupe auto, diagnostic preview
- Caméra déconnectée : retour accueil automatique + abort capture
- Bullet-proof caméra : reconnexion auto + fix crash capture + AF LiveView
- Capture : viewfinder=0 avant prise (AF phase-détection), timeout 20s, spinner UI
- surveiller_dslr : reset USB auto après 3 échecs → retiré (contre-productif)
- Fix preview live : position:absolute sur img
- Remote debugging Chromium port 9222
- Kiosk : git pull auto + diagnostic caméra dans admin
### Imprimante & impression (4-23 juin)
- Statut détaillé imprimante + message erreur CUPS traduit
- Bandeau erreur imprimante tous écrans + reset USB
- Bouton Couper papier (micro-job blanc déclenche le cutter K60)
- Cadres : preview coin haut-gauche + compteur bloque impression pas capture
- Pellicule : marge de coupe plus large + warmup LiveView après capture
- Fix impression pellicule (portrait vs paysage, taille pleine, rotation)
- Cadre de visée live correspondant au recadrage final pellicule/collage
- Calibration impression : offsets coupe (mm) + rotation 180° configurable
- Fix impression paysage : inverser largeur/hauteur
### DSLR robustesse (21-28 juin)
- Flash Canon configurable dans admin
- DSLR anti-veille : autopoweroff Canon + udev no-autosuspend
- Keepalive Canon : get_config() toutes les 10s quand preview KO
- DSLR auto-recovery : USB reset + cadre overlay non-mirroré
- USB rebind Canon : unbind/rebind sysfs pour recovery hard PTP freeze
- Escalade recovery : uhubctl power cycle + backoff 60s après 10 échecs
- Viewfinder off avant capture pour AF phase-detect
- Viewfinder warmup en arrière-plan (accélération capture)
### Email (22 juin)
- Email plein écran avec suggestions d'adresses précédentes
- Fermer formulaire avant envoi + raccourcis domaines FR
- Email spool : file d'attente quand envoi échoue, retry 60s
- Spool envoi au démarrage uniquement, message "dans la semaine"
- Rapport email à contact@copydev.fr après vidage du spool
### Événements & galerie (22-23 juin)
- Galerie booth : QR avec auto-login, event_id aléatoire, flyer imprimable
- Admin : galerie en ligne dans Destinations + booth envoie toutes les photos
- Gestion événements + cadres par event + fix cadre paysage + dossier non-imprimées
- Thème Viking Celte : bois sculpté, bronze, entrelacs
- Cadre imposé visible en overlay sur live + preview + partage
- Formats activables par événement + cadre imposé exclut strips
- Lien galerie événement dans admin
### Résilience & réseau (26-27 juin)
- Écran pause, reconnexion WS auto, watchdog systemd, WiFi monitor
- Admin WiFi : scan réseaux, connexion, mots de passe mémorisés
- Kiosk : flag maintenance + anti-doublon backend
- Watchdog systemd + fix stabilité kiosk/backend
- QR code par photo : popup QR dynamique + page vue photo sur booth
- Fix QR code URL (routes publiques /g/)
- Fix QR après photo : corriger share.js qui écrasait afficherQR()
- Masquer popup QR à chaque changement d'écran
- Supprimer le timeout auto-retour du menu admin
### Admin galerie & copies (30 juin)
- Admin galerie + compteur copies + flash capture + erreur imprimante toast
- Bump cache versions (style v12, camera v17, gallery v4)
### Admin backoffice (9 juillet)
- Rubriques Général / Événement dans admin
- Vidéos situation (tutoriels visuels)
- Mode surprise
- Rembobinage ruban (instruction Mitsubishi)
### Suivi consommables (12 juillet)
- Compteur papier, ruban et photos avec diagnostic ratio
- Compteur poses perdues + ratio corrigé 0.5 (10 poses par feuille, 20 feuilles par rouleau)
### Module relais HID + éclairage auto (1-2 août)
- Module LCUS 4 canaux (5131:2007) piloté via hidapi/ctypes (`backend/relais.py`)
- Assignation : R1=Proj gauche (NO), R2=Proj droit (NO), R3=Canon alim (NF), R4=libre
- udev rule `/etc/udev/rules.d/99-usbrelay.rules` pour accès sans sudo
- 5 endpoints API relais + admin éclairage avec status relais + boutons ON/OFF
- Éclairage auto : analyse luminosité visage → allume projecteurs selon 3 seuils configurables
- Power cycle Canon : relais NF = OFF=alimenté, ON=coupé ; intégré en niveau 10 de l'escalade recovery DSLR
- Bug fix : byte 4 du status LCUS = bruit (0x4F), seuls bytes 1-3 valides
### Vidéos didactiques multi-étapes (1 août)
- Système complet : situations avec N étapes vidéo, chaque étape boucle jusqu'à validation opérateur
- 6 situations par défaut (montage, démontage, bourrage, rouleau, wifi, calibration)
- Wizard overlay plein écran avec bouton "OK c'est bon" + navigation étapes
- Storage `data/videos/{situation}/etape_XX.mp4`, config.json pour toggles/labels
- 8 endpoints API vidéos + migration ancien format plat
### Panneau diagnostic + auto-recovery + overlays erreur (3 août)
- Onglet "Diagnostic" dans admin : 4 cartes santé (camera/imprimante/relais/système), actions (reconnecter/réactiver/redémarrer), logs live, USB
- Endpoints : `/api/systeme/sante`, `/api/systeme/logs`, `/api/systeme/redemarrer-backend`, `/api/imprimante/reactiver`, `/api/systeme/usb`
- Auto-recovery imprimante : `_surveiller_imprimante` toutes les 30s, cupsenable si disabled
- Overlay erreur utilisateur avec escalade : auto-fix → patience → opérateur → aide vidéo → redémarrage
- Wizard vidéo connecté aux erreurs avec auto-détection résolution (poll sante)
- Situation `camera_hs` ajoutée aux vidéos didactiques par défaut
### Fix impression galerie + robustesse (2 août)
- Bug: `imprimante: null` dans config.json → TypeError: subprocess recevait None en argument
- Cause: `.get("imprimante", "Mitsubishi")` retourne None si la clé existe avec valeur null
- Fix: pattern `or "Mitsubishi"` dans 5 endroits (printer.py + main.py)
- statut-detail endpoint: ajout try/except pour ne plus retourner 500
- api_imprimer: ajout try/except global pour erreurs inattendues
- Config Surface corrigée : `imprimante` passé de null à "Mitsubishi"
### Canon DSLR restart — problème ouvert (2 août)
- Canon ne redémarre pas après coupure alim secteur (bouton power doit être pressé)
- Interrupteur doit être laissé sur ON pour que le power cycle fonctionne
- Si ça ne suffit pas : bypass physique du bouton power (soudure pont ou optocoupler sur canal 4)
- Camera fixée au plancher, trappe batterie inaccessible
---
## Commits non pushés (au 2 août 2026)
Incluent relais HID, vidéos didactiques, consommables, admin backoffice.
Push Gitea bloqué (pas de credentials SSH/HTTPS configurés sur Surface).
---
## Tâches Trello du projet
- [📋 À faire] faire essaie avec mon dslr et imprimante ricoh et installer sur ecran tactile terra
(card_id: 69c1122fec1b558a76e207b0)
Utilise `claude-trello` pour mettre à jour les cartes pendant le travail.
Labels: green=fait, yellow=en cours, red=bloqué, (aucun)=à faire

2269
photobooth_pygame.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
SUBSYSTEM=="usb", ATTR{idVendor}=="04a9", TEST=="power/control", ATTR{power/control}="on"

43
scripts/bench_camera.py Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Test direct gphoto2 : mesure temps preview et capture."""
import time
try:
import gphoto2 as gp
except ImportError:
print("gphoto2 non installe")
exit(1)
cam = gp.Camera()
cam.init()
print("Camera connectee")
# Test preview
print("\n--- Preview (5 frames) ---")
times = []
for i in range(5):
t0 = time.time()
f = cam.capture_preview()
data = bytes(f.get_data_and_size())
dt = time.time() - t0
times.append(dt)
print("Frame " + str(i+1) + ": " + str(round(dt, 3)) + "s " + str(len(data)//1024) + "KB")
print("Moyenne preview: " + str(round(sum(times)/len(times), 3)) + "s")
# Test capture photo
print("\n--- Capture photo ---")
t0 = time.time()
chemin = cam.capture(gp.GP_CAPTURE_IMAGE)
dt_declenchement = time.time() - t0
print("Declenchement: " + str(round(dt_declenchement, 3)) + "s")
t1 = time.time()
cf = gp.CameraFile()
cam.file_get(chemin.folder, chemin.name, gp.GP_FILE_TYPE_NORMAL, cf)
data = bytes(cf.get_data_and_size())
dt_transfert = time.time() - t1
print("Transfert: " + str(round(dt_transfert, 3)) + "s " + str(len(data)//1024) + "KB")
print("Total capture: " + str(round(time.time() - t0, 3)) + "s")
cam.exit()

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Génère des cadres de démonstration pour chaque format d'impression."""
from pathlib import Path
from PIL import Image, ImageDraw
RACINE = Path(__file__).resolve().parent.parent
DOSSIER_CADRES = RACINE / "frontend" / "assets" / "cadres"
FORMATS = {
"strip": (600, 1800),
"10x15": (1200, 1800),
"15x20": (1800, 2400),
}
# ─── Cadres simples (tous formats) ───────────────────────────────────────────
def cadre_simple(largeur, hauteur, couleur_bord, epaisseur=30, couleur_coin=None):
img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
cc = couleur_coin or couleur_bord
ep = epaisseur
# 4 bords
d.rectangle([0, 0, largeur, ep], fill=couleur_bord)
d.rectangle([0, hauteur - ep, largeur, hauteur], fill=couleur_bord)
d.rectangle([0, 0, ep, hauteur], fill=couleur_bord)
d.rectangle([largeur - ep, 0, largeur, hauteur], fill=couleur_bord)
# coins
tc = ep + 20
for x, y in [(0, 0), (largeur - tc, 0), (0, hauteur - tc), (largeur - tc, hauteur - tc)]:
d.rectangle([x, y, x + tc, y + tc], fill=cc)
# ligne intérieure fine
m = ep + 8
d.rectangle([m, m, largeur - m, hauteur - m], outline=cc, width=2)
return img
# ─── Cadres pellicule (format strip uniquement) ───────────────────────────────
def cadre_pellicule(largeur, hauteur, couleur_bord=(15, 15, 15, 255),
couleur_perf=(50, 50, 50, 255), label=""):
"""Cadre pellicule avec perforations sur les côtés gauche et droit."""
img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
bord = 52 # largeur de la bande pellicule de chaque côté
perf_h = 22 # hauteur d'une perforation
perf_w = 16 # largeur d'une perforation
perf_r = 4 # rayon arrondi
perf_gap = 14 # espace entre perforations
# Bandes noires gauche et droite
d.rectangle([0, 0, bord, hauteur], fill=couleur_bord)
d.rectangle([largeur - bord, 0, largeur, hauteur], fill=couleur_bord)
# Bande fine haut et bas
bord_h = 10
d.rectangle([0, 0, largeur, bord_h], fill=couleur_bord)
d.rectangle([0, hauteur - bord_h, largeur, hauteur], fill=couleur_bord)
# Perforations gauche
y = perf_gap
while y + perf_h < hauteur:
x = (bord - perf_w) // 2
d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=couleur_perf)
y += perf_h + perf_gap
# Perforations droite
y = perf_gap
while y + perf_h < hauteur:
x = largeur - bord + (bord - perf_w) // 2
d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=couleur_perf)
y += perf_h + perf_gap
# Ligne intérieure fine pour délimiter la zone photo
m = bord + 4
d.rectangle([m, bord_h + 4, largeur - m, hauteur - bord_h - 4], outline=(80, 80, 80, 180), width=1)
return img
def cadre_pellicule_vintage(largeur, hauteur):
"""Pellicule couleur sépia avec numéros de frame."""
img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
bord = 58
bord_sepia = (40, 28, 12, 255)
perf_col = (90, 65, 30, 255)
perf_h, perf_w, perf_r, perf_gap = 20, 14, 3, 16
d.rectangle([0, 0, bord, hauteur], fill=bord_sepia)
d.rectangle([largeur - bord, 0, largeur, hauteur], fill=bord_sepia)
d.rectangle([0, 0, largeur, 8], fill=bord_sepia)
d.rectangle([0, hauteur - 8, largeur, hauteur], fill=bord_sepia)
for side in [0, 1]:
y = perf_gap
while y + perf_h < hauteur:
x = (bord - perf_w) // 2 if side == 0 else largeur - bord + (bord - perf_w) // 2
d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=perf_col)
y += perf_h + perf_gap
# Filet intérieur
m = bord + 5
d.rectangle([m, 12, largeur - m, hauteur - 12], outline=(100, 75, 35, 200), width=2)
return img
def cadre_pellicule_couleur(largeur, hauteur, teinte=(220, 50, 50)):
"""Pellicule colorée style photobooth rétro."""
img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
bord = 50
r, g, b = teinte
col_bord = (r, g, b, 255)
col_perf = (min(255, r + 60), min(255, g + 60), min(255, b + 60), 255)
perf_h, perf_w, perf_r, perf_gap = 20, 14, 4, 14
d.rectangle([0, 0, bord, hauteur], fill=col_bord)
d.rectangle([largeur - bord, 0, largeur, hauteur], fill=col_bord)
d.rectangle([0, 0, largeur, 8], fill=col_bord)
d.rectangle([0, hauteur - 8, largeur, hauteur], fill=col_bord)
for side in [0, 1]:
y = perf_gap
while y + perf_h < hauteur:
x = (bord - perf_w) // 2 if side == 0 else largeur - bord + (bord - perf_w) // 2
d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=col_perf)
y += perf_h + perf_gap
m = bord + 5
d.rectangle([m, 12, largeur - m, hauteur - 12], outline=col_perf, width=2)
return img
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
# Cadres simples pour tous les formats
SIMPLES = [
("classique_blanc.png", (255, 255, 255, 255), (210, 210, 210, 255), 30),
("elegant_noir.png", (20, 20, 20, 255), (70, 70, 70, 255), 40),
("rose_gold.png", (198, 143, 115, 255), (230, 180, 150, 255), 35),
("festif_dore.png", (212, 175, 55, 255), (255, 215, 0, 255), 32),
]
for fmt, (larg, haut) in FORMATS.items():
dossier = DOSSIER_CADRES / fmt
dossier.mkdir(parents=True, exist_ok=True)
for nom, col_bord, col_coin, ep in SIMPLES:
cadre = cadre_simple(larg, haut, col_bord, ep, col_coin)
cadre.save(dossier / nom, "PNG")
print(f" ✓ {fmt}/{nom}")
# Cadres pellicule — uniquement format strip
dossier_strip = DOSSIER_CADRES / "strip"
larg, haut = FORMATS["strip"]
pellicules = [
("pellicule_noir.png", cadre_pellicule(larg, haut)),
("pellicule_vintage.png", cadre_pellicule_vintage(larg, haut)),
("pellicule_rouge.png", cadre_pellicule_couleur(larg, haut, (200, 40, 40))),
("pellicule_bleu.png", cadre_pellicule_couleur(larg, haut, (30, 80, 180))),
("pellicule_vert.png", cadre_pellicule_couleur(larg, haut, (30, 140, 60))),
]
for nom, img in pellicules:
img.save(dossier_strip / nom, "PNG")
print(f" ✓ strip/{nom} (pellicule)")
if __name__ == "__main__":
print("Génération des cadres de démonstration...")
main()
print("Terminé.")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 B

After

Width:  |  Height:  |  Size: 493 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 B

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 B

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 B

After

Width:  |  Height:  |  Size: 78 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 B

After

Width:  |  Height:  |  Size: 78 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 B

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 B

After

Width:  |  Height:  |  Size: 78 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 B

After

Width:  |  Height:  |  Size: 78 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 B

After

Width:  |  Height:  |  Size: 99 B

View File

@@ -1,35 +1,25 @@
# Theme GRUB tactile - Triple Boot Terra
title-text: ""
desktop-color: "#1a1a2e"
desktop-image: "background.png"
desktop-color: "#0f1428"
message-color: "#ffffff"
message-bg-color: "#000000"
terminal-font: "DejaVu Sans Bold 24"
+ boot_menu {
left = 10%
top = 15%
width = 80%
height = 70%
item_font = "DejaVu Sans Bold 36"
item_color = "#cccccc"
selected_item_font = "DejaVu Sans Bold 42"
left = 15%
top = 25%
width = 70%
height = 60%
item_font = "DejaVu Sans Bold 48"
item_color = "#aabbcc"
selected_item_font = "DejaVu Sans Bold 48"
selected_item_color = "#ffffff"
item_height = 120
item_padding = 20
item_spacing = 30
item_icon_space = 30
icon_width = 96
icon_height = 96
item_height = 160
item_padding = 30
item_spacing = 20
item_icon_space = 40
icon_width = 128
icon_height = 128
selected_item_pixmap_style = "select_*.png"
}
+ label {
left = 0
top = 90%
width = 100%
height = 30
text = "Toucher pour selectionner - Appuyer sur Entree pour demarrer"
font = "DejaVu Sans 18"
color = "#888888"
align = "center"
}

View File

@@ -0,0 +1,34 @@
#!/bin/bash
# Installe PyQt6 WebEngine et configure le service kiosk Qt
set -e
echo "=== Installation PyQt6 WebEngine ==="
pip3 install PyQt6 PyQt6-WebEngine --break-system-packages
echo "=== Creation service systemd ==="
cat > /etc/systemd/system/photobooth-kiosk-qt.service << EOF
[Unit]
Description=Photobooth Kiosk Qt
After=graphical-session.target photobooth.service
Wants=photobooth.service
[Service]
Type=simple
User=$(logname)
Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/$(logname)/.Xauthority
ExecStart=/usr/bin/python3 /home/$(logname)/photobooth/scripts/kiosk-qt.py
Restart=always
RestartSec=3
[Install]
WantedBy=graphical-session.target
EOF
systemctl daemon-reload
systemctl enable photobooth-kiosk-qt
echo "=== Pour basculer sur le kiosk Qt ==="
echo " sudo systemctl stop photobooth-kiosk"
echo " sudo systemctl disable photobooth-kiosk"
echo " sudo systemctl start photobooth-kiosk-qt"

37
scripts/install-photostation.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Installe la photostation sur la Surface pour intégration photobooth
set -e
DEST="/opt/photostation"
REPO="https://git.copydev.fr/jules/photostation.git"
echo "=== Installation Photostation ==="
# Dépendances système
sudo apt-get install -y \
python3-pyqt6 python3-pyqt6.qtsvg \
python3-pillow python3-flask \
python3-qrcode python3-cups \
libgl1 libglib2.0-0 2>&1 | grep -E 'Install|Upgrade|already'
# Cloner ou mettre à jour
if [ -d "$DEST/.git" ]; then
echo "Mise à jour du repo..."
git -C "$DEST" pull
else
echo "Clonage du repo..."
sudo git clone "$REPO" "$DEST"
sudo chown -R jules:jules "$DEST"
fi
# Venv avec accès aux paquets système (PyQt6 est en paquet système)
if [ ! -d "$DEST/venv" ]; then
python3 -m venv --system-site-packages "$DEST/venv"
fi
# Dépendances Python supplémentaires
"$DEST/venv/bin/pip" install --quiet qrcode[pil] 2>/dev/null || true
echo ""
echo "=== Installation terminée ==="
echo "Test : DISPLAY=:0 bash -c 'source $DEST/venv/bin/activate && python $DEST/src/main.py'"

View File

@@ -19,6 +19,10 @@ sudo apt install -y \
xinput \
xdotool
# Empecher le DSLR Canon de se mettre en veille USB (sinon il se deconnecte seul)
sudo cp "$(dirname "$0")/99-canon-no-autosuspend.rules" /etc/udev/rules.d/
sudo udevadm control --reload-rules
# Repertoire du projet
APP_DIR="/home/jules/photobooth"

91
scripts/kiosk-qt.py Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Kiosk PyQt6 WebEngine pour photobooth.
Alternative au browser : process unique, pas de "already running", restart propre.
Usage : python3 scripts/kiosk-qt.py
"""
import sys
import time
import subprocess
from PyQt6.QtCore import Qt, QUrl, QTimer
from PyQt6.QtWidgets import QApplication
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebEngineCore import QWebEngineSettings
BACKEND_URL = "http://localhost:8080"
RETRY_INTERVAL_MS = 3000
def wait_backend(url: str, timeout: int = 30) -> bool:
"""Attend que le backend soit dispo avant d'afficher."""
import urllib.request
for _ in range(timeout):
try:
urllib.request.urlopen(url, timeout=1)
return True
except Exception:
time.sleep(1)
return False
class KioskWindow(QWebEngineView):
def __init__(self):
super().__init__()
# Plein ecran sans decoration
self.setWindowFlags(Qt.WindowType.FramelessWindowHint)
self.showFullScreen()
# Parametres WebEngine
settings = self.settings()
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.TouchIconsEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.ScrollAnimatorEnabled, True)
# Cacher le curseur
self.setCursor(Qt.CursorShape.BlankCursor)
self.setUrl(QUrl(BACKEND_URL))
# Watchdog : recharge si la page ne repond plus (WebSocket coupe)
self._watchdog = QTimer(self)
self._watchdog.setInterval(10000)
self._watchdog.timeout.connect(self._check_backend)
self._watchdog.start()
def _check_backend(self):
import urllib.request
try:
urllib.request.urlopen(BACKEND_URL, timeout=2)
except Exception:
self.reload()
def keyPressEvent(self, event):
# F5 = reload, F11 = fullscreen toggle, Echap ignoré
if event.key() == Qt.Key.Key_F5:
self.reload()
elif event.key() == Qt.Key.Key_F11:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
def main():
app = QApplication(sys.argv)
app.setApplicationName("Photobooth Kiosk")
# Attendre que le backend soit pret
if not wait_backend(BACKEND_URL):
print("Backend non disponible apres 30s", file=sys.stderr)
sys.exit(1)
window = KioskWindow()
sys.exit(app.exec())
if __name__ == "__main__":
main()

81
scripts/kiosk-session.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/bin/bash
# Verrou atomique — une seule session kiosk autorisee
exec 9>/tmp/photobooth-kiosk.lock
flock -n 9 || { echo "[kiosk] Déjà en cours, abandon."; exit 0; }
PHOTOBOOTH_DIR=/home/jules/photobooth
# Port du backend (lu depuis config.json, fallback 8080)
BACKEND_PORT=$(python3 -c "
import json, sys
try:
d = json.load(open('$PHOTOBOOTH_DIR/data/config.json'))
print(d.get('serveur', {}).get('port', 8080))
except: print(8080)
" 2>/dev/null)
BACKEND_URL="http://localhost:${BACKEND_PORT}"
# Tuer tout backend orphelin d'une session precedente
pkill -f 'python.*backend.main' 2>/dev/null || true
sleep 1
xset s off
xset -dpms
xset s noblank
# Automount USB (pour la photostation)
udiskie --no-notify --no-appindicator &
# Liberer 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
# Backend en boucle : redemarre automatiquement s'il plante
_backend_loop() {
while true; do
while [ -f /tmp/photobooth-maintenance ]; do sleep 2; done
cd "$PHOTOBOOTH_DIR"
source .venv/bin/activate
authbind --deep python -m backend.main 2>&1 | tee -a /tmp/photobooth-backend.log
echo "[kiosk] backend terminé, redémarrage dans 3s..."
sleep 3
done
}
_backend_loop &
BACKEND_LOOP_PID=$!
# Nettoyer le backend si le kiosk est tue
trap "kill $BACKEND_LOOP_PID 2>/dev/null; pkill -f 'python.*backend.main' 2>/dev/null" EXIT
# Attendre que le backend soit pret (max 30s)
for i in $(seq 1 30); do
curl -sf "${BACKEND_URL}/api/config" > /dev/null 2>&1 && break
sleep 1
done
# Chromium en boucle : redemarre s'il plante, attend si photostation active
while true; do
while [ -f /tmp/photostation_running ]; do sleep 1; done
chromium \
--kiosk \
--noerrdialogs \
--disable-infobars \
--disable-translate \
--disable-features=TranslateUI,Translate,LockProfileCookieDatabase \
--disable-session-crashed-bubble \
--disable-component-update \
--check-for-update-interval=31536000 \
--autoplay-policy=no-user-gesture-required \
--start-fullscreen \
--user-data-dir=/home/jules/.config/chromium-kiosk \
--touch-events=enabled \
--enable-touch-drag-drop \
--force-device-scale-factor=3 \
--password-store=basic \
--lang=fr \
--remote-debugging-port=9222 \
"${BACKEND_URL}"
echo "[kiosk] Chromium terminé, redémarrage dans 2s..."
sleep 2
done

View File

@@ -1,15 +1,25 @@
[Unit]
Description=Photobooth
Description=Photobooth backend
After=network.target graphical.target
Wants=graphical.target
[Service]
Type=simple
Type=notify
User=jules
WorkingDirectory=/home/jules/Documents/Projet/photobooth-app
ExecStart=/home/jules/Documents/Projet/photobooth-app/scripts/start.sh
WorkingDirectory=/home/jules/photobooth
ExecStart=/home/jules/photobooth/scripts/start.sh
ExecStopPost=/bin/bash -c 'pkill -9 -f "python.*backend.main" 2>/dev/null; sleep 1'
Restart=always
RestartSec=5
RestartSec=3
WatchdogSec=30
KillMode=control-group
KillSignal=SIGTERM
TimeoutStopSec=10
StartLimitIntervalSec=120
StartLimitBurst=10
Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/jules/.Xauthority
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=graphical.target

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,8 @@
[Plymouth Theme]
Name=Photobooth
Description=Chat photographe - Animation de demarrage
ModuleName=script
[script]
ImageDir=/usr/share/plymouth/themes/photobooth
ScriptFile=/usr/share/plymouth/themes/photobooth/photobooth.script

View File

@@ -0,0 +1,18 @@
// Animation du chat photographe
frame = 0;
total_frames = 20;
for (i = 0; i < total_frames; i++) {
frames[i] = Image("frame-" + String(i).PadLeft(4, "0") + ".png");
}
bg = Image("background.png");
fun refresh_callback() {
bg.Draw(0, 0);
frames[frame].Draw(0, 0);
frame = (frame + 1) % total_frames;
}
Plymouth.SetRefreshFunction(refresh_callback);
Plymouth.SetRefreshRate(5);

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# Demarrage du photobooth (backend + chromium kiosk)
# Demarrage du backend photobooth (Chromium est gere par la session kiosk)
set -e
DIR="$(cd "$(dirname "$0")/.." && pwd)"
@@ -8,32 +8,5 @@ cd "$DIR"
# Activer le venv
source .venv/bin/activate
# Demarrer le backend en arriere-plan
echo "Demarrage du backend..."
python -m backend.main &
BACKEND_PID=$!
# Attendre que le backend soit pret
sleep 3
# Cacher le curseur souris
unclutter -idle 0.1 -root &
# Demarrer Chromium en mode kiosk
echo "Demarrage de Chromium kiosk..."
chromium-browser \
--kiosk \
--noerrdialogs \
--disable-infobars \
--disable-translate \
--disable-features=TranslateUI \
--disable-session-crashed-bubble \
--disable-component-update \
--check-for-update-interval=31536000 \
--autoplay-policy=no-user-gesture-required \
--start-fullscreen \
--incognito \
http://localhost:8080
# Si Chromium se ferme, arreter le backend
kill $BACKEND_PID 2>/dev/null
echo "Demarrage du backend photobooth..."
exec python -m backend.main

48
scripts/test_opencv.py Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Test liveview avec OpenCV. Lance: DISPLAY=:0 python3 scripts/test_opencv.py"""
import time
import io
import numpy as np
import cv2
import gphoto2 as gp
cam = gp.Camera()
cam.init()
print("Camera connectee")
t_start = time.time()
frame_count = 0
premier_frame = None
print("Appuie sur ESC pour quitter")
while True:
try:
t0 = time.time()
f = cam.capture_preview()
data = bytes(f.get_data_and_size())
dt = time.time() - t0
if premier_frame is None:
premier_frame = time.time() - t_start
print(f"1ere image en : {premier_frame:.2f}s")
arr = np.frombuffer(data, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
img = cv2.resize(img, (960, 640))
frame_count += 1
fps = frame_count / (time.time() - t_start)
cv2.putText(img, f"FPS: {fps:.1f} frame: {dt*1000:.0f}ms", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
cv2.imshow("Test liveview OpenCV", img)
if cv2.waitKey(1) & 0xFF == 27: # ESC
break
except Exception as e:
print(f"Erreur: {e}")
break
cam.exit()
cv2.destroyAllWindows()
print(f"Total: {frame_count} frames, {frame_count/(time.time()-t_start):.1f} FPS")

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""
Prototype photobooth Pygame.
- Bouton -> liveview -> 3 2 1 -> photo x4 -> grille 4 photos
"""
import time
import threading
import io
import os
from pathlib import Path
from datetime import datetime
import pygame
import gphoto2 as gp
from PIL import Image
# Config
LARGEUR, HAUTEUR = 1920, 1280
NB_PHOTOS = 4
DUREE_COMPTE = 3
DOSSIER_PHOTOS = Path("/tmp/photobooth_test")
DOSSIER_PHOTOS.mkdir(exist_ok=True)
# Couleurs
NOIR = (0, 0, 0)
BLANC = (255, 255, 255)
ROSE = (233, 30, 99)
GRIS = (40, 40, 40)
class Photobooth:
def __init__(self):
pygame.init()
self.screen = pygame.display.set_mode((LARGEUR, HAUTEUR), pygame.FULLSCREEN)
pygame.display.set_caption("Photobooth")
self.clock = pygame.time.Clock()
self.font_grand = pygame.font.SysFont(None, 300)
self.font_moyen = pygame.font.SysFont(None, 100)
self.font_petit = pygame.font.SysFont(None, 60)
# Camera
self.cam = gp.Camera()
self.cam.init()
# Etat
self.etat = "accueil" # accueil, preview, compte, capture, galerie
self.frame_courante = None
self.frame_lock = threading.Lock()
self.preview_actif = False
# Capture via le thread preview (evite le baisse de miroir)
self.capture_demande = False
self.capture_resultat = None
self.capture_done = threading.Event()
self.photos = []
self.num_photo = 0
self.chiffre_compte = 0
# Thread preview
self.thread_preview = threading.Thread(target=self._loop_preview, daemon=True)
self.thread_preview.start()
def _loop_preview(self):
while True:
if self.preview_actif:
try:
if self.capture_demande:
try:
chemin_cam = self.cam.capture(gp.GP_CAPTURE_IMAGE)
cf = gp.CameraFile()
self.cam.file_get(chemin_cam.folder, chemin_cam.name, gp.GP_FILE_TYPE_NORMAL, cf)
nom = f"photo_{datetime.now().strftime('%H%M%S_%f')}.jpg"
chemin = str(DOSSIER_PHOTOS / nom)
cf.save(chemin)
self.capture_resultat = chemin
print(f"Photo capturee: {chemin}")
except Exception as e:
print(f"Erreur capture: {e}")
self.capture_resultat = None
self.capture_demande = False
self.capture_done.set()
else:
f = self.cam.capture_preview()
data = bytes(f.get_data_and_size())
surf = pygame.image.load(io.BytesIO(data))
surf = pygame.transform.scale(surf, (LARGEUR, HAUTEUR))
with self.frame_lock:
self.frame_courante = surf
except Exception as e:
print(f"Preview erreur: {e}")
time.sleep(0.3) # Camera peut etre occupee apres capture
else:
time.sleep(0.05)
def capturer_photo(self):
"""Demande une capture au thread preview (miroir reste leve)."""
self.capture_resultat = None
self.capture_done.clear()
self.capture_demande = True
ok = self.capture_done.wait(timeout=10)
if not ok:
print("Timeout capture")
self.capture_demande = False
return self.capture_resultat
def dessiner_accueil(self):
self.screen.fill(NOIR)
txt = self.font_moyen.render("PHOTOBOOTH", True, ROSE)
self.screen.blit(txt, (LARGEUR//2 - txt.get_width()//2, 300))
# Bouton
btn = pygame.Rect(LARGEUR//2 - 300, HAUTEUR//2, 600, 150)
pygame.draw.rect(self.screen, ROSE, btn, border_radius=20)
t = self.font_moyen.render("COMMENCER", True, BLANC)
self.screen.blit(t, (btn.centerx - t.get_width()//2, btn.centery - t.get_height()//2))
return btn
def dessiner_preview(self):
with self.frame_lock:
if self.frame_courante:
self.screen.blit(self.frame_courante, (0, 0))
else:
self.screen.fill(NOIR)
# Info
t = self.font_petit.render(f"Photo {self.num_photo+1}/{NB_PHOTOS} - Souriez!", True, BLANC)
self.screen.blit(t, (20, 20))
def dessiner_compte(self):
with self.frame_lock:
if self.frame_courante:
self.screen.blit(self.frame_courante, (0, 0))
else:
self.screen.fill(NOIR)
# Overlay sombre
overlay = pygame.Surface((LARGEUR, HAUTEUR), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 100))
self.screen.blit(overlay, (0, 0))
# Chiffre
txt = self.font_grand.render(str(self.chiffre_compte), True, BLANC)
self.screen.blit(txt, (LARGEUR//2 - txt.get_width()//2, HAUTEUR//2 - txt.get_height()//2))
def dessiner_galerie(self):
self.screen.fill(NOIR)
if not self.photos:
t = self.font_petit.render("Aucune photo", True, BLANC)
self.screen.blit(t, (LARGEUR//2 - t.get_width()//2, HAUTEUR//2))
else:
# 4 photos en grille 2x2
w, h = LARGEUR//2 - 20, HAUTEUR//2 - 20
positions = [(10, 10), (LARGEUR//2+10, 10), (10, HAUTEUR//2+10), (LARGEUR//2+10, HAUTEUR//2+10)]
for i, chemin in enumerate(self.photos[:4]):
if i >= len(positions):
break
try:
# PIL pour redimensionner efficacement les grands JPEG Canon
img = Image.open(chemin).convert('RGB')
img = img.resize((w, h), Image.LANCZOS)
surf = pygame.image.fromstring(img.tobytes(), img.size, 'RGB')
self.screen.blit(surf, positions[i])
except Exception as e:
print(f"Erreur galerie photo {i}: {e}")
# Bouton recommencer
btn = pygame.Rect(LARGEUR//2 - 200, HAUTEUR - 120, 400, 100)
pygame.draw.rect(self.screen, ROSE, btn, border_radius=15)
t = self.font_petit.render("RECOMMENCER", True, BLANC)
self.screen.blit(t, (btn.centerx - t.get_width()//2, btn.centery - t.get_height()//2))
return btn
def lancer_sequence(self):
"""Lance la séquence 4 photos dans un thread."""
def sequence():
self.photos = []
for i in range(NB_PHOTOS):
self.num_photo = i
# Preview
self.preview_actif = True
self.etat = "preview"
time.sleep(1.5)
# Compte a rebours
self.etat = "compte"
for c in range(DUREE_COMPTE, 0, -1):
self.chiffre_compte = c
time.sleep(1)
# Flash + capture (miroir reste leve, liveview continue)
self.etat = "capture"
chemin = self.capturer_photo()
if chemin:
self.photos.append(chemin)
print(f"Photo {i+1} : {chemin}")
time.sleep(0.3)
# Galerie
self.preview_actif = False
self.etat = "galerie"
threading.Thread(target=sequence, daemon=True).start()
def run(self):
btn_accueil = None
btn_galerie = None
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False
if event.type == pygame.MOUSEBUTTONDOWN:
pos = pygame.mouse.get_pos()
if self.etat == "accueil" and btn_accueil and btn_accueil.collidepoint(pos):
self.lancer_sequence()
if self.etat == "galerie" and btn_galerie and btn_galerie.collidepoint(pos):
self.etat = "accueil"
self.photos = []
if self.etat == "accueil":
btn_accueil = self.dessiner_accueil()
btn_galerie = None
elif self.etat == "preview":
self.dessiner_preview()
elif self.etat == "compte":
self.dessiner_compte()
elif self.etat == "capture":
self.dessiner_compte() # Reste sur le liveview pendant la capture
elif self.etat == "galerie":
btn_galerie = self.dessiner_galerie()
btn_accueil = None
pygame.display.flip()
self.clock.tick(30)
self.preview_actif = False
self.cam.exit()
pygame.quit()
if __name__ == "__main__":
app = Photobooth()
app.run()

Some files were not shown because too many files have changed in this diff Show More