Files
photobooth/photobooth_pygame.py
Jules 1120513309 Switch trigger_capture() : eosremoterelease ne genere FILE_ADDED qu'une fois
eosremoterelease='Immediate' envoyait GP_EVENT_FILE_ADDED uniquement
pour la 1ere pose (bug Canon/gphoto2 connu). trigger_capture() genere
l'evenement pour CHAQUE pose. En liveview actif (miroir deja leve),
trigger_capture() ne bouge pas le miroir — uniquement l'obturateur.
Timeout reduit 15s -> 12s, poll 500ms -> 200ms (reaction plus rapide).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 16:06:30 +02:00

2270 lines
111 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 = """<!DOCTYPE html><html lang=fr><head>
<meta charset=UTF-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>Admin — Photobooth</title>
<style>
:root{--bg:#1a1a2e;--c:#16213e;--p:#e91e63;--t:#fff;--t2:#a0a0b0;--ok:#4caf50;--er:#f44336}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--t);font-family:-apple-system,BlinkMacSystemFont,sans-serif;padding-bottom:40px}
.top{background:var(--c);padding:18px 16px;border-bottom:2px solid var(--p);text-align:center}
.top h1{color:var(--p);font-size:1.5rem;margin-bottom:4px}
.top small{color:var(--t2);font-size:.8rem}
.wrap{padding:16px}
.card{background:var(--c);border-radius:14px;padding:16px;margin-bottom:14px;border:1px solid #252550}
.row{display:flex;align-items:center;justify-content:space-between;padding:14px 0;border-bottom:1px solid #252550}
.row:last-child{border-bottom:none}
.tog{padding:9px 22px;border:none;border-radius:20px;font-size:.85rem;font-weight:700;cursor:pointer}
.on{background:var(--ok);color:#fff}.off{background:var(--er);color:#fff}
.btn{display:block;width:100%;padding:16px;border:none;border-radius:12px;font-size:1rem;font-weight:700;cursor:pointer;margin-top:12px;color:#fff;text-align:center;text-decoration:none}
.p{background:var(--p)}.s{background:#303060}.d{background:var(--er)}
.big{text-align:center;color:var(--p);font-size:2.2rem;font-weight:700;padding:10px}
.sub{color:var(--t2);font-size:.85rem;margin-bottom:6px}
.msg{text-align:center;padding:12px;border-radius:10px;margin-top:10px;display:none}
.ok2{background:#1b5e20;color:#a5d6a7;display:block}.er2{background:#b71c1c;color:#ffcdd2;display:block}
</style>
</head><body>
<div class=top>
<h1>⚙ Administration</h1>
<small id=ip>Chargement...</small>
</div>
<div class=wrap>
<div class=card>
<div class=sub>Événement en cours</div>
<div class=big id=evnom>...</div>
<div style="text-align:center;color:var(--t2)" id=theme></div>
</div>
<div class=card>
<div class=sub>Fonctionnalités</div>
<div class=row><span>Photo simple</span><button class=tog id=t_photo_simple onclick="tog('photo_simple')">...</button></div>
<div class=row><span>Pellicule</span><button class=tog id=t_multi_shot onclick="tog('multi_shot')">...</button></div>
<div class=row><span>Impression</span><button class=tog id=t_impression onclick="tog('impression')">...</button></div>
<div class=row><span>Email</span><button class=tog id=t_email onclick="tog('email')">...</button></div>
<div class=row><span>QR Code</span><button class=tog id=t_qr_code onclick="tog('qr_code')">...</button></div>
</div>
<div class=card>
<div class=sub>Compteur photos</div>
<div class=big id=ctr>...</div>
<button class="btn d" onclick="resetCtr()">Remettre le compteur à 0</button>
</div>
<a href=/wizard class="btn p">Configurer un événement (Wizard)</a>
<div class=msg id=msg></div>
</div>
<script>
var cfg={};
function load(){
fetch('/api/config').then(r=>r.json()).then(d=>{
cfg=d;
var ev=d.evenement||{};
document.getElementById('evnom').textContent=ev.nom||'—';
document.getElementById('theme').textContent='Thème : '+(ev.theme||'base');
var f=d.fonctionnalites||{};
['photo_simple','multi_shot','impression','email','qr_code'].forEach(k=>{
var v=f[k]!==false;
var b=document.getElementById('t_'+k);
b.textContent=v?'ON':'OFF';
b.className='tog '+(v?'on':'off');
});
var c=d.compteur||{};
document.getElementById('ctr').textContent=(c.photos_prises||0)+' / '+(c.limite||400);
});
fetch('/api/status').then(r=>r.json()).then(d=>{
document.getElementById('ip').textContent='IP : '+(d.ip||'?')+' — port 5000';
}).catch(()=>{});
}
function tog(k){
if(!cfg.fonctionnalites)cfg.fonctionnalites={};
cfg.fonctionnalites[k]=(cfg.fonctionnalites[k]===false)?true:false;
save();
}
function resetCtr(){
if(!confirm('Remettre le compteur à 0 ?'))return;
if(!cfg.compteur)cfg.compteur={};
cfg.compteur.photos_prises=0;
save();
}
function save(){
fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(cfg)})
.then(r=>r.json()).then(d=>{showMsg(d.ok?'Sauvegardé !':'Erreur : '+(d.err||''),d.ok);load();});
}
function showMsg(t,ok){
var m=document.getElementById('msg');
m.className='msg '+(ok?'ok2':'er2');
m.textContent=t;
setTimeout(()=>{m.className='msg';},3000);
}
load();
</script>
</body></html>"""
_WIZARD_HTML = """<!DOCTYPE html><html lang=fr><head>
<meta charset=UTF-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>Wizard — Photobooth</title>
<style>
:root{--bg:#1a1a2e;--c:#16213e;--p:#e91e63;--t:#fff;--t2:#a0a0b0;--ok:#4caf50;--er:#f44336}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--t);font-family:-apple-system,BlinkMacSystemFont,sans-serif;padding-bottom:40px}
.top{background:var(--c);padding:16px;border-bottom:2px solid var(--p);text-align:center}
.top h1{color:var(--p);font-size:1.4rem;margin-bottom:8px}
.prog{height:4px;background:#333;border-radius:2px;margin-top:8px}
.pf{height:4px;background:var(--p);border-radius:2px;transition:.3s;width:16.6%}
.wrap{padding:16px}
.step{display:none}.step.active{display:block}
h2{font-size:1.1rem;color:var(--t2);margin-bottom:16px}
.card{background:var(--c);border-radius:14px;padding:16px;margin-bottom:14px;border:1px solid #252550}
input[type=text]{width:100%;background:#232345;border:1px solid #444;border-radius:10px;padding:14px;color:#fff;font-size:1rem;margin:8px 0}
.thg{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:8px}
.thc{border-radius:12px;padding:14px 8px;text-align:center;cursor:pointer;border:2px solid transparent;font-size:.85rem;font-weight:700;transition:.2s}
.thc.sel{border-color:#fff;box-shadow:0 0 14px rgba(255,255,255,.3)}
.row{display:flex;align-items:center;justify-content:space-between;padding:14px 0;border-bottom:1px solid #252550}
.row:last-child{border-bottom:none}
.tog{padding:9px 22px;border:none;border-radius:20px;font-size:.85rem;font-weight:700;cursor:pointer}
.on{background:var(--ok);color:#fff}.off{background:var(--er);color:#fff}
.nbc{display:flex;align-items:center;justify-content:center;gap:20px;padding:20px}
.nbb{width:60px;height:60px;background:#303060;border:none;border-radius:50%;color:#fff;font-size:1.8rem;cursor:pointer}
.nbv{font-size:2.5rem;font-weight:700;color:var(--p);min-width:60px;text-align:center}
.nav{display:flex;gap:10px;margin-top:16px}
.btn{flex:1;padding:16px;border:none;border-radius:12px;font-size:1rem;font-weight:700;cursor:pointer;color:#fff;text-align:center;text-decoration:none}
.p{background:var(--p)}.s{background:#303060}
table{width:100%;border-collapse:collapse}
td{padding:12px 8px;border-bottom:1px solid #252550;font-size:.95rem}
td:first-child{color:var(--t2)}td:last-child{font-weight:700}
</style>
</head><body>
<div class=top>
<h1>Configuration événement</h1>
<div class=prog><div class=pf id=pf></div></div>
</div>
<div class=wrap>
<div class=step id=s1>
<h2>1 — Nom de l'événement</h2>
<div class=card>
<input type=text id=nom placeholder="Ex : Mariage Martin, Anniversaire 40 ans..." autocomplete=off>
</div>
<div class=nav>
<a href=/admin class="btn s">Annuler</a>
<button class="btn p" onclick="go(2)">Suivant →</button>
</div>
</div>
<div class=step id=s2>
<h2>2 — Thème visuel</h2>
<div class=thg>
<div class=thc id=th_base style="background:#1a1a2e;color:#e91e63" onclick="selT('base')">Sombre</div>
<div class=thc id=th_sikapics style="background:#fbe7e8;color:#c31f48" onclick="selT('sikapics')">SikaPics</div>
<div class=thc id=th_copydev style="background:#08122a;color:#29b6f6" onclick="selT('copydev')">CopyDev</div>
</div>
<div class=nav>
<button class="btn s" onclick="go(1)">← Retour</button>
<button class="btn p" onclick="go(3)">Suivant →</button>
</div>
</div>
<div class=step id=s3>
<h2>3 — Modes photo</h2>
<div class=card>
<div class=row><span>Photo simple (1 prise)</span><button class=tog id=m_photo_simple onclick="togM('photo_simple')">ON</button></div>
<div class=row><span>Pellicule (plusieurs poses)</span><button class=tog id=m_multi_shot onclick="togM('multi_shot')">ON</button></div>
</div>
<div class=nav>
<button class="btn s" onclick="go(2)">← Retour</button>
<button class="btn p" onclick="go(4)">Suivant →</button>
</div>
</div>
<div class=step id=s4>
<h2>4 — Photos par pellicule</h2>
<div class=card>
<div class=nbc>
<button class=nbb onclick="nbC(-1)">−</button>
<div class=nbv id=nb>4</div>
<button class=nbb onclick="nbC(1)">+</button>
</div>
</div>
<div class=nav>
<button class="btn s" onclick="go(3)">← Retour</button>
<button class="btn p" onclick="go(5)">Suivant →</button>
</div>
</div>
<div class=step id=s5>
<h2>5 — Options de partage</h2>
<div class=card>
<div class=row><span>Impression</span><button class=tog id=sh_impression onclick="togS('impression')">ON</button></div>
<div class=row><span>Email</span><button class=tog id=sh_email onclick="togS('email')">ON</button></div>
<div class=row><span>QR Code galerie</span><button class=tog id=sh_qr_code onclick="togS('qr_code')">ON</button></div>
<div class=row><span>Galerie</span><button class=tog id=sh_galerie onclick="togS('galerie')">ON</button></div>
</div>
<div class=nav>
<button class="btn s" onclick="go(4)">← Retour</button>
<button class="btn p" onclick="go(6)">Résumé →</button>
</div>
</div>
<div class=step id=s6>
<h2>6 — Résumé</h2>
<div class=card><div id=summary></div></div>
<div class=nav>
<button class="btn s" onclick="go(5)">← Retour</button>
<button class="btn p" onclick="lancer()">Lancer le photobooth</button>
</div>
</div>
</div>
<script>
var D={nom:'',theme:'base',nb_photos:4,modes:{photo_simple:true,multi_shot:true},share:{impression:true,email:true,qr_code:true,galerie:true}};
function go(s){
if(s==2) D.nom=document.getElementById('nom').value.trim();
document.querySelectorAll('.step').forEach(e=>e.classList.remove('active'));
document.getElementById('s'+s).classList.add('active');
document.getElementById('pf').style.width=((s/6)*100)+'%';
if(s==6) fillSummary();
window.scrollTo(0,0);
}
function selT(t){
D.theme=t;
document.querySelectorAll('.thc').forEach(c=>c.classList.remove('sel'));
document.getElementById('th_'+t).classList.add('sel');
}
function togM(k){
D.modes[k]=!D.modes[k];
var b=document.getElementById('m_'+k);
b.textContent=D.modes[k]?'ON':'OFF';
b.className='tog '+(D.modes[k]?'on':'off');
}
function togS(k){
D.share[k]=!D.share[k];
var b=document.getElementById('sh_'+k);
b.textContent=D.share[k]?'ON':'OFF';
b.className='tog '+(D.share[k]?'on':'off');
}
function nbC(d){
D.nb_photos=Math.max(1,Math.min(8,D.nb_photos+d));
document.getElementById('nb').textContent=D.nb_photos;
}
function fillSummary(){
var tn={base:'Sombre',sikapics:'SikaPics',copydev:'CopyDev'};
var rows=[
['Événement',D.nom||'—'],
['Thème',tn[D.theme]||D.theme],
['Modes',Object.keys(D.modes).filter(k=>D.modes[k]).join(', ')||'—'],
['Photos/pellicule',D.nb_photos],
['Partage',Object.keys(D.share).filter(k=>D.share[k]).join(', ')||'—'],
];
var h='<table>';
rows.forEach(function(r){h+='<tr><td>'+r[0]+'</td><td>'+r[1]+'</td></tr>';});
document.getElementById('summary').innerHTML=h+'</table>';
}
function lancer(){
fetch('/api/wizard',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(D)})
.then(r=>r.json()).then(d=>{
if(d.ok) window.location='/admin';
else alert('Erreur : '+(d.err||'inconnue'));
});
}
fetch('/api/config').then(r=>r.json()).then(d=>{
var ev=d.evenement||{};
D.nom=ev.nom||''; D.theme=ev.theme||'base';
document.getElementById('nom').value=D.nom;
selT(D.theme);
var f=d.fonctionnalites||{};
D.modes.photo_simple=f.photo_simple!==false;
D.modes.multi_shot=f.multi_shot!==false;
D.share.impression=f.impression!==false;
D.share.email=f.email!==false;
D.share.qr_code=f.qr_code!==false;
D.share.galerie=f.galerie!==false;
D.nb_photos=(d.multi_shot||{}).nombre_photos||4;
document.getElementById('nb').textContent=D.nb_photos;
['photo_simple','multi_shot'].forEach(k=>{
var b=document.getElementById('m_'+k);
b.textContent=D.modes[k]?'ON':'OFF';
b.className='tog '+(D.modes[k]?'on':'off');
});
['impression','email','qr_code','galerie'].forEach(k=>{
var b=document.getElementById('sh_'+k);
b.textContent=D.share[k]?'ON':'OFF';
b.className='tog '+(D.share[k]?'on':'off');
});
});
go(1);
</script>
</body></html>"""
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:
# Le liveview est actif (capture_preview() tourne) → miroir DÉJÀ levé
# trigger_capture() en liveview = obturateur seul, pas de mouvement miroir
print("Capture demandée — trigger_capture...")
self.cam.trigger_capture()
ch = None
deadline = time.time() + 12
while time.time() < deadline:
ev_type, ev_data = self.cam.wait_for_event(200)
if ev_type == gp.GP_EVENT_FILE_ADDED:
print(f"Fichier reçu : {ev_data.name}")
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)
print(f"Photo sauvegardée : {nom}"); break
if ch is None:
print("AVERTISSEMENT : aucun fichier reçu dans les 12s")
self.cap_result=ch; self.cap_demande=False; self.cap_done.set()
errs = 0; post_capture_grace = 8
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(1.5)
self.etat=S_COMPTE
for c in range(delai,0,-1): self.chiffre=c; time.sleep(1)
print(f"Pose {i+1}/{nb} — déclenchement")
self.etat=S_CAP; ch=self._capturer()
print(f"Pose {i+1}/{nb} — résultat : {ch}")
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)
else:
self._prev_thumbs.append(None) # placeholder pour garder l'index cohérent
self.etat=S_PREV # repasse en preview pour montrer la vignette
time.sleep(0.6)
print(f"Séquence terminée — {len(self.photos)}/{nb} photos")
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_w<W-10:
pygame.draw.rect(self.scr,(180,180,180),(x,hy,hole_w,hole_h),border_radius=5)
pygame.draw.rect(self.scr,(8,8,8),(x+3,hy+3,hole_w-6,hole_h-6),border_radius=3)
x+=62
for i in range(nb):
fx=gap+i*(fw+gap); fy=frame_y
surf=self.gal_surfs[i] if i<len(self.gal_surfs) else None
if surf:
ts=_pfit(surf,fw,fh)
self.scr.blit(ts,(fx,fy))
pygame.draw.rect(self.scr,T["prim"],(fx,fy,fw,fh),2)
else:
pygame.draw.rect(self.scr,(15,15,15),(fx,fy,fw,fh))
nf=self.F["xxs"].render(f"{i+1:02d}A",True,(90,90,90))
num_y=frame_y+fh+_SPR_GAP+4
if num_y+20<film_y+_FILM_H:
self.scr.blit(nf,(fx+fw//2-nf.get_width()//2,num_y))
elif self.format in ("bande3x2","bande4x2"):
nb=3 if self.format=="bande3x2" else 4
pw=W//nb; sh=(H-150)//2
for strip_y in (5, sh+10):
for i,surf in enumerate(self.gal_surfs[:nb]):
rx=i*pw
if surf:
self.scr.blit(surf,(rx,strip_y))
pygame.draw.rect(self.scr,T["prim"],pygame.Rect(rx,strip_y,pw,sh),2)
# Séparateur horizontal central
pygame.draw.line(self.scr,T["carte"],(0,sh+7),(W,sh+7),2)
else: # grille_4
gw,gh=W//2-15,H//2-75
pos=[(5,5),(W//2+5,5),(5,H//2-65),(W//2+5,H//2-65)]
for i,surf in enumerate(self.gal_surfs[:4]):
x,y=pos[i]
if surf:
self.scr.blit(surf,(x,y))
pygame.draw.rect(self.scr,T["prim"],pygame.Rect(x,y,gw,gh),2)
else:
pygame.draw.rect(self.scr,T["carte"],pygame.Rect(x,y,gw,gh))
bande=pygame.Surface((W,120),pygame.SRCALPHA); bande.fill((0,0,0,200))
self.scr.blit(bande,(0,H-120))
btns={}; feat=self.cfg.get("fonctionnalites",{})
bx=50; bw,bh=260,88; by=H-110
for key,label in [("imprimer","Imprimer"),("email","Email")]:
fk={"imprimer":"impression","email":"email"}[key]
if feat.get(fk,True):
r=pygame.Rect(bx,by,bw,bh); self._btn(r,T["carte"],label,"sm",tc=T["texte"]); btns[key]=r; bx+=bw+20
r=pygame.Rect(W-bw-50,by,bw,bh); self._btn(r,T["prim"],"Nouvelle session","sm"); btns["nouveau"]=r
# QR session — même QR que l'accueil (galerie de l'événement)
qr = self.qr_surf
if qr:
qw=qr.get_width(); qh=qr.get_height()
qx=W-qw-15; qy=5
bg=pygame.Surface((qw+20,qh+40),pygame.SRCALPHA); bg.fill((0,0,0,180))
self.scr.blit(bg,(qx-10,qy))
self.scr.blit(qr,(qx,qy+20))
sl=self.F["xs"].render("Voir la galerie",True,(255,255,255))
self.scr.blit(sl,(qx+qw//2-sl.get_width()//2,qy+4))
# Indicateur upload en cours
if not self.session_qr and self.gal_ready:
sl=self.F["xs"].render("⬆ Upload...",True,T["texte2"])
self.scr.blit(sl,(W-sl.get_width()-20,12))
return btns
# ── Partage ───────────────────────────────────────────────────────────────
def draw_partage(self):
self._bg(); T=self.T
s=self._txt("Partagez votre photo !","lg",T["texte"]); self._c(s,50)
if self.photo_active and Path(self.photo_active).exists():
try:
img=Image.open(self.photo_active).convert("RGB"); img.thumbnail((680,460),Image.LANCZOS)
surf=pygame.image.fromstring(img.tobytes(),img.size,"RGB")
px=W//2-surf.get_width()//2; py=160
pygame.draw.rect(self.scr,(0,0,0),pygame.Rect(px+6,py+8,surf.get_width(),surf.get_height()),border_radius=4)
self.scr.blit(surf,(px,py))
pygame.draw.rect(self.scr,T["prim"],pygame.Rect(px,py,surf.get_width(),surf.get_height()),2,border_radius=4)
except: pass
feat=self.cfg.get("fonctionnalites",{}); btns={}
acts=[("imprimer","impression","Imprimer"),("email","email","Email"),("qr","qr_code","QR Code")]
avail=[a for a in acts if feat.get(a[1],True)]
bw=260; gap=40; bh=170
bx=W//2-(len(avail)*bw+(len(avail)-1)*gap)//2; by=660
for key,_,label in avail:
r=pygame.Rect(bx,by,bw,bh); self._card(r)
s=self._txt(label,"sm",T["texte"]); self.scr.blit(s,(r.centerx-s.get_width()//2,r.centery-s.get_height()//2))
btns[key]=r; bx+=bw+gap
if self.share_msg:
col=(76,175,80) if "OK" in self.share_msg else (244,67,54)
s=self._txt(self.share_msg,"sm",col,False); self._c(s,by+bh+24)
r_fin=pygame.Rect(W//2+50,H-105,300,76); self._btn(r_fin,T["prim"],"Terminer","sm"); btns["terminer"]=r_fin
r_nw=pygame.Rect(W//2-350,H-105,300,76); self._btn(r_nw,T["carte"],"Nouvelle photo","sm",tc=T["texte"]); btns["nouveau"]=r_nw
return btns
# ── Email clavier ─────────────────────────────────────────────────────────
def draw_email(self):
self._bg(); T=self.T
s=self._txt("Entrez votre adresse email","lg",T["texte"]); self._c(s,50)
field=pygame.Rect(W//2-520,150,1040,82)
self._card(field,rayon=14)
buf=self.F["md"].render(self.email_buf or " ",True,T["texte"])
self.scr.blit(buf,(field.x+20,field.centery-buf.get_height()//2))
lignes=[list("1234567890"),list("azertyuiop"),list("qsdfghjklm"),list("wxcvbn@.-_")]
extras=[("<--",120),("@gmail.com",280),("@hotmail.fr",300),(".com",150),(".fr",120)]
btns={}; ky=278; kw,kh=88,76; gap=7
for ligne in lignes:
kx=W//2-(len(ligne)*(kw+gap)-gap)//2
for ch in ligne:
r=pygame.Rect(kx,ky,kw,kh); self._btn(r,T["carte"],ch,"sm",12,tc=T["texte"]); btns[("k",ch)]=r; kx+=kw+gap
ky+=kh+gap
ky+=8; kx=W//2-(sum(ew+gap for _,ew in extras)-gap)//2
for ex,ew in extras:
r=pygame.Rect(kx,ky,ew,kh)
col=(244,67,54) if ex=="<--" else T["carte"]
self._btn(r,col,ex,"xs",12,tc=T["texte"] if ex!="<--" else (255,255,255))
btns[("k",ex)]=r; kx+=ew+gap
ky+=kh+22
rs=pygame.Rect(W//2-340,ky,310,76); self._btn(rs,T["prim"],"Envoyer","sm"); btns["envoyer"]=rs
ra=pygame.Rect(W//2+30,ky,310,76); self._btn(ra,T["carte"],"Annuler","sm",tc=T["texte"]); btns["annuler"]=ra
if self.email_msg:
col=(76,175,80) if "OK" in self.email_msg else (244,67,54)
s=self._txt(self.email_msg,"sm",col,False); self._c(s,ky+90)
return btns
# ══════════════════════════════════════════════════════════════════════════
# WIZARD
# ══════════════════════════════════════════════════════════════════════════
def draw_wizard(self):
self._bg(); T=self.T; btns={}
STEPS=7
# Barre de progression
self._progress_bar(self.wiz_step,STEPS,y=30)
# Fermer
rc=pygame.Rect(W-90,10,70,70); self._btn(rc,T["carte"],"X","md",35,tc=T["texte"]); btns["fermer"]=rc
step=self.wiz_step
# ── Étape 1 : Nom de l'événement ──
if step==1:
s=self._txt("Nom de l'evenement","lg",T["texte"]); self._c(s,80)
s=self._txt("Mariage, anniversaire, soiree...","sm",T["texte2"],False); self._c(s,185)
field=pygame.Rect(W//2-520,255,1040,82); self._card(field,rayon=14)
buf=self.F["md"].render(self.wiz_kbbuf or " ",True,T["texte"])
self.scr.blit(buf,(field.x+20,field.centery-buf.get_height()//2))
lignes=[list("1234567890 "),list("azertyuiop"),list("qsdfghjklm"),list("wxcvbn-'& ")]
extras=[("<--",120),("Espace",200),(".com",150),(".fr",120)]
ky=370; kw,kh=84,72; gap=7
for ligne in lignes:
kx=W//2-(len(ligne)*(kw+gap)-gap)//2
for ch in ligne:
lbl=" " if ch==" " else ch
r=pygame.Rect(kx,ky,kw,kh); self._btn(r,T["carte"],lbl,"sm",12,tc=T["texte"]); btns[("k",ch)]=r; kx+=kw+gap
ky+=kh+gap
ky+=6; kx=W//2-(sum(ew+gap for _,ew in extras)-gap)//2
for ex,ew in extras:
r=pygame.Rect(kx,ky,ew,kh)
col=(244,67,54) if ex=="<--" else T["carte"]
self._btn(r,col,ex,"xs",12,tc=T["texte"] if ex!="<--" else (255,255,255))
btns[("k",ex)]=r; kx+=ew+gap
ky+=kh+24
rn=pygame.Rect(W//2-210,ky,420,76); self._btn(rn,T["prim"],"Suivant","sm"); btns["next"]=rn
# ── Étape 2 : Code de session galerie ──
elif step==2:
s=self._txt("Code de session galerie","lg",T["texte"]); self._c(s,80)
s2=self._txt("Les photos de cet evenement seront accessibles via ce code","sm",T["texte2"],False); self._c(s2,160)
# Affichage du code (9 chiffres) — fond carte pour isoler visuellement
c=self.wiz_code_buf
if len(c)==9: code_disp=f"{c[:3]}-{c[3:6]}-{c[6:]}"
elif len(c)>0: 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)