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>
@@ -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."""
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
104
backend/main.py
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
if len(parties) < 2:
|
||||||
|
continue
|
||||||
|
nom = parties[1]
|
||||||
|
arretee = "disabled" in ligne.lower() or "stopped" in ligne.lower()
|
||||||
|
imprimantes.append({
|
||||||
"nom": nom,
|
"nom": nom,
|
||||||
"statut": "prete" if info.get("printer-state") == 3 else "occupee",
|
"statut": "arretee" if arretee else "prete",
|
||||||
"info": info.get("printer-info", ""),
|
})
|
||||||
}
|
return imprimantes or [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}]
|
||||||
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:
|
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)
|
||||||
|
|
||||||
job_id = conn.printFile(imprimante, str(chemin_photo), "Photobooth", options)
|
return {
|
||||||
log.info(f"Impression lancee : job #{job_id} sur {imprimante}")
|
"succes": False,
|
||||||
return True
|
"erreur": ERREUR_IMPRESSION,
|
||||||
|
"message": "Impression échouée après 3 tentatives",
|
||||||
except cups.IPPError as e:
|
}
|
||||||
log.error(f"Erreur impression : {e}")
|
finally:
|
||||||
return False
|
if tmp_cree:
|
||||||
|
chemin_print.unlink(missing_ok=True)
|
||||||
|
|||||||
BIN
frontend/assets/cadres/10x15/classique_blanc.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/assets/cadres/10x15/elegant_noir.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/assets/cadres/10x15/festif_dore.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/assets/cadres/10x15/rose_gold.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/assets/cadres/15x20/classique_blanc.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/assets/cadres/15x20/elegant_noir.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/assets/cadres/15x20/festif_dore.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/assets/cadres/15x20/rose_gold.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/assets/cadres/strip/classique_blanc.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
frontend/assets/cadres/strip/elegant_noir.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
frontend/assets/cadres/strip/festif_dore.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
frontend/assets/cadres/strip/pellicule_bleu.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/assets/cadres/strip/pellicule_noir.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/assets/cadres/strip/pellicule_rouge.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/assets/cadres/strip/pellicule_vert.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/assets/cadres/strip/pellicule_vintage.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/assets/cadres/strip/rose_gold.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
@@ -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;
|
||||||
|
|||||||
@@ -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×20 cm</option>
|
||||||
|
<option value="10x15">10×15 cm</option>
|
||||||
|
<option value="10x15-2up">10×15 cm — 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()">⚠ É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×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×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×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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -186,13 +186,47 @@ 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;
|
||||||
|
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');
|
allerA('capture');
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Onglets admin ---
|
// --- Onglets admin ---
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
48
frontend/js/effects.js
vendored
@@ -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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
178
scripts/generer_cadres_demo.py
Normal 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é.")
|
||||||