Init photobooth - borne photo evenementielle RPi4

Backend FastAPI complet : camera gphoto2, filtres/overlays Pillow,
chroma key OpenCV, multi-shot/collage, GIF, impression CUPS, email SMTP,
QR code. Frontend web vanilla (HTML/CSS/JS) pour Chromium kiosk.
Menu admin cache (appui long coin ecran). Toutes les fonctionnalites
activables/desactivables. Scripts install + systemd pour RPi4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 21:46:31 +01:00
commit c8e4ec80c1
29 changed files with 2854 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
__pycache__/
*.pyc
*.pyo
.venv/
venv/
*.egg-info/
dist/
build/
.env
*.swp
*.swo
*~
data/photos/*
data/exports/*
!data/photos/.gitkeep
!data/exports/.gitkeep
*.log
.DS_Store

0
backend/__init__.py Normal file
View File

140
backend/camera.py Normal file
View File

@@ -0,0 +1,140 @@
import io
import logging
import subprocess
import time
from datetime import datetime
from pathlib import Path
from backend.config import DOSSIER_PHOTOS, charger_config
log = logging.getLogger("photobooth.camera")
# Essayer d'importer gphoto2, sinon mode simulation
try:
import gphoto2 as gp
GPHOTO2_DISPONIBLE = True
except ImportError:
GPHOTO2_DISPONIBLE = False
log.warning("python-gphoto2 non installe, mode simulation active")
class Camera:
"""Controle de l'appareil DSLR via gphoto2."""
def __init__(self):
self.camera = None
self.contexte = None
self.connectee = False
def connecter(self) -> bool:
"""Connecte l'appareil photo."""
if not GPHOTO2_DISPONIBLE:
log.info("Mode simulation : camera virtuelle connectee")
self.connectee = True
return True
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
self.camera = None
self.contexte = None
self.connectee = False
log.info("Camera deconnectee")
def capturer(self) -> Path | None:
"""Capture une photo et retourne le chemin du fichier."""
if not self.connectee:
log.error("Camera non connectee")
return None
horodatage = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
nom_fichier = f"photo_{horodatage}.jpg"
chemin_dest = DOSSIER_PHOTOS / nom_fichier
if not GPHOTO2_DISPONIBLE:
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}")
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()
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}")
return None
def lister_appareils(self) -> list[str]:
"""Liste les appareils photo detectes."""
if not GPHOTO2_DISPONIBLE:
return ["[Simulation] Camera virtuelle"]
try:
appareils = gp.Camera.autodetect(self.contexte or gp.Context())
return [f"{nom} ({port})" for nom, port in appareils]
except gp.GPhoto2Error:
return []
def _capture_simulation(self, chemin: Path) -> Path:
"""Genere une image de test en mode simulation."""
from PIL import Image, ImageDraw, ImageFont
img = Image.new("RGB", (1920, 1280), color=(40, 40, 40))
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}")
return chemin
def _preview_simulation(self) -> bytes:
"""Genere un apercu de test en mode simulation."""
from PIL import Image, ImageDraw
img = Image.new("RGB", (640, 480), color=(30, 30, 30))
draw = ImageDraw.Draw(img)
draw.text((320, 240), "PREVIEW", fill=(200, 200, 200), anchor="mm")
draw.ellipse([300, 220, 340, 260], outline=(233, 30, 99), width=2)
buf = io.BytesIO()
img.save(buf, "JPEG", quality=70)
return buf.getvalue()
# Instance globale
camera = Camera()

71
backend/collage.py Normal file
View File

@@ -0,0 +1,71 @@
import logging
from pathlib import Path
from PIL import Image, ImageDraw
from backend.config import DOSSIER_EXPORTS, charger_config
log = logging.getLogger("photobooth.collage")
def creer_strip(photos: list[Path], marge: int = 20, couleur_fond: str = "#ffffff") -> Path:
"""Cree une bande verticale (strip) avec les photos."""
images = [Image.open(p) for p in photos]
# Uniformiser la largeur
largeur = min(img.width for img in images)
images_redim = []
for img in images:
ratio = largeur / img.width
hauteur = int(img.height * ratio)
images_redim.append(img.resize((largeur, hauteur), Image.LANCZOS))
hauteur_totale = sum(img.height for img in images_redim) + marge * (len(images_redim) + 1)
largeur_totale = largeur + marge * 2
strip = Image.new("RGB", (largeur_totale, hauteur_totale), couleur_fond)
y = marge
for img in images_redim:
strip.paste(img, (marge, y))
y += img.height + marge
nom = f"strip_{photos[0].stem}.jpg"
chemin = DOSSIER_EXPORTS / nom
strip.save(chemin, "JPEG", quality=95)
log.info(f"Strip cree : {chemin}")
return chemin
def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 15,
couleur_fond: str = "#ffffff") -> Path:
"""Cree un collage en grille avec les photos."""
images = [Image.open(p) for p in photos]
# Taille uniforme pour chaque cellule
largeur_cellule = min(img.width for img in images)
hauteur_cellule = min(img.height for img in images)
images_redim = []
for img in images:
img_crop = ImageDraw.Draw(img)
img_redim = img.resize((largeur_cellule, hauteur_cellule), Image.LANCZOS)
images_redim.append(img_redim)
lignes = (len(images_redim) + colonnes - 1) // colonnes
largeur_totale = colonnes * largeur_cellule + (colonnes + 1) * marge
hauteur_totale = lignes * hauteur_cellule + (lignes + 1) * marge
collage = Image.new("RGB", (largeur_totale, hauteur_totale), couleur_fond)
for i, img in enumerate(images_redim):
col = i % colonnes
lig = i // colonnes
x = marge + col * (largeur_cellule + marge)
y = marge + lig * (hauteur_cellule + marge)
collage.paste(img, (x, y))
nom = f"collage_{photos[0].stem}.jpg"
chemin = DOSSIER_EXPORTS / nom
collage.save(chemin, "JPEG", quality=95)
log.info(f"Collage cree : {chemin}")
return chemin

52
backend/config.py Normal file
View File

@@ -0,0 +1,52 @@
import json
import os
import shutil
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
CHEMIN_CONFIG = RACINE / "data" / "config.json"
CHEMIN_CONFIG_DEFAUT = RACINE / "data" / "config.defaut.json"
DOSSIER_PHOTOS = RACINE / "data" / "photos"
DOSSIER_EXPORTS = RACINE / "data" / "exports"
DOSSIER_OVERLAYS = RACINE / "frontend" / "assets" / "overlays"
DOSSIER_FONDS = RACINE / "frontend" / "assets" / "backgrounds"
def charger_config() -> dict:
"""Charge la configuration depuis config.json."""
if not CHEMIN_CONFIG.exists():
if CHEMIN_CONFIG_DEFAUT.exists():
shutil.copy(CHEMIN_CONFIG_DEFAUT, CHEMIN_CONFIG)
else:
return {}
with open(CHEMIN_CONFIG, "r", encoding="utf-8") as f:
return json.load(f)
def sauvegarder_config(config: dict):
"""Sauvegarde la configuration dans config.json."""
CHEMIN_CONFIG.parent.mkdir(parents=True, exist_ok=True)
with open(CHEMIN_CONFIG, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
def mettre_a_jour_config(modifications: dict) -> dict:
"""Met a jour la configuration avec les modifications fournies (merge profond)."""
config = charger_config()
_merge_profond(config, modifications)
sauvegarder_config(config)
return config
def _merge_profond(base: dict, modifications: dict):
"""Fusionne recursivement les modifications dans la base."""
for cle, valeur in modifications.items():
if cle in base and isinstance(base[cle], dict) and isinstance(valeur, dict):
_merge_profond(base[cle], valeur)
else:
base[cle] = valeur
# Initialisation des dossiers au chargement du module
for dossier in [DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS]:
dossier.mkdir(parents=True, exist_ok=True)

142
backend/effects.py Normal file
View File

@@ -0,0 +1,142 @@
import logging
from io import BytesIO
from pathlib import Path
from PIL import Image, ImageEnhance, ImageFilter, ImageOps
from backend.config import DOSSIER_OVERLAYS, DOSSIER_EXPORTS, DOSSIER_FONDS
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",
}
def appliquer_filtre(chemin_photo: Path, filtre: str) -> Path:
"""Applique un filtre a une photo et sauvegarde dans exports."""
img = Image.open(chemin_photo)
if filtre == "nb":
img = ImageOps.grayscale(img).convert("RGB")
elif filtre == "sepia":
img = ImageOps.grayscale(img)
img = ImageOps.colorize(img, black="#704214", white="#f0e0c0")
elif filtre == "vintage":
img = ImageOps.grayscale(img)
img = ImageOps.colorize(img, black="#2b1700", white="#f5deb3")
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(0.85)
elif filtre == "contraste":
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(1.5)
elif filtre == "lumineux":
enhancer = ImageEnhance.Brightness(img)
img = enhancer.enhance(1.3)
elif filtre == "chaud":
r, g, b = img.split()
r = r.point(lambda x: min(255, x + 20))
b = b.point(lambda x: max(0, x - 20))
img = Image.merge("RGB", (r, g, b))
elif filtre == "froid":
r, g, b = img.split()
r = r.point(lambda x: max(0, x - 20))
b = b.point(lambda x: min(255, x + 20))
img = Image.merge("RGB", (r, g, b))
elif filtre == "flou_artistique":
img = img.filter(ImageFilter.GaussianBlur(radius=2))
nom_export = f"{chemin_photo.stem}_{filtre}.jpg"
chemin_export = DOSSIER_EXPORTS / nom_export
img.save(chemin_export, "JPEG", quality=95)
log.info(f"Filtre '{filtre}' applique : {chemin_export}")
return chemin_export
def appliquer_overlay(chemin_photo: Path, nom_overlay: str) -> Path:
"""Applique un overlay PNG par-dessus la photo."""
chemin_overlay = DOSSIER_OVERLAYS / nom_overlay
if not chemin_overlay.exists():
log.error(f"Overlay introuvable : {chemin_overlay}")
return chemin_photo
photo = Image.open(chemin_photo).convert("RGBA")
overlay = Image.open(chemin_overlay).convert("RGBA")
overlay = overlay.resize(photo.size, Image.LANCZOS)
composite = Image.alpha_composite(photo, overlay)
resultat = composite.convert("RGB")
nom_export = f"{chemin_photo.stem}_overlay.jpg"
chemin_export = DOSSIER_EXPORTS / nom_export
resultat.save(chemin_export, "JPEG", quality=95)
log.info(f"Overlay '{nom_overlay}' applique : {chemin_export}")
return chemin_export
def chroma_key(chemin_photo: Path, nom_fond: str | None = None,
couleur_cle: str = "#00ff00", tolerance: int = 40) -> Path:
"""Remplace le fond vert par un fond choisi."""
try:
import cv2
import numpy as np
except ImportError:
log.error("OpenCV non installe, chroma key indisponible")
return chemin_photo
img = cv2.imread(str(chemin_photo))
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
# Plage de vert pour le chroma key
vert_bas = np.array([35 - tolerance // 2, 50, 50])
vert_haut = np.array([85 + tolerance // 2, 255, 255])
masque = cv2.inRange(hsv, vert_bas, vert_haut)
# Adoucir le masque
masque = cv2.GaussianBlur(masque, (5, 5), 0)
# Charger le fond
if nom_fond:
chemin_fond = DOSSIER_FONDS / nom_fond
if chemin_fond.exists():
fond = cv2.imread(str(chemin_fond))
fond = cv2.resize(fond, (img.shape[1], img.shape[0]))
else:
fond = np.full_like(img, (200, 200, 200))
else:
fond = np.full_like(img, (200, 200, 200))
# Combiner
masque_inv = cv2.bitwise_not(masque)
premier_plan = cv2.bitwise_and(img, img, mask=masque_inv)
arriere_plan = cv2.bitwise_and(fond, fond, mask=masque)
resultat = cv2.add(premier_plan, arriere_plan)
nom_export = f"{chemin_photo.stem}_chroma.jpg"
chemin_export = DOSSIER_EXPORTS / nom_export
cv2.imwrite(str(chemin_export), resultat)
log.info(f"Chroma key applique : {chemin_export}")
return chemin_export
def lister_overlays() -> list[str]:
"""Liste les overlays disponibles."""
if not DOSSIER_OVERLAYS.exists():
return []
return [f.name for f in DOSSIER_OVERLAYS.iterdir() if f.suffix.lower() == ".png"]
def lister_fonds() -> list[str]:
"""Liste les fonds disponibles pour le chroma key."""
if not DOSSIER_FONDS.exists():
return []
extensions = {".jpg", ".jpeg", ".png"}
return [f.name for f in DOSSIER_FONDS.iterdir() if f.suffix.lower() in extensions]

59
backend/gallery.py Normal file
View File

@@ -0,0 +1,59 @@
import logging
import os
from datetime import datetime
from pathlib import Path
from backend.config import DOSSIER_PHOTOS, DOSSIER_EXPORTS
log = logging.getLogger("photobooth.gallery")
def lister_photos(dossier: str = "exports") -> list[dict]:
"""Liste les photos d'un dossier avec leurs metadonnees."""
chemin = DOSSIER_EXPORTS if dossier == "exports" else DOSSIER_PHOTOS
photos = []
extensions = {".jpg", ".jpeg", ".png", ".gif"}
if not chemin.exists():
return photos
for fichier in sorted(chemin.iterdir(), key=lambda f: f.stat().st_mtime, reverse=True):
if fichier.suffix.lower() in extensions:
stat = fichier.stat()
photos.append({
"nom": fichier.name,
"chemin": str(fichier.relative_to(chemin.parent.parent)),
"taille": stat.st_size,
"date": datetime.fromtimestamp(stat.st_mtime).isoformat(),
})
return photos
def compter_photos() -> dict:
"""Retourne le compteur de photos pour l'evenement."""
nb_photos = len(list(DOSSIER_PHOTOS.glob("*.jpg"))) + len(list(DOSSIER_PHOTOS.glob("*.jpeg")))
nb_exports = len(list(DOSSIER_EXPORTS.glob("*"))) - len(list(DOSSIER_EXPORTS.glob(".gitkeep")))
return {
"photos_prises": nb_photos,
"exports": max(0, nb_exports),
}
def supprimer_photo(nom: str, dossier: str = "exports") -> bool:
"""Supprime une photo par son nom."""
chemin = DOSSIER_EXPORTS if dossier == "exports" else DOSSIER_PHOTOS
fichier = chemin / nom
if fichier.exists() and fichier.parent == chemin:
fichier.unlink()
log.info(f"Photo supprimee : {fichier}")
return True
return False
def vider_galerie():
"""Supprime toutes les photos (reset evenement)."""
for dossier in [DOSSIER_PHOTOS, DOSSIER_EXPORTS]:
for fichier in dossier.iterdir():
if fichier.name != ".gitkeep":
fichier.unlink()
log.info("Galerie videe")

41
backend/gif_maker.py Normal file
View File

@@ -0,0 +1,41 @@
import logging
from pathlib import Path
from PIL import Image
from backend.config import DOSSIER_EXPORTS, charger_config
log = logging.getLogger("photobooth.gif")
def creer_gif(photos: list[Path], duree_frame_ms: int | None = None) -> Path:
"""Cree un GIF anime a partir d'une liste de photos."""
config = charger_config()
if duree_frame_ms is None:
duree_frame_ms = config.get("gif", {}).get("duree_frame_ms", 300)
images = []
for p in photos:
img = Image.open(p).convert("RGB")
# Redimensionner pour un GIF leger
img.thumbnail((800, 600), Image.LANCZOS)
images.append(img)
if not images:
log.error("Aucune image pour le GIF")
return None
nom = f"gif_{photos[0].stem}.gif"
chemin = DOSSIER_EXPORTS / nom
images[0].save(
chemin,
save_all=True,
append_images=images[1:],
duration=duree_frame_ms,
loop=0,
optimize=True,
)
log.info(f"GIF cree : {chemin} ({len(images)} frames, {duree_frame_ms}ms)")
return chemin

64
backend/mailer.py Normal file
View File

@@ -0,0 +1,64 @@
import logging
import smtplib
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
log = logging.getLogger("photobooth.mailer")
def envoyer_photo(destinataire: str, chemin_photo: Path) -> bool:
"""Envoie une photo par email."""
config = charger_config()
conf_email = config.get("email", {})
conf_event = config.get("evenement", {})
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:
log.error("Configuration email incomplete")
return False
sujet = conf_email.get("sujet", "Votre photo - {evenement}").format(
evenement=conf_event.get("nom", "Photobooth")
)
message = conf_email.get("message", "Voici votre photo !").format(
evenement=conf_event.get("nom", "Photobooth")
)
msg = MIMEMultipart()
msg["From"] = expediteur
msg["To"] = destinataire
msg["Subject"] = sujet
msg.attach(MIMEText(message, "plain", "utf-8"))
# Piece jointe
if chemin_photo.exists():
with open(chemin_photo, "rb") as f:
piece = MIMEBase("image", "jpeg")
piece.set_payload(f.read())
encoders.encode_base64(piece)
piece.add_header(
"Content-Disposition",
f"attachment; filename={chemin_photo.name}"
)
msg.attach(piece)
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"Email envoye a {destinataire}")
return True
except (smtplib.SMTPException, OSError) as e:
log.error(f"Erreur envoi email : {e}")
return False

364
backend/main.py Normal file
View File

@@ -0,0 +1,364 @@
import asyncio
import base64
import json
import logging
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from backend.config import (
RACINE, DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS,
charger_config, sauvegarder_config, mettre_a_jour_config,
)
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
from backend.gif_maker import creer_gif
from backend.printer import lister_imprimantes, imprimer
from backend.mailer import envoyer_photo
from backend.qrcode_gen import generer_qr, qr_galerie
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
log = logging.getLogger("photobooth")
# Clients WebSocket connectes
clients_ws: list[WebSocket] = []
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Demarrage et arret de l'application."""
log.info("Demarrage du photobooth")
camera.connecter()
yield
log.info("Arret du photobooth")
camera.deconnecter()
app = FastAPI(title="Photobooth", lifespan=lifespan)
# Servir les fichiers statiques
app.mount("/assets", StaticFiles(directory=str(RACINE / "frontend" / "assets")), name="assets")
app.mount("/css", StaticFiles(directory=str(RACINE / "frontend" / "css")), name="css")
app.mount("/js", StaticFiles(directory=str(RACINE / "frontend" / "js")), name="js")
app.mount("/data", StaticFiles(directory=str(RACINE / "data")), name="data")
# --- Pages ---
@app.get("/")
async def page_principale():
return FileResponse(str(RACINE / "frontend" / "index.html"))
# --- API Config ---
@app.get("/api/config")
async def api_config():
return charger_config()
@app.post("/api/config")
async def api_config_update(modifications: dict):
config = mettre_a_jour_config(modifications)
await diffuser_ws({"type": "config_maj", "config": config})
return config
# --- API Camera ---
@app.post("/api/capturer")
async def api_capturer():
chemin = camera.capturer()
if chemin is None:
return JSONResponse({"erreur": "Echec capture"}, status_code=500)
nom = chemin.name
await diffuser_ws({"type": "photo_capturee", "nom": nom})
return {"nom": nom, "chemin": f"/data/photos/{nom}"}
@app.get("/api/preview")
async def api_preview():
donnees = camera.preview()
if donnees is None:
return JSONResponse({"erreur": "Preview indisponible"}, status_code=500)
b64 = base64.b64encode(donnees).decode("ascii")
return {"image": f"data:image/jpeg;base64,{b64}"}
@app.get("/api/camera/statut")
async def api_camera_statut():
return {
"connectee": camera.connectee,
"appareils": camera.lister_appareils(),
}
@app.post("/api/camera/reconnecter")
async def api_camera_reconnecter():
camera.deconnecter()
ok = camera.connecter()
return {"connectee": ok}
# --- API Effets ---
@app.get("/api/filtres")
async def api_filtres():
return FILTRES
@app.post("/api/filtre")
async def api_appliquer_filtre(donnees: dict):
nom_photo = donnees.get("photo", "")
filtre = donnees.get("filtre", "original")
chemin = DOSSIER_PHOTOS / nom_photo
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
if filtre == "original":
return {"nom": nom_photo, "chemin": f"/data/photos/{nom_photo}"}
chemin_export = appliquer_filtre(chemin, filtre)
return {"nom": chemin_export.name, "chemin": f"/data/exports/{chemin_export.name}"}
@app.get("/api/overlays")
async def api_overlays():
return lister_overlays()
@app.post("/api/overlay")
async def api_appliquer_overlay(donnees: dict):
nom_photo = donnees.get("photo", "")
nom_overlay = donnees.get("overlay", "")
chemin = DOSSIER_PHOTOS / nom_photo
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
chemin_export = appliquer_overlay(chemin, nom_overlay)
return {"nom": chemin_export.name, "chemin": f"/data/exports/{chemin_export.name}"}
@app.post("/api/chroma")
async def api_chroma_key(donnees: dict):
nom_photo = donnees.get("photo", "")
nom_fond = donnees.get("fond")
chemin = DOSSIER_PHOTOS / nom_photo
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
config = charger_config()
conf_chroma = config.get("chroma_key", {})
chemin_export = chroma_key(
chemin, nom_fond,
couleur_cle=conf_chroma.get("couleur", "#00ff00"),
tolerance=conf_chroma.get("tolerance", 40),
)
return {"nom": chemin_export.name, "chemin": f"/data/exports/{chemin_export.name}"}
@app.get("/api/fonds")
async def api_fonds():
return lister_fonds()
# --- API Collage / GIF ---
@app.post("/api/strip")
async def api_strip(donnees: dict):
noms = donnees.get("photos", [])
chemins = [DOSSIER_PHOTOS / n for n in noms]
for c in chemins:
if not c.exists():
return JSONResponse({"erreur": f"Photo introuvable : {c.name}"}, status_code=404)
chemin = creer_strip(chemins)
return {"nom": chemin.name, "chemin": f"/data/exports/{chemin.name}"}
@app.post("/api/collage")
async def api_collage(donnees: dict):
noms = donnees.get("photos", [])
colonnes = donnees.get("colonnes", 2)
chemins = [DOSSIER_PHOTOS / n for n in noms]
for c in chemins:
if not c.exists():
return JSONResponse({"erreur": f"Photo introuvable : {c.name}"}, status_code=404)
chemin = creer_collage(chemins, colonnes=colonnes)
return {"nom": chemin.name, "chemin": f"/data/exports/{chemin.name}"}
@app.post("/api/gif")
async def api_gif(donnees: dict):
noms = donnees.get("photos", [])
chemins = [DOSSIER_PHOTOS / n for n in noms]
for c in chemins:
if not c.exists():
return JSONResponse({"erreur": f"Photo introuvable : {c.name}"}, status_code=404)
chemin = creer_gif(chemins)
if chemin is None:
return JSONResponse({"erreur": "Echec creation GIF"}, status_code=500)
return {"nom": chemin.name, "chemin": f"/data/exports/{chemin.name}"}
# --- API Impression ---
@app.get("/api/imprimantes")
async def api_imprimantes():
return lister_imprimantes()
@app.post("/api/imprimer")
async def api_imprimer(donnees: dict):
nom = donnees.get("photo", "")
# Chercher dans exports d'abord, puis photos
chemin = DOSSIER_EXPORTS / nom
if not chemin.exists():
chemin = DOSSIER_PHOTOS / nom
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
ok = imprimer(chemin)
return {"succes": ok}
# --- API Email ---
@app.post("/api/email")
async def api_email(donnees: dict):
email_dest = donnees.get("email", "")
nom = donnees.get("photo", "")
if not email_dest:
return JSONResponse({"erreur": "Email requis"}, status_code=400)
chemin = DOSSIER_EXPORTS / nom
if not chemin.exists():
chemin = DOSSIER_PHOTOS / nom
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
ok = envoyer_photo(email_dest, chemin)
return {"succes": ok}
# --- API QR Code ---
@app.get("/api/qr/galerie")
async def api_qr_galerie():
chemin = qr_galerie()
if chemin is None:
return JSONResponse({"erreur": "URL galerie non configuree"}, status_code=400)
return {"chemin": f"/data/exports/{chemin.name}"}
# --- API Galerie ---
@app.get("/api/galerie")
async def api_galerie():
return lister_photos("exports")
@app.get("/api/galerie/compteur")
async def api_compteur():
return compter_photos()
@app.delete("/api/galerie/{nom}")
async def api_supprimer_photo(nom: str):
ok = supprimer_photo(nom)
return {"succes": ok}
@app.post("/api/galerie/vider")
async def api_vider_galerie():
vider_galerie()
return {"succes": True}
# --- API Upload overlay/fond ---
@app.post("/api/upload/overlay")
async def api_upload_overlay(fichier: UploadFile = File(...)):
chemin = DOSSIER_OVERLAYS / fichier.filename
with open(chemin, "wb") as f:
f.write(await fichier.read())
return {"nom": fichier.filename}
@app.post("/api/upload/fond")
async def api_upload_fond(fichier: UploadFile = File(...)):
chemin = DOSSIER_FONDS / fichier.filename
with open(chemin, "wb") as f:
f.write(await fichier.read())
return {"nom": fichier.filename}
# --- API Systeme ---
@app.post("/api/systeme/redemarrer")
async def api_redemarrer():
import subprocess
log.warning("Redemarrage systeme demande")
subprocess.Popen(["sudo", "reboot"])
return {"succes": True}
@app.post("/api/systeme/eteindre")
async def api_eteindre():
import subprocess
log.warning("Extinction systeme demandee")
subprocess.Popen(["sudo", "shutdown", "-h", "now"])
return {"succes": True}
# --- WebSocket ---
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
clients_ws.append(ws)
log.info(f"Client WS connecte ({len(clients_ws)} clients)")
try:
while True:
data = await ws.receive_text()
msg = json.loads(data)
await traiter_message_ws(msg, ws)
except WebSocketDisconnect:
clients_ws.remove(ws)
log.info(f"Client WS deconnecte ({len(clients_ws)} clients)")
async def traiter_message_ws(msg: dict, ws: WebSocket):
"""Traite les messages WebSocket entrants."""
type_msg = msg.get("type", "")
if type_msg == "ping":
await ws.send_json({"type": "pong"})
elif type_msg == "preview":
donnees = camera.preview()
if donnees:
b64 = base64.b64encode(donnees).decode("ascii")
await ws.send_json({"type": "preview", "image": f"data:image/jpeg;base64,{b64}"})
async def diffuser_ws(message: dict):
"""Envoie un message a tous les clients WebSocket."""
deconnectes = []
for ws in clients_ws:
try:
await ws.send_json(message)
except Exception:
deconnectes.append(ws)
for ws in deconnectes:
clients_ws.remove(ws)
# Point d'entree
if __name__ == "__main__":
import uvicorn
config = charger_config()
conf_srv = config.get("serveur", {})
uvicorn.run(
"backend.main:app",
host=conf_srv.get("host", "0.0.0.0"),
port=conf_srv.get("port", 8080),
reload=False,
log_level="info",
)

81
backend/printer.py Normal file
View File

@@ -0,0 +1,81 @@
import logging
from pathlib import Path
from backend.config import charger_config
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")
def lister_imprimantes() -> list[dict]:
"""Liste les imprimantes disponibles via CUPS."""
if not CUPS_DISPONIBLE:
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 []
def imprimer(chemin_photo: Path, imprimante: str | None = None, copies: int | None = None) -> bool:
"""Imprime une photo sur l'imprimante configuree."""
config = charger_config()
conf_imp = config.get("impression", {})
if imprimante is None:
imprimante = conf_imp.get("imprimante")
if copies is None:
copies = conf_imp.get("copies", 1)
if not chemin_photo.exists():
log.error(f"Fichier introuvable : {chemin_photo}")
return False
if not CUPS_DISPONIBLE:
log.info(f"[Simulation] Impression de {chemin_photo} x{copies}")
return True
try:
conn = cups.Connection()
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
options = {
"copies": str(copies),
"media": conf_imp.get("format", "10x15"),
"fit-to-page": "true",
}
job_id = conn.printFile(imprimante, str(chemin_photo), "Photobooth", options)
log.info(f"Impression lancee : job #{job_id} sur {imprimante}")
return True
except cups.IPPError as e:
log.error(f"Erreur impression : {e}")
return False

41
backend/qrcode_gen.py Normal file
View File

@@ -0,0 +1,41 @@
import logging
from io import BytesIO
from pathlib import Path
from backend.config import DOSSIER_EXPORTS, charger_config
log = logging.getLogger("photobooth.qrcode")
try:
import qrcode
QRCODE_DISPONIBLE = True
except ImportError:
QRCODE_DISPONIBLE = False
log.warning("qrcode non installe")
def generer_qr(contenu: str, nom: str = "qr.png") -> Path | None:
"""Genere un QR code PNG."""
if not QRCODE_DISPONIBLE:
log.error("Module qrcode non disponible")
return None
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(contenu)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
chemin = DOSSIER_EXPORTS / nom
img.save(chemin)
log.info(f"QR code genere : {chemin}")
return chemin
def qr_galerie() -> Path | None:
"""Genere le QR code pointant vers la galerie en ligne."""
config = charger_config()
url = config.get("qr_code", {}).get("url_galerie", "")
if not url:
log.warning("URL galerie non configuree")
return None
return generer_qr(url, "qr_galerie.png")

11
backend/requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
fastapi==0.115.0
uvicorn[standard]==0.30.0
python-gphoto2==2.5.0
Pillow==10.4.0
imageio==2.35.0
opencv-python-headless==4.10.0.84
qrcode[pil]==7.4.2
pycups==2.0.4
python-multipart==0.0.9
aiofiles==24.1.0
websockets==12.0

63
data/config.json Normal file
View File

@@ -0,0 +1,63 @@
{
"evenement": {
"nom": "Mon Evenement",
"logo": null,
"overlay": null,
"couleur_primaire": "#e91e63",
"couleur_secondaire": "#ffffff"
},
"fonctionnalites": {
"photo_simple": true,
"multi_shot": true,
"gif": true,
"filtres": true,
"overlays": true,
"chroma_key": false,
"impression": true,
"email": true,
"qr_code": true,
"galerie": true,
"compteur": true
},
"camera": {
"iso": "auto",
"balance_blancs": "auto",
"compte_a_rebours": 3
},
"impression": {
"imprimante": null,
"copies": 1,
"format": "10x15"
},
"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
},
"gif": {
"nombre_frames": 4,
"delai_entre_frames": 0.8,
"duree_frame_ms": 300
},
"chroma_key": {
"couleur": "#00ff00",
"tolerance": 40,
"fond_par_defaut": null
},
"serveur": {
"host": "0.0.0.0",
"port": 8080
}
}

0
data/exports/.gitkeep Normal file
View File

0
data/photos/.gitkeep Normal file
View File

602
frontend/css/style.css Normal file
View File

@@ -0,0 +1,602 @@
/* === Variables et Reset === */
:root {
--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);
--transition: 0.3s ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
user-select: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--fond);
color: var(--texte);
font-size: 18px;
}
/* === Ecrans === */
.ecran {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.4s ease;
}
.ecran.actif {
opacity: 1;
pointer-events: all;
}
/* === Ecran d'accueil === */
.accueil-contenu {
text-align: center;
}
.accueil-contenu h1 {
font-size: 3.5rem;
font-weight: 300;
letter-spacing: 0.1em;
margin-bottom: 1rem;
color: var(--primaire);
}
.accueil-instruction {
font-size: 1.4rem;
color: var(--texte-secondaire);
animation: pulse-texte 2s ease-in-out infinite;
}
@keyframes pulse-texte {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.accueil-animation {
margin-top: 3rem;
}
.cercle-pulse {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--primaire);
margin: 0 auto;
animation: pulse-cercle 2s ease-in-out infinite;
}
@keyframes pulse-cercle {
0% { transform: scale(1); opacity: 0.7; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(1); opacity: 0.7; }
}
/* Zone admin invisible */
.zone-admin {
position: absolute;
bottom: 0;
right: 0;
width: 80px;
height: 80px;
}
/* === Choix du mode === */
.grille-modes {
display: flex;
gap: 2rem;
margin: 2rem 0;
}
.btn-mode {
background: var(--fond-carte);
border: 2px solid transparent;
border-radius: var(--rayon);
padding: 2rem 3rem;
color: var(--texte);
font-size: 1.2rem;
cursor: pointer;
transition: var(--transition);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
box-shadow: var(--ombre);
}
.btn-mode:active {
transform: scale(0.95);
border-color: var(--primaire);
}
.btn-mode .mode-icone {
font-size: 3rem;
}
/* === Capture / Preview live === */
.preview-live {
width: 100%;
height: 100%;
position: relative;
background: #000;
}
.preview-live img {
width: 100%;
height: 100%;
object-fit: contain;
}
.compte-a-rebours {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
z-index: 10;
}
.compte-a-rebours span {
font-size: 12rem;
font-weight: 700;
color: var(--primaire);
animation: pop 0.5s ease;
}
@keyframes pop {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
.flash-blanc {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: #fff;
z-index: 20;
animation: flash 0.3s ease-out forwards;
}
@keyframes flash {
0% { opacity: 1; }
100% { opacity: 0; }
}
.capture-info {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.5);
padding: 0.5rem 1.5rem;
border-radius: 2rem;
font-size: 1rem;
z-index: 5;
}
/* === Preview photo + Filtres === */
.preview-photo-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
max-height: 65vh;
}
.preview-photo-container img {
max-width: 100%;
max-height: 100%;
border-radius: var(--rayon);
box-shadow: var(--ombre);
}
.barre-filtres, .barre-overlays {
display: flex;
gap: 0.8rem;
padding: 1rem;
overflow-x: auto;
width: 100%;
justify-content: center;
flex-wrap: wrap;
}
.btn-filtre, .btn-overlay {
background: var(--fond-carte);
border: 2px solid transparent;
border-radius: 12px;
padding: 0.6rem 1.2rem;
color: var(--texte);
font-size: 0.9rem;
cursor: pointer;
transition: var(--transition);
white-space: nowrap;
}
.btn-filtre.actif, .btn-overlay.actif {
border-color: var(--primaire);
background: rgba(233, 30, 99, 0.2);
}
.btn-filtre:active, .btn-overlay:active {
transform: scale(0.95);
}
.preview-actions {
display: flex;
gap: 1rem;
padding: 1rem;
}
/* === Partage === */
#ecran-partage h2 {
margin-top: 2rem;
font-weight: 300;
}
.photo-miniature {
margin: 1rem 0;
max-height: 30vh;
}
.photo-miniature img {
max-height: 30vh;
border-radius: var(--rayon);
box-shadow: var(--ombre);
}
.grille-partage {
display: flex;
gap: 1.5rem;
margin: 1.5rem 0;
}
.btn-partage {
background: var(--fond-carte);
border: 2px solid transparent;
border-radius: var(--rayon);
padding: 1.5rem 2rem;
color: var(--texte);
font-size: 1.1rem;
cursor: pointer;
transition: var(--transition);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
box-shadow: var(--ombre);
}
.btn-partage:active {
transform: scale(0.95);
border-color: var(--primaire);
}
.partage-icone {
font-size: 2.5rem;
}
.form-email {
display: flex;
gap: 0.8rem;
align-items: center;
margin: 1rem 0;
}
.form-email input {
background: var(--fond-carte);
border: 2px solid var(--texte-secondaire);
border-radius: 12px;
padding: 0.8rem 1.2rem;
color: var(--texte);
font-size: 1.1rem;
width: 300px;
outline: none;
}
.form-email input:focus {
border-color: var(--primaire);
}
.zone-qr {
margin: 1rem 0;
}
.zone-qr img {
width: 200px;
height: 200px;
border-radius: 12px;
background: #fff;
padding: 8px;
}
.statut-partage {
padding: 0.8rem 1.5rem;
border-radius: 12px;
font-size: 1rem;
margin: 0.5rem 0;
}
.statut-partage.succes { background: rgba(76, 175, 80, 0.2); color: var(--succes); }
.statut-partage.erreur { background: rgba(244, 67, 54, 0.2); color: var(--danger); }
.partage-bas {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
/* === Galerie === */
.grille-galerie {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
width: 100%;
max-height: 80vh;
overflow-y: auto;
}
.grille-galerie img {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
border-radius: 12px;
cursor: pointer;
transition: var(--transition);
}
.grille-galerie img:active {
transform: scale(0.95);
}
/* === Boutons communs === */
.btn-action {
background: var(--primaire);
color: #fff;
border: none;
border-radius: 12px;
padding: 0.8rem 2rem;
font-size: 1.1rem;
cursor: pointer;
transition: var(--transition);
box-shadow: 0 4px 16px rgba(233, 30, 99, 0.3);
}
.btn-action:active {
transform: scale(0.95);
}
.btn-secondaire {
background: transparent;
color: var(--texte-secondaire);
border: 2px solid var(--texte-secondaire);
border-radius: 12px;
padding: 0.8rem 2rem;
font-size: 1.1rem;
cursor: pointer;
transition: var(--transition);
}
.btn-secondaire:active {
transform: scale(0.95);
}
.btn-retour {
position: absolute;
bottom: 2rem;
background: transparent;
color: var(--texte-secondaire);
border: 2px solid var(--texte-secondaire);
border-radius: 12px;
padding: 0.6rem 1.5rem;
font-size: 1rem;
cursor: pointer;
}
.btn-fermer {
background: none;
border: none;
color: var(--texte);
font-size: 2rem;
cursor: pointer;
padding: 0.5rem;
}
.btn-danger {
background: var(--danger);
color: #fff;
border: none;
border-radius: 12px;
padding: 0.8rem 2rem;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
}
.btn-danger:active {
transform: scale(0.95);
}
/* === Admin === */
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 1rem 2rem;
}
.admin-contenu {
width: 100%;
height: calc(100% - 60px);
display: flex;
overflow: hidden;
}
.admin-onglets {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 1rem;
min-width: 160px;
background: var(--fond-carte);
overflow-y: auto;
}
.onglet {
background: transparent;
border: none;
color: var(--texte-secondaire);
padding: 0.8rem 1rem;
text-align: left;
cursor: pointer;
border-radius: 8px;
font-size: 0.95rem;
transition: var(--transition);
}
.onglet.actif {
background: var(--primaire);
color: #fff;
}
.admin-panneau {
display: none;
flex-direction: column;
gap: 1rem;
padding: 2rem;
flex: 1;
overflow-y: auto;
}
.admin-panneau.actif {
display: flex;
}
/* Toggles */
.toggle-groupe {
display: flex;
flex-direction: column;
gap: 1rem;
}
.toggle {
display: flex;
align-items: center;
gap: 1rem;
cursor: pointer;
font-size: 1rem;
}
.toggle input {
display: none;
}
.toggle-slider {
width: 50px;
height: 28px;
background: #555;
border-radius: 14px;
position: relative;
transition: var(--transition);
flex-shrink: 0;
}
.toggle-slider::after {
content: '';
position: absolute;
top: 3px; left: 3px;
width: 22px; height: 22px;
background: #fff;
border-radius: 50%;
transition: var(--transition);
}
.toggle input:checked + .toggle-slider {
background: var(--primaire);
}
.toggle input:checked + .toggle-slider::after {
left: 25px;
}
/* Champs admin */
.champ {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.champ label {
color: var(--texte-secondaire);
font-size: 0.9rem;
}
.champ input, .champ select {
background: var(--fond);
border: 2px solid #333;
border-radius: 8px;
padding: 0.6rem 1rem;
color: var(--texte);
font-size: 1rem;
outline: none;
}
.champ input:focus, .champ select:focus {
border-color: var(--primaire);
}
/* === Utilitaires === */
.cache {
display: none !important;
}
/* Scrollbar tactile */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #444;
border-radius: 3px;
}
/* Responsive tactile */
@media (max-width: 800px) {
.accueil-contenu h1 { font-size: 2.5rem; }
.grille-modes { flex-direction: column; gap: 1rem; }
.btn-mode { padding: 1.5rem 2rem; }
.grille-partage { flex-direction: column; gap: 1rem; }
.admin-contenu { flex-direction: column; }
.admin-onglets { flex-direction: row; min-width: unset; overflow-x: auto; }
}

