From e3d1d9cc078c3f48646ea72e3cc3b9fe1b02dd27 Mon Sep 17 00:00:00 2001 From: Jules Date: Wed, 3 Jun 2026 21:38:31 +0200 Subject: [PATCH] Impression Mitsubishi K60 + cadres + interface admin distante MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/camera.py | 2 + backend/collage.py | 12 +- backend/config.py | 4 + backend/effects.py | 36 ++- backend/main.py | 104 ++++++- backend/printer.py | 288 ++++++++++++++---- .../assets/cadres/10x15/classique_blanc.png | Bin 0 -> 12838 bytes frontend/assets/cadres/10x15/elegant_noir.png | Bin 0 -> 12844 bytes frontend/assets/cadres/10x15/festif_dore.png | Bin 0 -> 12855 bytes frontend/assets/cadres/10x15/rose_gold.png | Bin 0 -> 12873 bytes .../assets/cadres/15x20/classique_blanc.png | Bin 0 -> 22308 bytes frontend/assets/cadres/15x20/elegant_noir.png | Bin 0 -> 22304 bytes frontend/assets/cadres/15x20/festif_dore.png | Bin 0 -> 22322 bytes frontend/assets/cadres/15x20/rose_gold.png | Bin 0 -> 22342 bytes .../assets/cadres/strip/classique_blanc.png | Bin 0 -> 8302 bytes frontend/assets/cadres/strip/elegant_noir.png | Bin 0 -> 8306 bytes frontend/assets/cadres/strip/festif_dore.png | Bin 0 -> 8308 bytes .../assets/cadres/strip/pellicule_bleu.png | Bin 0 -> 11013 bytes .../assets/cadres/strip/pellicule_noir.png | Bin 0 -> 10687 bytes .../assets/cadres/strip/pellicule_rouge.png | Bin 0 -> 11037 bytes .../assets/cadres/strip/pellicule_vert.png | Bin 0 -> 11007 bytes .../assets/cadres/strip/pellicule_vintage.png | Bin 0 -> 11123 bytes frontend/assets/cadres/strip/rose_gold.png | Bin 0 -> 8325 bytes frontend/css/style.css | 171 +++++++++++ frontend/index.html | 66 +++- frontend/js/admin.js | 123 +++++++- frontend/js/app.js | 49 ++- frontend/js/camera.js | 3 +- frontend/js/effects.js | 48 +++ frontend/js/share.js | 6 +- memoire.md | 3 + scripts/generer_cadres_demo.py | 178 +++++++++++ 32 files changed, 1009 insertions(+), 84 deletions(-) create mode 100644 frontend/assets/cadres/10x15/classique_blanc.png create mode 100644 frontend/assets/cadres/10x15/elegant_noir.png create mode 100644 frontend/assets/cadres/10x15/festif_dore.png create mode 100644 frontend/assets/cadres/10x15/rose_gold.png create mode 100644 frontend/assets/cadres/15x20/classique_blanc.png create mode 100644 frontend/assets/cadres/15x20/elegant_noir.png create mode 100644 frontend/assets/cadres/15x20/festif_dore.png create mode 100644 frontend/assets/cadres/15x20/rose_gold.png create mode 100644 frontend/assets/cadres/strip/classique_blanc.png create mode 100644 frontend/assets/cadres/strip/elegant_noir.png create mode 100644 frontend/assets/cadres/strip/festif_dore.png create mode 100644 frontend/assets/cadres/strip/pellicule_bleu.png create mode 100644 frontend/assets/cadres/strip/pellicule_noir.png create mode 100644 frontend/assets/cadres/strip/pellicule_rouge.png create mode 100644 frontend/assets/cadres/strip/pellicule_vert.png create mode 100644 frontend/assets/cadres/strip/pellicule_vintage.png create mode 100644 frontend/assets/cadres/strip/rose_gold.png create mode 100644 scripts/generer_cadres_demo.py diff --git a/backend/camera.py b/backend/camera.py index e23c65e..c92a335 100644 --- a/backend/camera.py +++ b/backend/camera.py @@ -1,6 +1,7 @@ import io import logging import subprocess +import threading import time from datetime import datetime @@ -66,6 +67,7 @@ class Camera: self.webcam = None self.webcam_index = -1 self.preview_dslr_ok = True # True si le dernier preview DSLR a reussi + self._gp_lock = threading.Lock() def connecter(self, source=None) -> bool: """Connecte la camera. source: 'gphoto2', 'webcam:0', 'webcam:2', etc.""" diff --git a/backend/collage.py b/backend/collage.py index 2ef70f4..e656893 100644 --- a/backend/collage.py +++ b/backend/collage.py @@ -4,6 +4,7 @@ from pathlib import Path from PIL import Image, ImageDraw from backend.config import DOSSIER_EXPORTS, charger_config +from backend.effects import appliquer_cadre_impression 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)) -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). 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)) x += largeur_cellule + sep + if cadre: + strip = appliquer_cadre_impression(strip, "strip", cadre) + nom = f"strip_{photos[0].stem}.jpg" chemin = DOSSIER_EXPORTS / nom 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, - couleur_fond: str = "#ffffff") -> Path: + couleur_fond: str = "#ffffff", cadre: str | None = None) -> Path: """Cree un collage en grille au format 10x15cm.""" nb = len(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) collage.paste(img, (x, y)) + if cadre: + collage = appliquer_cadre_impression(collage, "10x15", cadre) + nom = f"collage_{photos[0].stem}.jpg" chemin = DOSSIER_EXPORTS / nom collage.save(chemin, "JPEG", quality=95, dpi=(300, 300)) diff --git a/backend/config.py b/backend/config.py index 0da2a89..d5a0913 100644 --- a/backend/config.py +++ b/backend/config.py @@ -11,6 +11,8 @@ DOSSIER_EXPORTS = RACINE / "data" / "exports" DOSSIER_OVERLAYS = RACINE / "frontend" / "assets" / "overlays" DOSSIER_FONDS = RACINE / "frontend" / "assets" / "backgrounds" DOSSIER_ANIMATIONS = RACINE / "frontend" / "assets" / "animations" +DOSSIER_CADRES = RACINE / "frontend" / "assets" / "cadres" +FORMATS_CADRES = ["strip", "10x15", "15x20"] def charger_config() -> dict: @@ -51,3 +53,5 @@ def _merge_profond(base: dict, modifications: dict): # Initialisation des dossiers au chargement du module for dossier in [DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS, DOSSIER_ANIMATIONS]: dossier.mkdir(parents=True, exist_ok=True) +for fmt in FORMATS_CADRES: + (DOSSIER_CADRES / fmt).mkdir(parents=True, exist_ok=True) diff --git a/backend/effects.py b/backend/effects.py index 6d95ba9..9a14d04 100644 --- a/backend/effects.py +++ b/backend/effects.py @@ -4,21 +4,15 @@ from pathlib import Path 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") # Filtres disponibles FILTRES = { - "original": "Original", - "nb": "Noir & Blanc", - "sepia": "Sepia", - "vintage": "Vintage", - "contraste": "Contraste fort", - "lumineux": "Lumineux", - "chaud": "Tons chauds", - "froid": "Tons froids", - "flou_artistique": "Flou artistique", + "original": "Couleur", + "nb": "Noir & Blanc", + "sepia": "Sepia", } @@ -127,6 +121,28 @@ def chroma_key(chemin_photo: Path, nom_fond: str | None = None, 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]: """Liste les overlays disponibles.""" if not DOSSIER_OVERLAYS.exists(): diff --git a/backend/main.py b/backend/main.py index fafba97..b4ce74f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,12 +9,13 @@ from contextlib import asynccontextmanager 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.staticfiles import StaticFiles from backend.config import ( RACINE, DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS, DOSSIER_ANIMATIONS, + DOSSIER_CADRES, FORMATS_CADRES, charger_config, sauvegarder_config, mettre_a_jour_config, ) from backend.camera import camera, GPHOTO2_DISPONIBLE @@ -23,7 +24,7 @@ try: except ImportError: gp = None 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.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur, recuperer_booth_password from backend.printer import lister_imprimantes, imprimer @@ -150,8 +151,17 @@ app.mount("/data", StaticFiles(directory=str(RACINE / "data")), name="data") # --- Pages --- +@app.get("/admin", include_in_schema=False) +async def page_admin(): + return FileResponse(str(RACINE / "frontend" / "index.html")) + + @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")) @@ -454,10 +464,16 @@ async def api_supprimer_fond(nom: str): @app.post("/api/strip") async def api_strip(donnees: dict): noms = donnees.get("photos", []) - chemins = [DOSSIER_PHOTOS / n for n in noms] - for c in chemins: + source = donnees.get("source", "photos") + dossier_src = DOSSIER_EXPORTS if source == "exports" else DOSSIER_PHOTOS + chemins = [] + for n in noms: + c = dossier_src / n 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) # Creer aussi la page d'impression (2 bandes sur 10x15 paysage) chemin_print = creer_impression_strip(chemin_strip) @@ -489,6 +505,17 @@ async def api_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") async def api_imprimer(donnees: dict): nom = donnees.get("photo", "") @@ -503,10 +530,11 @@ async def api_imprimer(donnees: dict): chemin = DOSSIER_PHOTOS / nom if not chemin.exists(): return JSONResponse({"erreur": "Photo introuvable"}, status_code=404) - ok = imprimer(chemin, copies=copies) - if ok: + cadre_override = donnees.get("cadre") # cadre choisi en session, prioritaire sur le config + resultat = imprimer(chemin, copies=copies, cadre_override=cadre_override) + if resultat.get("succes"): distribuer_photo(chemin, imprimee=True) - return {"succes": ok} + return resultat # --- API Booth (galerie live) --- @@ -620,7 +648,7 @@ async def api_detecter_usb(): return detecter_usb() -# --- API Cadres actifs --- +# --- API Cadres actifs (overlays photo) --- @app.get("/api/cadres") async def api_cadres(): @@ -637,6 +665,60 @@ async def api_cadres_update(donnees: dict): 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 --- ANIMATIONS_CAR = { @@ -769,7 +851,7 @@ if __name__ == "__main__": uvicorn.run( "backend.main:app", host=conf_srv.get("host", "0.0.0.0"), - port=conf_srv.get("port", 8080), + port=conf_srv.get("port", 80), reload=False, log_level="info", ) diff --git a/backend/printer.py b/backend/printer.py index e4505aa..e30bb03 100644 --- a/backend/printer.py +++ b/backend/printer.py @@ -1,81 +1,265 @@ import logging +import subprocess +import tempfile +import time from pathlib import Path from backend.config import charger_config +from backend.effects import appliquer_cadre_impression log = logging.getLogger("photobooth.printer") -# Essayer d'importer cups -try: - import cups - CUPS_DISPONIBLE = True -except ImportError: - CUPS_DISPONIBLE = False - log.warning("pycups non installe, impression indisponible") +# PageSize CUPS + dimensions exactes à 300 DPI pour la Mitsubishi CP-K60DW-S +# Le resize vers ces dimensions exactes évite toute bordure blanche +FORMATS = { + "10x15": {"cups": "w288h432", "px": (1200, 1800)}, + "15x15": {"cups": "w432h432", "px": (1800, 1800)}, + "15x20": {"cups": "w432h576", "px": (1800, 2400)}, + "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]: - """Liste les imprimantes disponibles via CUPS.""" - if not CUPS_DISPONIBLE: + """Liste les imprimantes CUPS disponibles.""" + code, out, _ = _run(["lpstat", "-p"]) + if code != 0 or not out.strip(): return [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}] - try: - conn = cups.Connection() - imprimantes = conn.getPrinters() - return [ - { - "nom": nom, - "statut": "prete" if info.get("printer-state") == 3 else "occupee", - "info": info.get("printer-info", ""), - } - for nom, info in imprimantes.items() - ] - except cups.IPPError as e: - log.error(f"Erreur CUPS : {e}") - return [] + imprimantes = [] + for ligne in out.splitlines(): + if not ligne.startswith("printer "): + 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, + "statut": "arretee" if arretee else "prete", + }) + return imprimantes or [{"nom": "[Simulation] Imprimante virtuelle", "statut": "prete"}] -def imprimer(chemin_photo: Path, imprimante: str | None = None, copies: int | None = None) -> bool: - """Imprime une photo sur l'imprimante configuree.""" +def imprimer( + 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() conf_imp = config.get("impression", {}) if imprimante is None: - imprimante = conf_imp.get("imprimante") + imprimante = conf_imp.get("imprimante", "Mitsubishi") if copies is None: 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(): log.error(f"Fichier introuvable : {chemin_photo}") - return False + return {"succes": False, "erreur": ERREUR_FICHIER, "message": "Fichier photo introuvable"} - if not CUPS_DISPONIBLE: - log.info(f"[Simulation] Impression de {chemin_photo} x{copies}") - return True + chemin_print = _preparer_image(chemin_photo, largeur, hauteur, fmt_base, cadre, orientation) + tmp_cree = chemin_print != chemin_photo + + 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: - 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: - 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 + code, out, err = _run(cmd, timeout=30) - options = { - "copies": str(copies), - "media": conf_imp.get("format", "10x15"), - "fit-to-page": "true", + if code == 0: + job = out.strip() + log.info(f"Impression lancée : {job} ({format_papier} {largeur}x{hauteur}px, x{copies})") + 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", } - - job_id = conn.printFile(imprimante, str(chemin_photo), "Photobooth", options) - log.info(f"Impression lancee : job #{job_id} sur {imprimante}") - return True - - except cups.IPPError as e: - log.error(f"Erreur impression : {e}") - return False + finally: + if tmp_cree: + chemin_print.unlink(missing_ok=True) diff --git a/frontend/assets/cadres/10x15/classique_blanc.png b/frontend/assets/cadres/10x15/classique_blanc.png new file mode 100644 index 0000000000000000000000000000000000000000..d4cf13b80decdf4bf70138527fdb6878eaadc0c8 GIT binary patch literal 12838 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz|O(J1{Bc=u<&AFFnsIj;uumf=gr-Noh=R` zu7TMvN=*J+oNCBYdXXsR6#HaOF8AMS)Ajo-pIgN}vN?ZO7^s!O=-uyowhsmEce8_d z2jW?DK=h^IK-ukPx5?}=EXxP^XvK`;b$6xbJz(MnavDq+2~t}=PCKv31XTRNPY)Q> z3=AH>-^hJf@_N^?cf263Xny7AbF5+@W+oG5RPx%b+y4HPczip*_*|*X@xs{a*V94f zRvfF3I85sP#O&-qDqO;gff~?MiUBgHR@{{p=FUmhr!1g^)h}Ac`Vh!Ic=*r?(O`kMENRl3KyAEM>}w-dVaq}>N(3i$g27u zUf&BFS%Gz`!>k61s9O=)Z*MCD4Ptm8FCGD+m)LG+pJVb|X1h6v7gF=|>0EB2Hz&-y z&Fpupy8cnN2T*T=@V)PQZ69(#&EffN=xpmV)cbnd6|7ZU zJOV^7xtqp6Bk}gO(r=&$Y$%U?+ieAv;e;q4ms6yOz6y{C2=x58_AbuLjzW@NXu!4#?-PWv literal 0 HcmV?d00001 diff --git a/frontend/assets/cadres/10x15/festif_dore.png b/frontend/assets/cadres/10x15/festif_dore.png new file mode 100644 index 0000000000000000000000000000000000000000..1b2305a712755fe511529f6043714d0d8efecc46 GIT binary patch literal 12855 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz|O(J1{Bc=u<&AFF#PA~;uumf=gr-XzRZpy zt`{X;3?nA&=F?ir_grE3?1fCqEj`b&O8s_8@A>g+ec8I#5swr0brs=uKfUcr|z_14Vx z0nhvAkAvb}L0bMiH?@5b09~{%3|YHBjHusCs-@PYJt0lUfPQ80boFyt=akR{00pbA AQUCw| literal 0 HcmV?d00001 diff --git a/frontend/assets/cadres/10x15/rose_gold.png b/frontend/assets/cadres/10x15/rose_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..c432c71f70f8efbe04341e405f1f0f663a31279f GIT binary patch literal 12873 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz|O(J1{Bc=u<&AFFcR=|aSW-L^XBeG-@^$K zu7T32T_GMFsz%Ogp5lh?F4H}Yn7tKLR;@{ln&o10t-kYI=G#YC!{ zKT@`gK+_UR&#%{CtiI-1<`*E3fnkUH0VW`&;5#y?T;r+(x5|!Rwy9zU`uf1Fb<;l^ zRUM9sNdSe-1U15xnf)hyF_7XP|Dl?;N!ljl-u{~?w-+3~x&42p&gIqts$jUpLK*d} z|NHjTFCF)R?yY%!^J`JuvdmfAejh$t4l;bld;Q*}S9*wdIT4;4RYFV>7!9S-P$H_V z7)>an31u{)5Ld5`=9SUBGMZOL^9rzKJDL$jgJ3ijjAjI4yX2!yhtU#qw8R`OF-J?x z(GrvJ0PbjQFQaZZNRw z%)ZC-T%+Rk?E4}N3?9P3RsDmSl79aB#~L}q%eA+Uzv2bCb#v^_k2|QcH6VA+`p28A zSU`sUdBnsGq#Cx|e#N-??)KU>_ducsx2MM6+)1=uZ?{z)$a(+#@uqkW&<2J-%;FIs fQgTe~DWM4f=1VYG literal 0 HcmV?d00001 diff --git a/frontend/assets/cadres/15x20/classique_blanc.png b/frontend/assets/cadres/15x20/classique_blanc.png new file mode 100644 index 0000000000000000000000000000000000000000..af7213812d16f4bdb50e4be449dbc5623643d2f4 GIT binary patch literal 22308 zcmeHPO-vI(7#*PKLM60DBPxXyciNp(6^`UbUM5H`o8zg zy#02s?f35~$X}Ds7%TAYRSz(>RAMYIAT7~b+O0=Zj2-&mQ{4wIwx>o1Zd_GX_NI-P@W`ZyBd`rAQmY;AfXIzJp%2Z!S!jwPF3 zx5>o!T|AVC4bK+$Zsq7sSMK||rPI2A|HHBF_^U@PEzTXTJ31k@HP2S;?9br`|M8-> z>b2%i&hrvM8pI6)1b~160zjar6leh;01FTZG`oZr00OW8fdDNa1fT_g04zWt5CYHw zKmZmX5C{Qi0U!Vi5D0_-v;Yu*1qcK}09pVDzybsUApk7^1YiLIfe=_MT2T8rrO@dr zH=llzTfOVe3PZ2OLCuU^mqf{9k-d?(Y%Ucwk^$A0Z8RmC%wkl#TQ7p84FmD&RA2-i zQ9K|-AS1+sFcgwtU<_GAJg$IDi%A3p1SVSIfhH&<(E$pHB?Oib6bDFBuFGk z;sJ~(cx=Q2tRS(1Bp$$Gg~dudfW-=nm3RP)6*uaL2e=1``?LlRU?9LiAR@p(fPp|f zfPnx5fp`D|0R{r`00sgK1meLWHV{rEoBgsB*(g2enQ6OHd`hWVelA+Qa^TzPI=ZsI zRgKzFmfY?-(&`)xUKVezyzt~+*JHCfbRk|2}FMa#^kr{ literal 0 HcmV?d00001 diff --git a/frontend/assets/cadres/15x20/elegant_noir.png b/frontend/assets/cadres/15x20/elegant_noir.png new file mode 100644 index 0000000000000000000000000000000000000000..d2c472aed5cdbe537185f2ba493551a1528270a4 GIT binary patch literal 22304 zcmeI4T}V@57{_1NG0&92IWsY+gcc1J42&od!w;&p1}!S` zGHuzdbRkjL2hq}m2-Ls|yGW!c5Z#6);VzcYdERrhz?%ayqWGVycklVy`#-as`N`wt+p$Ba;0SHit&>q=YF4LZ|3Ih?RmV6U9BGtNQYf7V+<-d5TU}LkzTqCjyMktA%ZkQBp^e=MTi49Ep8%+g9r?gWMD8z+-GwR zV306K0s#gIgCq`Mka)>L95B3yD&RW6AYqUM0t^xcNgTi+VUWZD3=$uqI0yJpXW}}* zAYqUM0t^xc$rA#eAn^oAAiyADki-FsbWo&29H5R3wSL6GzgMF*(Bj*|*6gqf&c0aF zuJLex>$vq?-B9ktIvUR(^M+1L<+^R(2U_0rOwk(k?V;;^NB8pFlzEs<^knAw-5FUw zS= z%{|rD(xfk6SKJDVN57%LSRD{~7a(wY8co-AW~^G=6tcV~Zg^8QY(&D{8Qg~Q^C z#trLl7}DbSXj@mU=8G*b<~(%b!hv6xGn*zh_TKw8lPi-ZkdCTnnO6tr!*T!nt^5S* ztm-oIpCK)X9U8@xQ&OeIVnCH%)%na(XDB<@k&oesCq#y*SkRq+cXw z;`1atk5zo5=_&2+;eYS3?Z^}L>q5YvN{<}-y5`4Ru!G zfB-B&AV3QU0cZgr01FTZgaEVv5P$^;1VR8>00_VW1Og!dEdT^y0Rn*#fEEA(umFKT z2tW$}0a#Exfsm*O_(XbK@kP=f-EqxiQ;G-Is@0R*bxIXsL{N%PD@{r{;f5Ne3f1jU ziVqV#YCR+-B2*azMoJYfc*N^-5h9Qg5{3{Il3;*BBGdA}vS1N`g%;@n5+o8N@c;=D zuUW(ch6ITONj$(CB;Fv22QZ>^1OpNzd`OuGNRY6hWg;L!B0&-nkRXvDi3ixI!)7M& z0DF+wgCrinVTZ#`Jb=Rvhn;u;haC<(@c;$_3K!|xu>x8FP^HR^c(d(L* zHx0SwbDoQ$>*uB0pORO7@&-~NnufN&9<%H_vk>c8?U|(xsM0YvxWCn_mXQ{s2W2E* zpPE)1bRPfPpwl}W+8oJL-l^lyF?e#SVbT3L-@Mc2z6Tpp(|SHx_H)diXdMz2W1$e; cK8wG%YhH7HkF_>=w!}=JIU8FX19o@!AJKOl+5i9m literal 0 HcmV?d00001 diff --git a/frontend/assets/cadres/15x20/rose_gold.png b/frontend/assets/cadres/15x20/rose_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..7646b298e22abcd20e59c5fc8a315dba1a315ba2 GIT binary patch literal 22342 zcmeHPZ%7ky82&joy*Y+ANDM2PmO@48hgy_1x3#i?EeS~i3#C6Kq8O71ky0D9$g*!) zSVBUdLMUvLXlV@6(1HbFn3+M!M4Q^gJ~*ts_q+AQ5mfl0c<$4WyW8*Xo_n76xqI*3 zd!@);piN3kVvK2R2dsw~OB5JWmk9}xHEo&`(~Om9ZB}#f`L^kihX)nqYcGGw^Q|h* zev#GMXbZR;VqSK6!p+?5BM;8H4I2!z>X}g2$CRF>SKlW>lih{M(~F_Xd1Yj>$Pye3 zO$`LpC82F~doC+-&*eM1CkJ9#x4I37HyX)dPOy9=q zcWv$1QF>Kl;VT545GRfFRz1H34Pv9nw^@|Sb~8O?wpozsy%fDZ&-hLZpq!8KKK6g#nv3}okM-6Zr`u)emE8;T1J-Y$sFxe{`Q?K zc@Cf-6vzMpAYgz15JXG|u>cT&1rP!~fFc$E0cT&1rP!ufLH(szyb(?5Lk&=FzdU!PO2jnzIxI> zzY+E;<%-j0L9T#Yt&=Mmb2H_7$ug^4gWBtg7D4LFaxv=qCb@zV6Y)A-U<4kKb{ilO zf`KuF5kyGh0bB%5OFV!H45-o|9^ex+K0y-? zkYPuLop^u@J2LFVgMT-HVEQs*)?F$RRnPAwrJS*-hlH2fEWfz%oqkh)@~vvsjPbFC zu8{p{BhmiarwfjG*XAu#hHInJOsv-%cIFOXN*pt6aM(lg$u^l(e|BVB|lqx{^m@}dxpk1 b-2c6k@%XUyTvu(qhi;l}pWWJNDQo%xa=9l( literal 0 HcmV?d00001 diff --git a/frontend/assets/cadres/strip/classique_blanc.png b/frontend/assets/cadres/strip/classique_blanc.png new file mode 100644 index 0000000000000000000000000000000000000000..36e09ad9483dd0985e291b63f8b2542d06ea853a GIT binary patch literal 8302 zcmeAS@N?(olHy`uVBq!ia0y~yV2WU1VCUdq1ByJju)l|aLBZ71#WAE}&fB{OeOU|z z7#w@_=HCAE_yb!?+#d(03zH6pxEh?lwx-PH*__u;1N<6l}T0XbmzqkAKK@BN|>mU^Ewu=7P~&Fq#WS zbHQjX7|jKvxnMLGjOK#TTripoMsvYvE*Q-Pqq$%-7Yw>wkaGq&xvZ#ly|nkPF$2T> za-$5xk1?=A!?0@o2Rc)I$ztaD0e0suI{t`-0Q literal 0 HcmV?d00001 diff --git a/frontend/assets/cadres/strip/elegant_noir.png b/frontend/assets/cadres/strip/elegant_noir.png new file mode 100644 index 0000000000000000000000000000000000000000..865232f0bc678586d40bec8e7641d37fa8ccbed4 GIT binary patch literal 8306 zcmeAS@N?(olHy`uVBq!ia0y~yV2WU1VCUdq1ByJju)l|aLBZ0~#WAE}&fB{Oa}OH` zFdWn<;9PaYbwh^eL$52%pHCS($YozNNY~miU(CJZ&M}Ge>yO&V_1{!xI1u#i`|me` zdS7j;85kP;l{YXjBr$O_D5wn$#`c@cXA0&$H>+l2h&WUHjUjx~K_-R{P927Z83)iA zH_VPjyxQ)`&=9^Eq)4~&;)_BVJt@`(?zLq}Y0az*0>Z#xHEblz*qD3&`}Y02{@I+j zpTF(r-m3eb%^5yO{`;I;|GjP_ZU|>mU^Ewu=7P~&Fq#WSbHQjX7|jKvxnMLGjOK#TTripoMsvYvE*Q-Pqq$&2 zG=78Tsc*IMufhWv9z>C38?h74#7s()r)H!4k@;qm3}ii0fNKdD NJYD@<);T3K0RSFOJ4*ln literal 0 HcmV?d00001 diff --git a/frontend/assets/cadres/strip/festif_dore.png b/frontend/assets/cadres/strip/festif_dore.png new file mode 100644 index 0000000000000000000000000000000000000000..d1422b9d97711faf471c3faf0159d5dff1e68a1f GIT binary patch literal 8308 zcmeAS@N?(olHy`uVBq!ia0y~yV2WU1VCUdq1ByJju)l|aLBZP7#WAE}&fB|=Ud)aH z4j1!ICW_pBE;G;M`H8sB?ULbotsM3yX7A@Nm^NQ(S(V)O!k)7=b#ue}85sT?eEqrf zE62*^SD6_Y-nb<&G8|zMV{q^pc*e6r>Eo{yvp%PuxoXV7@cs5$_nZw33`tDf3<_!( zjBQV6vN1GF1*-sh#iq%5F~f)bZ@#&nWy7i-(*&@{=f_2Hb+i7x{rl_nWi$KbpKa@z z8F~uirPsWZmeyftm~nuKp@VajF&YS?DPc4hjOK#TTripoMsvYvE*Q-Pqq$%-7mVhD z(OfW^3r2InXf7Dd1*5rOG#8BKg3(+snhQpA!DucR%>|>mU^Ewu=7K?x3(h_^>$~cF z<=N>OtJ4`6-ffTWzd_1Wch2eMMGOpKn+^geg6D?uf8da-W^4GLx?MlZ3^*xo*vQHt zAdJDd{mJL>@uz#PuAj%SVgKt_j6rzRV(O-?$i{r8=hmgpZ$8Wit|nmcboFyt=akR{ E0J;26NdN!< literal 0 HcmV?d00001 diff --git a/frontend/assets/cadres/strip/pellicule_bleu.png b/frontend/assets/cadres/strip/pellicule_bleu.png new file mode 100644 index 0000000000000000000000000000000000000000..9df0837e0ce4f13aca2b8e3d7dc312ef3d6429fd GIT binary patch literal 11013 zcmeI2Ye-XZ6u{4&&l#(c=m+yrmKvqfC@BcE%ra~E2yG_VrcM(5kde$+cBdG*>Pt_x z$HJmSOxFS_)N+L(f`~nQq2`c6&1aD|o!;BMJ_ch^yM66^JDhts|NH-+^ZTE3FGm#} z87^>ga{>TC#Nn`G0K9kr2S-PHMx*a&x&pAREh21xY|h<@0j<_wtB8+@2_m-g4iyRI z9mn0B4xH(@RUs6ryk+(`I@>>HRcX>w+DFa?Y|!1ARe1F087fJtl%AQQ`Jikg)f53h zr`fu~KCz5|=HCnBL5ns=Xd_8=leZ}W=;#LW1`PwYYha`AQ-EYTT2eek_0fjsWQk}q zEIgd=rYR}4iEeCRZ4~mNx~}=A+TQ&8hRTrn@R}4b(4D(am;2Ul5j^qpntnBVr@{^v zT134*wT=WNpO7s+T7S6!Ap3DcvuPFH>T;Zf6S9eyP4(+Q-=0-k#%}TCmn6wdO`WI4 z4)!EHJNjN%U*ZRPi8xEOJrtB>Iv$kp{W%C8x#PJ*o#~*AGHvI08UcP{#gfB#*Lbq3 zIoS(z_eKLnueo|Rdlkl4{*-3Y243tO@bP8xLMzGbi7UvcX~`fUvTXd?uBU-Rd@#5; zCk|Z_t^|kEX@z^{t8?nyK!05o7-2Kk>d>fhNdO)H`m)1Yc3skK+8zhUJXL>G%!Zzm zcQa$g*C%7O5nxiBt>QBYI3DMzh#O28`<4JoXWs@UH7@7y;QZUL(ih&g9D~9hThG-N zwcp7adoai!Fk#O7PE2K7aD~h_@@0pqg)^l`{AUU1FP~iR%q8SzL0QU})WW)-)Yo88 z?){LMTx18r{F@gOJD+#uHM)YnxRpIiZ#5{TqIb&~Zgvt&4E;r;_^cgPNva75E+1jS ztUjkRCNRw0-GRvbkyS*7%Y}x>{Nsg&RfbD(#wuf3L9EeWmElsQu`#ozv9Pqk#*9m2 zi;WrgPbT)R!^R97GyIm}#10~Z$RIL^4E`~*Y+5Zf*qCu5#bk_C2CEEK850iH2#5?K zgUDDzipd6%L1Yja6Aso0hzufw$XG*)$p(=@WDpq>4%P^W%zulFb z3w=#dv_0rU*0RHLJt04A$E3_q=v?LKATYz5DeTyrGdq+>&JUAwQVQH*qkL5F4j0~#{l@{>4Ko!u%o&gU{?Yg4#|D4KoffF?4Y1kigjc>Bi?LudgyN zTsOU~oS9(zbHaPcg{%xBelNfVMuO7IPXhu5n~9jSO_sN zGU0Df_fs2&1Ddvv*oepkD2C(6F_8&x_qroOX`_GTjo8}kwa1Ma7+Qs^fZ;kjgNb38 z@3#(7MRF2)T;-d}PhYbb8eY$OJJDpryqXi=doD16k~G*r9qu*pSsUlDGgvHjze9yw z1j>X!GxvUffe58~5af6dI$iR&{ z8|qdbEs|q+Fh#BqR)!MFDxh)}j~bwv-|j6&gwl=WHgDGL+j{M>Bsd2H3twLoMuy93 z)knGn2$%Ml34OiIH=CbTrVIy4&wc|Ip8j@Es@*aeK}i~H;5622`dcHAid#$*NEQK_ zd9S(z5lX+OwBNnD{aTd`GdKrdIy6Q#(&+FVwt>Xap3-Pf>BwlMG+HTP z>%5H)-;p}oGdg@n^cWqUEH_#yjaEvdl@b~AzoR`R&|KZ&axpr5 zH#&Sby3hf1oT{el0jz?F!v=U?8>$nfL; z<9_@9e?G@wwqNw^`~CX=hJU3Q8tU)YM2^w?1UJGDE$KE5jd|JUd9`~NXB zG@PmT`_I78uwS~7l|eulXuKhIMzzoXhD-H-zt{ga{434S@PGII)AbE`O;Al6_g@u# zxO@MZY!wM?kHmVqIC+3q7O+zbk88yFapn8;#8 zChYz8_ZpH}-!%BGSH0hvJN-Hn!y1E|Ko`g!5oY+Ht+wlks3L=dPXZ&u5f(8L7}IRN z*?i10WjLTN96O2a+Eo6EcqSa7_l;?`Xrwus>-LBqT}l2uN|x$>j6G>%*8CYK-8KzdJi&1k=7v|mGXIWXF<8SU2qXL&~FYe<@b8qGDM zxn?xikTP96I?G9VcLqLjGup2qx;-;GhDK(|3{GsL&4AHn!03t^Qdd2V=9YpN-+c{dP`ZtNcyA?ZID<=l}nc{L7qy;eQ=`{o}Xe r`~Us9EZ@M)&;l2Ow=z)~|CysFrMfD{EiVEtdu8x+^>bP0l+XkKkiJKr literal 0 HcmV?d00001 diff --git a/frontend/assets/cadres/strip/pellicule_vert.png b/frontend/assets/cadres/strip/pellicule_vert.png new file mode 100644 index 0000000000000000000000000000000000000000..eaa7643fa348eb185b8ba13a877278314e504749 GIT binary patch literal 11007 zcmeI&dq`7J7y$5Zb8b^HEzwFarBcDn6cwY)StvGInkBicxiPddiHv+^a%U8@h%7Br zDa8td%*O)BGV@m2%jltI8kuHA`JRTFGk4n~E(B9<{TbhXA7|grx#xc8ch5hca!Yua zwWXsa09Xq*hHM33ivf6iK36x=^Qz$#fX5?Y$oi=Ci$h&1m8U9I92x0<(0ktE6UPod zk@5W&@s?$mu0F?)ukrPl^hfT0ep|DvnX4|Vm+)>a(e{;4SuispfcK`ln`g;>b*E7C>5AT_eW|TqZnh>aHKC7*Agb zq(z=2m)jm1$O57%WL46E*>Q9xwFY|OsdAJ%QfvWId9p91rubFMmtbPi;|TqHh_Xj zE&j}L${5V(eX|b~5pH*uTiVsd;eS;WLvq-X%;NMKN9f5@ zdI`-2p7QQi+3U>cPB4<0OB0gOoyI?1=s(%Bj361)AFEleMLHR88N6limZ3Q?36Knu zK{8CF&^bs3$sie;1Cs#BAQ>dXL<*gQWRMJ!p*b)KkPMPRGEAhw9*Ngmmtf+wOQa4O^Qg= zA}KKy%uNj^3*Cl+q2Z;XP1Ic3MVn32w7hgqo8cg8DxI&-w}OIrRTJz z#p>%-lCw_DO`cVf)xAN7!e#qX{1yDYofA!>G9kgkCsq3OMVfA_$z$y8J!&~yZlpL0m zKVEu(iZdw}kx~)y(qqcT(>P(}(L~vHLxcu4Hg%J`qOTcs9P8DWeb|wO$K^uZI^zr6 zDfUiJkmaPl&mRF`dn7l?R9@bXpV1(v;E{King$s*E+EcRQ3|v&RfPQKi%%e)G|p<$ zW4Ts;YAn zpr3Wov|azNRFh4lX-2+|b|iN)LTE6hIHEY+N7p|KytmaJhTIQaT+A%54#;u=U1SlOtq7t+ZoWudnT$EpX!=8p6}-#`kQtg2Va6R1b-E(*)y0YojJs6VK+_ zz>r?hpL5@_+=+eZ<2Fz=26wx=$XZygRUj@B=&7#bv};V;;OQcJ9k2Yh6?F9iy-BYq zAj=ta<@t)!E5<%Ij}?K%Fl+TjvLo?1yX_Yl3ItpHOjN8?>gZ6ra2ihlO^`aBIIMwH|yj_5KS zYO9}|m)I=>zu+hMy** BYh(Zb literal 0 HcmV?d00001 diff --git a/frontend/assets/cadres/strip/rose_gold.png b/frontend/assets/cadres/strip/rose_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..bcfe867b5e06892995db48241f87930ddb330181 GIT binary patch literal 8325 zcmeAS@N?(olHy`uVBq!ia0y~yV2WU1VCUdq1ByJju)l|aLBYq<#WAE}&fB{iJy`-p z90J|f9P$vDy@5^JQ22F(*=?_FY_kOpX55ZYRu<)X_oMy2rRbgypV}(xEoJ$icX#gH zzpq!0k>SU&vv=RV5DY7}tYTnjxXGcz&@kfw6GI2*AY;V#$|dA}_urlRg@@rl`0~dO zZK~R~-bi3%IKm>v;NXM7$eH<5+>@a}FlPe;L(*k=sl9a-B^e?NW*N`cXP5zl(y)=0 zK|q)YMs(b_^Rumj>;G^0{V{X)`N_ZL?PqNG|J0m!!_s9N4-UeR8f-B)>YmX=GC0eW z(UduwGDlP9Xf7Dd1*5rOG#8BKg3(+snhQpA!DucR%>|>mU^Ewu=7P~&Fq#WSbHQjX z7|jKvxnMLGjOK#TTrh-l!Hps-xpRx#BQAeE{Me|9jp4!SnbJqtxEU1GKr_TlL@+Mf zPi1EKa2-6O9KM)6V)pTQj0v;T{&l4_Retour + +
+

