Impression Mitsubishi K60 + cadres + interface admin distante

- Intégration selphy_print (backend CUPS dyesub Mitsubishi CP-K60DW-S)
- Formats impression : 15x20, 10x15, 10x15 2-strips avec cadre et orientation portrait/paysage
- Cadres d'impression par format (strip/10x15/15x20) : upload, sélection, aperçu live
- Cadres démo générés : pellicule noir/vintage/couleurs + bordures classique/doré/rose gold
- Écran de choix de cadre avant capture (clic direct, aperçu grand format)
- Filtres réduits à 3 (Couleur, N&B, Sépia) et appliqués sur tous les strips
- Port 80 via authbind, route /admin avec redirection auto depuis poste distant
- Autologin LightDM corrigé (pam-autologin-service)
- Bouton évacuer bourrage papier dans admin
- Fix : _gp_lock manquant dans Camera.__init__

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:38:31 +02:00
parent 6451f5774c
commit e3d1d9cc07
32 changed files with 1009 additions and 84 deletions

View File

@@ -1,6 +1,7 @@
import io import io
import logging import logging
import subprocess import subprocess
import threading
import time import time
from datetime import datetime from datetime import datetime
@@ -66,6 +67,7 @@ class Camera:
self.webcam = None self.webcam = None
self.webcam_index = -1 self.webcam_index = -1
self.preview_dslr_ok = True # True si le dernier preview DSLR a reussi self.preview_dslr_ok = True # True si le dernier preview DSLR a reussi
self._gp_lock = threading.Lock()
def connecter(self, source=None) -> bool: def connecter(self, source=None) -> bool:
"""Connecte la camera. source: 'gphoto2', 'webcam:0', 'webcam:2', etc.""" """Connecte la camera. source: 'gphoto2', 'webcam:0', 'webcam:2', etc."""

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from backend.config import DOSSIER_EXPORTS, charger_config from backend.config import DOSSIER_EXPORTS, charger_config
from backend.effects import appliquer_cadre_impression
log = logging.getLogger("photobooth.collage") log = logging.getLogger("photobooth.collage")
@@ -28,7 +29,8 @@ def _crop_center(img: Image.Image, ratio_cible: float) -> Image.Image:
return img.crop((0, offset, img.width, offset + nouvelle_hauteur)) return img.crop((0, offset, img.width, offset + nouvelle_hauteur))
def creer_strip(photos: list[Path], marge: int = 10, couleur_fond: str = "#000000") -> Path: def creer_strip(photos: list[Path], marge: int = 10, couleur_fond: str = "#000000",
cadre: str | None = None) -> Path:
"""Cree une bande portrait style pellicule (4 photos de gauche a droite). """Cree une bande portrait style pellicule (4 photos de gauche a droite).
Pellicule noire avec perforations, photos separees par des bandes noires. Pellicule noire avec perforations, photos separees par des bandes noires.
@@ -83,6 +85,9 @@ def creer_strip(photos: list[Path], marge: int = 10, couleur_fond: str = "#00000
strip.paste(img, (x, perf_hauteur)) strip.paste(img, (x, perf_hauteur))
x += largeur_cellule + sep x += largeur_cellule + sep
if cadre:
strip = appliquer_cadre_impression(strip, "strip", cadre)
nom = f"strip_{photos[0].stem}.jpg" nom = f"strip_{photos[0].stem}.jpg"
chemin = DOSSIER_EXPORTS / nom chemin = DOSSIER_EXPORTS / nom
strip.save(chemin, "JPEG", quality=95, dpi=(300, 300)) strip.save(chemin, "JPEG", quality=95, dpi=(300, 300))
@@ -115,7 +120,7 @@ def creer_impression_strip(chemin_strip: Path, marge: int = 20,
def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 25, def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 25,
couleur_fond: str = "#ffffff") -> Path: couleur_fond: str = "#ffffff", cadre: str | None = None) -> Path:
"""Cree un collage en grille au format 10x15cm.""" """Cree un collage en grille au format 10x15cm."""
nb = len(photos) nb = len(photos)
images = [Image.open(p) for p in photos] images = [Image.open(p) for p in photos]
@@ -141,6 +146,9 @@ def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 25,
y = marge + lig * (hauteur_cellule + marge) y = marge + lig * (hauteur_cellule + marge)
collage.paste(img, (x, y)) collage.paste(img, (x, y))
if cadre:
collage = appliquer_cadre_impression(collage, "10x15", cadre)
nom = f"collage_{photos[0].stem}.jpg" nom = f"collage_{photos[0].stem}.jpg"
chemin = DOSSIER_EXPORTS / nom chemin = DOSSIER_EXPORTS / nom
collage.save(chemin, "JPEG", quality=95, dpi=(300, 300)) collage.save(chemin, "JPEG", quality=95, dpi=(300, 300))

View File

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

View File