257
frontend/index.html Normal file
View File

@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Photobooth</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="app">
<!-- Ecran d'accueil -->
<section id="ecran-accueil" class="ecran actif">
<div class="accueil-contenu">
<h1 id="nom-evenement">Photobooth</h1>
<p class="accueil-instruction">Touchez pour commencer</p>
<div class="accueil-animation">
<div class="cercle-pulse"></div>
</div>
</div>
<!-- Zone admin : coin bas-droit, appui long 3s -->
<div id="zone-admin" class="zone-admin"></div>
</section>
<!-- Choix du mode -->
<section id="ecran-mode" class="ecran">
<h2>Choisissez votre mode</h2>
<div class="grille-modes">
<button class="btn-mode" data-mode="simple">
<div class="mode-icone">&#128247;</div>
<span>Photo</span>
</button>
<button class="btn-mode" data-mode="multi" id="btn-multi">
<div class="mode-icone">&#127924;</div>
<span>Multi-shot</span>
</button>
<button class="btn-mode" data-mode="gif" id="btn-gif">
<div class="mode-icone">&#127910;</div>
<span>GIF</span>
</button>
</div>
<button class="btn-retour" onclick="allerA('accueil')">Retour</button>
</section>
<!-- Compte a rebours + Capture -->
<section id="ecran-capture" class="ecran">
<div id="preview-live" class="preview-live">
<img id="img-preview" src="" alt="Preview">
</div>
<div id="compte-a-rebours" class="compte-a-rebours cache">
<span id="chiffre-car">3</span>
</div>
<div id="flash-blanc" class="flash-blanc cache"></div>
<div class="capture-info">
<span id="capture-compteur"></span>
</div>
</section>
<!-- Preview photo + Filtres -->
<section id="ecran-preview" class="ecran">
<div class="preview-photo-container">
<img id="photo-resultat" src="" alt="Photo">
</div>
<div id="barre-filtres" class="barre-filtres">
<!-- Rempli dynamiquement -->
</div>
<div id="barre-overlays" class="barre-overlays cache">
<!-- Rempli dynamiquement -->
</div>
<div class="preview-actions">
<button class="btn-action" onclick="allerA('partage')">Valider</button>
<button class="btn-secondaire" onclick="recommencer()">Reprendre</button>
</div>
</section>
<!-- Partage -->
<section id="ecran-partage" class="ecran">
<h2>Partagez votre photo !</h2>
<div class="photo-miniature">
<img id="photo-partage" src="" alt="Photo">
</div>
<div class="grille-partage">
<button class="btn-partage" id="btn-imprimer" onclick="lancerImpression()">
<div class="partage-icone">&#128424;</div>
<span>Imprimer</span>
</button>
<button class="btn-partage" id="btn-email" onclick="ouvrirEmail()">
<div class="partage-icone">&#128231;</div>
<span>Email</span>
</button>
<button class="btn-partage" id="btn-qr" onclick="afficherQR()">
<div class="partage-icone">&#128274;</div>
<span>QR Code</span>
</button>
</div>
<!-- Formulaire email -->
<div id="form-email" class="form-email cache">
<input type="email" id="input-email" placeholder="votre@email.com" autocomplete="off">
<button class="btn-action" onclick="envoyerEmail()">Envoyer</button>
<button class="btn-secondaire" onclick="fermerEmail()">Annuler</button>
</div>
<!-- QR Code -->
<div id="zone-qr" class="zone-qr cache">
<img id="img-qr" src="" alt="QR Code">
</div>
<!-- Message statut -->
<div id="statut-partage" class="statut-partage cache"></div>
<div class="partage-bas">
<button class="btn-action" onclick="allerA('accueil')">Terminer</button>
<button class="btn-secondaire" onclick="recommencer()">Nouvelle photo</button>
</div>
</section>
<!-- Galerie -->
<section id="ecran-galerie" class="ecran">
<h2>Galerie</h2>
<div id="grille-galerie" class="grille-galerie">
<!-- Rempli dynamiquement -->
</div>
<button class="btn-retour" onclick="allerA('accueil')">Retour</button>
</section>
<!-- Menu Admin -->
<section id="ecran-admin" class="ecran">
<div class="admin-header">
<h2>Administration</h2>
<button class="btn-fermer" onclick="allerA('accueil')">&times;</button>
</div>
<div class="admin-contenu">
<!-- Onglets -->
<div class="admin-onglets">
<button class="onglet actif" data-onglet="fonctionnalites">Fonctions</button>
<button class="onglet" data-onglet="evenement">Evenement</button>
<button class="onglet" data-onglet="camera-admin">Camera</button>
<button class="onglet" data-onglet="impression-admin">Impression</button>
<button class="onglet" data-onglet="email-admin">Email</button>
<button class="onglet" data-onglet="galerie-admin">Galerie</button>
<button class="onglet" data-onglet="systeme">Systeme</button>
</div>
<!-- Panneau Fonctionnalites -->
<div class="admin-panneau actif" id="panneau-fonctionnalites">
<div class="toggle-groupe">
<label class="toggle"><input type="checkbox" id="tog-photo-simple" checked><span class="toggle-slider"></span> Photo simple</label>
<label class="toggle"><input type="checkbox" id="tog-multi-shot"><span class="toggle-slider"></span> Multi-shot</label>
<label class="toggle"><input type="checkbox" id="tog-gif"><span class="toggle-slider"></span> GIF anime</label>
<label class="toggle"><input type="checkbox" id="tog-filtres"><span class="toggle-slider"></span> Filtres</label>
<label class="toggle"><input type="checkbox" id="tog-overlays"><span class="toggle-slider"></span> Overlays / Cadres</label>
<label class="toggle"><input type="checkbox" id="tog-chroma-key"><span class="toggle-slider"></span> Chroma key (fond vert)</label>
<label class="toggle"><input type="checkbox" id="tog-impression"><span class="toggle-slider"></span> Impression</label>
<label class="toggle"><input type="checkbox" id="tog-email"><span class="toggle-slider"></span> Email</label>
<label class="toggle"><input type="checkbox" id="tog-qr-code"><span class="toggle-slider"></span> QR Code</label>
<label class="toggle"><input type="checkbox" id="tog-galerie"><span class="toggle-slider"></span> Galerie</label>
<label class="toggle"><input type="checkbox" id="tog-compteur"><span class="toggle-slider"></span> Compteur</label>
</div>
</div>
<!-- Panneau Evenement -->
<div class="admin-panneau" id="panneau-evenement">
<div class="champ">
<label>Nom de l'evenement</label>
<input type="text" id="admin-nom-event" placeholder="Mon Evenement">
</div>
<div class="champ">
<label>Couleur primaire</label>
<input type="color" id="admin-couleur-primaire" value="#e91e63">
</div>
<div class="champ">
<label>Couleur secondaire</label>
<input type="color" id="admin-couleur-secondaire" value="#ffffff">
</div>
<button class="btn-action" onclick="sauvegarderEvenement()">Sauvegarder</button>
</div>
<!-- Panneau Camera -->
<div class="admin-panneau" id="panneau-camera-admin">
<div class="champ">
<label>Statut</label>
<span id="admin-camera-statut">--</span>
<button class="btn-secondaire" onclick="reconnecterCamera()">Reconnecter</button>
</div>
<div class="champ">
<label>Compte a rebours (secondes)</label>
<input type="number" id="admin-car" min="1" max="10" value="3">
</div>
</div>
<!-- Panneau Impression -->
<div class="admin-panneau" id="panneau-impression-admin">
<div class="champ">
<label>Imprimante</label>
<select id="admin-imprimante"><option value="">Chargement...</option></select>
</div>
<div class="champ">
<label>Copies</label>
<input type="number" id="admin-copies" min="1" max="5" value="1">
</div>
</div>
<!-- Panneau Email -->
<div class="admin-panneau" id="panneau-email-admin">
<div class="champ">
<label>Serveur SMTP</label>
<input type="text" id="admin-smtp-host" placeholder="smtp.gmail.com">
</div>
<div class="champ">
<label>Port</label>
<input type="number" id="admin-smtp-port" value="587">
</div>
<div class="champ">
<label>Utilisateur</label>
<input type="text" id="admin-smtp-user" placeholder="user@gmail.com">
</div>
<div class="champ">
<label>Mot de passe</label>
<input type="password" id="admin-smtp-pass">
</div>
<div class="champ">
<label>Expediteur</label>
<input type="text" id="admin-expediteur" placeholder="photobooth@event.com">
</div>
<button class="btn-action" onclick="sauvegarderEmail()">Sauvegarder</button>
</div>
<!-- Panneau Galerie -->
<div class="admin-panneau" id="panneau-galerie-admin">
<div class="champ">
<label>Photos prises : <span id="admin-nb-photos">0</span></label>
<label>Exports : <span id="admin-nb-exports">0</span></label>
</div>
<div class="champ">
<label>URL galerie en ligne</label>
<input type="text" id="admin-url-galerie" placeholder="https://...">
</div>
<button class="btn-action" onclick="sauvegarderGalerie()">Sauvegarder URL</button>
<button class="btn-danger" onclick="confirmerViderGalerie()">Vider toute la galerie</button>
</div>
<!-- Panneau Systeme -->
<div class="admin-panneau" id="panneau-systeme">
<button class="btn-danger" onclick="confirmerRedemarrage()">Redemarrer le systeme</button>
<button class="btn-danger" onclick="confirmerExtinction()">Eteindre le systeme</button>
</div>
</div>
</section>
</div>
<script src="/js/websocket.js"></script>
<script src="/js/app.js"></script>
<script src="/js/camera.js"></script>
<script src="/js/effects.js"></script>
<script src="/js/gallery.js"></script>
<script src="/js/share.js"></script>
<script src="/js/admin.js"></script>
</body>
</html>