Choisissez un cadre

+
+
+
+ Sans cadre +
+
+ +
+
@@ -83,6 +95,7 @@
Photo +
@@ -358,11 +371,29 @@
+
+ + +
+
+ +
+ + +
+
+
+ +
@@ -435,8 +466,39 @@
-

Cadres / Overlays

-

Cochez les cadres a proposer aux utilisateurs.

+ +

Cadres d'impression

+

Selectionnez un cadre PNG par format. Le cadre est applique sur chaque impression. Laissez vide pour imprimer sans cadre.

+ +
+
+

Strip (5×15 cm)

+
+
+ + +
+
+
+

10×15 cm

+
+
+ + +
+
+
+

15×20 cm

+
+
+ + +
+
+
+ +

Cadres / Overlays photo

+

Cochez les cadres a proposer aux utilisateurs en live.

Aucun cadre importe

diff --git a/frontend/js/admin.js b/frontend/js/admin.js index 6f1efc0..e8e61c1 100644 --- a/frontend/js/admin.js +++ b/frontend/js/admin.js @@ -19,6 +19,7 @@ async function chargerAdmin() { chargerCompteur(); chargerDestinations(); chargerCadres(); + chargerCadresImpression(); chargerAnimations(); chargerFonctionnalites(); chargerEmailAdmin(); @@ -57,6 +58,10 @@ async function chargerMateriel() { // Imprimantes await rafraichirImprimantes(); 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); } @@ -78,6 +83,19 @@ async function reconnecterCamera() { 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() { await apiPost('/api/config', { camera: { @@ -86,7 +104,9 @@ async function sauvegarderMateriel() { }, impression: { imprimante: getValue('admin-imprimante'), + format: getValue('admin-format-papier'), copies_max: parseInt(getValue('admin-copies-max')) || 5, + orientations: _getOrientations(), }, }); afficherStatut('Materiel sauvegarde', 'succes'); @@ -208,7 +228,108 @@ async function sauvegarderDestinations() { 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() { const data = await apiGet('/api/cadres'); diff --git a/frontend/js/app.js b/frontend/js/app.js index 736ed74..9d58eff 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -186,15 +186,49 @@ document.addEventListener('keydown', (e) => { // --- Modes --- +let cadreChoisi = null; // cadre sélectionné par l'utilisateur avant capture + function setupModes() { document.querySelectorAll('#ecran-mode .btn-mode').forEach(btn => { - btn.addEventListener('click', () => { + btn.addEventListener('click', async () => { 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 = `${nom}${nom.replace('.png','')}`; + div.onclick = () => { + cadreChoisi = nom; + allerA('capture'); + }; + grille.appendChild(div); + } + return true; +} + +function lancerSansCadre() { + cadreChoisi = null; + allerA('capture'); +} + // --- Onglets admin --- function setupOnglets() { @@ -269,9 +303,10 @@ wsOnMessage('config_maj', (msg) => { appliquerConfig(); }); -// Erreur camera : afficher overlay + rechargement auto apres 10s +// Erreur camera : afficher overlay + rechargement auto apres 10s (kiosk seulement) let _erreurCameraTimer = null; wsOnMessage('camera_erreur', (msg) => { + if (window.location.pathname === '/admin') return; document.getElementById('camera-erreur').classList.remove('cache'); if (_erreurCameraTimer) clearTimeout(_erreurCameraTimer); _erreurCameraTimer = setTimeout(() => { location.reload(); }, 10000); @@ -398,4 +433,10 @@ async function wizardTerminer() { } // Demarrage -document.addEventListener('DOMContentLoaded', init); +document.addEventListener('DOMContentLoaded', () => { + init().then(() => { + if (window.location.pathname === '/admin') { + allerA('admin-choix'); + } + }); +}); diff --git a/frontend/js/camera.js b/frontend/js/camera.js index 97af382..de8ce72 100644 --- a/frontend/js/camera.js +++ b/frontend/js/camera.js @@ -298,8 +298,9 @@ async function traiterCapture() { function afficherPreviewPhoto(chemin) { document.getElementById('photo-resultat').src = chemin; document.getElementById('photo-partage').src = chemin; + afficherCadreOverlay(cadreChoisi); - if (modeActuel === 'simple' && config.fonctionnalites?.filtres) { + if (config.fonctionnalites?.filtres) { chargerFiltres(); } if (config.fonctionnalites?.overlays) { diff --git a/frontend/js/effects.js b/frontend/js/effects.js index 07fbb2b..fb42c6f 100644 --- a/frontend/js/effects.js +++ b/frontend/js/effects.js @@ -23,11 +23,18 @@ async function appliquerFiltreUI(filtre, btn) { btn.classList.add('actif'); 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') { const chemin = '/data/photos/' + photosSession[0]; document.getElementById('photo-resultat').src = chemin; document.getElementById('photo-partage').src = chemin; photoFinale = photosSession[0]; + afficherCadreOverlay(cadreChoisi); return; } @@ -40,9 +47,50 @@ async function appliquerFiltreUI(filtre, btn) { photoFinale = resultat.nom; document.getElementById('photo-resultat').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() { const overlays = await apiGet('/api/overlays'); const barre = document.getElementById('barre-overlays'); diff --git a/frontend/js/share.js b/frontend/js/share.js index 887d8db..057c316 100644 --- a/frontend/js/share.js +++ b/frontend/js/share.js @@ -25,7 +25,11 @@ async function lancerImpression() { // Pour les strips, imprimer la version 2 bandes sur 10x15 const fichierImpression = photoImpression || photoFinale; 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) { afficherStatut(`${nbExemplaires} exemplaire(s) envoye(s) a l'imprimante !`, 'succes'); } else { diff --git a/memoire.md b/memoire.md index 96dfc4d..adc3543 100644 --- a/memoire.md +++ b/memoire.md @@ -29,6 +29,9 @@ https://git.copydev.fr/jules/photobooth Phase 1-3 terminees (backend complet + frontend complet). Phase 4 : scripts production (install.sh, start.sh, systemd). +## Matériel reçu +- Imprimante sublimation Mitsubishi (reçue le 2026-05-28) + ## Notes - Projet cree le 2026-03-21 - Mode simulation camera si gphoto2 non installe (dev sans DSLR) diff --git a/scripts/generer_cadres_demo.py b/scripts/generer_cadres_demo.py new file mode 100644 index 0000000..2c15325 --- /dev/null +++ b/scripts/generer_cadres_demo.py @@ -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é.")