@@ -4,21 +4,15 @@ from pathlib import Path
from PIL import Image, ImageEnhance, ImageFilter, ImageOps from PIL import Image, ImageEnhance, ImageFilter, ImageOps
from backend.config import DOSSIER_OVERLAYS, DOSSIER_EXPORTS, DOSSIER_FONDS from backend.config import DOSSIER_OVERLAYS, DOSSIER_EXPORTS, DOSSIER_FONDS, DOSSIER_CADRES
log = logging.getLogger("photobooth.effects") log = logging.getLogger("photobooth.effects")
# Filtres disponibles # Filtres disponibles
FILTRES = { FILTRES = {
"original": "Original", "original": "Couleur",
"nb": "Noir & Blanc", "nb": "Noir & Blanc",
"sepia": "Sepia", "sepia": "Sepia",
"vintage": "Vintage",
"contraste": "Contraste fort",
"lumineux": "Lumineux",
"chaud": "Tons chauds",
"froid": "Tons froids",
"flou_artistique": "Flou artistique",
} }
@@ -127,6 +121,28 @@ def chroma_key(chemin_photo: Path, nom_fond: str | None = None,
return chemin_export return chemin_export
def appliquer_cadre_impression(img: Image.Image, format_papier: str, nom_cadre: str) -> Image.Image:
"""Composite un cadre PNG par-dessus l'image assemblée."""
chemin = DOSSIER_CADRES / format_papier / nom_cadre
if not chemin.exists():
log.warning(f"Cadre introuvable : {chemin}")
return img
cadre = Image.open(chemin).convert("RGBA")
if cadre.size != img.size:
cadre = cadre.resize(img.size, Image.LANCZOS)
base = img.convert("RGBA")
composite = Image.alpha_composite(base, cadre)
return composite.convert("RGB")
def lister_cadres(format_papier: str) -> list[str]:
"""Liste les cadres disponibles pour un format donné."""
dossier = DOSSIER_CADRES / format_papier
if not dossier.exists():
return []
return sorted(f.name for f in dossier.iterdir() if f.suffix.lower() == ".png")
def lister_overlays() -> list[str]: def lister_overlays() -> list[str]:
"""Liste les overlays disponibles.""" """Liste les overlays disponibles."""
if not DOSSIER_OVERLAYS.exists(): if not DOSSIER_OVERLAYS.exists():

View File

@@ -9,12 +9,13 @@ from contextlib import asynccontextmanager
import cv2 import cv2
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, UploadFile, File
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse from fastapi.responses import FileResponse, JSONResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from backend.config import ( from backend.config import (
RACINE, DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS, DOSSIER_ANIMATIONS, RACINE, DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS, DOSSIER_ANIMATIONS,
DOSSIER_CADRES, FORMATS_CADRES,
charger_config, sauvegarder_config, mettre_a_jour_config, charger_config, sauvegarder_config, mettre_a_jour_config,
) )
from backend.camera import camera, GPHOTO2_DISPONIBLE from backend.camera import camera, GPHOTO2_DISPONIBLE
@@ -23,7 +24,7 @@ try:
except ImportError: except ImportError:
gp = None gp = None
from backend.gallery import lister_photos, compter_photos, supprimer_photo, vider_galerie 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.effects import appliquer_filtre, appliquer_overlay, chroma_key, lister_overlays, lister_fonds, lister_cadres, FILTRES
from backend.collage import creer_strip, creer_collage, creer_impression_strip from backend.collage import creer_strip, creer_collage, creer_impression_strip
from backend.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur, recuperer_booth_password from backend.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur, recuperer_booth_password
from backend.printer import lister_imprimantes, imprimer from backend.printer import lister_imprimantes, imprimer
@@ -150,8 +151,17 @@ app.mount("/data", StaticFiles(directory=str(RACINE / "data")), name="data")
# --- Pages --- # --- Pages ---
@app.get("/admin", include_in_schema=False)
async def page_admin():
return FileResponse(str(RACINE / "frontend" / "index.html"))
@app.get("/") @app.get("/")
async def page_principale(): async def page_principale(request: Request):
from fastapi.responses import RedirectResponse
host = request.client.host if request.client else ""
if host not in ("127.0.0.1", "::1", "localhost"):
return RedirectResponse(url="/admin", status_code=302)
return FileResponse(str(RACINE / "frontend" / "index.html")) return FileResponse(str(RACINE / "frontend" / "index.html"))
@@ -454,10 +464,16 @@ async def api_supprimer_fond(nom: str):
@app.post("/api/strip") @app.post("/api/strip")
async def api_strip(donnees: dict): async def api_strip(donnees: dict):
noms = donnees.get("photos", []) noms = donnees.get("photos", [])
chemins = [DOSSIER_PHOTOS / n for n in noms] source = donnees.get("source", "photos")
for c in chemins: dossier_src = DOSSIER_EXPORTS if source == "exports" else DOSSIER_PHOTOS
chemins = []
for n in noms:
c = dossier_src / n
if not c.exists(): if not c.exists():
return JSONResponse({"erreur": f"Photo introuvable : {c.name}"}, status_code=404) c = DOSSIER_EXPORTS / n # fallback
if not c.exists():
return JSONResponse({"erreur": f"Photo introuvable : {n}"}, status_code=404)
chemins.append(c)
chemin_strip = creer_strip(chemins) chemin_strip = creer_strip(chemins)
# Creer aussi la page d'impression (2 bandes sur 10x15 paysage) # Creer aussi la page d'impression (2 bandes sur 10x15 paysage)
chemin_print = creer_impression_strip(chemin_strip) chemin_print = creer_impression_strip(chemin_strip)
@@ -489,6 +505,17 @@ async def api_imprimantes():
return lister_imprimantes() return lister_imprimantes()
@app.post("/api/imprimante/evacuer")
async def api_evacuer_bourrage():
"""Annule tous les jobs et réactive l'imprimante pour débloquer un bourrage."""
import subprocess
config = charger_config()
nom = config.get("impression", {}).get("imprimante", "Mitsubishi")
subprocess.run(["sudo", "cancel", "-a", nom], capture_output=True)
subprocess.run(["sudo", "cupsenable", nom], capture_output=True)
return {"succes": True, "message": f"Jobs annulés, {nom} réactivée"}
@app.post("/api/imprimer") @app.post("/api/imprimer")
async def api_imprimer(donnees: dict): async def api_imprimer(donnees: dict):
nom = donnees.get("photo", "") nom = donnees.get("photo", "")
@@ -503,10 +530,11 @@ async def api_imprimer(donnees: dict):
chemin = DOSSIER_PHOTOS / nom chemin = DOSSIER_PHOTOS / nom
if not chemin.exists(): if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404) return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
ok = imprimer(chemin, copies=copies) cadre_override = donnees.get("cadre") # cadre choisi en session, prioritaire sur le config
if ok: resultat = imprimer(chemin, copies=copies, cadre_override=cadre_override)
if resultat.get("succes"):
distribuer_photo(chemin, imprimee=True) distribuer_photo(chemin, imprimee=True)
return {"succes": ok} return resultat
# --- API Booth (galerie live) --- # --- API Booth (galerie live) ---
@@ -620,7 +648,7 @@ async def api_detecter_usb():
return detecter_usb() return detecter_usb()
# --- API Cadres actifs --- # --- API Cadres actifs (overlays photo) ---
@app.get("/api/cadres") @app.get("/api/cadres")
async def api_cadres(): async def api_cadres():
@@ -637,6 +665,60 @@ async def api_cadres_update(donnees: dict):
return {"actifs": actifs} return {"actifs": actifs}
# --- API Cadres impression (par format) ---
@app.get("/api/cadres-impression/{format_papier}")
async def api_cadres_impression_list(format_papier: str):
if format_papier not in FORMATS_CADRES:
return JSONResponse({"erreur": "Format inconnu"}, status_code=400)
config = charger_config()
actif = config.get("impression", {}).get("cadres_actifs", {}).get(format_papier)
return {"format": format_papier, "disponibles": lister_cadres(format_papier), "actif": actif}
@app.post("/api/cadres-impression/{format_papier}/actif")
async def api_cadres_impression_set(format_papier: str, donnees: dict):
if format_papier not in FORMATS_CADRES:
return JSONResponse({"erreur": "Format inconnu"}, status_code=400)
nom = donnees.get("nom") # None = désactiver
config = charger_config()
cadres_actifs = config.get("impression", {}).get("cadres_actifs", {})
cadres_actifs[format_papier] = nom
mettre_a_jour_config({"impression": {"cadres_actifs": cadres_actifs}})
return {"format": format_papier, "actif": nom}
@app.post("/api/upload/cadre/{format_papier}")
async def api_upload_cadre(format_papier: str, fichier: UploadFile = File(...)):
if format_papier not in FORMATS_CADRES:
return JSONResponse({"erreur": "Format inconnu"}, status_code=400)
if not fichier.filename.lower().endswith(".png"):
return JSONResponse({"erreur": "Seuls les PNG sont acceptés"}, status_code=400)
dossier = DOSSIER_CADRES / format_papier
dossier.mkdir(parents=True, exist_ok=True)
chemin = dossier / fichier.filename
with open(chemin, "wb") as f:
f.write(await fichier.read())
return {"nom": fichier.filename, "format": format_papier}
@app.delete("/api/cadres-impression/{format_papier}/{nom}")
async def api_supprimer_cadre(format_papier: str, nom: str):
if format_papier not in FORMATS_CADRES:
return JSONResponse({"erreur": "Format inconnu"}, status_code=400)
chemin = DOSSIER_CADRES / format_papier / nom
if not chemin.exists() or chemin.parent != DOSSIER_CADRES / format_papier:
return JSONResponse({"erreur": "Fichier introuvable"}, status_code=404)
chemin.unlink()
# Désactiver si c'était le cadre actif
config = charger_config()
cadres_actifs = config.get("impression", {}).get("cadres_actifs", {})
if cadres_actifs.get(format_papier) == nom:
cadres_actifs[format_papier] = None
mettre_a_jour_config({"impression": {"cadres_actifs": cadres_actifs}})
return {"succes": True}
# --- API Animations compte a rebours --- # --- API Animations compte a rebours ---
ANIMATIONS_CAR = { ANIMATIONS_CAR = {
@@ -769,7 +851,7 @@ if __name__ == "__main__":
uvicorn.run( uvicorn.run(
"backend.main:app", "backend.main:app",
host=conf_srv.get("host", "0.0.0.0"), host=conf_srv.get("host", "0.0.0.0"),
port=conf_srv.get("port", 8080), port=conf_srv.get("port", 80),
reload=False, reload=False,
log_level="info", log_level="info",
) )

View File

@@ -1,81 +1,265 @@
import logging import logging
import subprocess
import tempfile
import time
from pathlib import Path from pathlib import Path
from backend.config import charger_config from backend.config import charger_config
from backend.effects import appliquer_cadre_impression
log = logging.getLogger("photobooth.printer") log = logging.getLogger("photobooth.printer")
# Essayer d'importer cups # PageSize CUPS + dimensions exactes à 300 DPI pour la Mitsubishi CP-K60DW-S
try: # Le resize vers ces dimensions exactes évite toute bordure blanche
import cups FORMATS = {
CUPS_DISPONIBLE = True "10x15": {"cups": "w288h432", "px": (1200, 1800)},
except ImportError: "15x15": {"cups": "w432h432", "px": (1800, 1800)},
CUPS_DISPONIBLE = False "15x20": {"cups": "w432h576", "px": (1800, 2400)},
log.warning("pycups non installe, impression indisponible") "10x15-2up": {"cups": "w288h432-div2", "px": (600, 1800)}, # 1 strip 5x15cm, printer duplique
"15x20-2up": {"cups": "w432h576-div2", "px": (900, 2400)}, # 1 strip 7.5x20cm, printer duplique
}
ERREUR_FICHIER = "fichier_introuvable"
ERREUR_IMPRIMANTE = "imprimante_indisponible"
ERREUR_BOURRAGE = "bourrage_papier"
ERREUR_PAPIER = "plus_de_papier"
ERREUR_IMPRESSION = "erreur_impression"
def _run(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout, r.stderr
except subprocess.TimeoutExpired:
return -1, "", "timeout"
except FileNotFoundError:
return -1, "", f"commande introuvable : {cmd[0]}"
def preparer_apercu(chemin: Path, largeur: int, hauteur: int,
orientation: str = "paysage") -> Path:
"""
Génère un aperçu JPEG recadré aux bonnes dimensions (pour le frontend).
orientation : 'portrait' pivote le DSLR paysage de 90° avant recadrage.
"""
from PIL import Image, ImageOps
img = Image.open(chemin).convert("RGB")
img = ImageOps.exif_transpose(img)
if orientation == "portrait" and img.width > img.height:
img = img.rotate(90, expand=True)
img = _crop_to(img, largeur, hauteur)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
img.save(tmp.name, "JPEG", quality=85)
return Path(tmp.name)
def _crop_to(img, largeur: int, hauteur: int):
"""Cover crop centre vers les dimensions exactes."""
from PIL import Image
iw, ih = img.size
ratio_cible = largeur / hauteur
ratio_src = iw / ih
if ratio_src > ratio_cible:
new_w = int(ih * ratio_cible)
left = (iw - new_w) // 2
img = img.crop((left, 0, left + new_w, ih))
elif ratio_src < ratio_cible:
new_h = int(iw / ratio_cible)
top = (ih - new_h) // 2
img = img.crop((0, top, iw, top + new_h))
return img.resize((largeur, hauteur), Image.LANCZOS)
def _preparer_image(chemin: Path, largeur: int, hauteur: int,
format_papier: str | None = None, cadre: str | None = None,
orientation: str = "paysage") -> Path:
"""
Pivote si nécessaire, recadre aux dimensions exactes (cover crop),
applique le cadre, sauvegarde en temp.
"""
try:
from PIL import Image, ImageOps
except ImportError:
log.warning("PIL absent — image envoyée telle quelle, risque de bordures")
return chemin
img = Image.open(chemin).convert("RGB")
img = ImageOps.exif_transpose(img) # respecte l'orientation EXIF
# Portrait demandé mais DSLR paysage → rotation 90°
if orientation == "portrait" and img.width > img.height:
img = img.rotate(90, expand=True)
# Paysage demandé mais image portrait (ex. prise avec DSLR tourné) → rotation -90°
elif orientation == "paysage" and img.height > img.width:
img = img.rotate(-90, expand=True)
img = _crop_to(img, largeur, hauteur)
if cadre and format_papier:
img = appliquer_cadre_impression(img, format_papier, cadre)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
img.save(tmp.name, "JPEG", quality=95, dpi=(300, 300))
log.debug(f"Image préparée : {orientation} {largeur}x{hauteur}px → {tmp.name}")
return Path(tmp.name)
def _statut(nom: str) -> str:
"""Retourne 'idle', 'printing', 'stopped' ou 'inconnue'."""
code, out, _ = _run(["lpstat", "-p", nom])
if code != 0:
return "inconnue"
out = out.lower()
if "disabled" in out or "stopped" in out:
return "stopped"
if "printing" in out or "processing" in out:
return "printing"
if "idle" in out or "inactive" in out:
return "idle"
return "inconnue"
def _reactiver(nom: str) -> bool:
"""Réactive l'imprimante via cupsenable (sudoers sans mot de passe requis)."""
code, _, err = _run(["sudo", "cupsenable", nom], timeout=5)
if code == 0:
log.info(f"Imprimante {nom} réactivée")
return True
log.error(f"Impossible de réactiver {nom} : {err}")
return False
def _detecter_erreur_physique(nom: str) -> str | None:
"""Interroge l'état physique via le backend dyesub."""
try:
r = subprocess.run(
["/usr/lib/cups/backend/gutenprint53+usb", "-s"],
capture_output=True, text=True, timeout=10,
)
combined = (r.stdout + r.stderr).lower()
except Exception:
return None
if "jam" in combined:
return ERREUR_BOURRAGE
if "media out" in combined or "no media" in combined or "remaining: 0/" in combined:
return ERREUR_PAPIER
return None
def lister_imprimantes() -> list[dict]: def lister_imprimantes() -> list[dict]:
"""Liste les imprimantes disponibles via CUPS.""" """Liste les imprimantes CUPS disponibles."""
if not CUPS_DISPONIBLE: code, out, _ = _run(["lpstat", "-p"])
if code != 0 or not out.strip():
return [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}] return [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}]
try: imprimantes = []
conn = cups.Connection() for ligne in out.splitlines():
imprimantes = conn.getPrinters() if not ligne.startswith("printer "):
return [ continue
{ parties = ligne.split()
"nom": nom, if len(parties) < 2:
"statut": "prete" if info.get("printer-state") == 3 else "occupee", continue
"info": info.get("printer-info", ""), nom = parties[1]
} arretee = "disabled" in ligne.lower() or "stopped" in ligne.lower()
for nom, info in imprimantes.items() imprimantes.append({
] "nom": nom,
except cups.IPPError as e: "statut": "arretee" if arretee else "prete",
log.error(f"Erreur CUPS : {e}") })
return [] return imprimantes or [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}]
def imprimer(chemin_photo: Path, imprimante: str | None = None, copies: int | None = None) -> bool: def imprimer(
"""Imprime une photo sur l'imprimante configuree.""" chemin_photo: Path,
imprimante: str | None = None,
copies: int | None = None,
format_papier: str | None = None,
cadre_override: str | None = None,
) -> dict:
"""
Imprime une photo sans bordure blanche.
L'image est recadrée et redimensionnée aux dimensions exactes (300 DPI)
avant envoi à CUPS.
Retourne {"succes": True} ou {"succes": False, "erreur": CODE, "message": str}
"""
config = charger_config() config = charger_config()
conf_imp = config.get("impression", {}) conf_imp = config.get("impression", {})
if imprimante is None: if imprimante is None:
imprimante = conf_imp.get("imprimante") imprimante = conf_imp.get("imprimante", "Mitsubishi")
if copies is None: if copies is None:
copies = conf_imp.get("copies", 1) copies = conf_imp.get("copies", 1)
if format_papier is None:
format_papier = conf_imp.get("format", "15x20")
fmt = FORMATS.get(format_papier, FORMATS["15x20"])
page_size = fmt["cups"]
largeur, hauteur = fmt["px"]
fmt_base = format_papier.replace("-2up", "")
cadre = cadre_override if cadre_override is not None else conf_imp.get("cadres_actifs", {}).get(fmt_base)
orientation = conf_imp.get("orientations", {}).get(fmt_base, "portrait")
if not chemin_photo.exists(): if not chemin_photo.exists():
log.error(f"Fichier introuvable : {chemin_photo}") log.error(f"Fichier introuvable : {chemin_photo}")
return False return {"succes": False, "erreur": ERREUR_FICHIER, "message": "Fichier photo introuvable"}
if not CUPS_DISPONIBLE: chemin_print = _preparer_image(chemin_photo, largeur, hauteur, fmt_base, cadre, orientation)
log.info(f"[Simulation] Impression de {chemin_photo} x{copies}") tmp_cree = chemin_print != chemin_photo
return True
if _statut(imprimante) == "stopped":
log.warning(f"Imprimante {imprimante} arrêtée — réactivation")
if not _reactiver(imprimante):
return {
"succes": False,
"erreur": ERREUR_IMPRIMANTE,
"message": "Imprimante arrêtée, réactivation impossible (vérifiez sudo cupsenable)",
}
time.sleep(1)
try: try:
conn = cups.Connection() for tentative in range(1, 4):
cmd = [
"lp",
"-d", imprimante,
"-n", str(copies),
"-o", f"PageSize={page_size}",
"-o", "StpiShrinkOutput=Crop",
str(chemin_print),
]
if imprimante is None: code, out, err = _run(cmd, timeout=30)
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 = { if code == 0:
"copies": str(copies), job = out.strip()
"media": conf_imp.get("format", "10x15"), log.info(f"Impression lancée : {job} ({format_papier} {largeur}x{hauteur}px, x{copies})")
"fit-to-page": "true", return {"succes": True, "job": job}
log.warning(f"Impression échouée (tentative {tentative}/3) : {err.strip()}")
if tentative < 3:
if _statut(imprimante) == "stopped":
erreur = _detecter_erreur_physique(imprimante)
if erreur == ERREUR_BOURRAGE:
return {
"succes": False,
"erreur": ERREUR_BOURRAGE,
"message": "Bourrage papier — retirez le papier coincé puis réessayez",
}
if erreur == ERREUR_PAPIER:
return {
"succes": False,
"erreur": ERREUR_PAPIER,
"message": "Plus de papier — rechargez le rouleau",
}
_reactiver(imprimante)
time.sleep(2)
return {
"succes": False,
"erreur": ERREUR_IMPRESSION,
"message": "Impression échouée après 3 tentatives",
} }
finally:
job_id = conn.printFile(imprimante, str(chemin_photo), "Photobooth", options) if tmp_cree:
log.info(f"Impression lancee : job #{job_id} sur {imprimante}") chemin_print.unlink(missing_ok=True)
return True
except cups.IPPError as e:
log.error(f"Erreur impression : {e}")
return False

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -398,6 +398,7 @@ html, body {
justify-content: center; justify-content: center;
padding: 1rem; padding: 1rem;
max-height: 65vh; max-height: 65vh;
position: relative;
} }
.preview-photo-container img { .preview-photo-container img {
@@ -407,6 +408,17 @@ html, body {
box-shadow: var(--ombre); box-shadow: var(--ombre);
} }
.cadre-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 100%;
max-height: 100%;
pointer-events: none;
border-radius: var(--rayon);
}
.barre-fonds { .barre-fonds {
display: flex; display: flex;
gap: 0.8rem; gap: 0.8rem;
@@ -1120,6 +1132,165 @@ h3 {
background: #fff; background: #fff;
} }
/* --- Cadres impression --- */
.cadres-formats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.2rem;
margin-bottom: 1.5rem;
}
.cadres-format-bloc {
background: var(--fond);
border-radius: 10px;
padding: 1rem;
}
.cadres-format-bloc h4 {
margin: 0 0 0.8rem;
font-size: 0.9rem;
color: var(--texte-secondaire);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cadres-liste {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.8rem;
}
.cadre-imp-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.5rem 0.7rem;
background: var(--fond-secondaire, #1a1a2e);
border-radius: 6px;
cursor: pointer;
}
.cadre-imp-item input[type="radio"] {
accent-color: var(--primaire);
width: 16px;
height: 16px;
flex-shrink: 0;
}
.cadre-imp-item .cadre-preview {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 4px;
background: #fff;
flex-shrink: 0;
}
.cadre-imp-item span {
flex: 1;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cadre-imp-item .btn-icone {
background: none;
border: none;
color: var(--danger);
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.8rem;
flex-shrink: 0;
}
.cadre-imp-item .btn-icone:hover {
background: rgba(244, 67, 54, 0.15);
}
.cadres-upload {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.cadres-upload .input-fichier {
flex: 1;
min-width: 0;
padding: 0.4rem;
font-size: 0.8rem;
}
/* --- Fin cadres impression --- */
/* --- Choix cadre avant capture --- */
#ecran-cadre-choix {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 1.5rem;
}
.grille-cadres-choix {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
justify-content: center;
overflow-y: auto;
max-height: 70vh;
padding: 0.5rem;
}
.cadre-choix-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.7rem;
cursor: pointer;
padding: 0;
border-radius: 14px;
border: 4px solid transparent;
background: transparent;
transition: border-color 0.15s, transform 0.15s;
width: 140px;
}
.cadre-choix-item:active {
transform: scale(0.95);
}
.cadre-choix-item img {
width: 140px;
height: 210px;
object-fit: cover;
border-radius: 10px;
background: #111;
display: block;
}
.cadre-choix-item span {
font-size: 0.85rem;
text-align: center;
color: var(--texte-secondaire);
}
.cadre-choix-aucun .cadre-choix-vide {
width: 140px;
height: 210px;
border-radius: 10px;
border: 3px dashed rgba(255,255,255,0.2);
background: rgba(255,255,255,0.04);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
color: rgba(255,255,255,0.2);
}
.input-fichier { .input-fichier {
background: var(--fond); background: var(--fond);
border: 2px dashed #444; border: 2px dashed #444;

View File

@@ -60,6 +60,18 @@
<button class="btn-retour" onclick="allerA('accueil')">Retour</button> <button class="btn-retour" onclick="allerA('accueil')">Retour</button>
</section> </section>
<!-- Choix de cadre avant capture -->
<section id="ecran-cadre-choix" class="ecran">
<h2>Choisissez un cadre</h2>
<div id="grille-cadres-choix" class="grille-cadres-choix">
<div class="cadre-choix-item cadre-choix-aucun" onclick="lancerSansCadre()">
<div class="cadre-choix-vide"></div>
<span>Sans cadre</span>
</div>
</div>
<button class="btn-retour" onclick="allerA('mode')">Retour</button>
</section>
<!-- Compte a rebours + Capture --> <!-- Compte a rebours + Capture -->
<section id="ecran-capture" class="ecran"> <section id="ecran-capture" class="ecran">
<div id="preview-live" class="preview-live"> <div id="preview-live" class="preview-live">
@@ -83,6 +95,7 @@
<section id="ecran-preview" class="ecran"> <section id="ecran-preview" class="ecran">
<div class="preview-photo-container"> <div class="preview-photo-container">
<img id="photo-resultat" src="" alt="Photo"> <img id="photo-resultat" src="" alt="Photo">
<img id="cadre-overlay-preview" class="cadre-overlay cache" src="" alt="">
</div> </div>
<div id="barre-filtres" class="barre-filtres"> <div id="barre-filtres" class="barre-filtres">
</div> </div>
@@ -358,11 +371,29 @@
<select id="admin-imprimante"><option value="">Detection...</option></select> <select id="admin-imprimante"><option value="">Detection...</option></select>
<button class="btn-secondaire btn-petit" onclick="rafraichirImprimantes()">Rafraichir</button> <button class="btn-secondaire btn-petit" onclick="rafraichirImprimantes()">Rafraichir</button>
</div> </div>
<div class="champ">
<label>Format papier</label>
<select id="admin-format-papier">
<option value="15x20">15&times;20 cm</option>
<option value="10x15">10&times;15 cm</option>
<option value="10x15-2up">10&times;15 cm &mdash; 2 strips</option>
</select>
</div>
<div class="champ">
<label>Orientation photo</label>
<div class="radio-group">
<label><input type="radio" name="admin-orientation" value="portrait"> Portrait</label>
<label><input type="radio" name="admin-orientation" value="paysage"> Paysage</label>
</div>
</div>
<div class="champ"> <div class="champ">
<label>Nombre max d'exemplaires</label> <label>Nombre max d'exemplaires</label>
<input type="number" id="admin-copies-max" min="1" max="20" value="5"> <input type="number" id="admin-copies-max" min="1" max="20" value="5">
</div> </div>
<button class="btn-action" onclick="sauvegarderMateriel()">Sauvegarder</button> <button class="btn-action" onclick="sauvegarderMateriel()">Sauvegarder</button>
<div class="champ" style="margin-top:1rem">
<button class="btn-danger" onclick="evacuerBourrage()">&#9888; Évacuer bourrage papier</button>
</div>
</div> </div>
<!-- Panneau Compteur --> <!-- Panneau Compteur -->
@@ -435,8 +466,39 @@
<!-- Panneau Personnalisation (Cadres + Fonds + Animations) --> <!-- Panneau Personnalisation (Cadres + Fonds + Animations) -->
<div class="admin-panneau" id="panneau-personnalisation"> <div class="admin-panneau" id="panneau-personnalisation">
<h3>Cadres / Overlays</h3>
<p class="aide">Cochez les cadres a proposer aux utilisateurs.</p> <h3>Cadres d'impression</h3>
<p class="aide">Selectionnez un cadre PNG par format. Le cadre est applique sur chaque impression. Laissez vide pour imprimer sans cadre.</p>
<div class="cadres-formats">
<div class="cadres-format-bloc" id="cadres-bloc-strip">
<h4>Strip (5&times;15 cm)</h4>
<div class="cadres-liste" id="cadres-liste-strip"></div>
<div class="champ cadres-upload">
<input type="file" id="input-cadre-strip" accept=".png" class="input-fichier">
<button class="btn-secondaire btn-petit" onclick="uploaderCadreFormat('strip')">Importer</button>
</div>
</div>
<div class="cadres-format-bloc" id="cadres-bloc-10x15">
<h4>10&times;15 cm</h4>
<div class="cadres-liste" id="cadres-liste-10x15"></div>
<div class="champ cadres-upload">
<input type="file" id="input-cadre-10x15" accept=".png" class="input-fichier">
<button class="btn-secondaire btn-petit" onclick="uploaderCadreFormat('10x15')">Importer</button>
</div>
</div>
<div class="cadres-format-bloc" id="cadres-bloc-15x20">
<h4>15&times;20 cm</h4>
<div class="cadres-liste" id="cadres-liste-15x20"></div>
<div class="champ cadres-upload">
<input type="file" id="input-cadre-15x20" accept=".png" class="input-fichier">
<button class="btn-secondaire btn-petit" onclick="uploaderCadreFormat('15x20')">Importer</button>
</div>
</div>
</div>
<h3>Cadres / Overlays photo</h3>
<p class="aide">Cochez les cadres a proposer aux utilisateurs en live.</p>
<div id="liste-cadres" class="liste-cadres"> <div id="liste-cadres" class="liste-cadres">
<p class="texte-secondaire">Aucun cadre importe</p> <p class="texte-secondaire">Aucun cadre importe</p>
</div> </div>

View File

@@ -19,6 +19,7 @@ async function chargerAdmin() {
chargerCompteur(); chargerCompteur();
chargerDestinations(); chargerDestinations();
chargerCadres(); chargerCadres();
chargerCadresImpression();
chargerAnimations(); chargerAnimations();
chargerFonctionnalites(); chargerFonctionnalites();
chargerEmailAdmin(); chargerEmailAdmin();
@@ -57,6 +58,10 @@ async function chargerMateriel() {
// Imprimantes // Imprimantes
await rafraichirImprimantes(); await rafraichirImprimantes();
if (imp.imprimante) document.getElementById('admin-imprimante').value = imp.imprimante; if (imp.imprimante) document.getElementById('admin-imprimante').value = imp.imprimante;
if (imp.format) document.getElementById('admin-format-papier').value = imp.format;
const fmt = (imp.format || '15x20').replace('-2up', '');
const orient = (imp.orientations || {})[fmt] || 'portrait';
document.querySelector(`input[name="admin-orientation"][value="${orient}"]`).checked = true;
setValue('admin-copies-max', imp.copies_max || 5); setValue('admin-copies-max', imp.copies_max || 5);
} }
@@ -78,6 +83,19 @@ async function reconnecterCamera() {
chargerMateriel(); chargerMateriel();
} }
function _getOrientations() {
const fmt = (getValue('admin-format-papier') || '15x20').replace('-2up', '');
const orient = document.querySelector('input[name="admin-orientation"]:checked')?.value || 'portrait';
// On conserve les orientations des autres formats depuis la config courante
const existing = (config.impression || {}).orientations || {};
return { ...existing, [fmt]: orient };
}
async function evacuerBourrage() {
const r = await apiPost('/api/imprimante/evacuer', {});
afficherStatut(r.message || 'Imprimante réactivée', r.succes ? 'succes' : 'erreur');
}
async function sauvegarderMateriel() { async function sauvegarderMateriel() {
await apiPost('/api/config', { await apiPost('/api/config', {
camera: { camera: {
@@ -86,7 +104,9 @@ async function sauvegarderMateriel() {
}, },
impression: { impression: {
imprimante: getValue('admin-imprimante'), imprimante: getValue('admin-imprimante'),
format: getValue('admin-format-papier'),
copies_max: parseInt(getValue('admin-copies-max')) || 5, copies_max: parseInt(getValue('admin-copies-max')) || 5,
orientations: _getOrientations(),
}, },
}); });
afficherStatut('Materiel sauvegarde', 'succes'); afficherStatut('Materiel sauvegarde', 'succes');
@@ -208,7 +228,108 @@ async function sauvegarderDestinations() {
afficherStatut('Destinations sauvegardees', 'succes'); afficherStatut('Destinations sauvegardees', 'succes');
} }
// === CADRES === // === CADRES IMPRESSION (par format) ===
const FORMATS_IMPRESSION = ['strip', '10x15', '15x20'];
async function chargerCadresImpression() {
for (const fmt of FORMATS_IMPRESSION) {
await chargerCadresFormat(fmt);
}
}
async function chargerCadresFormat(fmt) {
const data = await apiGet(`/api/cadres-impression/${fmt}`);
const liste = document.getElementById(`cadres-liste-${fmt}`);
liste.innerHTML = '';
// Option "Aucun cadre"
const divAucun = _creerItemCadreImpression(fmt, null, data.actif);
liste.appendChild(divAucun);
if (data.disponibles.length === 0) {
const p = document.createElement('p');
p.className = 'texte-secondaire';
p.textContent = 'Aucun cadre importe';
liste.appendChild(p);
return;
}
for (const nom of data.disponibles) {
const div = _creerItemCadreImpression(fmt, nom, data.actif);
liste.appendChild(div);
}
}
function _creerItemCadreImpression(fmt, nom, actif) {
const div = document.createElement('div');
div.className = 'cadre-imp-item';
const rb = document.createElement('input');
rb.type = 'radio';
rb.name = `cadre-imp-${fmt}`;
rb.value = nom || '';
rb.checked = (actif === nom);
rb.onchange = () => activerCadreFormat(fmt, nom);
if (nom) {
const img = document.createElement('img');
img.src = `/assets/cadres/${fmt}/${nom}`;
img.alt = nom;
img.className = 'cadre-preview';
const btnDel = document.createElement('button');
btnDel.className = 'btn-danger btn-icone';
btnDel.title = 'Supprimer';
btnDel.textContent = '✕';
btnDel.onclick = () => supprimerCadreFormat(fmt, nom);
const span = document.createElement('span');
span.textContent = nom.replace('.png', '');
div.appendChild(rb);
div.appendChild(img);
div.appendChild(span);
div.appendChild(btnDel);
} else {
const span = document.createElement('span');
span.textContent = 'Aucun cadre';
div.appendChild(rb);
div.appendChild(span);
}
return div;
}
async function activerCadreFormat(fmt, nom) {
await apiPost(`/api/cadres-impression/${fmt}/actif`, { nom: nom || null });
afficherStatut(`Cadre ${fmt} mis a jour`, 'succes');
}
async function supprimerCadreFormat(fmt, nom) {
await fetch(`/api/cadres-impression/${fmt}/${encodeURIComponent(nom)}`, { method: 'DELETE' });
await chargerCadresFormat(fmt);
afficherStatut('Cadre supprime', 'succes');
}
async function uploaderCadreFormat(fmt) {
const input = document.getElementById(`input-cadre-${fmt}`);
if (!input.files.length) return;
const formData = new FormData();
formData.append('fichier', input.files[0]);
const r = await fetch(`/api/upload/cadre/${fmt}`, { method: 'POST', body: formData });
if (r.ok) {
input.value = '';
await chargerCadresFormat(fmt);
afficherStatut(`Cadre ${fmt} importe`, 'succes');
} else {
afficherStatut('Erreur import cadre', 'erreur');
}
}
// === CADRES OVERLAYS PHOTO ===
async function chargerCadres() { async function chargerCadres() {
const data = await apiGet('/api/cadres'); const data = await apiGet('/api/cadres');

View File

@@ -186,15 +186,49 @@ document.addEventListener('keydown', (e) => {
// --- Modes --- // --- Modes ---
let cadreChoisi = null; // cadre sélectionné par l'utilisateur avant capture
function setupModes() { function setupModes() {
document.querySelectorAll('#ecran-mode .btn-mode').forEach(btn => { document.querySelectorAll('#ecran-mode .btn-mode').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', async () => {
modeActuel = btn.dataset.mode; modeActuel = btn.dataset.mode;
allerA('capture'); cadreChoisi = null;
const hasCadres = await chargerCadresChoix();
allerA(hasCadres ? 'cadre-choix' : 'capture');
}); });
}); });
} }
async function chargerCadresChoix() {
const cfg = await apiGet('/api/config');
const fmt = (cfg.impression?.format || '15x20').replace('-2up', '');
const data = await apiGet(`/api/cadres-impression/${fmt}`).catch(() => null);
if (!data || data.disponibles.length === 0) return false;
const grille = document.getElementById('grille-cadres-choix');
// Conserver "Sans cadre" en premier, supprimer les anciens cadres
const aucun = grille.querySelector('.cadre-choix-aucun');
grille.innerHTML = '';
if (aucun) grille.appendChild(aucun);
for (const nom of data.disponibles) {
const div = document.createElement('div');
div.className = 'cadre-choix-item';
div.innerHTML = `<img src="/assets/cadres/${fmt}/${nom}" alt="${nom}"><span>${nom.replace('.png','')}</span>`;
div.onclick = () => {
cadreChoisi = nom;
allerA('capture');
};
grille.appendChild(div);
}
return true;
}
function lancerSansCadre() {
cadreChoisi = null;
allerA('capture');
}
// --- Onglets admin --- // --- Onglets admin ---
function setupOnglets() { function setupOnglets() {
@@ -269,9 +303,10 @@ wsOnMessage('config_maj', (msg) => {
appliquerConfig(); appliquerConfig();
}); });
// Erreur camera : afficher overlay + rechargement auto apres 10s // Erreur camera : afficher overlay + rechargement auto apres 10s (kiosk seulement)
let _erreurCameraTimer = null; let _erreurCameraTimer = null;
wsOnMessage('camera_erreur', (msg) => { wsOnMessage('camera_erreur', (msg) => {
if (window.location.pathname === '/admin') return;
document.getElementById('camera-erreur').classList.remove('cache'); document.getElementById('camera-erreur').classList.remove('cache');
if (_erreurCameraTimer) clearTimeout(_erreurCameraTimer); if (_erreurCameraTimer) clearTimeout(_erreurCameraTimer);
_erreurCameraTimer = setTimeout(() => { location.reload(); }, 10000); _erreurCameraTimer = setTimeout(() => { location.reload(); }, 10000);
@@ -398,4 +433,10 @@ async function wizardTerminer() {
} }
// Demarrage // Demarrage
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', () => {
init().then(() => {
if (window.location.pathname === '/admin') {
allerA('admin-choix');
}
});
});

View File

@@ -298,8 +298,9 @@ async function traiterCapture() {
function afficherPreviewPhoto(chemin) { function afficherPreviewPhoto(chemin) {
document.getElementById('photo-resultat').src = chemin; document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin; document.getElementById('photo-partage').src = chemin;
afficherCadreOverlay(cadreChoisi);
if (modeActuel === 'simple' && config.fonctionnalites?.filtres) { if (config.fonctionnalites?.filtres) {
chargerFiltres(); chargerFiltres();
} }
if (config.fonctionnalites?.overlays) { if (config.fonctionnalites?.overlays) {

View File

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

View File

@@ -25,7 +25,11 @@ async function lancerImpression() {
// Pour les strips, imprimer la version 2 bandes sur 10x15 // Pour les strips, imprimer la version 2 bandes sur 10x15
const fichierImpression = photoImpression || photoFinale; const fichierImpression = photoImpression || photoFinale;
afficherStatut(`Impression de ${nbExemplaires} exemplaire(s)...`, 'succes'); afficherStatut(`Impression de ${nbExemplaires} exemplaire(s)...`, 'succes');
const resultat = await apiPost('/api/imprimer', { photo: fichierImpression, copies: nbExemplaires }); const resultat = await apiPost('/api/imprimer', {
photo: fichierImpression,
copies: nbExemplaires,
cadre: cadreChoisi || undefined,
});
if (resultat.succes) { if (resultat.succes) {
afficherStatut(`${nbExemplaires} exemplaire(s) envoye(s) a l'imprimante !`, 'succes'); afficherStatut(`${nbExemplaires} exemplaire(s) envoye(s) a l'imprimante !`, 'succes');
} else { } else {

View File

@@ -29,6 +29,9 @@ https://git.copydev.fr/jules/photobooth
Phase 1-3 terminees (backend complet + frontend complet). Phase 1-3 terminees (backend complet + frontend complet).
Phase 4 : scripts production (install.sh, start.sh, systemd). Phase 4 : scripts production (install.sh, start.sh, systemd).
## Matériel reçu
- Imprimante sublimation Mitsubishi (reçue le 2026-05-28)
## Notes ## Notes
- Projet cree le 2026-03-21 - Projet cree le 2026-03-21
- Mode simulation camera si gphoto2 non installe (dev sans DSLR) - Mode simulation camera si gphoto2 non installe (dev sans DSLR)

View File

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