diff --git a/backend/destinations.py b/backend/destinations.py
index 6c8cf38..008d547 100644
--- a/backend/destinations.py
+++ b/backend/destinations.py
@@ -1,7 +1,10 @@
import ftplib
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
@@ -26,6 +29,15 @@ def distribuer_photo(chemin_photo: Path, imprimee: bool = False):
if dest.get("ftp", False):
envoyer_ftp(chemin_photo, dest)
+ # Galerie live (booth)
+ booth = config.get("booth", {})
+ if booth.get("actif", False):
+ threading.Thread(
+ target=envoyer_booth,
+ args=(chemin_photo, booth),
+ daemon=True,
+ ).start()
+
# Incrementer le compteur
compteur = config.get("compteur", {})
if compteur.get("actif", False):
@@ -83,6 +95,73 @@ def envoyer_ftp(chemin_photo: Path, config_dest: dict):
return False
+def envoyer_booth(chemin_photo: Path, config_booth: dict):
+ """Envoie une photo vers la galerie live booth."""
+ url = config_booth.get("url", "").rstrip("/")
+ api_key = config_booth.get("api_key", "")
+ event_id = config_booth.get("event_id", "default")
+
+ if not url:
+ log.warning("Booth non configure (URL manquante)")
+ return False
+
+ try:
+ import mimetypes
+ 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}/api/{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:
+ if resp.status == 200:
+ log.info(f"Photo envoyee au booth : {filename}")
+ return True
+ else:
+ log.error(f"Booth erreur HTTP {resp.status}")
+ return False
+ except (URLError, OSError) as e:
+ log.error(f"Erreur envoi booth : {e}")
+ return False
+
+
+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("/")
+ api_key = booth.get("api_key", "")
+ event_id = booth.get("event_id", "default")
+
+ if not url:
+ return {"password": None}
+
+ try:
+ req = Request(f"{url}/api/{event_id}/info")
+ req.add_header("X-Api-Key", api_key)
+ with urlopen(req, timeout=5) as resp:
+ import json
+ data = json.loads(resp.read())
+ return data
+ except (URLError, OSError) as e:
+ log.error(f"Erreur recuperation booth info : {e}")
+ return {"password": None}
+
+
def detecter_usb() -> list[str]:
"""Detecte les cles USB montees."""
chemins_possibles = [Path("/media"), Path("/mnt")]
diff --git a/backend/main.py b/backend/main.py
index 0aa4327..2817350 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -17,7 +17,7 @@ from backend.camera import camera
from backend.gallery import lister_photos, compter_photos, supprimer_photo, vider_galerie
from backend.effects import appliquer_filtre, appliquer_overlay, chroma_key, lister_overlays, lister_fonds, FILTRES
from backend.collage import creer_strip, creer_collage, creer_impression_strip
-from backend.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur
+from backend.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur, recuperer_booth_password
from backend.printer import lister_imprimantes, imprimer
from backend.mailer import envoyer_photo
from backend.qrcode_gen import generer_qr, qr_galerie
@@ -243,6 +243,13 @@ async def api_imprimer(donnees: dict):
return {"succes": ok}
+# --- API Booth (galerie live) ---
+
+@app.get("/api/booth/info")
+async def api_booth_info():
+ return recuperer_booth_password()
+
+
# --- API Email ---
@app.post("/api/email")
@@ -262,6 +269,21 @@ async def api_email(donnees: dict):
# --- API QR Code ---
+@app.get("/api/qr")
+async def api_qr(url: str = ""):
+ """Genere un QR code pour une URL arbitraire."""
+ if not url:
+ return JSONResponse({"erreur": "URL requise"}, status_code=400)
+ import qrcode
+ import io
+ qr = qrcode.make(url, box_size=6, border=2)
+ buf = io.BytesIO()
+ qr.save(buf, format="PNG")
+ buf.seek(0)
+ from fastapi.responses import StreamingResponse
+ return StreamingResponse(buf, media_type="image/png")
+
+
@app.get("/api/qr/galerie")
async def api_qr_galerie():
chemin = qr_galerie()
diff --git a/booth/package.json b/booth/package.json
new file mode 100644
index 0000000..62845b6
--- /dev/null
+++ b/booth/package.json
@@ -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"
+ }
+}
diff --git a/booth/public/index.html b/booth/public/index.html
new file mode 100644
index 0000000..b5c99b7
--- /dev/null
+++ b/booth/public/index.html
@@ -0,0 +1,432 @@
+
+
+
+
+
+ Galerie Photo
+
+
+
+
+
+
+
+
+
+
+
+
📸
+
Les photos apparaitront ici en temps reel
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
diff --git a/booth/server.js b/booth/server.js
new file mode 100644
index 0000000..ac6e5d5
--- /dev/null
+++ b/booth/server.js
@@ -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}`);
+});
diff --git a/frontend/css/style.css b/frontend/css/style.css
index 47db76b..ddb6811 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -249,6 +249,36 @@ html, body {
font-size: 3rem;
}
+.btn-mode .mode-icone-large {
+ font-size: 5rem;
+}
+
+/* === Booth info (code + QR sur accueil) === */
+.booth-info {
+ position: absolute;
+ bottom: 1rem;
+ left: 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+ background: rgba(0,0,0,0.5);
+ padding: 0.5rem 1rem;
+ border-radius: 12px;
+}
+
+.booth-qr {
+ width: 64px;
+ height: 64px;
+ border-radius: 6px;
+}
+
+.booth-code {
+ font-size: 1.4rem;
+ font-weight: bold;
+ color: rgba(255,255,255,0.7);
+ letter-spacing: 4px;
+}
+
/* === Capture / Preview live === */
.preview-live {
width: 100%;
diff --git a/frontend/index.html b/frontend/index.html
index 4e640a1..aa7ad8a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -24,6 +24,11 @@
400 / 400
+
+
+
![]()
+
+
⚙
@@ -33,12 +38,12 @@
Choisissez votre mode
diff --git a/frontend/js/app.js b/frontend/js/app.js
index add1b14..72dae5e 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -14,6 +14,7 @@ async function init() {
setupEcranAccueil();
setupModes();
setupOnglets();
+ chargerBoothInfo();
}
function appliquerConfig() {
@@ -209,6 +210,28 @@ 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) {
+ document.getElementById('booth-code').textContent = info.password;
+ // QR code vers le site booth
+ const eventId = booth.event_id || 'default';
+ const qrUrl = `${booth.url}/${eventId}`;
+ document.getElementById('booth-qr').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`;
+ el.classList.remove('cache');
+ }
+ } catch (e) {
+ el.classList.add('cache');
+ }
+}
+
function afficherStatut(message, type = 'succes') {
const el = document.getElementById('statut-partage');
el.textContent = message;