#!/usr/bin/env python3 """ Photobooth Pygame - SikaPics / CopyDev / Base Wizard + thèmes + design qualité """ import sys, time, threading, io, math, json, os, smtplib, subprocess, traceback, faulthandler from pathlib import Path from datetime import datetime from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.base import MIMEBase from email import encoders import pygame import gphoto2 as gp from PIL import Image, ImageDraw, ImageFont, ImageOps try: import qrcode as qrlib; HAS_QR = True except: HAS_QR = False try: import cups; HAS_CUPS = True except: HAS_CUPS = False try: from flask import Flask as _Flask, request as _req, jsonify as _jsonify, redirect as _redir HAS_FLASK = True except ImportError: HAS_FLASK = False faulthandler.enable() # ── Chemins ────────────────────────────────────────────────────────────────── BASE = Path(__file__).parent CONFIG_PATH = BASE / "data/config.json" LOGOS_DIR = BASE / "frontend/assets/logos" PHOTOS_DIR = BASE / "data/photos" PHOTOS_DIR.mkdir(parents=True, exist_ok=True) W, H = 1920, 1280 # ── Constantes pellicule ────────────────────────────────────────────────────── _FILM_H = 860 # hauteur totale de la bande _SPR_H = 50 # hauteur zone perforations (haut et bas) _SPR_GAP = 6 # espace entre perfos et cadre _PELL_GAP = 6 # espace entre cadres _PELL_FH = _FILM_H - 2 * (_SPR_H + _SPR_GAP) # hauteur d'un cadre = 748 # ── Thèmes ─────────────────────────────────────────────────────────────────── THEMES = { "base": { "nom": "Sombre", "fond": (26, 26, 46), "fond2": (16, 21, 62), "carte": (22, 33, 62), "prim": (233, 30, 99), "texte": (255, 255, 255), "texte2": (160, 160, 176), "clair": False, "logo": None, }, "sikapics": { "nom": "SikaPics", "fond": (251, 231, 232), "fond2": (255, 240, 241), "carte": (255, 255, 255), "prim": (195, 31, 72), "texte": (36, 4, 57), "texte2": (120, 90, 110), "clair": True, "logo": "sikapics.png", }, "copydev": { "nom": "CopyDev", "fond": (8, 18, 40), "fond2": (12, 28, 60), "carte": (18, 42, 84), "prim": (41, 182, 246), "texte": (255, 255, 255), "texte2": (140, 185, 230), "clair": False, "logo": "copydev.png", }, "photobooth": { "nom": "Photobooth", "fond": (235, 242, 255), "fond2": (220, 232, 255), "carte": (255, 255, 255), "prim": (25, 100, 230), "texte": (15, 30, 80), "texte2": (90, 120, 180), "clair": True, "logo": None, }, } # ── Helpers image ──────────────────────────────────────────────────────────── def _pfit(surf, w, h): """Scale + crop centré d'une surface pygame pour remplir (w, h) sans déformer.""" sw, sh = surf.get_size() if sw == w and sh == h: return surf scale = max(w / sw, h / sh) nw, nh = max(w, int(sw * scale)), max(h, int(sh * scale)) scaled = pygame.transform.scale(surf, (nw, nh)) x, y = (nw - w) // 2, (nh - h) // 2 return scaled.subsurface((x, y, w, h)).copy() # ── Utilitaire USB ─────────────────────────────────────────────────────────── def _usb_reset_done(): """Reset USB des DSLR via unbind/bind sysfs pour libérer tout claim obsolète.""" import glob, subprocess as _sp for dev_path in glob.glob('/sys/bus/usb/devices/[0-9]*-[0-9.]*'): prod_file = f"{dev_path}/product" if not os.path.exists(prod_file): continue try: prod = open(prod_file).read().strip() except: continue if not any(b in prod for b in ('Canon', 'Nikon', 'Sony', 'Digital Camera')): continue try: dev_name = os.path.basename(dev_path) _sp.run(['sudo', 'sh', '-c', f'echo {dev_name} > /sys/bus/usb/drivers/usb/unbind'], capture_output=True, timeout=5) time.sleep(0.8) _sp.run(['sudo', 'sh', '-c', f'echo {dev_name} > /sys/bus/usb/drivers/usb/bind'], capture_output=True, timeout=5) print(f"USB unbind/bind: {dev_name} ({prod})") time.sleep(2.0) except Exception as e: print(f"USB reset err: {e}") # ── États ──────────────────────────────────────────────────────────────────── S_ACCUEIL = "accueil" S_MODE = "mode" S_PREV = "preview" S_COMPTE = "compte" S_CAP = "capture" S_GAL = "galerie" S_SHARE = "partage" S_EMAIL = "email" S_WIZ = "wizard" S_ADMIN = "admin" S_FORMAT = "format" S_PIN = "pin" ADMIN_TABS = [ ("fonctions", "Fonctions"), ("materiel", "Matériel"), ("compteur", "Compteur"), ("evenement", "Événement"), ("systeme", "Système"), ] # Formats multipose : (key, label, nb_photos, description) FORMATS = [ ("grille_4", "Grille 2×2", 4, "4 poses · collage carré"), ("bande3x2", "Bande × 2", 3, "3 poses · bande doublée"), ("bande4x2", "Bande × 2", 4, "4 poses · bande doublée"), ] # ── Web admin mobile (Flask sur port 5000) ──────────────────────────────────── _web_state = {"reload_flag": threading.Event(), "ip": None} _ADMIN_HTML = """ Admin — Photobooth

⚙ Administration

Chargement...
Événement en cours
...
Fonctionnalités
Photo simple
Pellicule
Impression
Email
QR Code
Compteur photos
...
Configurer un événement (Wizard)
""" _WIZARD_HTML = """ Wizard — Photobooth

Configuration événement

1 — Nom de l'événement

2 — Thème visuel

Sombre
SikaPics
CopyDev

3 — Modes photo

Photo simple (1 prise)
Pellicule (plusieurs poses)

4 — Photos par pellicule

4

5 — Options de partage

Impression
Email
QR Code galerie
Galerie

6 — Résumé