161
frontend/js/admin.js Normal file
View File

@@ -0,0 +1,161 @@
/* Module administration - Menu cache */
// Mapping toggle ID -> cle config
const TOGGLES_MAP = {
'tog-photo-simple': 'photo_simple',
'tog-multi-shot': 'multi_shot',
'tog-gif': 'gif',
'tog-filtres': 'filtres',
'tog-overlays': 'overlays',
'tog-chroma-key': 'chroma_key',
'tog-impression': 'impression',
'tog-email': 'email',
'tog-qr-code': 'qr_code',
'tog-galerie': 'galerie',
'tog-compteur': 'compteur',
};
async function chargerAdmin() {
config = await apiGet('/api/config');
const fonc = config.fonctionnalites || {};
const event = config.evenement || {};
const cam = config.camera || {};
const imp = config.impression || {};
const email = config.email || {};
const qr = config.qr_code || {};
// Toggles fonctionnalites
for (const [id, cle] of Object.entries(TOGGLES_MAP)) {
const el = document.getElementById(id);
if (el) el.checked = fonc[cle] !== false;
}
// Evenement
setValue('admin-nom-event', event.nom);
setValue('admin-couleur-primaire', event.couleur_primaire || '#e91e63');
setValue('admin-couleur-secondaire', event.couleur_secondaire || '#ffffff');
// Camera
const statut = await apiGet('/api/camera/statut');
document.getElementById('admin-camera-statut').textContent =
statut.connectee ? 'Connectee' : 'Deconnectee';
setValue('admin-car', cam.compte_a_rebours || 3);
// Impression
const imprimantes = await apiGet('/api/imprimantes');
const select = document.getElementById('admin-imprimante');
select.innerHTML = '';
for (const p of imprimantes) {
const opt = document.createElement('option');
opt.value = p.nom;
opt.textContent = `${p.nom} (${p.statut})`;
select.appendChild(opt);
}
if (imp.imprimante) select.value = imp.imprimante;
setValue('admin-copies', imp.copies || 1);
// Email
setValue('admin-smtp-host', email.smtp_host);
setValue('admin-smtp-port', email.smtp_port || 587);
setValue('admin-smtp-user', email.smtp_user);
setValue('admin-smtp-pass', email.smtp_password);
setValue('admin-expediteur', email.expediteur);
// Galerie
const compteur = await apiGet('/api/galerie/compteur');
document.getElementById('admin-nb-photos').textContent = compteur.photos_prises || 0;
document.getElementById('admin-nb-exports').textContent = compteur.exports || 0;
setValue('admin-url-galerie', qr.url_galerie);
// Ecouter les changements de toggles
setupToggleListeners();
}
function setupToggleListeners() {
for (const [id, cle] of Object.entries(TOGGLES_MAP)) {
const el = document.getElementById(id);
if (!el) continue;
// Retirer les anciens listeners en clonant
const nouveau = el.cloneNode(true);
el.parentNode.replaceChild(nouveau, el);
nouveau.addEventListener('change', () => {
apiPost('/api/config', { fonctionnalites: { [cle]: nouveau.checked } });
});
}
}
async function sauvegarderEvenement() {
await apiPost('/api/config', {
evenement: {
nom: getValue('admin-nom-event'),
couleur_primaire: getValue('admin-couleur-primaire'),
couleur_secondaire: getValue('admin-couleur-secondaire'),
},
camera: {
compte_a_rebours: parseInt(getValue('admin-car')) || 3,
},
});
afficherStatut('Evenement sauvegarde', 'succes');
}
async function sauvegarderEmail() {
await apiPost('/api/config', {
email: {
smtp_host: getValue('admin-smtp-host'),
smtp_port: parseInt(getValue('admin-smtp-port')) || 587,
smtp_user: getValue('admin-smtp-user'),
smtp_password: getValue('admin-smtp-pass'),
expediteur: getValue('admin-expediteur'),
},
});
afficherStatut('Email sauvegarde', 'succes');
}
async function sauvegarderGalerie() {
await apiPost('/api/config', {
qr_code: { url_galerie: getValue('admin-url-galerie') },
impression: {
imprimante: getValue('admin-imprimante'),
copies: parseInt(getValue('admin-copies')) || 1,
},
});
afficherStatut('Configuration sauvegardee', 'succes');
}
async function reconnecterCamera() {
const resultat = await apiPost('/api/camera/reconnecter');
document.getElementById('admin-camera-statut').textContent =
resultat.connectee ? 'Connectee' : 'Deconnectee';
}
function confirmerViderGalerie() {
if (confirm('Supprimer TOUTES les photos ? Cette action est irreversible.')) {
apiPost('/api/galerie/vider').then(() => {
afficherStatut('Galerie videe', 'succes');
chargerAdmin();
});
}
}
function confirmerRedemarrage() {
if (confirm('Redemarrer le systeme ?')) {
apiPost('/api/systeme/redemarrer');
}
}
function confirmerExtinction() {
if (confirm('Eteindre le systeme ?')) {
apiPost('/api/systeme/eteindre');
}
}
// Utilitaires
function setValue(id, val) {
const el = document.getElementById(id);
if (el) el.value = val || '';
}
function getValue(id) {
const el = document.getElementById(id);
return el ? el.value : '';
}

