Mode pellicule : bande style film 35mm activable dans l'admin

- Nouveau toggle "Mode pellicule" dans l'onglet Fonctions de l'admin
- _draw_pellicule() : bande noire cinéma avec perforations haut/bas,
  cadres numérotés (01A, 02A...) ; le live view est rendu dans le cadre
  courant, les photos prises occupent leur case, les futures sont en gris
- draw_preview() / draw_compte() : utilisent _draw_pellicule() quand activé
- _make_composite() : génère une bande simple (non doublée) en mode pellicule
- Thumbnails chargés à _PELL_FH (748px) pour la qualité native en pellicule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 17:17:43 +02:00
parent 1c1f0323cc
commit 34f66bde1c

View File

@@ -36,6 +36,13 @@ PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
W, H = 1920, 1280 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 ─────────────────────────────────────────────────────────────────── # ── Thèmes ───────────────────────────────────────────────────────────────────
THEMES = { THEMES = {
"base": { "base": {
@@ -783,7 +790,9 @@ class PB:
nb = 4 nb = 4
delai = self.cfg.get("camera",{}).get("compte_a_rebours",3) 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 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 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): for i in range(nb):
self.num_ph=i; self.etat=S_PREV; time.sleep(1.5) self.num_ph=i; self.etat=S_PREV; time.sleep(1.5)
@@ -793,7 +802,7 @@ class PB:
if ch: if ch:
self.photos.append(ch); self.compteur+=1; self._sav_cpt() self.photos.append(ch); self.compteur+=1; self._sav_cpt()
try: try:
img = ImageOps.exif_transpose(Image.open(ch).convert("RGB")).resize((strip_bw, 200), Image.LANCZOS) img = ImageOps.exif_transpose(Image.open(ch).convert("RGB")).resize((strip_bw, thumb_h), Image.LANCZOS)
self._prev_thumbs.append(pygame.image.fromstring(img.tobytes(), img.size, "RGB")) self._prev_thumbs.append(pygame.image.fromstring(img.tobytes(), img.size, "RGB"))
except: except:
self._prev_thumbs.append(None) self._prev_thumbs.append(None)
@@ -831,11 +840,26 @@ class PB:
except: pass except: pass
def _make_composite(self, photos): def _make_composite(self, photos):
"""Crée une image composite (bande ou grille) à partir des 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)) canvas = Image.new("RGB", (W, H), (0, 0, 0))
if self.format in ("bande3x2", "bande4x2"): if self.format in ("bande3x2", "bande4x2"):
nb = 3 if self.format == "bande3x2" else 4 nb = 3 if self.format == "bande3x2" else 4
pw = W // nb 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.exif_transpose(Image.open(ch).convert("RGB")).resize((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 sh = (H - 150) // 2
for i, ch in enumerate(photos[:nb]): for i, ch in enumerate(photos[:nb]):
try: try:
@@ -854,6 +878,7 @@ class PB:
canvas.paste(img, pos[i]) canvas.paste(img, pos[i])
except Exception as e: except Exception as e:
print(f"composite grille: {e}") print(f"composite grille: {e}")
dest = "/tmp/photobooth_composite.jpg" dest = "/tmp/photobooth_composite.jpg"
canvas.save(dest, "JPEG", quality=90) canvas.save(dest, "JPEG", quality=90)
return dest return dest
@@ -1065,6 +1090,103 @@ class PB:
return btns return btns
# ── Preview / Compte / Capture ──────────────────────────────────────────── # ── 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 = th if th.get_size() == (fw, fh) else pygame.transform.scale(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 = pygame.transform.scale(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): def _draw_multi_strip(self):
"""Overlay bas : cases multipose (done/courant/futur).""" """Overlay bas : cases multipose (done/courant/futur)."""
T = self.T T = self.T
@@ -1103,19 +1225,30 @@ class PB:
lbl = self.F["lg"].render(str(i + 1), True, T["texte2"]) 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)) 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): def draw_preview(self):
if self._is_pell():
self._draw_pellicule(show_live=True)
elif self.mode == "multi":
self._lv() self._lv()
if self.mode == "multi":
self._draw_multi_strip() self._draw_multi_strip()
else: else:
self._lv()
bande=pygame.Surface((W,80),pygame.SRCALPHA); bande.fill((0,0,0,170)) bande=pygame.Surface((W,80),pygame.SRCALPHA); bande.fill((0,0,0,170))
self.scr.blit(bande,(0,H-80)) self.scr.blit(bande,(0,H-80))
s=self.F["sm"].render("Souriez !",True,(255,255,255)) s=self.F["sm"].render("Souriez !",True,(255,255,255))
self.scr.blit(s,(W//2-s.get_width()//2,H-62)) self.scr.blit(s,(W//2-s.get_width()//2,H-62))
def draw_compte(self): def draw_compte(self):
T = self.T
if self._is_pell():
self._draw_pellicule(show_live=True)
self._overlay(90)
else:
self._lv(); self._overlay(110) self._lv(); self._overlay(110)
ch=str(self.chiffre); T=self.T ch=str(self.chiffre)
s=self.F["xxl"].render(ch,True,(255,255,255)) s=self.F["xxl"].render(ch,True,(255,255,255))
hr=s.get_width()//2+90 hr=s.get_width()//2+90
hs=pygame.Surface((hr*2,hr*2),pygame.SRCALPHA) hs=pygame.Surface((hr*2,hr*2),pygame.SRCALPHA)
@@ -1124,10 +1257,10 @@ class PB:
pygame.draw.circle(hs,(*T["prim"],a),(hr,hr),hr-i*16) pygame.draw.circle(hs,(*T["prim"],a),(hr,hr),hr-i*16)
self.scr.blit(hs,(W//2-hr,H//2-hr)) self.scr.blit(hs,(W//2-hr,H//2-hr))
self._c(s,H//2-s.get_height()//2) self._c(s,H//2-s.get_height()//2)
if self.mode == "multi": if self.mode == "multi" and not self._is_pell():
self._draw_multi_strip() self._draw_multi_strip()
else: elif self.mode == "simple":
total=1 if self.mode=="simple" else (3 if self.format=="bande3x2" else 4) total=1
info=self.F["sm"].render(f"Photo {self.num_ph+1} / {total}",True,T["texte2"]) 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)) self.scr.blit(info,(W//2-info.get_width()//2,H-90))
@@ -1540,16 +1673,19 @@ class PB:
def _adm_fonctions(self,btns,y): def _adm_fonctions(self,btns,y):
feat=self.cfg.get("fonctionnalites",{}) feat=self.cfg.get("fonctionnalites",{})
items=[("photo_simple","Photo simple"),("multi_shot","Pellicule"), items=[("photo_simple","Photo simple"),("multi_shot","Pellicule"),
("impression","Impression"), ("email","Email"), ("pellicule_mode","Mode pellicule"),("impression","Impression"),
("qr_code","QR Code"), ("filtres","Filtres"), ("email","Email"), ("qr_code","QR Code"),
("overlays","Overlays"), ("chroma_key","Chroma key"), ("filtres","Filtres"), ("overlays","Overlays"),
("galerie","Galerie web"), ("compteur_feat","Compteur")] ("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 cw=860; gap=28; rh=64
lx=W//2-cw-gap//2; rx=W//2+gap//2 lx=W//2-cw-gap//2; rx=W//2+gap//2
for i,(key,label) in enumerate(items): for i,(key,label) in enumerate(items):
col=i%2; row=i//2 col=i%2; row=i//2
x=lx if col==0 else rx x=lx if col==0 else rx
self._tog_row(btns,key,label,feat.get(key,True),x,y+row*(rh+10),cw) 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): def _adm_materiel(self,btns,y):
T=self.T; cc=self.cfg.get("camera",{}); ci=self.cfg.get("impression",{}) T=self.T; cc=self.cfg.get("camera",{}); ci=self.cfg.get("impression",{})