""" def _start_web_server(): if not HAS_FLASK: print("Flask absent — admin web désactivé (pip install flask)") return import logging, socket logging.getLogger('werkzeug').setLevel(logging.ERROR) fa = _Flask(__name__) @fa.route("/") def _idx(): return _redir("/admin") @fa.route("/admin") def _adm(): return _ADMIN_HTML @fa.route("/wizard") def _wiz(): return _WIZARD_HTML @fa.route("/api/config") def _gcfg(): try: return _jsonify(json.loads(CONFIG_PATH.read_text())) except: return _jsonify({}) @fa.route("/api/config", methods=["POST"]) def _scfg(): try: CONFIG_PATH.write_text(json.dumps(_req.get_json(), indent=2, ensure_ascii=False)) _web_state["reload_flag"].set() return _jsonify({"ok": True}) except Exception as e: return _jsonify({"ok": False, "err": str(e)}) @fa.route("/api/wizard", methods=["POST"]) def _awiz(): try: w = _req.get_json() c = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {} c.setdefault("evenement", {}).update({"nom": w.get("nom",""), "theme": w.get("theme","base")}) c.setdefault("multi_shot", {})["nombre_photos"] = int(w.get("nb_photos", 4)) f = c.setdefault("fonctionnalites", {}) f.update(w.get("modes", {})) f.update(w.get("share", {})) CONFIG_PATH.write_text(json.dumps(c, indent=2, ensure_ascii=False)) _web_state["reload_flag"].set() return _jsonify({"ok": True}) except Exception as e: return _jsonify({"ok": False, "err": str(e)}) @fa.route("/api/status") def _status(): try: ip = subprocess.check_output("hostname -I", shell=True).decode().split()[0] except: ip = "?" return _jsonify({"ip": ip}) try: ip = subprocess.check_output("hostname -I", shell=True).decode().split()[0].strip() except: ip = "?" for port in (80, 8000, 8001): url = f"http://{ip}" + (f":{port}" if port != 80 else "") try: _web_state["ip"] = ip if port == 80 else f"{ip}:{port}" print(f"Web admin mobile: {url}/ (wizard: {url}/wizard)") fa.run(host="0.0.0.0", port=port, threaded=True, use_reloader=False) break except OSError: print(f"Port {port} indisponible, essai suivant...") def cfg_load(): try: return json.loads(CONFIG_PATH.read_text()) except: return {} def cfg_save(c): try: CONFIG_PATH.write_text(json.dumps(c, indent=2, ensure_ascii=False)) except: pass class PB: # ── Init ───────────────────────────────────────────────────────────────── def __init__(self): pygame.init() pygame.mouse.set_visible(False) self.scr = pygame.display.set_mode((W, H), pygame.FULLSCREEN) self.clock = pygame.time.Clock() fn = "dejavusans" self.F = { "xxl": pygame.font.SysFont(fn, 300, bold=True), "xl": pygame.font.SysFont(fn, 150, bold=True), "lg": pygame.font.SysFont(fn, 96, bold=True), "md": pygame.font.SysFont(fn, 68), "sm": pygame.font.SysFont(fn, 50), "xs": pygame.font.SysFont(fn, 36), "xxs": pygame.font.SysFont(fn, 26), } self.cfg = cfg_load() self.T = THEMES.get(self.cfg.get("evenement", {}).get("theme", "base"), THEMES["base"]) self._bg_cache = {} self._logo_cache = {} # ── Caméra ── self.sim = "--sim" in sys.argv self.cam = None if not self.sim: _usb_reset_done() # libère tout claim USB obsolète avant init for i in range(3): try: self.cam = gp.Camera(); self.cam.init() print(f"Camera OK ({i+1})"); break except Exception as e: print(f" cam {i+1}/3: {e}"); self.cam = None; time.sleep(1) if not self.cam: print("Camera absente → simulation"); self.sim = True else: print("Mode simulation") # ── État ── self.etat = S_ACCUEIL self.mode = "simple" self.format = self.cfg.get("multi_shot",{}).get("format","grille_4") self.frame = None self.flock = threading.Lock() self.prev_actif = not self.sim # miroir levé dès le démarrage self.cap_demande = False self.cap_result = None self.cap_done = threading.Event() self.photos = [] self._prev_thumbs = [] # miniatures pour le strip multipose self.num_ph = 0 self.chiffre = 0 self.photo_active = None self.gal_surfs = [] self.gal_ready = False self.session_qr = None self.session_url = None self.t0 = time.time() self.admin_t = 0 self.admin_tab = "fonctions" self.pin_buf = "" self.pin_err = False self._sys_info = {} self._sys_info_t = 0 self.email_buf = "" self.share_msg = "" self.email_msg = "" self.compteur = self.cfg.get("compteur", {}).get("photos_prises", 0) self.cpt_max = self.cfg.get("compteur", {}).get("limite", 400) # ── Wizard ── self.wiz_step = 1 self.wiz_data = {} self.wiz_kbbuf = "" # clavier wizard self.wiz_code_buf = "" # code session step 2 self.wiz_code_status = None # None / "test..." / "ok" / "erreur" # ── IP locale (pour QR codes) ── try: self._local_ip = subprocess.check_output("hostname -I", shell=True).decode().split()[0].strip() except: self._local_ip = "?" # ── Surveillance config (sync FastAPI + web mobile) ── self._cfg_mtime = CONFIG_PATH.stat().st_mtime if CONFIG_PATH.exists() else 0 self._cfg_check = time.time() self._admin_qr = None # QR code URL admin mobile (généré lazy) # ── QR galerie (vers le web server comme la première version) ── self.qr_surf = self._make_qr() threading.Thread(target=self._cam_loop, daemon=True).start() threading.Thread(target=_start_web_server, daemon=True).start() # ── Thème ──────────────────────────────────────────────────────────────── def _apply_theme(self, key): self.T = THEMES.get(key, THEMES["base"]) self._bg_cache = {} def _bg(self): k = id(self.T) if k not in self._bg_cache: c1, c2 = self.T["fond"], self.T["fond2"] s = pygame.Surface((1, H)) for y in range(H): t = y/(H-1) s.set_at((0,y), tuple(int(c1[i]*(1-t)+c2[i]*t) for i in range(3))) self._bg_cache[k] = pygame.transform.scale(s, (W, H)) self.scr.blit(self._bg_cache[k], (0,0)) def _logo(self): k = self.T["logo"] if k is None: return None if k not in self._logo_cache: p = LOGOS_DIR / k if p.exists(): raw = pygame.image.load(str(p)).convert_alpha() lw = int(W * 0.38) lh = int(raw.get_height() * lw / raw.get_width()) self._logo_cache[k] = pygame.transform.smoothscale(raw, (lw, lh)) else: self._logo_cache[k] = None return self._logo_cache.get(k) # ── Helpers dessin ─────────────────────────────────────────────────────── def _card(self, rect, bord=None, fond=None, rayon=22, bord_w=2): bord = bord or self.T["prim"] fond = fond or self.T["carte"] # Ombre ss = pygame.Surface((rect.w+8, rect.h+8), pygame.SRCALPHA) pygame.draw.rect(ss, (0,0,0,60), (4,4,rect.w,rect.h), border_radius=rayon) self.scr.blit(ss, (rect.x-2, rect.y-2)) # Fond pygame.draw.rect(self.scr, fond, rect, border_radius=rayon) # Lueur bord for i in range(5,0,-1): gs = pygame.Surface((rect.w+i*2, rect.h+i*2), pygame.SRCALPHA) a = int(30*i/5) pygame.draw.rect(gs, (*bord,a), (0,0,rect.w+i*2,rect.h+i*2), border_radius=rayon+i, width=2) self.scr.blit(gs, (rect.x-i, rect.y-i)) # Bordure pygame.draw.rect(self.scr, bord, rect, bord_w, border_radius=rayon) def _btn(self, rect, couleur, texte, fkey="sm", rayon=16, tc=None): tc = tc or (self.T["texte"] if self.T["clair"] and couleur==self.T["carte"] else (255,255,255)) # Ombre pygame.draw.rect(self.scr, (0,0,0), pygame.Rect(rect.x+3,rect.y+5,rect.w,rect.h), border_radius=rayon) # Corps pygame.draw.rect(self.scr, couleur, rect, border_radius=rayon) # Reflet haut rs = pygame.Surface((rect.w, rect.h//2), pygame.SRCALPHA) rs.fill((255,255,255,18)) self.scr.blit(rs, (rect.x, rect.y)) # Texte t = self.F[fkey].render(texte, True, tc) self.scr.blit(t, (rect.centerx-t.get_width()//2, rect.centery-t.get_height()//2)) return rect def _txt(self, texte, fkey, c=None, shadow=True): c = c or self.T["texte"] f = self.F[fkey] s = f.render(texte, True, c) if not shadow: return s so = f.render(texte, True, (0,0,0) if not self.T["clair"] else (255,255,255)) so.set_alpha(60) surf = pygame.Surface((s.get_width()+2, s.get_height()+2), pygame.SRCALPHA) surf.blit(so, (2,2)); surf.blit(s, (0,0)) return surf def _c(self, surf, y): self.scr.blit(surf, (W//2-surf.get_width()//2, y)) def _overlay(self, a=130): s = pygame.Surface((W,H), pygame.SRCALPHA); s.fill((0,0,0,a)); self.scr.blit(s,(0,0)) def _lv(self): if self.sim: t = time.time() v = int(30+15*math.sin(t*2)) self.scr.fill((0,v,0)) s = self.F["xs"].render("[SIMULATION - PAS DE DSLR]", True, (255,255,255)) self.scr.blit(s,(W//2-s.get_width()//2,H//2-s.get_height()//2)) return with self.flock: if self.frame: self.scr.blit(self.frame,(0,0)) else: self.scr.fill(self.T["fond"]) def _progress_bar(self, step, total, y=40): bw = 600; bx = W//2-bw//2; bh = 8 pygame.draw.rect(self.scr, self.T["carte"], (bx,y,bw,bh), border_radius=4) fw = int(bw * step/total) if fw > 0: pygame.draw.rect(self.scr, self.T["prim"], (bx,y,fw,bh), border_radius=4) def _make_qr(self): if not HAS_QR: return None # 1. Code session booth (généré par le wizard) → priorité absolue b = self.cfg.get("booth", {}) code = b.get("session_code", "") if code: url = f"{b.get('url','https://photobooth.copydev.fr').rstrip('/')}/s/{code}" else: # 2. URL galerie configurée manuellement url = self.cfg.get("qr_code",{}).get("url_galerie","") if not url: return None try: img = qrlib.make(url, border=2).convert("RGB").resize((200,200),Image.NEAREST) return pygame.image.fromstring(img.tobytes(),img.size,"RGB") except: return None # ── Thread caméra ──────────────────────────────────────────────────────── def _cam_loop(self): errs = 0 post_capture_grace = 0 # frames de grâce après une prise (ignore erreurs transitoires) while True: # En simulation : tentative de connexion automatique toutes les 15s # Pas de USB reset ici — reset seulement en cas de reconnexion if self.sim: time.sleep(15) for _ in range(2): try: c = gp.Camera(); c.init() # Vérification fonctionnelle : un preview doit réussir _f = c.capture_preview(); _bytes = bytes(_f.get_data_and_size()) if self.cam: try: self.cam.exit() except: pass self.cam = c; self.sim = False; self.prev_actif = True surf = pygame.image.load(io.BytesIO(_bytes)) surf = pygame.transform.scale(surf, (W, H)) with self.flock: self.frame = surf print("DSLR détecté automatiquement") break except Exception as e: print(f"Auto-detect caméra: {e}") try: c.exit() except: pass time.sleep(1) continue if self.prev_actif: try: if self.cam is None: raise RuntimeError("camera None") if self.cap_demande: # Avant la prise : activer le viewfinder EOS (miroir levé, liveview ON) try: cfg_c = self.cam.get_config() vf = cfg_c.get_child_by_name('eosviewfinder') vf.set_value(1) self.cam.set_config(cfg_c) time.sleep(0.2) except: pass # Déclenchement obturateur seul via eosremoterelease triggered = False try: cfg_c = self.cam.get_config() rl = cfg_c.get_child_by_name('eosremoterelease') rl.set_value('Immediate') self.cam.set_config(cfg_c) triggered = True except Exception: pass if not triggered: self.cam.trigger_capture() ch = None deadline = time.time() + 15 while time.time() < deadline: ev_type, ev_data = self.cam.wait_for_event(500) if ev_type == gp.GP_EVENT_FILE_ADDED: cf = gp.CameraFile() self.cam.file_get(ev_data.folder, ev_data.name, gp.GP_FILE_TYPE_NORMAL, cf) nom = f"photo_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.jpg" ch = str(PHOTOS_DIR/nom); cf.save(ch); break # Réinitialise le remote release try: cfg_c = self.cam.get_config() rl = cfg_c.get_child_by_name('eosremoterelease') rl.set_value('None') self.cam.set_config(cfg_c) except: pass # Relance le viewfinder et laisse le capteur se stabiliser try: cfg_c = self.cam.get_config() vf = cfg_c.get_child_by_name('eosviewfinder') vf.set_value(1) self.cam.set_config(cfg_c) except: pass time.sleep(1.0) # stabilisation capteur Canon entre deux prises # Drainer les événements résiduels for _ in range(8): try: self.cam.wait_for_event(50) except: break self.cap_result=ch; self.cap_demande=False; self.cap_done.set() errs = 0; post_capture_grace = 10 else: f=self.cam.capture_preview(); data=bytes(f.get_data_and_size()) surf=pygame.image.load(io.BytesIO(data)) surf=pygame.transform.scale(surf,(W,H)) with self.flock: self.frame=surf errs = 0 if post_capture_grace > 0: post_capture_grace -= 1 except Exception as e: if post_capture_grace > 0: # Erreurs transitoires post-capture normales sur EOS — on ignore post_capture_grace -= 1 print(f"cam post-capture ({post_capture_grace} restants): {e}") time.sleep(0.2) else: errs += 1 print(f"cam erreur ({errs}): {e}") if self.cap_demande and errs >= 8: # abandon de la capture seulement après 8 erreurs consécutives self.cap_result=None; self.cap_demande=False; self.cap_done.set() if errs >= 10: # reconnexion automatique print("Caméra perdue — tentative de reconnexion...") self.prev_actif = False with self.flock: self.frame = None try: if self.cam: self.cam.exit() except: pass self.cam = None; errs = 0 for _nb_retry in range(10): time.sleep(3) _usb_reset_done() try: c = gp.Camera(); c.init() self.cam = c; self.prev_actif = True print("Caméra reconnectée automatiquement") break except Exception as re: print(f"Reconnexion échouée ({_nb_retry+1}): {re}") else: self.sim = True print("Caméra introuvable → mode simulation") else: time.sleep(0.2) else: time.sleep(0.05) def _capturer(self): if self.sim: time.sleep(1.5) img=Image.new("RGB",(W,H),color=(80,60,100)) nom=f"photo_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.jpg" ch=str(PHOTOS_DIR/nom); img.save(ch,"JPEG",quality=85); return ch self.cap_result=None; self.cap_done.clear(); self.cap_demande=True if not self.cap_done.wait(timeout=15): self.cap_demande=False return self.cap_result # ── Séquence photos ─────────────────────────────────────────────────────── def _sequence(self): if self.mode == "simple": nb = 1 elif self.format == "bande3x2": nb = 3 else: # grille_4 et bande4x2 nb = 4 delai = self.cfg.get("camera",{}).get("compte_a_rebours",3) self.photos=[]; self._prev_thumbs=[]; self.gal_surfs=[]; self.gal_ready=False; self.session_qr=None; self.session_url=None is_pell = self.cfg.get("fonctionnalites",{}).get("pellicule_mode",False) strip_bw = W // nb - 4 thumb_h = _PELL_FH if is_pell else 200 # haute résolution en mode pellicule for i in range(nb): self.num_ph=i; self.etat=S_PREV; time.sleep(2.5) self.etat=S_COMPTE for c in range(delai,0,-1): self.chiffre=c; time.sleep(1) self.etat=S_CAP; ch=self._capturer() if ch: self.photos.append(ch); self.compteur+=1; self._sav_cpt() try: img = ImageOps.fit(ImageOps.exif_transpose(Image.open(ch).convert("RGB")), (strip_bw, thumb_h), Image.LANCZOS) self._prev_thumbs.append(pygame.image.fromstring(img.tobytes(), img.size, "RGB")) except: self._prev_thumbs.append(None) time.sleep(0.4) if not self.photos: self.etat=S_ACCUEIL; return self._upload_booth_bg(self.photos) # upload + QR session en arrière-plan if self.mode=="simple": self.photo_active=self.photos[0]; self.share_msg=""; self.etat=S_SHARE else: threading.Thread(target=self._load_gal,daemon=True).start() self.etat=S_GAL def _load_gal(self): surfs = [] is_pell = self.cfg.get("fonctionnalites",{}).get("pellicule_mode",False) if is_pell and self.format in ("bande3x2","bande4x2"): nb = 3 if self.format=="bande3x2" else 4 fw = (W - _PELL_GAP*(nb+1)) // nb for ch in self.photos[:nb]: try: img=ImageOps.fit(ImageOps.exif_transpose(Image.open(ch).convert("RGB")),(fw,_PELL_FH),Image.LANCZOS) surfs.append(pygame.image.fromstring(img.tobytes(),img.size,"RGB")) except: surfs.append(None) elif self.format in ("bande3x2","bande4x2"): nb = 3 if self.format=="bande3x2" else 4 pw = W // nb; sh = (H - 150) // 2 for ch in self.photos[:nb]: try: img=ImageOps.fit(ImageOps.exif_transpose(Image.open(ch).convert("RGB")),(pw,sh),Image.LANCZOS) surfs.append(pygame.image.fromstring(img.tobytes(),img.size,"RGB")) except: surfs.append(None) else: # grille_4 gw,gh=W//2-15,H//2-75 for ch in self.photos[:4]: try: img=ImageOps.fit(Image.open(ch).convert("RGB"),(gw,gh),Image.LANCZOS) surfs.append(pygame.image.fromstring(img.tobytes(),img.size,"RGB")) except: surfs.append(None) self.gal_surfs=surfs; self.gal_ready=True def _sav_cpt(self): try: c=cfg_load(); c.setdefault("compteur",{})["photos_prises"]=self.compteur; cfg_save(c) except: pass def _make_composite(self, photos): """Crée une image composite (bande ou grille) à partir des photos. Mode pellicule : bande simple. Sinon : bande doublée ou grille.""" is_pell = self.cfg.get("fonctionnalites",{}).get("pellicule_mode",False) canvas = Image.new("RGB", (W, H), (0, 0, 0)) if self.format in ("bande3x2", "bande4x2"): nb = 3 if self.format == "bande3x2" else 4 pw = W // nb if is_pell: # Bande unique centrée verticalement sh = H - 160 top = 80 for i, ch in enumerate(photos[:nb]): try: img = ImageOps.fit(ImageOps.exif_transpose(Image.open(ch).convert("RGB")), (pw, sh), Image.LANCZOS) canvas.paste(img, (i * pw, top)) except Exception as e: print(f"composite pellicule: {e}") else: # Bande doublée (comportement original) sh = (H - 150) // 2 for i, ch in enumerate(photos[:nb]): try: img = ImageOps.fit(ImageOps.exif_transpose(Image.open(ch).convert("RGB")), (pw, sh), Image.LANCZOS) canvas.paste(img, (i * pw, 5)) canvas.paste(img, (i * pw, sh + 10)) except Exception as e: print(f"composite bande: {e}") else: # grille_4 gw = W // 2 - 15 gh = H // 2 - 75 pos = [(5, 5), (W // 2 + 5, 5), (5, H // 2 - 65), (W // 2 + 5, H // 2 - 65)] for i, ch in enumerate(photos[:4]): try: img = ImageOps.fit(Image.open(ch).convert("RGB"), (gw, gh), Image.LANCZOS) canvas.paste(img, pos[i]) except Exception as e: print(f"composite grille: {e}") dest = "/tmp/photobooth_composite.jpg" canvas.save(dest, "JPEG", quality=90) return dest def _upload_booth(self, photos): """Upload vers photobooth.copydev.fr/api/sessions/{code}/upload. En mode multi, upload le composite ; en mode simple, upload la photo.""" b = self.cfg.get("booth", {}) url = b.get("url", "https://photobooth.copydev.fr").rstrip("/") code = b.get("session_code", "") if not code: return None # Mode multi → composite unique if self.mode == "multi" and len(photos) > 1: try: to_upload = [self._make_composite(photos)] except Exception as e: print(f"composite échoué, upload individuel: {e}") to_upload = photos else: to_upload = photos from urllib.request import Request, urlopen ok = 0 for ch in to_upload: try: boundary = "----PBUpload" with open(ch, "rb") as f: data = f.read() nom = Path(ch).name body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"fichier\"; filename=\"{nom}\"\r\nContent-Type: image/jpeg\r\n\r\n").encode() + data + f"\r\n--{boundary}--\r\n".encode() req = Request(f"{url}/api/sessions/{code}/upload", data=body, method="POST") req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") with urlopen(req, timeout=10) as r: if r.status == 200: ok += 1 except Exception as e: print(f"booth upload: {e}") return f"{url}/s/{code}" if ok > 0 else None def _upload_booth_bg(self, photos): """Lance l'upload booth en arrière-plan et met à jour le QR de session.""" def _do(): sess_url = self._upload_booth(photos) if sess_url and HAS_QR: try: img = qrlib.make(sess_url, border=2).convert("RGB").resize((200,200), Image.NEAREST) self.session_qr = pygame.image.fromstring(img.tobytes(), img.size, "RGB") self.session_url = sess_url print(f"Session booth: {sess_url}") except Exception as e: print(f"booth QR: {e}") threading.Thread(target=_do, daemon=True).start() def _imprimer(self, ch, copies=1): if not HAS_CUPS or not ch: return False try: conn=cups.Connection(); ps=conn.getPrinters() nom=self.cfg.get("impression",{}).get("imprimante") or (list(ps.keys())[0] if ps else None) if not nom: return False conn.printFile(nom,ch,"Photobooth",{"copies":str(copies),"fit-to-page":"True"}); return True except Exception as e: print(f"print: {e}"); return False def _email_send(self, dest, ch): ce=self.cfg.get("email",{}); nom=self.cfg.get("evenement",{}).get("nom","Photobooth") try: msg=MIMEMultipart(); msg["From"]=ce.get("expediteur",""); msg["To"]=dest msg["Subject"]=ce.get("sujet","Votre photo").replace("{evenement}",nom) msg.attach(MIMEText(ce.get("message","Voici votre photo !").replace("{evenement}",nom),"plain")) with open(ch,"rb") as f: p=MIMEBase("application","octet-stream"); p.set_payload(f.read()) encoders.encode_base64(p) p.add_header("Content-Disposition",f'attachment; filename="{Path(ch).name}"') msg.attach(p) s=smtplib.SMTP(ce.get("smtp_host",""),ce.get("smtp_port",587)); s.starttls() s.login(ce.get("smtp_user",""),ce.get("smtp_password","")) s.sendmail(msg["From"],[dest],msg.as_string()); s.quit(); return True except Exception as e: print(f"email: {e}"); return False # ══════════════════════════════════════════════════════════════════════════ # ÉCRANS # ══════════════════════════════════════════════════════════════════════════ # ── Accueil ────────────────────────────────────────────────────────────── def draw_accueil(self): self._bg(); t=time.time()-self.t0 y=70; T=self.T logo=self._logo() if logo: self._c(logo,y); y+=logo.get_height()+30 else: s=self._txt("Photobooth","xl",T["prim"]); self._c(s,y); y+=s.get_height()+30 nom=self.cfg.get("evenement",{}).get("nom","") or "Photobooth" s=self._txt(nom,"lg",T["prim"]); self._c(s,y); y+=s.get_height()+50 # Instruction pulsante a=int(140+115*math.sin(t*math.pi)) ins=self._txt("Touchez pour commencer","sm",T["texte2"],False); ins.set_alpha(a) self._c(ins,y); y+=ins.get_height()+55 # Cercle animé r1=int(50+20*math.sin(t*math.pi)); r2=int(80+25*math.sin(t*math.pi+0.5)) cs=pygame.Surface((240,240),pygame.SRCALPHA) a2=int(25+25*abs(math.sin(t*math.pi))) pygame.draw.circle(cs,(*T["prim"],a2),(120,120),r2) pygame.draw.circle(cs,(*T["prim"],70),(120,120),r1) pygame.draw.circle(cs,(*T["prim"],255),(120,120),16) self._c(cs,y) # QR bas-gauche if self.qr_surf: qy=H-self.qr_surf.get_height()-55 self.scr.blit(self.qr_surf,(20,qy)) # Compteur if self.cfg.get("compteur",{}).get("actif"): s=self._txt(f"{max(0,self.cpt_max-self.compteur)} / {self.cpt_max} photos restantes","xxs",T["texte2"],False) self.scr.blit(s,(20,H-36)) # Bouton admin ⚙ (discret mais visible) r_adm=pygame.Rect(W-80,H-80,66,66) pygame.draw.circle(self.scr,T["carte"],(r_adm.centerx,r_adm.centery),33) pygame.draw.circle(self.scr,T["prim"],(r_adm.centerx,r_adm.centery),33,2) sg=self.F["md"].render("⚙",True,T["prim"]); self.scr.blit(sg,(r_adm.centerx-sg.get_width()//2,r_adm.centery-sg.get_height()//2)) # ── Mode ───────────────────────────────────────────────────────────────── def draw_mode(self): self._bg(); T=self.T s=self._txt("Choisissez votre mode","lg",T["texte"]); self._c(s,60) feat=self.cfg.get("fonctionnalites",{}) btns={}; cw=780; ch=680; gap=60 tx=(W-(2*cw+gap))//2; ty=190 infos=[("simple","photo_simple","Photo","1 prise","[ ]"), ("multi","multi_shot","Pellicule", f'{self.cfg.get("multi_shot",{}).get("nombre_photos",4)} poses',"[|||]")] for idx,(key,fk,titre,sous,ic) in enumerate(infos): if not feat.get(fk,True): continue x=tx+idx*(cw+gap); r=pygame.Rect(x,ty,cw,ch) self._card(r); s=self.F["xl"].render(ic,True,T["prim"]) self.scr.blit(s,(r.centerx-s.get_width()//2,ty+70)) s=self._txt(titre,"lg",T["texte"]); self.scr.blit(s,(r.centerx-s.get_width()//2,ty+310)) s=self._txt(sous,"sm",T["texte2"]); self.scr.blit(s,(r.centerx-s.get_width()//2,ty+420)) btns[key]=r r_ret=pygame.Rect(W//2-210,H-105,420,72) self._btn(r_ret,T["carte"],"Retour","sm",tc=T["texte"]) btns["retour"]=r_ret return btns # ── Format multipose ───────────────────────────────────────────────────── def _draw_format_preview(self, x, y, w, h, fmt): T=self.T; c=T["prim"]; c2=(*T["prim"],80) if fmt=="grille_4": gw,gh=w//2-4,h//2-4 for row in range(2): for col in range(2): pygame.draw.rect(self.scr,c,(x+col*(gw+4),y+row*(gh+4),gw,gh),border_radius=4) elif fmt=="bande3": # 2 bandes horizontales empilées, 3 photos côte à côte par bande sh=h//2-4; fw=w//3-3 for s in range(2): sy=y+s*(sh+4) for i in range(3): pygame.draw.rect(self.scr,c,(x+i*(fw+3),sy,fw,sh),border_radius=3) elif fmt=="bande4": # 2 bandes horizontales empilées, 4 photos côte à côte par bande sh=h//2-4; fw=w//4-2 for s in range(2): sy=y+s*(sh+4) for i in range(4): pygame.draw.rect(self.scr,c,(x+i*(fw+2),sy,fw,sh),border_radius=3) def draw_format(self): self._bg(); T=self.T; btns={} s=self._txt("Choisissez votre mise en page","lg",T["texte"]); self._c(s,55) specs=[ ("grille_4", "Grille 2×2", "4 poses · collage carré", "grille_4"), ("bande3x2", "Bande × 2", "3 poses · bande doublée", "bande3"), ("bande4x2", "Bande × 2", "4 poses · bande doublée", "bande4"), ] cw=460; ch=560; gap=55 tx=(W-(3*cw+2*gap))//2; ty=160 for idx,(key,titre,desc,pfmt) in enumerate(specs): x=tx+idx*(cw+gap); r=pygame.Rect(x,ty,cw,ch) sel=(key==self.format) bord=T["prim"] if sel else (80,80,110) self._card(r,bord=bord,bord_w=4 if sel else 2) # Aperçu dessiné pw,ph=cw-80,240; px=x+40; py=ty+30 bg2=pygame.Surface((pw,ph),pygame.SRCALPHA); bg2.fill((*T["fond2"],180)) self.scr.blit(bg2,(px,py)) self._draw_format_preview(px+10,py+10,pw-20,ph-20,pfmt) sn=self._txt(titre,"md",T["texte"]) self.scr.blit(sn,(r.centerx-sn.get_width()//2,ty+300)) sd=self._txt(desc,"xs",T["texte2"],False) self.scr.blit(sd,(r.centerx-sd.get_width()//2,ty+380)) if sel: sc=self.F["xs"].render("✓ Sélectionné",True,T["prim"]) self.scr.blit(sc,(r.centerx-sc.get_width()//2,ty+460)) btns[key]=r r_ret=pygame.Rect(W//2-210,H-105,420,72) self._btn(r_ret,T["carte"],"Retour","sm",tc=T["texte"]); btns["retour"]=r_ret return btns # ── Preview / Compte / Capture ──────────────────────────────────────────── def _draw_pellicule(self, show_live=True): """Affichage style pellicule 35mm : bande unique avec cadres numérotés.""" T = self.T nb = 3 if self.format == "bande3x2" else 4 # Dimensions film_y = (H - _FILM_H) // 2 # 210 frame_y = film_y + _SPR_H + _SPR_GAP # 266 gap = _PELL_GAP fw = (W - gap * (nb + 1)) // nb # largeur d'un cadre fh = _PELL_FH # 748 # ── Fond noir cinéma ── self.scr.fill((8, 8, 8)) # ── Corps de la bande (fond sombre + bords) ── pygame.draw.rect(self.scr, (18, 18, 18), (0, film_y, W, _FILM_H)) pygame.draw.rect(self.scr, (35, 35, 35), (0, film_y, W, _FILM_H), 1) # ── Perforations (sprocket holes) haut et bas ── hole_w, hole_h = 32, 22 hole_y_top = film_y + _SPR_H // 2 - hole_h // 2 hole_y_bot = film_y + _FILM_H - _SPR_H // 2 - hole_h // 2 x = 18 while x + hole_w < W - 10: for hy in (hole_y_top, hole_y_bot): # Cadre clair pygame.draw.rect(self.scr, (180, 180, 180), (x, hy, hole_w, hole_h), border_radius=5) # Trou sombre intérieur pygame.draw.rect(self.scr, (8, 8, 8), (x + 3, hy + 3, hole_w - 6, hole_h - 6), border_radius=3) x += 62 # ── Séparateurs entre cadres ── for i in range(1, nb): sx = gap + i * (fw + gap) - gap // 2 pygame.draw.rect(self.scr, (8, 8, 8), (sx - 2, frame_y - 2, 4, fh + 4)) # ── Cadres ── for i in range(nb): fx = gap + i * (fw + gap) fy = frame_y if i < len(self._prev_thumbs): # Photo prise th = self._prev_thumbs[i] if th: ts = _pfit(th, fw, fh) self.scr.blit(ts, (fx, fy)) # Léger grain/vignette pellicule vig = pygame.Surface((fw, fh), pygame.SRCALPHA) vig.fill((0, 0, 0, 18)) self.scr.blit(vig, (fx, fy)) pygame.draw.rect(self.scr, T["prim"], (fx, fy, fw, fh), 2) # Coche validée ck = self.F["xs"].render("✓", True, T["prim"]) cb = pygame.Surface((ck.get_width() + 10, ck.get_height() + 6), pygame.SRCALPHA) cb.fill((0, 0, 0, 160)) self.scr.blit(cb, (fx + fw - ck.get_width() - 14, fy + 4)) self.scr.blit(ck, (fx + fw - ck.get_width() - 9, fy + 7)) elif i == self.num_ph and show_live: # Live view dans le cadre courant if self.sim: t = time.time() v = int(30 + 15 * math.sin(t * 2)) sim_s = pygame.Surface((fw, fh)); sim_s.fill((0, v, 0)) self.scr.blit(sim_s, (fx, fy)) else: with self.flock: if self.frame: lv = _pfit(self.frame, fw, fh) self.scr.blit(lv, (fx, fy)) else: pygame.draw.rect(self.scr, (25, 25, 25), (fx, fy, fw, fh)) # Cadre pulsant pulse = int(210 + 45 * math.sin(time.time() * 5)) pygame.draw.rect(self.scr, (pulse, pulse, pulse), (fx, fy, fw, fh), 3) # Label "EN COURS" lbl = self.F["xxs"].render(f"▶ {i + 1}", True, (255, 255, 255)) lb = pygame.Surface((lbl.get_width() + 12, lbl.get_height() + 6), pygame.SRCALPHA) lb.fill((0, 0, 0, 160)) self.scr.blit(lb, (fx + 4, fy + 5)) self.scr.blit(lbl, (fx + 10, fy + 8)) else: # Placeholder futur pygame.draw.rect(self.scr, (12, 12, 12), (fx, fy, fw, fh)) pygame.draw.rect(self.scr, (45, 45, 45), (fx, fy, fw, fh), 1) lbl = self.F["md"].render(str(i + 1), True, (45, 45, 45)) self.scr.blit(lbl, (fx + fw // 2 - lbl.get_width() // 2, fy + fh // 2 - lbl.get_height() // 2)) # Numéro de frame style pellicule (sous le cadre, dans la zone sprocket bas) num_y = frame_y + fh + _SPR_GAP + 4 if num_y + 20 < film_y + _FILM_H: nf = self.F["xxs"].render(f"{i + 1:02d}A", True, (90, 90, 90)) self.scr.blit(nf, (fx + fw // 2 - nf.get_width() // 2, num_y)) def _draw_multi_strip(self): """Overlay bas : cases multipose (done/courant/futur).""" T = self.T nb = 3 if self.format == "bande3x2" else 4 STRIP_H = 200 STRIP_Y = H - STRIP_H - 10 BW = W // nb # Fond semi-transparent bg = pygame.Surface((W, STRIP_H + 20), pygame.SRCALPHA) bg.fill((0, 0, 0, 160)) self.scr.blit(bg, (0, STRIP_Y - 10)) for i in range(nb): bx = i * BW + 2 by = STRIP_Y bw = BW - 4 bh = STRIP_H if i < len(self._prev_thumbs): th = self._prev_thumbs[i] if th: ts = _pfit(th, bw, bh) self.scr.blit(ts, (bx, by)) pygame.draw.rect(self.scr, T["prim"], (bx, by, bw, bh), 2) tick = self.F["xs"].render("✓", True, T["prim"]) self.scr.blit(tick, (bx + bw - tick.get_width() - 6, by + 4)) elif i == self.num_ph: # Case courante : cadre pulsant blanc pulse = int(200 + 55 * math.sin(time.time() * 5)) pygame.draw.rect(self.scr, (pulse, pulse, pulse), (bx, by, bw, bh), 3) lbl = self.F["xs"].render(f"▶ {i + 1}", True, (255, 255, 255)) self.scr.blit(lbl, (bx + 8, by + 6)) else: ph = pygame.Surface((bw, bh), pygame.SRCALPHA) ph.fill((30, 30, 30, 140)) self.scr.blit(ph, (bx, by)) pygame.draw.rect(self.scr, T["texte2"], (bx, by, bw, bh), 1) lbl = self.F["lg"].render(str(i + 1), True, T["texte2"]) self.scr.blit(lbl, (bx + bw // 2 - lbl.get_width() // 2, by + bh // 2 - lbl.get_height() // 2)) def _is_pell(self): return self.mode == "multi" and self.cfg.get("fonctionnalites",{}).get("pellicule_mode",False) def draw_preview(self): if self._is_pell(): self._draw_pellicule(show_live=True) elif self.mode == "multi": self._lv() self._draw_multi_strip() else: self._lv() bande=pygame.Surface((W,80),pygame.SRCALPHA); bande.fill((0,0,0,170)) self.scr.blit(bande,(0,H-80)) s=self.F["sm"].render("Souriez !",True,(255,255,255)) self.scr.blit(s,(W//2-s.get_width()//2,H-62)) def draw_compte(self): T = self.T if self._is_pell(): self._draw_pellicule(show_live=True) # Pas d'overlay global : le halo coloré suffit pour la lisibilité else: self._lv(); self._overlay(110) ch=str(self.chiffre) s=self.F["xxl"].render(ch,True,(255,255,255)) hr=s.get_width()//2+90 hs=pygame.Surface((hr*2,hr*2),pygame.SRCALPHA) for i in range(5): a=int(50-i*8) pygame.draw.circle(hs,(*T["prim"],a),(hr,hr),hr-i*16) self.scr.blit(hs,(W//2-hr,H//2-hr)) self._c(s,H//2-s.get_height()//2) if self.mode == "multi" and not self._is_pell(): self._draw_multi_strip() elif self.mode == "simple": total=1 info=self.F["sm"].render(f"Photo {self.num_ph+1} / {total}",True,T["texte2"]) self.scr.blit(info,(W//2-info.get_width()//2,H-90)) def draw_capture(self): if self._is_pell(): self._draw_pellicule(show_live=False) self._overlay(70) else: self._lv(); self._overlay(90) s=self._txt("Prise de vue...","md"); self._c(s,H//2-s.get_height()//2) # ── Galerie ─────────────────────────────────────────────────────────────── def draw_galerie(self): self._bg(); T=self.T if not self.gal_ready: t=time.time() s=self._txt("Chargement des photos...","sm",T["texte2"],False); self._c(s,H//2-50) for k in range(4): a=int(128+127*math.sin(t*3+k*1.5)) pygame.draw.circle(self.scr,(*T["prim"],a),(W//2-60+k*40,H//2+30),12) elif self._is_pell(): # ── Galerie style pellicule ── nb=3 if self.format=="bande3x2" else 4 gap=_PELL_GAP; fw=(W-gap*(nb+1))//nb; fh=_PELL_FH film_y=(H-_FILM_H)//2; frame_y=film_y+_SPR_H+_SPR_GAP self.scr.fill((8,8,8)) pygame.draw.rect(self.scr,(18,18,18),(0,film_y,W,_FILM_H)) pygame.draw.rect(self.scr,(35,35,35),(0,film_y,W,_FILM_H),1) hole_w,hole_h=32,22 for hy in (film_y+_SPR_H//2-hole_h//2, film_y+_FILM_H-_SPR_H//2-hole_h//2): x=18 while x+hole_w0: code_disp=c else: code_disp="—" code_card=pygame.Rect(W//2-400,195,800,100); self._card(code_card) sc=self.F["lg"].render(code_disp,True,T["prim"]) # lg=96px, no overlap self.scr.blit(sc,(code_card.centerx-sc.get_width()//2, code_card.centery-sc.get_height()//2)) # Statut du test if self.wiz_code_status=="ok": st_col=(76,175,80); st_txt="Connexion OK — session prete" elif self.wiz_code_status=="erreur": st_col=(244,67,54); st_txt="Erreur de connexion" elif self.wiz_code_status=="test...": st_col=(255,193,7); st_txt="Test en cours..." else: st_col=T["texte2"]; st_txt="Appuyez sur Tester pour verifier" ss=self.F["xs"].render(st_txt,True,st_col); self._c(ss,312) # Pavé numérique compact — démarre à y=360 nkeys=["1","2","3","4","5","6","7","8","9","⌫","0","OK"] nkw,nkh,nkg=150,100,14 ngrid_w=3*nkw+2*nkg; tx2=W//2-ngrid_w//2; ty2=364 for i,k in enumerate(nkeys): col_i=i%3; row_i=i//3 r=pygame.Rect(tx2+col_i*(nkw+nkg),ty2+row_i*(nkh+nkg),nkw,nkh) if k=="OK": cb=(76,175,80); tc2=(255,255,255) elif k=="⌫": cb=(244,67,54); tc2=(255,255,255) else: cb=T["carte"]; tc2=T["texte"] self._btn(r,cb,k,"md",tc=tc2); btns[("code",k)]=r # Pavé bottom = 364 + 4*(100+14) - 14 = 364 + 442 = 806 # Boutons action : Générer | Tester | Suivant rg=pygame.Rect(W//2-700,H-155,360,72); self._btn(rg,(80,80,130),"Generer nouveau","xs",tc=(255,255,255)); btns["code_gen"]=rg rt=pygame.Rect(W//2-320,H-155,360,72) t_col=(80,80,130) if len(self.wiz_code_buf)!=9 else T["prim"] self._btn(rt,t_col,"Tester connexion","xs",tc=(255,255,255)); btns["code_test"]=rt rn=pygame.Rect(W//2+60,H-155,360,72); self._btn(rn,T["prim"],"Suivant","sm"); btns["next"]=rn rpv=pygame.Rect(W//2-700,H-70,340,60); self._btn(rpv,T["carte"],"Retour","xs",tc=T["texte"]); btns["prev"]=rpv # ── Étape 3 : Thème ── elif step==3: s=self._txt("Theme visuel","lg",T["texte"]); self._c(s,80) tlist=[ ("base", "Sombre", (26,26,46), (233,30,99), (255,255,255)), ("photobooth", "Photobooth", (235,242,255),(25,100,230), (15,30,80)), ("sikapics", "SikaPics", (251,231,232),(195,31,72), (36,4,57)), ("copydev", "CopyDev", (8,18,40), (41,182,246), (255,255,255)), ] cw=360; ch_h=460; gap=36; ty=180 tx=(W-(len(tlist)*cw+(len(tlist)-1)*gap))//2 sel=self.wiz_data.get("theme","base") for idx,(tkey,tnom,bg,prim,tex) in enumerate(tlist): x=tx+idx*(cw+gap); r=pygame.Rect(x,ty,cw,ch_h) bord=prim if tkey==sel else (100,100,120) bw=4 if tkey==sel else 2 self._card(r,bord=bord,fond=bg,bord_w=bw) # Preview mini preview=pygame.Rect(x+30,ty+30,cw-60,200) pygame.draw.rect(self.scr,bg,preview,border_radius=12) # Cercle d'accentuation pygame.draw.circle(self.scr,prim,(preview.centerx,preview.centery),50) pygame.draw.circle(self.scr,tex,(preview.centerx,preview.centery),20) # Nom st=self.F["sm"].render(tnom,True,tex if tkey=="sikapics" else (255,255,255)) self.scr.blit(st,(r.centerx-st.get_width()//2,ty+260)) # Coche si sélectionné if tkey==sel: sc=self.F["md"].render("[OK]",True,prim) self.scr.blit(sc,(r.centerx-sc.get_width()//2,ty+340)) btns[("theme",tkey)]=r rn=pygame.Rect(W//2+50,H-105,300,76); self._btn(rn,T["prim"],"Suivant","sm"); btns["next"]=rn rp=pygame.Rect(W//2-350,H-105,300,76); self._btn(rp,T["carte"],"Retour","sm",tc=T["texte"]); btns["prev"]=rp # ── Étape 4 : Modes photo ── elif step==4: s=self._txt("Modes photo","lg",T["texte"]); self._c(s,80) modes=[("photo_simple","Photo","1 prise"),("multi_shot","Pellicule","Plusieurs poses")] cw=520; gap=80; ty=210 tx=(W-(2*cw+gap))//2 feat=self.wiz_data.get("modes",{"photo_simple":True,"multi_shot":True}) for idx,(key,nom,desc) in enumerate(modes): x=tx+idx*(cw+gap); r=pygame.Rect(x,ty,cw,380) actif=feat.get(key,True) bord=T["prim"] if actif else (80,80,100) self._card(r,bord=bord) ic=self.F["xl"].render("[ ]" if key=="photo_simple" else "[|||]",True,T["prim"] if actif else T["texte2"]) self.scr.blit(ic,(r.centerx-ic.get_width()//2,ty+40)) sn=self._txt(nom,"md",T["texte"] if actif else T["texte2"]) self.scr.blit(sn,(r.centerx-sn.get_width()//2,ty+230)) sd=self._txt(desc,"xs",T["texte2"],False) self.scr.blit(sd,(r.centerx-sd.get_width()//2,ty+305)) if actif: sa=self.F["xs"].render("[Actif]",True,T["prim"]) self.scr.blit(sa,(r.centerx-sa.get_width()//2,ty+340)) btns[("mode",key)]=r rn=pygame.Rect(W//2+50,H-105,300,76); self._btn(rn,T["prim"],"Suivant","sm"); btns["next"]=rn rp=pygame.Rect(W//2-350,H-105,300,76); self._btn(rp,T["carte"],"Retour","sm",tc=T["texte"]); btns["prev"]=rp # ── Étape 5 : Nombre de photos ── elif step==5: s=self._txt("Photos par pellicule","lg",T["texte"]); self._c(s,80) nb=self.wiz_data.get("nb_photos",4) card=pygame.Rect(W//2-200,200,400,300); self._card(card) sn=self.F["xxl"].render(str(nb),True,T["prim"]) self.scr.blit(sn,(card.centerx-sn.get_width()//2,card.centery-sn.get_height()//2)) rm=pygame.Rect(W//2-360,H//2-60,140,140); self._btn(rm,T["carte"],"-","xl",70,tc=T["texte"]); btns["moins"]=rm rp2=pygame.Rect(W//2+220,H//2-60,140,140); self._btn(rp2,T["carte"],"+","xl",70,tc=T["texte"]); btns["plus"]=rp2 rn=pygame.Rect(W//2+50,H-105,300,76); self._btn(rn,T["prim"],"Suivant","sm"); btns["next"]=rn rpv=pygame.Rect(W//2-350,H-105,300,76); self._btn(rpv,T["carte"],"Retour","sm",tc=T["texte"]); btns["prev"]=rpv # ── Étape 6 : Partage ── elif step==6: s=self._txt("Options de partage","lg",T["texte"]); self._c(s,80) opts=[("impression","Imprimer"),("email","Email"),("qr_code","QR Code"),("galerie","Galerie")] share=self.wiz_data.get("share",{o:True for o,_ in opts}) cw=350; gap=40; ty=210 tx=(W-(2*cw+gap))//2 for idx,(key,nom) in enumerate(opts): row,col=divmod(idx,2); x=tx+col*(cw+gap); y2=ty+row*(240+gap) r=pygame.Rect(x,y2,cw,220) actif=share.get(key,True); bord=T["prim"] if actif else (80,80,100) self._card(r,bord=bord) sn=self._txt(nom,"sm",T["texte"] if actif else T["texte2"]) self.scr.blit(sn,(r.centerx-sn.get_width()//2,r.centery-sn.get_height()//2)) if actif: sa=self.F["xs"].render("[ON]",True,T["prim"]) self.scr.blit(sa,(r.centerx-sa.get_width()//2,r.y+160)) btns[("share",key)]=r rn=pygame.Rect(W//2+50,H-105,300,76); self._btn(rn,T["prim"],"Suivant","sm"); btns["next"]=rn rpv=pygame.Rect(W//2-350,H-105,300,76); self._btn(rpv,T["carte"],"Retour","sm",tc=T["texte"]); btns["prev"]=rpv # ── Étape 7 : Résumé / Lancer ── elif step==7: s=self._txt("C'est pret !","xl",T["prim"]); self._c(s,80) _c=self.wiz_data.get("session_code",self.wiz_code_buf) or "—" if len(_c)==9: _c=f"{_c[:3]}-{_c[3:6]}-{_c[6:]}" resume=[ ("Evenement", self.wiz_data.get("nom","—")), ("Code galerie", _c), ("Theme", THEMES.get(self.wiz_data.get("theme","base"),{}).get("nom","—")), ("Modes", ", ".join(k for k,v in self.wiz_data.get("modes",{}).items() if v) or "—"), ("Photos/pellicule", str(self.wiz_data.get("nb_photos",4))), ("Partage", ", ".join(k for k,v in self.wiz_data.get("share",{}).items() if v) or "—"), ] card=pygame.Rect(W//2-450,190,900,540); self._card(card) ry=215 for label,val in resume: sl=self.F["xs"].render(label+" :",True,T["texte2"]) sv=self.F["sm"].render(val,True,T["texte"]) self.scr.blit(sl,(card.x+40,ry)) self.scr.blit(sv,(card.x+280,ry)); ry+=66 rl=pygame.Rect(W//2-310,H-105,620,80); self._btn(rl,T["prim"],"Lancer le photobooth","md"); btns["lancer"]=rl rpv=pygame.Rect(W//2-350,H-195,300,72); self._btn(rpv,T["carte"],"Retour","sm",tc=T["texte"]); btns["prev"]=rpv return btns # ── PIN ────────────────────────────────────────────────────────────────── def draw_pin(self): self._bg(); T=self.T; btns={} s=self._txt("⚙ Administration","xl",T["texte"]); self._c(s,70) s=self._txt("Entrez le code d'accès","sm",T["texte2"],False); self._c(s,185) # Affichage PIN dots="●"*len(self.pin_buf)+"○"*(4-len(self.pin_buf)) sd=self.F["lg"].render(dots,True,T["prim"]); self._c(sd,228) if self.pin_err: se=self._txt("Code incorrect","sm",(244,67,54),False); self._c(se,318) # Pavé numérique — grandes touches centrées keys=["1","2","3","4","5","6","7","8","9","⌫","0","OK"] kw,kh,kg=220,150,22 grid_w=3*kw+2*kg; grid_h=4*kh+3*kg tx=W//2-grid_w//2; ty=H//2-grid_h//2+30 for i,k in enumerate(keys): col=i%3; row=i//3 r=pygame.Rect(tx+col*(kw+kg), ty+row*(kh+kg), kw, kh) if k=="OK": cb=T["prim"]; tc=(255,255,255) elif k=="⌫": cb=(100,50,80); tc=(255,255,255) else: cb=T["carte"]; tc=T["texte"] self._btn(r,cb,k,"lg",tc=tc); btns[("pin",k)]=r rc=pygame.Rect(W//2-210,H-95,420,72); self._btn(rc,T["carte"],"Annuler","sm",tc=T["texte"]); btns["annuler"]=rc return btns # ── Admin (tabbed) ──────────────────────────────────────────────────────── def draw_admin(self): self._bg(); T=self.T; btns={} s=self._txt("Administration","sm",T["texte"]); self._c(s,20) # Onglets tw=268; tgap=12; ty=84; th=58 tx=(W-(len(ADMIN_TABS)*tw+(len(ADMIN_TABS)-1)*tgap))//2 for i,(key,label) in enumerate(ADMIN_TABS): r=pygame.Rect(tx+i*(tw+tgap),ty,tw,th) sel=(key==self.admin_tab) col=T["prim"] if sel else T["carte"] tc2=(255,255,255) if sel or not T["clair"] else T["texte"] self._btn(r,col,label,"xs",tc=tc2); btns[("tab",key)]=r pygame.draw.line(self.scr,T["prim"],(40,155),(W-40,155),2) cy=168 if self.admin_tab=="fonctions": self._adm_fonctions(btns,cy) elif self.admin_tab=="materiel": self._adm_materiel(btns,cy) elif self.admin_tab=="compteur": self._adm_compteur(btns,cy) elif self.admin_tab=="evenement": self._adm_evenement(btns,cy) elif self.admin_tab=="systeme": self._adm_systeme(btns,cy) # Bas rw=pygame.Rect(W//2-360,H-105,340,72); self._btn(rw,T["prim"],"Wizard événement","sm"); btns["wizard"]=rw rc=pygame.Rect(W//2+20,H-105,340,72); self._btn(rc,T["carte"],"Retour","sm",tc=T["texte"]); btns["retour"]=rc return btns def _tog_row(self,btns,key,label,actif,x,y,w=860): T=self.T; r=pygame.Rect(x,y,w,64); self._card(r,bord=T["prim"] if actif else (80,80,110),rayon=12) sl=self.F["sm"].render(label,True,T["texte"]); self.scr.blit(sl,(r.x+22,r.centery-sl.get_height()//2)) c=(76,175,80) if actif else (244,67,54); tog=pygame.Rect(r.right-120,r.y+12,100,40) self._btn(tog,c,"ON" if actif else "OFF","xs"); btns[("t",key)]=tog def _adm_fonctions(self,btns,y): feat=self.cfg.get("fonctionnalites",{}) items=[("photo_simple","Photo simple"),("multi_shot","Pellicule"), ("pellicule_mode","Mode pellicule"),("impression","Impression"), ("email","Email"), ("qr_code","QR Code"), ("filtres","Filtres"), ("overlays","Overlays"), ("chroma_key","Chroma key"), ("galerie","Galerie web"), ("compteur_feat","Compteur"), ] # pellicule_mode désactivé par défaut (contrairement aux autres) defaults = {"pellicule_mode": False} cw=860; gap=28; rh=64 lx=W//2-cw-gap//2; rx=W//2+gap//2 for i,(key,label) in enumerate(items): col=i%2; row=i//2 x=lx if col==0 else rx self._tog_row(btns,key,label,feat.get(key,defaults.get(key,True)),x,y+row*(rh+10),cw) def _adm_materiel(self,btns,y): T=self.T; cc=self.cfg.get("camera",{}); ci=self.cfg.get("impression",{}) def pm_card(title,val,key_m,key_p,x,yy,w=430,h=140): r=pygame.Rect(x,yy,w,h); self._card(r) s=self._txt(title,"xs",T["texte2"],False); self.scr.blit(s,(r.x+16,yy+12)) sv=self.F["lg"].render(str(val),True,T["prim"]); self.scr.blit(sv,(r.centerx-sv.get_width()//2,yy+44)) rm=pygame.Rect(r.x+8,yy+44,90,56); self._btn(rm,T["carte"],"-","md",tc=T["texte"]); btns[key_m]=rm rp=pygame.Rect(r.right-98,yy+44,90,56); self._btn(rp,T["carte"],"+","md",tc=T["texte"]); btns[key_p]=rp pm_card("Compte à rebours (s)",cc.get("compte_a_rebours",3),"delay_m","delay_p",W//2-890,y) pm_card("Copies impression", ci.get("copies_max",1), "copies_m","copies_p",W//2-440,y) pm_card("Délai entre poses (s)",cc.get("delai_entre",1), "delayi_m","delayi_p",W//2+10,y) # Imprimante y2=y+158; r=pygame.Rect(W//2-890,y2,1780,80); self._card(r) s=self._txt("Imprimante","xs",T["texte2"],False); self.scr.blit(s,(r.x+16,y2+10)) imp=ci.get("imprimante","Détection automatique (première disponible)") sv=self.F["xs"].render(str(imp),True,T["texte"]); self.scr.blit(sv,(r.x+16,y2+46)) # Caméra y3=y2+96; r2=pygame.Rect(W//2-890,y3,1780,80); self._card(r2) s=self._txt("Caméra","xs",T["texte2"],False); self.scr.blit(s,(r2.x+16,y3+10)) mode="DSLR connecté (gphoto2)" if (self.cam and not self.sim) else "Simulation (pas de DSLR)" col=T["prim"] if (self.cam and not self.sim) else (244,67,54) sv=self.F["xs"].render(mode,True,col); self.scr.blit(sv,(r2.x+16,y3+46)) rb=pygame.Rect(r2.right-230,y3+14,210,54); self._btn(rb,T["carte"],"Reconnecter","xs",tc=T["texte"]); btns["reconnect_cam"]=rb def _adm_compteur(self,btns,y): T=self.T; cc=self.cfg.get("compteur",{}) actif=cc.get("actif",False); prises=cc.get("photos_prises",self.compteur); limite=cc.get("limite",400) # Toggle self._tog_row(btns,"compteur_actif","Compteur actif",actif,W//2-440,y,880) y+=84 # Card : nombre de photos prises (font lg=96px, height~106px → card 240px) card=pygame.Rect(W//2-440,y,880,240); self._card(card) sv=self.F["lg"].render(str(prises),True,T["prim"]) self.scr.blit(sv,(card.centerx-sv.get_width()//2,y+12)) s=self._txt("photos prises","sm",T["texte2"],False) self.scr.blit(s,(card.centerx-s.get_width()//2,y+130)) s=self._txt(f"{max(0,limite-prises)} restantes / {limite}","xs",T["texte2"],False) self.scr.blit(s,(card.centerx-s.get_width()//2,y+186)) y+=260 # Limite : label centré + -/valeur/+ slbl=self._txt("Limite","xs",T["texte2"],False); self._c(slbl,y); y+=42 rl=pygame.Rect(W//2-300,y,110,76); self._btn(rl,T["carte"],"-","lg",36,tc=T["texte"]); btns["lim_m"]=rl sv2=self.F["md"].render(str(limite),True,T["prim"]); self.scr.blit(sv2,(W//2-sv2.get_width()//2,y+12)) rr2=pygame.Rect(W//2+190,y,110,76); self._btn(rr2,T["carte"],"+","lg",36,tc=T["texte"]); btns["lim_p"]=rr2 y+=100 rrs=pygame.Rect(W//2-210,y,420,64); self._btn(rrs,(244,67,54),"Remettre a 0","sm"); btns["reset"]=rrs def _adm_evenement(self,btns,y): T=self.T; ev=self.cfg.get("evenement",{}) nom=ev.get("nom","—"); theme=ev.get("theme","base") sv=self._txt(nom,"lg",T["prim"]); self._c(sv,y); y+=sv.get_height()+16 s=self._txt("Thème visuel","sm",T["texte2"],False); self._c(s,y); y+=46 tl=[("base","Sombre",(26,26,46),(233,30,99),(255,255,255)), ("photobooth","Photobooth",(235,242,255),(25,100,230),(15,30,80)), ("sikapics","SikaPics",(251,231,232),(195,31,72),(36,4,57)), ("copydev","CopyDev",(8,18,40),(41,182,246),(255,255,255))] cw=320; gap=36; tx=(W-(4*cw+3*gap))//2 for idx,(tk,tn,bg,prim,tex) in enumerate(tl): x=tx+idx*(cw+gap); r=pygame.Rect(x,y,cw,220) bord=prim if tk==theme else (80,80,100); bw=4 if tk==theme else 2 self._card(r,bord=bord,fond=bg,bord_w=bw) pygame.draw.circle(self.scr,prim,(r.centerx,y+82),38) pygame.draw.circle(self.scr,tex,(r.centerx,y+82),16) st=self.F["sm"].render(tn,True,tex); self.scr.blit(st,(r.centerx-st.get_width()//2,y+146)) if tk==theme: sc=self.F["xs"].render("✓",True,prim); self.scr.blit(sc,(r.centerx-sc.get_width()//2,y+186)) btns[("theme",tk)]=r y+=240 ip=_web_state.get("ip","?") s=self._txt(f"Modifier le nom via mobile → http://{ip}/wizard","xs",T["texte2"],False); self._c(s,y) def _adm_systeme(self,btns,y): T=self.T; now=time.time() if now-self._sys_info_t>5: info={} try: info["ip"]=subprocess.check_output("hostname -I",shell=True).decode().strip() except: info["ip"]="?" try: with open('/proc/meminfo') as f: m={l.split(':')[0]:int(l.split(':')[1].strip().split()[0]) for l in f if ':' in l} info["ram"]=f"{m.get('MemAvailable',0)//1024} Mo libres / {m.get('MemTotal',0)//1024} Mo" except: info["ram"]="?" try: import shutil; tot,_,fr=shutil.disk_usage("/") info["disque"]=f"{fr//1e9:.1f} Go libres / {tot//1e9:.1f} Go" except: info["disque"]="?" try: with open('/sys/class/thermal/thermal_zone0/temp') as f: info["temp"]=f"{int(f.read().strip())//1000}°C" except: info["temp"]="N/A" info["camera"]="DSLR (gphoto2)" if (self.cam and not self.sim) else "Simulation" self._sys_info=info; self._sys_info_t=now rows=[("IP réseau", self._sys_info.get("ip","?")), ("RAM", self._sys_info.get("ram","?")), ("Disque /", self._sys_info.get("disque","?")), ("Température CPU", self._sys_info.get("temp","?")), ("Caméra", self._sys_info.get("camera","?")), ("Admin mobile", f"http://{_web_state.get('ip','?')}/")] card=pygame.Rect(W//2-600,y,1200,len(rows)*60+20); self._card(card) ry=y+16 for label,val in rows: sl=self.F["xs"].render(label,True,T["texte2"]); self.scr.blit(sl,(card.x+20,ry)) sv=self.F["xs"].render(str(val),True,T["texte"]); self.scr.blit(sv,(card.x+300,ry)); ry+=60 # QR admin (droite) ip=_web_state.get("ip") if ip and ip!="?" and HAS_QR and self._admin_qr is None: try: qimg=qrlib.make(f"http://{ip}/",border=2).convert("RGB").resize((200,200),Image.NEAREST) self._admin_qr=pygame.image.fromstring(qimg.tobytes(),qimg.size,"RGB") except: pass if self._admin_qr: qx=card.right+24; qy=y pygame.draw.rect(self.scr,(255,255,255),pygame.Rect(qx-5,qy-5,210,210),border_radius=6) self.scr.blit(self._admin_qr,(qx,qy)) sq=self._txt("Scanner","xxs",T["texte2"],False); self.scr.blit(sq,(qx+100-sq.get_width()//2,qy+208)) # Boutons système y2=card.bottom+20 rq=pygame.Rect(W//2-540,y2,160,68); self._btn(rq,(80,80,80),"Quitter","sm"); btns["quitter"]=rq rb=pygame.Rect(W//2-360,y2,320,68); self._btn(rb,(255,152,0),"Redémarrer","sm"); btns["reboot"]=rb re=pygame.Rect(W//2+40,y2,320,68); self._btn(re,(244,67,54),"Éteindre","sm"); btns["shutdown"]=re # ══════════════════════════════════════════════════════════════════════════ # BOUCLE PRINCIPALE # ══════════════════════════════════════════════════════════════════════════ def run(self): mbtn={}; gbtn={}; sbtn={}; ebtn={}; wbtn={}; abtn={}; fmtbtn={}; pbtn={} last_click=0; running=True while running: for ev in pygame.event.get(): if ev.type==pygame.QUIT: running=False if ev.type==pygame.KEYDOWN and ev.key==pygame.K_ESCAPE: running=False pos=None if ev.type==pygame.MOUSEBUTTONDOWN and ev.button==1: pos=pygame.mouse.get_pos() elif ev.type==pygame.FINGERDOWN: pos=(int(ev.x*W),int(ev.y*H)) if not pos: continue now=time.time() if now-last_click<0.3: continue last_click=now e=self.etat # ── Accueil ── if e==S_ACCUEIL: az=pygame.Rect(W-80,H-80,66,66) if az.collidepoint(pos): self.pin_buf=""; self.pin_err=False; self.etat=S_PIN else: feat=self.cfg.get("fonctionnalites",{}) s_ok=feat.get("photo_simple",True); m_ok=feat.get("multi_shot",True) if s_ok and m_ok: self.etat=S_MODE elif m_ok: self.mode="multi"; self.etat=S_FORMAT else: self.mode="simple"; threading.Thread(target=self._sequence,daemon=True).start() # ── Mode ── elif e==S_MODE: if mbtn.get("simple",pygame.Rect(0,0,0,0)).collidepoint(pos): self.mode="simple"; threading.Thread(target=self._sequence,daemon=True).start() elif mbtn.get("multi",pygame.Rect(0,0,0,0)).collidepoint(pos): self.mode="multi"; self.etat=S_FORMAT elif mbtn.get("retour",pygame.Rect(0,0,0,0)).collidepoint(pos): self.etat=S_ACCUEIL; self.t0=time.time() # ── Format ── elif e==S_FORMAT: if fmtbtn.get("retour",pygame.Rect(0,0,0,0)).collidepoint(pos): self.etat=S_MODE else: for key,_,nb,__ in FORMATS: if fmtbtn.get(key,pygame.Rect(0,0,0,0)).collidepoint(pos): self.format=key c=cfg_load(); c.setdefault("multi_shot",{})["format"]=key; cfg_save(c) threading.Thread(target=self._sequence,daemon=True).start() break # ── Galerie ── elif e==S_GAL: if gbtn.get("nouveau",pygame.Rect(0,0,0,0)).collidepoint(pos): self.etat=S_ACCUEIL; self.t0=time.time() elif gbtn.get("imprimer",pygame.Rect(0,0,0,0)).collidepoint(pos): if self.photos: self.photo_active=self.photos[0]; self.share_msg=""; self.etat=S_SHARE elif gbtn.get("email",pygame.Rect(0,0,0,0)).collidepoint(pos): if self.photos: self.photo_active=self.photos[0]; self.email_buf=""; self.email_msg=""; self.etat=S_EMAIL # ── Partage ── elif e==S_SHARE: if sbtn.get("terminer",pygame.Rect(0,0,0,0)).collidepoint(pos): self.etat=S_ACCUEIL; self.t0=time.time() elif sbtn.get("nouveau",pygame.Rect(0,0,0,0)).collidepoint(pos): self.mode="simple"; threading.Thread(target=self._sequence,daemon=True).start() elif sbtn.get("imprimer",pygame.Rect(0,0,0,0)).collidepoint(pos): ok=self._imprimer(self.photo_active) self.share_msg="[OK] Impression lancee" if ok else "[ERR] Impression echouee" elif sbtn.get("email",pygame.Rect(0,0,0,0)).collidepoint(pos): self.email_buf=""; self.email_msg=""; self.etat=S_EMAIL elif sbtn.get("qr",pygame.Rect(0,0,0,0)).collidepoint(pos): self.share_msg=self.cfg.get("qr_code",{}).get("url_galerie","Pas d'URL") # ── Email ── elif e==S_EMAIL: if ebtn.get("annuler",pygame.Rect(0,0,0,0)).collidepoint(pos): self.etat=S_SHARE if self.mode=="simple" else S_GAL elif ebtn.get("envoyer",pygame.Rect(0,0,0,0)).collidepoint(pos): if "@" in self.email_buf: dest=self.email_buf; ch=self.photo_active; self.email_msg="Envoi..." def _s(d=dest,c=ch): ok=self._email_send(d,c) self.email_msg="[OK] Email envoye !" if ok else "[ERR] Echec envoi" threading.Thread(target=_s,daemon=True).start() else: self.email_msg="[ERR] Adresse invalide" else: for ekey,r in ebtn.items(): if not isinstance(ekey,tuple) or len(ekey)!=2: continue t,v = ekey if t=="k" and r.collidepoint(pos): self.email_buf=self.email_buf[:-1] if v=="<--" else self.email_buf+v; break # ── Wizard ── elif e==S_WIZ: if wbtn.get("fermer",pygame.Rect(0,0,0,0)).collidepoint(pos): self.etat=S_ACCUEIL; self.t0=time.time() elif wbtn.get("next",pygame.Rect(0,0,0,0)).collidepoint(pos): if self.wiz_step==1: self.wiz_data["nom"]=self.wiz_kbbuf; self.wiz_step=2 elif self.wiz_step==2: self.wiz_data["session_code"]=self.wiz_code_buf; self.wiz_step=3 elif self.wiz_step<7: self.wiz_step+=1 elif wbtn.get("prev",pygame.Rect(0,0,0,0)).collidepoint(pos): if self.wiz_step>1: self.wiz_step-=1 elif wbtn.get("lancer",pygame.Rect(0,0,0,0)).collidepoint(pos): self._wiz_appliquer(); self.etat=S_ACCUEIL; self.t0=time.time() elif wbtn.get("moins",pygame.Rect(0,0,0,0)).collidepoint(pos): self.wiz_data["nb_photos"]=max(1,self.wiz_data.get("nb_photos",4)-1) elif wbtn.get("plus",pygame.Rect(0,0,0,0)).collidepoint(pos): self.wiz_data["nb_photos"]=min(8,self.wiz_data.get("nb_photos",4)+1) elif wbtn.get("code_gen",pygame.Rect(0,0,0,0)).collidepoint(pos): import random self.wiz_code_buf=f"{random.randint(100000000,999999999)}" self.wiz_code_status=None elif wbtn.get("code_test",pygame.Rect(0,0,0,0)).collidepoint(pos): if len(self.wiz_code_buf)==9: threading.Thread(target=self._test_session_code,args=(self.wiz_code_buf,),daemon=True).start() else: for key,r in wbtn.items(): if not isinstance(key,tuple) or len(key)!=2: continue if not r.collidepoint(pos): continue t,v = key if t=="k": if v=="<--": self.wiz_kbbuf=self.wiz_kbbuf[:-1] elif v=="Espace": self.wiz_kbbuf+=" " else: self.wiz_kbbuf+=v elif t=="code": if v=="⌫": self.wiz_code_buf=self.wiz_code_buf[:-1]; self.wiz_code_status=None elif v=="OK": if len(self.wiz_code_buf)==9: self.wiz_data["session_code"]=self.wiz_code_buf; self.wiz_step=3 elif v.isdigit() and len(self.wiz_code_buf)<9: self.wiz_code_buf+=v; self.wiz_code_status=None elif t=="theme": self.wiz_data["theme"]=v; self._apply_theme(v) elif t=="mode": m=self.wiz_data.setdefault("modes",{"photo_simple":True,"multi_shot":True}) m[v]=not m.get(v,True) elif t=="share": s=self.wiz_data.setdefault("share",{"impression":True,"email":True,"qr_code":True,"galerie":True}) s[v]=not s.get(v,True) break # ── PIN ── elif e==S_PIN: if pbtn.get("annuler",pygame.Rect(0,0,0,0)).collidepoint(pos): self.pin_buf=""; self.pin_err=False; self.etat=S_ACCUEIL else: for pkey,r in pbtn.items(): if not isinstance(pkey,tuple) or len(pkey)!=2: continue t,v = pkey if t=="pin" and r.collidepoint(pos): if v=="⌫": self.pin_buf=self.pin_buf[:-1] elif v=="OK": if self.pin_buf=="1234": self.pin_buf=""; self.pin_err=False; self.etat=S_ADMIN else: self.pin_err=True; self.pin_buf="" elif len(self.pin_buf)<4: self.pin_buf+=v break # ── Admin ── elif e==S_ADMIN: R=lambda k: abtn.get(k,pygame.Rect(0,0,0,0)) if R("retour").collidepoint(pos): self.etat=S_ACCUEIL; self.t0=time.time() elif R("wizard").collidepoint(pos): self._wiz_init(); self.etat=S_WIZ elif R("reset").collidepoint(pos): self.compteur=0; self._sav_cpt() elif R("delay_m").collidepoint(pos): self._admin_adj("camera","compte_a_rebours",-1,1,10) elif R("delay_p").collidepoint(pos): self._admin_adj("camera","compte_a_rebours",+1,1,10) elif R("delayi_m").collidepoint(pos): self._admin_adj("camera","delai_entre",-1,0,5) elif R("delayi_p").collidepoint(pos): self._admin_adj("camera","delai_entre",+1,0,5) elif R("copies_m").collidepoint(pos): self._admin_adj("impression","copies_max",-1,1,10) elif R("copies_p").collidepoint(pos): self._admin_adj("impression","copies_max",+1,1,10) elif R("lim_m").collidepoint(pos): self._admin_adj("compteur","limite",-50,50,9999) elif R("lim_p").collidepoint(pos): self._admin_adj("compteur","limite",+50,50,9999) elif R("reconnect_cam").collidepoint(pos): threading.Thread(target=self._reconnect_cam,daemon=True).start() elif R("quitter").collidepoint(pos): running=False elif R("reboot").collidepoint(pos): subprocess.Popen(["sudo","reboot"]) elif R("shutdown").collidepoint(pos): subprocess.Popen(["sudo","shutdown","-h","now"]) else: for akey,r in abtn.items(): if not isinstance(akey,tuple) or len(akey)!=2: continue if not r.collidepoint(pos): continue tp,key = akey if tp=="t": f=self.cfg.setdefault("fonctionnalites",{}) # Fonctionnalités dont la valeur par défaut est False (pas True) _false_defs={"pellicule_mode"} if key=="compteur_actif": self.cfg.setdefault("compteur",{})["actif"]=not self.cfg.get("compteur",{}).get("actif",False) elif key=="compteur_feat": f[key]=not f.get(key,True) elif key in _false_defs: f[key]=not f.get(key,False) else: f[key]=not f.get(key,True) cfg_save(self.cfg) elif tp=="tab": self.admin_tab=key elif tp=="theme": self.cfg.setdefault("evenement",{})["theme"]=key cfg_save(self.cfg); self._apply_theme(key) break # ── Dessin ── e=self.etat if e==S_ACCUEIL: self.draw_accueil() elif e==S_MODE: mbtn=self.draw_mode() elif e==S_PREV: self.draw_preview() elif e==S_COMPTE: self.draw_compte() elif e==S_CAP: self.draw_capture() elif e==S_GAL: gbtn=self.draw_galerie() elif e==S_SHARE: sbtn=self.draw_partage() elif e==S_EMAIL: ebtn=self.draw_email() elif e==S_FORMAT: fmtbtn=self.draw_format() elif e==S_WIZ: wbtn=self.draw_wizard() elif e==S_PIN: pbtn=self.draw_pin() elif e==S_ADMIN: abtn=self.draw_admin() # Sync config (web mobile Flask ET FastAPI backend) — toutes les 2s _now2 = time.time() if _now2 - self._cfg_check > 2: self._cfg_check = _now2 try: _mt = CONFIG_PATH.stat().st_mtime if _mt != self._cfg_mtime or _web_state["reload_flag"].is_set(): self._cfg_mtime = _mt _web_state["reload_flag"].clear() self.cfg = cfg_load() self._apply_theme(self.cfg.get("evenement",{}).get("theme","base")) self.qr_surf = self._make_qr() self.compteur= self.cfg.get("compteur",{}).get("photos_prises",0) self.cpt_max = self.cfg.get("compteur",{}).get("limite",400) self._admin_qr = None # régénérer le QR si IP a changé print("Config rechargée depuis le disque") # Génération lazy du QR si Flask vient de démarrer if self.qr_surf is None: self.qr_surf = self._make_qr() except Exception as _e: print(f"sync cfg: {_e}") pygame.display.flip() self.clock.tick(30) self.prev_actif=False try: self.cam.exit() except: pass pygame.quit() # ── Admin helpers ───────────────────────────────────────────────────────── def _admin_adj(self, section, key, delta, mini, maxi): c=cfg_load(); v=c.setdefault(section,{}).get(key,(mini+maxi)//2) c[section][key]=max(mini,min(maxi,v+delta)); cfg_save(c); self.cfg=c def _reconnect_cam(self): self.prev_actif=False; time.sleep(0.5) try: if self.cam: self.cam.exit() except: pass self.cam=None for i in range(3): try: self.cam=gp.Camera(); self.cam.init(); self.sim=False; self.prev_actif=True; print("Camera reconnectée"); return except: self.cam=None; time.sleep(1) self.sim=True; print("Reconnexion échouée → simulation") # ── Wizard : init & apply ───────────────────────────────────────────────── def _wiz_init(self): import random ev=self.cfg.get("evenement",{}) feat=self.cfg.get("fonctionnalites",{}) self.wiz_step=1 self.wiz_kbbuf=ev.get("nom","") # Code session : réutiliser l'existant ou en générer un nouveau existing_code = self.cfg.get("booth",{}).get("session_code","") self.wiz_code_buf = existing_code if existing_code else f"{random.randint(100000000,999999999)}" self.wiz_code_status = None self.wiz_data={ "nom": ev.get("nom",""), "theme": ev.get("theme","base"), "modes": {"photo_simple":feat.get("photo_simple",True),"multi_shot":feat.get("multi_shot",True)}, "nb_photos": self.cfg.get("multi_shot",{}).get("nombre_photos",4), "share": {"impression":feat.get("impression",True),"email":feat.get("email",True), "qr_code":feat.get("qr_code",True),"galerie":feat.get("galerie",True)}, "session_code": self.wiz_code_buf, } def _test_session_code(self, code): """Teste si le code session est accessible sur le serveur (thread bg).""" import urllib.request, random self.wiz_code_status = "test..." try: url = f"https://photobooth.copydev.fr/s/{code}" urllib.request.urlopen(url, timeout=6) self.wiz_code_status = "ok" except Exception as ex: print(f"Test session code erreur: {ex}") self.wiz_code_status = "erreur" def _wiz_appliquer(self): c=cfg_load() nom = self.wiz_data.get("nom","") or "photobooth" c.setdefault("evenement",{})["nom"] = nom c["evenement"]["theme"] = self.wiz_data.get("theme","base") c.setdefault("multi_shot",{})["nombre_photos"] = self.wiz_data.get("nb_photos",4) feat=c.setdefault("fonctionnalites",{}) feat.update(self.wiz_data.get("modes",{})) feat.update(self.wiz_data.get("share",{})) # ── Code session (saisi/généré dans le wizard step 2) ── code = self.wiz_data.get("session_code","") or self.wiz_code_buf if not code: import random; code=f"{random.randint(100000000,999999999)}" b = c.setdefault("booth",{}) b["url"] = "https://photobooth.copydev.fr" b["session_code"] = code b["actif"] = True cfg_save(c) self.cfg=c self._apply_theme(self.wiz_data.get("theme","base")) self.cpt_max=c.get("compteur",{}).get("limite",400) # QR pointe vers la galerie de la session sess_url = f"https://photobooth.copydev.fr/s/{code}" try: img = qrlib.make(sess_url, border=2).convert("RGB").resize((200,200),Image.NEAREST) self.qr_surf = pygame.image.fromstring(img.tobytes(),img.size,"RGB") except: self.qr_surf = self._make_qr() print(f"Session galerie: {sess_url} (code: {code})") if __name__=="__main__": LOG = Path("/tmp/photobooth_crash.log") try: PB().run() except Exception as e: msg = traceback.format_exc() print(msg, flush=True) LOG.write_text(f"{datetime.now()}\n{msg}") time.sleep(5)