-
Cadres / Overlays
-
Cochez les cadres a proposer aux utilisateurs.
+
+
Cadres d'impression
+
Selectionnez un cadre PNG par format. Le cadre est applique sur chaque impression. Laissez vide pour imprimer sans cadre.
+
+
+
+
Cadres / Overlays photo
+
Cochez les cadres a proposer aux utilisateurs en live.
diff --git a/frontend/js/admin.js b/frontend/js/admin.js
index 6f1efc0..e8e61c1 100644
--- a/frontend/js/admin.js
+++ b/frontend/js/admin.js
@@ -19,6 +19,7 @@ async function chargerAdmin() {
chargerCompteur();
chargerDestinations();
chargerCadres();
+ chargerCadresImpression();
chargerAnimations();
chargerFonctionnalites();
chargerEmailAdmin();
@@ -57,6 +58,10 @@ async function chargerMateriel() {
// Imprimantes
await rafraichirImprimantes();
if (imp.imprimante) document.getElementById('admin-imprimante').value = imp.imprimante;
+ if (imp.format) document.getElementById('admin-format-papier').value = imp.format;
+ const fmt = (imp.format || '15x20').replace('-2up', '');
+ const orient = (imp.orientations || {})[fmt] || 'portrait';
+ document.querySelector(`input[name="admin-orientation"][value="${orient}"]`).checked = true;
setValue('admin-copies-max', imp.copies_max || 5);
}
@@ -78,6 +83,19 @@ async function reconnecterCamera() {
chargerMateriel();
}
+function _getOrientations() {
+ const fmt = (getValue('admin-format-papier') || '15x20').replace('-2up', '');
+ const orient = document.querySelector('input[name="admin-orientation"]:checked')?.value || 'portrait';
+ // On conserve les orientations des autres formats depuis la config courante
+ const existing = (config.impression || {}).orientations || {};
+ return { ...existing, [fmt]: orient };
+}
+
+async function evacuerBourrage() {
+ const r = await apiPost('/api/imprimante/evacuer', {});
+ afficherStatut(r.message || 'Imprimante réactivée', r.succes ? 'succes' : 'erreur');
+}
+
async function sauvegarderMateriel() {
await apiPost('/api/config', {
camera: {
@@ -86,7 +104,9 @@ async function sauvegarderMateriel() {
},
impression: {
imprimante: getValue('admin-imprimante'),
+ format: getValue('admin-format-papier'),
copies_max: parseInt(getValue('admin-copies-max')) || 5,
+ orientations: _getOrientations(),
},
});
afficherStatut('Materiel sauvegarde', 'succes');
@@ -208,7 +228,108 @@ async function sauvegarderDestinations() {
afficherStatut('Destinations sauvegardees', 'succes');
}
-// === CADRES ===
+// === CADRES IMPRESSION (par format) ===
+
+const FORMATS_IMPRESSION = ['strip', '10x15', '15x20'];
+
+async function chargerCadresImpression() {
+ for (const fmt of FORMATS_IMPRESSION) {
+ await chargerCadresFormat(fmt);
+ }
+}
+
+async function chargerCadresFormat(fmt) {
+ const data = await apiGet(`/api/cadres-impression/${fmt}`);
+ const liste = document.getElementById(`cadres-liste-${fmt}`);
+ liste.innerHTML = '';
+
+ // Option "Aucun cadre"
+ const divAucun = _creerItemCadreImpression(fmt, null, data.actif);
+ liste.appendChild(divAucun);
+
+ if (data.disponibles.length === 0) {
+ const p = document.createElement('p');
+ p.className = 'texte-secondaire';
+ p.textContent = 'Aucun cadre importe';
+ liste.appendChild(p);
+ return;
+ }
+
+ for (const nom of data.disponibles) {
+ const div = _creerItemCadreImpression(fmt, nom, data.actif);
+ liste.appendChild(div);
+ }
+}
+
+function _creerItemCadreImpression(fmt, nom, actif) {
+ const div = document.createElement('div');
+ div.className = 'cadre-imp-item';
+
+ const rb = document.createElement('input');
+ rb.type = 'radio';
+ rb.name = `cadre-imp-${fmt}`;
+ rb.value = nom || '';
+ rb.checked = (actif === nom);
+ rb.onchange = () => activerCadreFormat(fmt, nom);
+
+ if (nom) {
+ const img = document.createElement('img');
+ img.src = `/assets/cadres/${fmt}/${nom}`;
+ img.alt = nom;
+ img.className = 'cadre-preview';
+
+ const btnDel = document.createElement('button');
+ btnDel.className = 'btn-danger btn-icone';
+ btnDel.title = 'Supprimer';
+ btnDel.textContent = '✕';
+ btnDel.onclick = () => supprimerCadreFormat(fmt, nom);
+
+ const span = document.createElement('span');
+ span.textContent = nom.replace('.png', '');
+
+ div.appendChild(rb);
+ div.appendChild(img);
+ div.appendChild(span);
+ div.appendChild(btnDel);
+ } else {
+ const span = document.createElement('span');
+ span.textContent = 'Aucun cadre';
+ div.appendChild(rb);
+ div.appendChild(span);
+ }
+
+ return div;
+}
+
+async function activerCadreFormat(fmt, nom) {
+ await apiPost(`/api/cadres-impression/${fmt}/actif`, { nom: nom || null });
+ afficherStatut(`Cadre ${fmt} mis a jour`, 'succes');
+}
+
+async function supprimerCadreFormat(fmt, nom) {
+ await fetch(`/api/cadres-impression/${fmt}/${encodeURIComponent(nom)}`, { method: 'DELETE' });
+ await chargerCadresFormat(fmt);
+ afficherStatut('Cadre supprime', 'succes');
+}
+
+async function uploaderCadreFormat(fmt) {
+ const input = document.getElementById(`input-cadre-${fmt}`);
+ if (!input.files.length) return;
+
+ const formData = new FormData();
+ formData.append('fichier', input.files[0]);
+
+ const r = await fetch(`/api/upload/cadre/${fmt}`, { method: 'POST', body: formData });
+ if (r.ok) {
+ input.value = '';
+ await chargerCadresFormat(fmt);
+ afficherStatut(`Cadre ${fmt} importe`, 'succes');
+ } else {
+ afficherStatut('Erreur import cadre', 'erreur');
+ }
+}
+
+// === CADRES OVERLAYS PHOTO ===
async function chargerCadres() {
const data = await apiGet('/api/cadres');
diff --git a/frontend/js/app.js b/frontend/js/app.js
index 736ed74..9d58eff 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -186,15 +186,49 @@ document.addEventListener('keydown', (e) => {
// --- Modes ---
+let cadreChoisi = null; // cadre sélectionné par l'utilisateur avant capture
+
function setupModes() {
document.querySelectorAll('#ecran-mode .btn-mode').forEach(btn => {
- btn.addEventListener('click', () => {
+ btn.addEventListener('click', async () => {
modeActuel = btn.dataset.mode;
- allerA('capture');
+ cadreChoisi = null;
+ const hasCadres = await chargerCadresChoix();
+ allerA(hasCadres ? 'cadre-choix' : 'capture');
});
});
}
+async function chargerCadresChoix() {
+ const cfg = await apiGet('/api/config');
+ const fmt = (cfg.impression?.format || '15x20').replace('-2up', '');
+ const data = await apiGet(`/api/cadres-impression/${fmt}`).catch(() => null);
+ if (!data || data.disponibles.length === 0) return false;
+
+ const grille = document.getElementById('grille-cadres-choix');
+ // Conserver "Sans cadre" en premier, supprimer les anciens cadres
+ const aucun = grille.querySelector('.cadre-choix-aucun');
+ grille.innerHTML = '';
+ if (aucun) grille.appendChild(aucun);
+
+ for (const nom of data.disponibles) {
+ const div = document.createElement('div');
+ div.className = 'cadre-choix-item';
+ div.innerHTML = `
${nom.replace('.png','')}`;
+ div.onclick = () => {
+ cadreChoisi = nom;
+ allerA('capture');
+ };
+ grille.appendChild(div);
+ }
+ return true;
+}
+
+function lancerSansCadre() {
+ cadreChoisi = null;
+ allerA('capture');
+}
+
// --- Onglets admin ---
function setupOnglets() {
@@ -269,9 +303,10 @@ wsOnMessage('config_maj', (msg) => {
appliquerConfig();
});
-// Erreur camera : afficher overlay + rechargement auto apres 10s
+// Erreur camera : afficher overlay + rechargement auto apres 10s (kiosk seulement)
let _erreurCameraTimer = null;
wsOnMessage('camera_erreur', (msg) => {
+ if (window.location.pathname === '/admin') return;
document.getElementById('camera-erreur').classList.remove('cache');
if (_erreurCameraTimer) clearTimeout(_erreurCameraTimer);
_erreurCameraTimer = setTimeout(() => { location.reload(); }, 10000);
@@ -398,4 +433,10 @@ async function wizardTerminer() {
}
// Demarrage
-document.addEventListener('DOMContentLoaded', init);
+document.addEventListener('DOMContentLoaded', () => {
+ init().then(() => {
+ if (window.location.pathname === '/admin') {
+ allerA('admin-choix');
+ }
+ });
+});
diff --git a/frontend/js/camera.js b/frontend/js/camera.js
index 97af382..de8ce72 100644
--- a/frontend/js/camera.js
+++ b/frontend/js/camera.js
@@ -298,8 +298,9 @@ async function traiterCapture() {
function afficherPreviewPhoto(chemin) {
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
+ afficherCadreOverlay(cadreChoisi);
- if (modeActuel === 'simple' && config.fonctionnalites?.filtres) {
+ if (config.fonctionnalites?.filtres) {
chargerFiltres();
}
if (config.fonctionnalites?.overlays) {
diff --git a/frontend/js/effects.js b/frontend/js/effects.js
index 07fbb2b..fb42c6f 100644
--- a/frontend/js/effects.js
+++ b/frontend/js/effects.js
@@ -23,11 +23,18 @@ async function appliquerFiltreUI(filtre, btn) {
btn.classList.add('actif');
filtreActuel = filtre;
+ if (modeActuel === 'multi') {
+ // En mode multi : ré-assembler le strip avec le filtre appliqué sur chaque photo
+ await appliquerFiltreStrip(filtre);
+ return;
+ }
+
if (filtre === 'original') {
const chemin = '/data/photos/' + photosSession[0];
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
photoFinale = photosSession[0];
+ afficherCadreOverlay(cadreChoisi);
return;
}
@@ -40,9 +47,50 @@ async function appliquerFiltreUI(filtre, btn) {
photoFinale = resultat.nom;
document.getElementById('photo-resultat').src = '/data/exports/' + resultat.nom;
document.getElementById('photo-partage').src = '/data/exports/' + resultat.nom;
+ afficherCadreOverlay(cadreChoisi);
}
}
+async function appliquerFiltreStrip(filtre) {
+ let photosAAssembler = photosSession;
+ let source = 'photos'; // les originaux sont dans data/photos/
+
+ if (filtre !== 'original') {
+ const promises = photosSession.map(p => apiPost('/api/filtre', { photo: p, filtre }));
+ const resultats = await Promise.all(promises);
+ photosAAssembler = resultats.filter(r => r.nom).map(r => r.nom);
+ source = 'exports'; // les filtrés sont dans data/exports/
+ if (photosAAssembler.length === 0) return;
+ }
+
+ const mode = config.multi_shot?.mode || 'strip';
+ const endpoint = mode === 'strip' ? '/api/strip' : '/api/collage';
+ const resultat = await apiPost(endpoint, { photos: photosAAssembler, source });
+
+ if (resultat.nom) {
+ photoFinale = resultat.nom;
+ if (resultat.impression) photoImpression = resultat.impression;
+ const src = '/data/exports/' + resultat.nom;
+ document.getElementById('photo-resultat').src = src;
+ document.getElementById('photo-partage').src = src;
+ afficherCadreOverlay(cadreChoisi);
+ }
+}
+
+function afficherCadreOverlay(nom) {
+ const el = document.getElementById('cadre-overlay-preview');
+ if (!el) return;
+ if (!nom) {
+ el.classList.add('cache');
+ el.src = '';
+ return;
+ }
+ const config_imp = config.impression || {};
+ const fmt = (config_imp.format || '15x20').replace('-2up', '');
+ el.src = `/assets/cadres/${fmt}/${nom}`;
+ el.classList.remove('cache');
+}
+
async function chargerOverlays() {
const overlays = await apiGet('/api/overlays');
const barre = document.getElementById('barre-overlays');
diff --git a/frontend/js/share.js b/frontend/js/share.js
index 887d8db..057c316 100644
--- a/frontend/js/share.js
+++ b/frontend/js/share.js
@@ -25,7 +25,11 @@ async function lancerImpression() {
// Pour les strips, imprimer la version 2 bandes sur 10x15
const fichierImpression = photoImpression || photoFinale;
afficherStatut(`Impression de ${nbExemplaires} exemplaire(s)...`, 'succes');
- const resultat = await apiPost('/api/imprimer', { photo: fichierImpression, copies: nbExemplaires });
+ const resultat = await apiPost('/api/imprimer', {
+ photo: fichierImpression,
+ copies: nbExemplaires,
+ cadre: cadreChoisi || undefined,
+ });
if (resultat.succes) {
afficherStatut(`${nbExemplaires} exemplaire(s) envoye(s) a l'imprimante !`, 'succes');
} else {
diff --git a/memoire.md b/memoire.md
index 96dfc4d..adc3543 100644
--- a/memoire.md
+++ b/memoire.md
@@ -29,6 +29,9 @@ https://git.copydev.fr/jules/photobooth
Phase 1-3 terminees (backend complet + frontend complet).
Phase 4 : scripts production (install.sh, start.sh, systemd).
+## Matériel reçu
+- Imprimante sublimation Mitsubishi (reçue le 2026-05-28)
+
## Notes
- Projet cree le 2026-03-21
- Mode simulation camera si gphoto2 non installe (dev sans DSLR)
diff --git a/scripts/generer_cadres_demo.py b/scripts/generer_cadres_demo.py
new file mode 100644
index 0000000..2c15325
--- /dev/null
+++ b/scripts/generer_cadres_demo.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+"""Génère des cadres de démonstration pour chaque format d'impression."""
+from pathlib import Path
+from PIL import Image, ImageDraw
+
+RACINE = Path(__file__).resolve().parent.parent
+DOSSIER_CADRES = RACINE / "frontend" / "assets" / "cadres"
+
+FORMATS = {
+ "strip": (600, 1800),
+ "10x15": (1200, 1800),
+ "15x20": (1800, 2400),
+}
+
+
+# ─── Cadres simples (tous formats) ───────────────────────────────────────────
+
+def cadre_simple(largeur, hauteur, couleur_bord, epaisseur=30, couleur_coin=None):
+ img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
+ d = ImageDraw.Draw(img)
+ cc = couleur_coin or couleur_bord
+ ep = epaisseur
+ # 4 bords
+ d.rectangle([0, 0, largeur, ep], fill=couleur_bord)
+ d.rectangle([0, hauteur - ep, largeur, hauteur], fill=couleur_bord)
+ d.rectangle([0, 0, ep, hauteur], fill=couleur_bord)
+ d.rectangle([largeur - ep, 0, largeur, hauteur], fill=couleur_bord)
+ # coins
+ tc = ep + 20
+ for x, y in [(0, 0), (largeur - tc, 0), (0, hauteur - tc), (largeur - tc, hauteur - tc)]:
+ d.rectangle([x, y, x + tc, y + tc], fill=cc)
+ # ligne intérieure fine
+ m = ep + 8
+ d.rectangle([m, m, largeur - m, hauteur - m], outline=cc, width=2)
+ return img
+
+
+# ─── Cadres pellicule (format strip uniquement) ───────────────────────────────
+
+def cadre_pellicule(largeur, hauteur, couleur_bord=(15, 15, 15, 255),
+ couleur_perf=(50, 50, 50, 255), label=""):
+ """Cadre pellicule avec perforations sur les côtés gauche et droit."""
+ img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
+ d = ImageDraw.Draw(img)
+
+ bord = 52 # largeur de la bande pellicule de chaque côté
+ perf_h = 22 # hauteur d'une perforation
+ perf_w = 16 # largeur d'une perforation
+ perf_r = 4 # rayon arrondi
+ perf_gap = 14 # espace entre perforations
+
+ # Bandes noires gauche et droite
+ d.rectangle([0, 0, bord, hauteur], fill=couleur_bord)
+ d.rectangle([largeur - bord, 0, largeur, hauteur], fill=couleur_bord)
+
+ # Bande fine haut et bas
+ bord_h = 10
+ d.rectangle([0, 0, largeur, bord_h], fill=couleur_bord)
+ d.rectangle([0, hauteur - bord_h, largeur, hauteur], fill=couleur_bord)
+
+ # Perforations gauche
+ y = perf_gap
+ while y + perf_h < hauteur:
+ x = (bord - perf_w) // 2
+ d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=couleur_perf)
+ y += perf_h + perf_gap
+
+ # Perforations droite
+ y = perf_gap
+ while y + perf_h < hauteur:
+ x = largeur - bord + (bord - perf_w) // 2
+ d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=couleur_perf)
+ y += perf_h + perf_gap
+
+ # Ligne intérieure fine pour délimiter la zone photo
+ m = bord + 4
+ d.rectangle([m, bord_h + 4, largeur - m, hauteur - bord_h - 4], outline=(80, 80, 80, 180), width=1)
+
+ return img
+
+
+def cadre_pellicule_vintage(largeur, hauteur):
+ """Pellicule couleur sépia avec numéros de frame."""
+ img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
+ d = ImageDraw.Draw(img)
+
+ bord = 58
+ bord_sepia = (40, 28, 12, 255)
+ perf_col = (90, 65, 30, 255)
+ perf_h, perf_w, perf_r, perf_gap = 20, 14, 3, 16
+
+ d.rectangle([0, 0, bord, hauteur], fill=bord_sepia)
+ d.rectangle([largeur - bord, 0, largeur, hauteur], fill=bord_sepia)
+ d.rectangle([0, 0, largeur, 8], fill=bord_sepia)
+ d.rectangle([0, hauteur - 8, largeur, hauteur], fill=bord_sepia)
+
+ for side in [0, 1]:
+ y = perf_gap
+ while y + perf_h < hauteur:
+ x = (bord - perf_w) // 2 if side == 0 else largeur - bord + (bord - perf_w) // 2
+ d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=perf_col)
+ y += perf_h + perf_gap
+
+ # Filet intérieur
+ m = bord + 5
+ d.rectangle([m, 12, largeur - m, hauteur - 12], outline=(100, 75, 35, 200), width=2)
+ return img
+
+
+def cadre_pellicule_couleur(largeur, hauteur, teinte=(220, 50, 50)):
+ """Pellicule colorée style photobooth rétro."""
+ img = Image.new("RGBA", (largeur, hauteur), (0, 0, 0, 0))
+ d = ImageDraw.Draw(img)
+
+ bord = 50
+ r, g, b = teinte
+ col_bord = (r, g, b, 255)
+ col_perf = (min(255, r + 60), min(255, g + 60), min(255, b + 60), 255)
+ perf_h, perf_w, perf_r, perf_gap = 20, 14, 4, 14
+
+ d.rectangle([0, 0, bord, hauteur], fill=col_bord)
+ d.rectangle([largeur - bord, 0, largeur, hauteur], fill=col_bord)
+ d.rectangle([0, 0, largeur, 8], fill=col_bord)
+ d.rectangle([0, hauteur - 8, largeur, hauteur], fill=col_bord)
+
+ for side in [0, 1]:
+ y = perf_gap
+ while y + perf_h < hauteur:
+ x = (bord - perf_w) // 2 if side == 0 else largeur - bord + (bord - perf_w) // 2
+ d.rounded_rectangle([x, y, x + perf_w, y + perf_h], radius=perf_r, fill=col_perf)
+ y += perf_h + perf_gap
+
+ m = bord + 5
+ d.rectangle([m, 12, largeur - m, hauteur - 12], outline=col_perf, width=2)
+ return img
+
+
+# ─── Main ─────────────────────────────────────────────────────────────────────
+
+def main():
+ # Cadres simples pour tous les formats
+ SIMPLES = [
+ ("classique_blanc.png", (255, 255, 255, 255), (210, 210, 210, 255), 30),
+ ("elegant_noir.png", (20, 20, 20, 255), (70, 70, 70, 255), 40),
+ ("rose_gold.png", (198, 143, 115, 255), (230, 180, 150, 255), 35),
+ ("festif_dore.png", (212, 175, 55, 255), (255, 215, 0, 255), 32),
+ ]
+
+ for fmt, (larg, haut) in FORMATS.items():
+ dossier = DOSSIER_CADRES / fmt
+ dossier.mkdir(parents=True, exist_ok=True)
+
+ for nom, col_bord, col_coin, ep in SIMPLES:
+ cadre = cadre_simple(larg, haut, col_bord, ep, col_coin)
+ cadre.save(dossier / nom, "PNG")
+ print(f" ✓ {fmt}/{nom}")
+
+ # Cadres pellicule — uniquement format strip
+ dossier_strip = DOSSIER_CADRES / "strip"
+ larg, haut = FORMATS["strip"]
+
+ pellicules = [
+ ("pellicule_noir.png", cadre_pellicule(larg, haut)),
+ ("pellicule_vintage.png", cadre_pellicule_vintage(larg, haut)),
+ ("pellicule_rouge.png", cadre_pellicule_couleur(larg, haut, (200, 40, 40))),
+ ("pellicule_bleu.png", cadre_pellicule_couleur(larg, haut, (30, 80, 180))),
+ ("pellicule_vert.png", cadre_pellicule_couleur(larg, haut, (30, 140, 60))),
+ ]
+
+ for nom, img in pellicules:
+ img.save(dossier_strip / nom, "PNG")
+ print(f" ✓ strip/{nom} (pellicule)")
+
+
+if __name__ == "__main__":
+ print("Génération des cadres de démonstration...")
+ main()
+ print("Terminé.")