commit c8e4ec80c1155e5dae80e9a8d9f84dbd6604cc2e Author: Jules Date: Sat Mar 21 21:46:31 2026 +0100 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..679186f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/camera.py b/backend/camera.py new file mode 100644 index 0000000..4163a5e --- /dev/null +++ b/backend/camera.py @@ -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() diff --git a/backend/collage.py b/backend/collage.py new file mode 100644 index 0000000..8fcf757 --- /dev/null +++ b/backend/collage.py @@ -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 diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..adf1453 --- /dev/null +++ b/backend/config.py @@ -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) diff --git a/backend/effects.py b/backend/effects.py new file mode 100644 index 0000000..6d95ba9 --- /dev/null +++ b/backend/effects.py @@ -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] diff --git a/backend/gallery.py b/backend/gallery.py new file mode 100644 index 0000000..fc52127 --- /dev/null +++ b/backend/gallery.py @@ -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") diff --git a/backend/gif_maker.py b/backend/gif_maker.py new file mode 100644 index 0000000..99526df --- /dev/null +++ b/backend/gif_maker.py @@ -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 diff --git a/backend/mailer.py b/backend/mailer.py new file mode 100644 index 0000000..204f050 --- /dev/null +++ b/backend/mailer.py @@ -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 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..eb294b5 --- /dev/null +++ b/backend/main.py @@ -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", + ) diff --git a/backend/printer.py b/backend/printer.py new file mode 100644 index 0000000..e4505aa --- /dev/null +++ b/backend/printer.py @@ -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 diff --git a/backend/qrcode_gen.py b/backend/qrcode_gen.py new file mode 100644 index 0000000..cdda8ac --- /dev/null +++ b/backend/qrcode_gen.py @@ -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") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..670572b --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/data/config.json b/data/config.json new file mode 100644 index 0000000..af04e5a --- /dev/null +++ b/data/config.json @@ -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 + } +} diff --git a/data/exports/.gitkeep b/data/exports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/photos/.gitkeep b/data/photos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..81106ce --- /dev/null +++ b/frontend/css/style.css @@ -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; } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..db0f3cc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,257 @@ + + + + + + Photobooth + + + +
+ + +
+
+

Photobooth

+

Touchez pour commencer

+
+
+
+
+ +
+
+ + +
+

Choisissez votre mode

+
+ + + +
+ +
+ + +
+
+ Preview +
+
+ 3 +
+
+
+ +
+
+ + +
+
+ Photo +
+
+ +
+
+ +
+
+ + +
+
+ + +
+

Partagez votre photo !

+
+ Photo +
+
+ + + +
+ + + +
+ QR Code +
+ +
+
+ + +
+
+ + +
+

Galerie

+
+ +
+ +
+ + +
+
+

Administration

+ +
+
+ +
+ + + + + + + +
+ + +
+
+ + + + + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + -- + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + +
+
+
+ +
+ + + + + + + + + + diff --git a/frontend/js/admin.js b/frontend/js/admin.js new file mode 100644 index 0000000..8844ce5 --- /dev/null +++ b/frontend/js/admin.js @@ -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 : ''; +} diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..4e36197 --- /dev/null +++ b/frontend/js/app.js @@ -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); diff --git a/frontend/js/camera.js b/frontend/js/camera.js new file mode 100644 index 0000000..4236b26 --- /dev/null +++ b/frontend/js/camera.js @@ -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)); +} diff --git a/frontend/js/effects.js b/frontend/js/effects.js new file mode 100644 index 0000000..b7335a1 --- /dev/null +++ b/frontend/js/effects.js @@ -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]; + } +} diff --git a/frontend/js/gallery.js b/frontend/js/gallery.js new file mode 100644 index 0000000..f491a11 --- /dev/null +++ b/frontend/js/gallery.js @@ -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 = '

Aucune photo pour le moment

'; + 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'); +} diff --git a/frontend/js/share.js b/frontend/js/share.js new file mode 100644 index 0000000..0faaf24 --- /dev/null +++ b/frontend/js/share.js @@ -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'); + } + } +} diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js new file mode 100644 index 0000000..67c9606 --- /dev/null +++ b/frontend/js/websocket.js @@ -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(); diff --git a/memoire.md b/memoire.md new file mode 100644 index 0000000..96dfc4d --- /dev/null +++ b/memoire.md @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..fdd99a7 --- /dev/null +++ b/scripts/install.sh @@ -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" diff --git a/scripts/photobooth.service b/scripts/photobooth.service new file mode 100644 index 0000000..1b6d278 --- /dev/null +++ b/scripts/photobooth.service @@ -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 diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 0000000..4c7b0f5 --- /dev/null +++ b/scripts/start.sh @@ -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