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:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal 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
0
backend/__init__.py
Normal file
140
backend/camera.py
Normal file
140
backend/camera.py
Normal 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
71
backend/collage.py
Normal 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
52
backend/config.py
Normal 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
142
backend/effects.py
Normal 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
59
backend/gallery.py
Normal 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
41
backend/gif_maker.py
Normal 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
64
backend/mailer.py
Normal 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
364
backend/main.py
Normal 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
81
backend/printer.py
Normal 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
41
backend/qrcode_gen.py
Normal 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
11
backend/requirements.txt
Normal 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
63
data/config.json
Normal 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
0
data/exports/.gitkeep
Normal file
0
data/photos/.gitkeep
Normal file
0
data/photos/.gitkeep
Normal file
602
frontend/css/style.css
Normal file
602
frontend/css/style.css
Normal 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
257
frontend/index.html
Normal 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">📷</div>
|
||||||
|
<span>Photo</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-mode" data-mode="multi" id="btn-multi">
|
||||||
|
<div class="mode-icone">🎴</div>
|
||||||
|
<span>Multi-shot</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-mode" data-mode="gif" id="btn-gif">
|
||||||
|
<div class="mode-icone">🎦</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">🖨</div>
|
||||||
|
<span>Imprimer</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-partage" id="btn-email" onclick="ouvrirEmail()">
|
||||||
|
<div class="partage-icone">📧</div>
|
||||||
|
<span>Email</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-partage" id="btn-qr" onclick="afficherQR()">
|
||||||
|
<div class="partage-icone">🔒</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')">×</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
161
frontend/js/admin.js
Normal 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
172
frontend/js/app.js
Normal 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
158
frontend/js/camera.js
Normal 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
103
frontend/js/effects.js
vendored
Normal 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
27
frontend/js/gallery.js
Normal 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
51
frontend/js/share.js
Normal 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
48
frontend/js/websocket.js
Normal 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
35
memoire.md
Normal 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
39
scripts/install.sh
Normal 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"
|
||||||
15
scripts/photobooth.service
Normal file
15
scripts/photobooth.service
Normal 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
39
scripts/start.sh
Normal 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
|
||||||
Reference in New Issue
Block a user