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 0000000..d4cf13b Binary files /dev/null and b/frontend/assets/cadres/10x15/classique_blanc.png differ diff --git a/frontend/assets/cadres/10x15/elegant_noir.png b/frontend/assets/cadres/10x15/elegant_noir.png new file mode 100644 index 0000000..fd7601d Binary files /dev/null and b/frontend/assets/cadres/10x15/elegant_noir.png differ diff --git a/frontend/assets/cadres/10x15/festif_dore.png b/frontend/assets/cadres/10x15/festif_dore.png new file mode 100644 index 0000000..1b2305a Binary files /dev/null and b/frontend/assets/cadres/10x15/festif_dore.png differ diff --git a/frontend/assets/cadres/10x15/rose_gold.png b/frontend/assets/cadres/10x15/rose_gold.png new file mode 100644 index 0000000..c432c71 Binary files /dev/null and b/frontend/assets/cadres/10x15/rose_gold.png differ diff --git a/frontend/assets/cadres/15x20/classique_blanc.png b/frontend/assets/cadres/15x20/classique_blanc.png new file mode 100644 index 0000000..af72138 Binary files /dev/null and b/frontend/assets/cadres/15x20/classique_blanc.png differ diff --git a/frontend/assets/cadres/15x20/elegant_noir.png b/frontend/assets/cadres/15x20/elegant_noir.png new file mode 100644 index 0000000..d2c472a Binary files /dev/null and b/frontend/assets/cadres/15x20/elegant_noir.png differ diff --git a/frontend/assets/cadres/15x20/festif_dore.png b/frontend/assets/cadres/15x20/festif_dore.png new file mode 100644 index 0000000..80f7c2a Binary files /dev/null and b/frontend/assets/cadres/15x20/festif_dore.png differ diff --git a/frontend/assets/cadres/15x20/rose_gold.png b/frontend/assets/cadres/15x20/rose_gold.png new file mode 100644 index 0000000..7646b29 Binary files /dev/null and b/frontend/assets/cadres/15x20/rose_gold.png differ diff --git a/frontend/assets/cadres/strip/classique_blanc.png b/frontend/assets/cadres/strip/classique_blanc.png new file mode 100644 index 0000000..36e09ad Binary files /dev/null and b/frontend/assets/cadres/strip/classique_blanc.png differ diff --git a/frontend/assets/cadres/strip/elegant_noir.png b/frontend/assets/cadres/strip/elegant_noir.png new file mode 100644 index 0000000..865232f Binary files /dev/null and b/frontend/assets/cadres/strip/elegant_noir.png differ diff --git a/frontend/assets/cadres/strip/festif_dore.png b/frontend/assets/cadres/strip/festif_dore.png new file mode 100644 index 0000000..d1422b9 Binary files /dev/null and b/frontend/assets/cadres/strip/festif_dore.png differ diff --git a/frontend/assets/cadres/strip/pellicule_bleu.png b/frontend/assets/cadres/strip/pellicule_bleu.png new file mode 100644 index 0000000..9df0837 Binary files /dev/null and b/frontend/assets/cadres/strip/pellicule_bleu.png differ diff --git a/frontend/assets/cadres/strip/pellicule_noir.png b/frontend/assets/cadres/strip/pellicule_noir.png new file mode 100644 index 0000000..4e7083d Binary files /dev/null and b/frontend/assets/cadres/strip/pellicule_noir.png differ diff --git a/frontend/assets/cadres/strip/pellicule_rouge.png b/frontend/assets/cadres/strip/pellicule_rouge.png new file mode 100644 index 0000000..2cc6ba0 Binary files /dev/null and b/frontend/assets/cadres/strip/pellicule_rouge.png differ diff --git a/frontend/assets/cadres/strip/pellicule_vert.png b/frontend/assets/cadres/strip/pellicule_vert.png new file mode 100644 index 0000000..eaa7643 Binary files /dev/null and b/frontend/assets/cadres/strip/pellicule_vert.png differ diff --git a/frontend/assets/cadres/strip/pellicule_vintage.png b/frontend/assets/cadres/strip/pellicule_vintage.png new file mode 100644 index 0000000..048fff7 Binary files /dev/null and b/frontend/assets/cadres/strip/pellicule_vintage.png differ diff --git a/frontend/assets/cadres/strip/rose_gold.png b/frontend/assets/cadres/strip/rose_gold.png new file mode 100644 index 0000000..bcfe867 Binary files /dev/null and b/frontend/assets/cadres/strip/rose_gold.png differ diff --git a/frontend/css/style.css b/frontend/css/style.css index 64ce121..fe4a53e 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -398,6 +398,7 @@ html, body { justify-content: center; padding: 1rem; max-height: 65vh; + position: relative; } .preview-photo-container img { @@ -407,6 +408,17 @@ html, body { 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 { display: flex; gap: 0.8rem; @@ -1120,6 +1132,165 @@ h3 { 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 { background: var(--fond); border: 2px dashed #444; diff --git a/frontend/index.html b/frontend/index.html index 1721bc5..977515a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -60,6 +60,18 @@ + +
+

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é.")