172
frontend/js/app.js Normal file
View File

@@ -0,0 +1,172 @@
/* Application principale - Routeur SPA et logique globale */
let config = {};
let modeActuel = 'simple';
let photosSession = []; // Photos de la session en cours
let photoFinale = null; // Photo finale (avec effets)
let ecranActuel = 'accueil';
// --- Initialisation ---
async function init() {
config = await apiGet('/api/config');
appliquerConfig();
setupEcranAccueil();
setupModes();
setupOnglets();
}
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');
// Nom evenement
const h1 = document.getElementById('nom-evenement');
if (h1) h1.textContent = event.nom || 'Photobooth';
// Visibilite des modes
toggleVisible('btn-multi', fonc.multi_shot);
toggleVisible('btn-gif', fonc.gif);
toggleVisible('btn-imprimer', fonc.impression);
toggleVisible('btn-email', fonc.email);
toggleVisible('btn-qr', fonc.qr_code);
}
// --- Navigation ---
function allerA(ecran) {
const tous = document.querySelectorAll('.ecran');
tous.forEach(e => e.classList.remove('actif'));
const cible = document.getElementById('ecran-' + ecran);
if (cible) {
cible.classList.add('actif');
ecranActuel = ecran;
}
// Actions au changement d'ecran
if (ecran === 'accueil') {
photosSession = [];
photoFinale = null;
arreterPreview();
} else if (ecran === 'capture') {
lancerCapture();
} else if (ecran === 'galerie') {
chargerGalerie();
} else if (ecran === 'admin') {
chargerAdmin();
}
}
function recommencer() {
photosSession = [];
photoFinale = null;
allerA('mode');
}
// --- Ecran accueil ---
function setupEcranAccueil() {
const accueil = document.getElementById('ecran-accueil');
// Toucher pour commencer
accueil.addEventListener('click', (e) => {
// Ignorer si c'est la zone admin
if (e.target.closest('.zone-admin')) return;
allerA('mode');
});
// Zone admin : appui long 3s
const zoneAdmin = document.getElementById('zone-admin');
let timerAdmin = null;
zoneAdmin.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
timerAdmin = setTimeout(() => allerA('admin'), 3000);
});
zoneAdmin.addEventListener('touchend', () => {
if (timerAdmin) clearTimeout(timerAdmin);
});
// Support souris aussi (pour dev)
zoneAdmin.addEventListener('mousedown', (e) => {
e.stopPropagation();
timerAdmin = setTimeout(() => allerA('admin'), 3000);
});
zoneAdmin.addEventListener('mouseup', () => {
if (timerAdmin) clearTimeout(timerAdmin);
});
}
// --- Modes ---
function setupModes() {
document.querySelectorAll('.btn-mode').forEach(btn => {
btn.addEventListener('click', () => {
modeActuel = btn.dataset.mode;
allerA('capture');
});
});
}
// --- Onglets admin ---
function setupOnglets() {
document.querySelectorAll('.onglet').forEach(onglet => {
onglet.addEventListener('click', () => {
document.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);
if (panneau) panneau.classList.add('actif');
});
});
}
// --- Utilitaires ---
async function apiGet(url) {
const resp = await fetch(url);
return resp.json();
}
async function apiPost(url, data = {}) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
}
function toggleVisible(id, visible) {
const el = document.getElementById(id);
if (el) {
if (visible === false) el.classList.add('cache');
else el.classList.remove('cache');
}
}
function afficherStatut(message, type = 'succes') {
const el = document.getElementById('statut-partage');
el.textContent = message;
el.className = 'statut-partage ' + type;
el.classList.remove('cache');
setTimeout(() => el.classList.add('cache'), 4000);
}
// WebSocket : mise a jour config en temps reel
wsOnMessage('config_maj', (msg) => {
config = msg.config;
appliquerConfig();
});
// Demarrage
document.addEventListener('DOMContentLoaded', init);

