- Retire eosviewfinder=1 avant/apres chaque prise : relancait le liveview (4-6s de blocage) → timeout 15s → ch=None → photos=[] → S_ACCUEIL - Le miroir est deja leve par capture_preview() continu, l'obturateur seul se declenche via eosremoterelease=Immediate - Logs detailles : chaque etape imprime son resultat pour debug - Apres capture echouee : _prev_thumbs.append(None) pour garder index cohérent - S_PREV 2.5s → 1.5s (plus reactif entre les poses) - Apres capture : revient en S_PREV 0.6s pour montrer la vignette - Print "Séquence terminée N/M photos" pour suivi Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2290 lines
112 KiB
Python
2290 lines
112 KiB
Python
#!/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 déjà actif (capture_preview() tourne en continu)
|
||
# → le miroir est DÉJÀ levé, on déclenche uniquement l'obturateur
|
||
print("Capture demandée — déclenchement obturateur...")
|
||
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
|
||
print("eosremoterelease=Immediate OK")
|
||
except Exception as ex:
|
||
print(f"eosremoterelease indispo ({ex}), trigger_capture fallback")
|
||
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:
|
||
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
|
||
elif ev_type == gp.GP_EVENT_TIMEOUT:
|
||
pass # normal, on continue d'attendre
|
||
if ch is None:
|
||
print("AVERTISSEMENT : aucun fichier reçu dans les 15s")
|
||
# Réinitialise le remote release sans toucher au viewfinder
|
||
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
|
||
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)
|