158
frontend/js/camera.js Normal file
View File

@@ -0,0 +1,158 @@
/* Module capture photo - Preview live, compte a rebours, declenchement */
let previewActif = false;
let previewInterval = null;
// --- Preview live ---
function lancerPreview() {
if (previewActif) return;
previewActif = true;
// Demander des previews via WebSocket
previewInterval = setInterval(() => {
if (previewActif) wsEnvoyer({ type: 'preview' });
}, 100); // ~10 fps
}
function arreterPreview() {
previewActif = false;
if (previewInterval) {
clearInterval(previewInterval);
previewInterval = null;
}
}
// Recevoir les previews
wsOnMessage('preview', (msg) => {
if (!previewActif) return;
const img = document.getElementById('img-preview');
if (img) img.src = msg.image;
});
// --- Capture ---
async function lancerCapture() {
photosSession = [];
lancerPreview();
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}`;
}
// Compte a rebours
await compteARebours();
// Flash
afficherFlash();
// Capture
arreterPreview();
const resultat = await apiPost('/api/capturer');
if (resultat.nom) {
photosSession.push(resultat.nom);
}
// Reprendre le preview si encore des photos a prendre
if (i < nbPhotos - 1) {
lancerPreview();
await pause(500);
}
}
// Traitement selon le mode
await traiterCapture();
}
function getNombrePhotos() {
if (modeActuel === 'simple') return 1;
if (modeActuel === 'multi') return config.multi_shot?.nombre_photos || 3;
if (modeActuel === 'gif') return config.gif?.nombre_frames || 4;
return 1;
}
async function compteARebours() {
const conteneur = document.getElementById('compte-a-rebours');
const chiffre = document.getElementById('chiffre-car');
const duree = config.camera?.compte_a_rebours || 3;
conteneur.classList.remove('cache');
for (let i = duree; i > 0; i--) {
chiffre.textContent = i;
// Re-trigger animation
chiffre.style.animation = 'none';
chiffre.offsetHeight; // force reflow
chiffre.style.animation = 'pop 0.5s ease';
await pause(1000);
}
conteneur.classList.add('cache');
}
function afficherFlash() {
const flash = document.getElementById('flash-blanc');
flash.classList.remove('cache');
flash.style.animation = 'none';
flash.offsetHeight;
flash.style.animation = 'flash 0.3s ease-out forwards';
setTimeout(() => flash.classList.add('cache'), 400);
}
async function traiterCapture() {
if (photosSession.length === 0) {
allerA('accueil');
return;
}
if (modeActuel === 'gif') {
// Creer le GIF
const resultat = await apiPost('/api/gif', { photos: photosSession });
if (resultat.nom) {
photoFinale = resultat.nom;
afficherPreviewPhoto('/data/exports/' + resultat.nom);
}
} else if (modeActuel === 'multi') {
// Creer le strip/collage
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) {
photoFinale = resultat.nom;
afficherPreviewPhoto('/data/exports/' + resultat.nom);
}
} else {
// Photo simple - aller au preview avec filtres
photoFinale = photosSession[0];
afficherPreviewPhoto('/data/photos/' + photosSession[0]);
}
allerA('preview');
}
function afficherPreviewPhoto(chemin) {
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
// Charger les filtres si photo simple
if (modeActuel === 'simple' && config.fonctionnalites?.filtres) {
chargerFiltres();
}
// Charger les overlays
if (config.fonctionnalites?.overlays) {
chargerOverlays();
}
}
function pause(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

103
frontend/js/effects.js vendored Normal file
View File

@@ -0,0 +1,103 @@
/* Module effets - Filtres et overlays */
let filtreActuel = 'original';
let overlayActuel = null;
async function chargerFiltres() {
const filtres = await apiGet('/api/filtres');
const barre = document.getElementById('barre-filtres');
barre.innerHTML = '';
for (const [id, nom] of Object.entries(filtres)) {
const btn = document.createElement('button');
btn.className = 'btn-filtre' + (id === 'original' ? ' actif' : '');
btn.textContent = nom;
btn.addEventListener('click', () => appliquerFiltreUI(id, btn));
barre.appendChild(btn);
}
}
async function appliquerFiltreUI(filtre, btn) {
// Activer visuellement
document.querySelectorAll('.btn-filtre').forEach(b => b.classList.remove('actif'));
btn.classList.add('actif');
filtreActuel = filtre;
if (filtre === 'original') {
const chemin = '/data/photos/' + photosSession[0];
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
photoFinale = photosSession[0];
return;
}
const resultat = await apiPost('/api/filtre', {
photo: photosSession[0],
filtre: filtre,
});
if (resultat.nom) {
photoFinale = resultat.nom;
document.getElementById('photo-resultat').src = '/data/exports/' + resultat.nom;
document.getElementById('photo-partage').src = '/data/exports/' + resultat.nom;
}
}
async function chargerOverlays() {
const overlays = await apiGet('/api/overlays');
const barre = document.getElementById('barre-overlays');
if (overlays.length === 0) {
barre.classList.add('cache');
return;
}
barre.classList.remove('cache');
barre.innerHTML = '';
// Bouton "sans overlay"
const btnAucun = document.createElement('button');
btnAucun.className = 'btn-overlay actif';
btnAucun.textContent = 'Sans cadre';
btnAucun.addEventListener('click', () => retirerOverlay(btnAucun));
barre.appendChild(btnAucun);
for (const nom of overlays) {
const btn = document.createElement('button');
btn.className = 'btn-overlay';
btn.textContent = nom.replace('.png', '');
btn.addEventListener('click', () => appliquerOverlayUI(nom, btn));
barre.appendChild(btn);
}
}
async function appliquerOverlayUI(nom, btn) {
document.querySelectorAll('.btn-overlay').forEach(b => b.classList.remove('actif'));
btn.classList.add('actif');
overlayActuel = nom;
const photo = photosSession[0];
const resultat = await apiPost('/api/overlay', { photo, overlay: nom });
if (resultat.nom) {
photoFinale = resultat.nom;
document.getElementById('photo-resultat').src = '/data/exports/' + resultat.nom;
document.getElementById('photo-partage').src = '/data/exports/' + resultat.nom;
}
}
function retirerOverlay(btn) {
document.querySelectorAll('.btn-overlay').forEach(b => b.classList.remove('actif'));
btn.classList.add('actif');
overlayActuel = null;
// Revenir a la photo avec filtre actuel (ou originale)
if (filtreActuel !== 'original') {
appliquerFiltreUI(filtreActuel, document.querySelector('.btn-filtre.actif'));
} else {
const chemin = '/data/photos/' + photosSession[0];
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
photoFinale = photosSession[0];
}
}

27
frontend/js/gallery.js Normal file
View File

@@ -0,0 +1,27 @@
/* Module galerie */
async function chargerGalerie() {
const photos = await apiGet('/api/galerie');
const grille = document.getElementById('grille-galerie');
grille.innerHTML = '';
if (photos.length === 0) {
grille.innerHTML = '<p style="color:var(--texte-secondaire);grid-column:1/-1;text-align:center">Aucune photo pour le moment</p>';
return;
}
for (const photo of photos) {
const img = document.createElement('img');
img.src = '/' + photo.chemin;
img.alt = photo.nom;
img.loading = 'lazy';
img.addEventListener('click', () => voirPhoto(photo));
grille.appendChild(img);
}
}
function voirPhoto(photo) {
photoFinale = photo.nom;
document.getElementById('photo-partage').src = '/' + photo.chemin;
allerA('partage');
}

51
frontend/js/share.js Normal file
View File

@@ -0,0 +1,51 @@
/* Module partage - Impression, email, QR code */
async function lancerImpression() {
if (!photoFinale) return;
afficherStatut('Impression en cours...', 'succes');
const resultat = await apiPost('/api/imprimer', { photo: photoFinale });
if (resultat.succes) {
afficherStatut('Photo envoyee a l\'imprimante !', 'succes');
} else {
afficherStatut('Erreur d\'impression', 'erreur');
}
}
function ouvrirEmail() {
document.getElementById('form-email').classList.remove('cache');
document.getElementById('input-email').focus();
}
function fermerEmail() {
document.getElementById('form-email').classList.add('cache');
document.getElementById('input-email').value = '';
}
async function envoyerEmail() {
const email = document.getElementById('input-email').value.trim();
if (!email || !photoFinale) return;
afficherStatut('Envoi en cours...', 'succes');
const resultat = await apiPost('/api/email', { email, photo: photoFinale });
if (resultat.succes) {
afficherStatut('Email envoye !', 'succes');
fermerEmail();
} else {
afficherStatut('Erreur d\'envoi email', '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');
}
}
}

48
frontend/js/websocket.js Normal file
View File

@@ -0,0 +1,48 @@
/* Communication WebSocket avec le backend */
let ws = null;
let wsReconnectTimer = null;
const wsCallbacks = {};
function wsConnecter() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => {
console.log('WebSocket connecte');
if (wsReconnectTimer) {
clearInterval(wsReconnectTimer);
wsReconnectTimer = null;
}
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
const cb = wsCallbacks[msg.type];
if (cb) cb(msg);
};
ws.onclose = () => {
console.log('WebSocket deconnecte, reconnexion dans 3s...');
if (!wsReconnectTimer) {
wsReconnectTimer = setInterval(wsConnecter, 3000);
}
};
ws.onerror = () => {
ws.close();
};
}
function wsEnvoyer(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
function wsOnMessage(type, callback) {
wsCallbacks[type] = callback;
}
// Connexion au demarrage
wsConnecter();

35
memoire.md Normal file
View File

@@ -0,0 +1,35 @@
# Photobooth
## Description
Photobooth professionnel pour location evenementielle.
RPi4 + ecran tactile + DSLR (gphoto2) + imprimante sublimation.
## Architecture
- **Backend** : Python FastAPI + WebSocket
- **Frontend** : HTML/CSS/JS vanilla dans Chromium kiosk
- **Camera** : python-gphoto2 (mode simulation si absent)
- **Impression** : CUPS (pycups)
- **Email** : SMTP (smtplib)
## Fonctionnalites
Toutes activables/desactivables dans le menu admin cache (appui long 3s coin bas-droit) :
- Photo simple, multi-shot (strip/collage), GIF anime
- Filtres (N&B, sepia, vintage, contraste, lumineux, chaud, froid, flou)
- Overlays / cadres PNG personnalisables
- Chroma key (fond vert via OpenCV)
- Impression sublimation
- Envoi email
- QR code galerie
- Compteur photos
## Repo Gitea
https://git.copydev.fr/jules/photobooth
## Statut
Phase 1-3 terminees (backend complet + frontend complet).
Phase 4 : scripts production (install.sh, start.sh, systemd).
## Notes
- Projet cree le 2026-03-21
- Mode simulation camera si gphoto2 non installe (dev sans DSLR)
- Config persistante dans data/config.json

39
scripts/install.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
# Installation du photobooth sur Raspberry Pi 4
set -e
echo "=== Installation Photobooth ==="
# Mise a jour systeme
sudo apt update && sudo apt upgrade -y
# Dependances systeme
sudo apt install -y \
python3 python3-pip python3-venv \
libgphoto2-dev \
cups libcups2-dev \
chromium-browser \
libopencv-dev python3-opencv \
unclutter
# Creer le venv
cd "$(dirname "$0")/.."
python3 -m venv .venv
source .venv/bin/activate
# Installer les dependances Python
pip install -r backend/requirements.txt
# Creer les dossiers de donnees
mkdir -p data/photos data/exports
mkdir -p frontend/assets/overlays frontend/assets/backgrounds frontend/assets/sounds
# Installer le service systemd
sudo cp scripts/photobooth.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable photobooth
echo ""
echo "=== Installation terminee ==="
echo "Demarrer avec : sudo systemctl start photobooth"
echo "Ou manuellement : ./scripts/start.sh"

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Photobooth
After=network.target graphical.target
[Service]
Type=simple
User=jules
WorkingDirectory=/home/jules/Documents/Projet/photobooth-app
ExecStart=/home/jules/Documents/Projet/photobooth-app/scripts/start.sh
Restart=always
RestartSec=5
Environment=DISPLAY=:0
[Install]
WantedBy=graphical.target

39
scripts/start.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
# Demarrage du photobooth (backend + chromium kiosk)
set -e
DIR="$(cd "$(dirname "$0")/.." && pwd)"
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