diff --git a/backend/config.py b/backend/config.py index adf1453..0da2a89 100644 --- a/backend/config.py +++ b/backend/config.py @@ -10,6 +10,7 @@ DOSSIER_PHOTOS = RACINE / "data" / "photos" DOSSIER_EXPORTS = RACINE / "data" / "exports" DOSSIER_OVERLAYS = RACINE / "frontend" / "assets" / "overlays" DOSSIER_FONDS = RACINE / "frontend" / "assets" / "backgrounds" +DOSSIER_ANIMATIONS = RACINE / "frontend" / "assets" / "animations" def charger_config() -> dict: @@ -48,5 +49,5 @@ def _merge_profond(base: dict, modifications: dict): # Initialisation des dossiers au chargement du module -for dossier in [DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS]: +for dossier in [DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS, DOSSIER_ANIMATIONS]: dossier.mkdir(parents=True, exist_ok=True) diff --git a/backend/main.py b/backend/main.py index 448e5a3..35176ba 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,7 +10,7 @@ from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from backend.config import ( - RACINE, DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS, + RACINE, DOSSIER_PHOTOS, DOSSIER_EXPORTS, DOSSIER_OVERLAYS, DOSSIER_FONDS, DOSSIER_ANIMATIONS, charger_config, sauvegarder_config, mettre_a_jour_config, ) from backend.camera import camera @@ -348,6 +348,44 @@ async def api_animations(): return ANIMATIONS_CAR +@app.get("/api/animations/custom") +async def api_animations_custom(): + """Liste les animations personnalisees (GIF/video).""" + extensions = {".gif", ".mp4", ".webm"} + fichiers = [] + if DOSSIER_ANIMATIONS.exists(): + for f in sorted(DOSSIER_ANIMATIONS.iterdir()): + if f.suffix.lower() in extensions: + fichiers.append(f.name) + config = charger_config() + actives = config.get("animations_custom", {}).get("actives", []) + return {"tous": fichiers, "actives": actives} + + +@app.post("/api/animations/custom") +async def api_animations_custom_update(donnees: dict): + actives = donnees.get("actives", []) + mettre_a_jour_config({"animations_custom": {"actives": actives}}) + return {"actives": actives} + + +@app.post("/api/upload/animation") +async def api_upload_animation(fichier: UploadFile = File(...)): + chemin = DOSSIER_ANIMATIONS / fichier.filename + with open(chemin, "wb") as f: + f.write(await fichier.read()) + return {"nom": fichier.filename} + + +@app.delete("/api/animations/custom/{nom}") +async def api_supprimer_animation(nom: str): + chemin = DOSSIER_ANIMATIONS / nom + if chemin.exists() and chemin.parent == DOSSIER_ANIMATIONS: + chemin.unlink() + return {"succes": True} + return JSONResponse({"erreur": "Fichier introuvable"}, status_code=404) + + # --- API Systeme --- @app.post("/api/systeme/redemarrer") diff --git a/data/config.json b/data/config.json index cb837bf..98ff337 100644 --- a/data/config.json +++ b/data/config.json @@ -21,7 +21,11 @@ "iso": "auto", "balance_blancs": "auto", "compte_a_rebours": 3, - "animation_compte_a_rebours": "classique" + "animation_compte_a_rebours": "classique", + "animations_css_actives": ["classique"] + }, + "animations_custom": { + "actives": [] }, "compteur": { "actif": true, diff --git a/frontend/css/style.css b/frontend/css/style.css index 55f1277..c784d20 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -888,6 +888,66 @@ h3 { align-items: center; justify-content: center; margin: 1rem 0; + position: relative; + overflow: hidden; +} + +.preview-anim-video { + width: 100%; + height: 100%; + object-fit: contain; +} + +/* Overlay video plein ecran pendant capture */ +.video-countdown-overlay { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background: #000; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; +} + +.video-countdown-overlay video, +.video-countdown-overlay img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +/* Animation item avec checkbox */ +.anim-item { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.6rem 1rem; + background: var(--fond); + border-radius: 8px; +} + +.anim-item input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: var(--primaire); +} + +.anim-item-preview { + width: 50px; + height: 50px; + border-radius: 6px; + object-fit: cover; + background: #000; +} + +.anim-item .btn-supprimer { + margin-left: auto; + background: none; + border: none; + color: var(--danger); + font-size: 1.2rem; + cursor: pointer; } .anim-chiffre { diff --git a/frontend/index.html b/frontend/index.html index c066878..b8e385b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -257,16 +257,27 @@
-

Animation du compte a rebours

-

Choisissez le style d'animation pour le 3... 2... 1...

+

Animations CSS

+

Cochez les animations a utiliser. Si plusieurs sont cochees, une sera choisie au hasard a chaque photo.

+ +

Animations personnalisees (video/GIF)

+

Importez vos propres animations (GIF, MP4, WebM). Elles seront jouees en plein ecran avant la capture.

+
+
+
+ + +
+

Apercu

3 +
- +
diff --git a/frontend/js/admin.js b/frontend/js/admin.js index 7b8a52a..cce30d7 100644 --- a/frontend/js/admin.js +++ b/frontend/js/admin.js @@ -264,60 +264,181 @@ async function uploaderCadre() { // === ANIMATIONS === -let animationChoisie = 'classique'; +const EMOJI_MAP = { 3: '🤪', 2: '😱', 1: '🔥' }; async function chargerAnimations() { + // Animations CSS internes const anims = await apiGet('/api/animations'); - animationChoisie = config.camera?.animation_compte_a_rebours || 'classique'; - + const cssActives = config.camera?.animations_css_actives || ['classique']; const liste = document.getElementById('liste-animations'); liste.innerHTML = ''; for (const [id, nom] of Object.entries(anims)) { const div = document.createElement('div'); - div.className = 'anim-option' + (id === animationChoisie ? ' actif' : ''); - div.textContent = nom; - div.onclick = () => choisirAnimation(id, div); + div.className = 'anim-item'; + + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = cssActives.includes(id); + cb.dataset.animId = id; + cb.onchange = () => majAnimationsCss(); + + const label = document.createElement('span'); + label.textContent = nom; + + div.appendChild(cb); + div.appendChild(label); + liste.appendChild(div); + } + + // Animations custom (video/GIF) + await chargerAnimationsCustom(); +} + +async function chargerAnimationsCustom() { + const data = await apiGet('/api/animations/custom'); + const liste = document.getElementById('liste-animations-custom'); + liste.innerHTML = ''; + + if (data.tous.length === 0) { + liste.innerHTML = '

Aucune animation importee

'; + return; + } + + for (const nom of data.tous) { + const div = document.createElement('div'); + div.className = 'anim-item'; + + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = data.actives.includes(nom); + cb.dataset.animNom = nom; + cb.onchange = () => majAnimationsCustom(); + + // Preview miniature + const ext = nom.split('.').pop().toLowerCase(); + let preview; + if (ext === 'gif') { + preview = document.createElement('img'); + preview.src = `/assets/animations/${nom}`; + preview.className = 'anim-item-preview'; + } else { + preview = document.createElement('video'); + preview.src = `/assets/animations/${nom}`; + preview.className = 'anim-item-preview'; + preview.muted = true; + preview.preload = 'metadata'; + } + + const label = document.createElement('span'); + label.textContent = nom; + + const btnSuppr = document.createElement('button'); + btnSuppr.className = 'btn-supprimer'; + btnSuppr.innerHTML = '✕'; + btnSuppr.onclick = () => supprimerAnimation(nom); + + div.appendChild(cb); + div.appendChild(preview); + div.appendChild(label); + div.appendChild(btnSuppr); liste.appendChild(div); } } -async function choisirAnimation(id, el) { - animationChoisie = id; - document.querySelectorAll('.anim-option').forEach(e => e.classList.remove('actif')); - el.classList.add('actif'); - - await apiPost('/api/config', { - camera: { animation_compte_a_rebours: id }, - }); - - testerAnimation(); +async function majAnimationsCss() { + const checkboxes = document.querySelectorAll('#liste-animations .anim-item input[type="checkbox"]'); + const actives = []; + checkboxes.forEach(cb => { if (cb.checked) actives.push(cb.dataset.animId); }); + // Au moins une doit rester active + if (actives.length === 0) actives.push('classique'); + await apiPost('/api/config', { camera: { animations_css_actives: actives } }); } -const EMOJI_MAP = { 3: '🤪', 2: '😱', 1: '🔥' }; +async function majAnimationsCustom() { + const checkboxes = document.querySelectorAll('#liste-animations-custom .anim-item input[type="checkbox"]'); + const actives = []; + checkboxes.forEach(cb => { if (cb.checked) actives.push(cb.dataset.animNom); }); + await apiPost('/api/animations/custom', { actives }); +} -function testerAnimation() { +async function uploaderAnimation() { + const input = document.getElementById('input-animation'); + if (!input.files.length) return; + + const formData = new FormData(); + formData.append('fichier', input.files[0]); + + await fetch('/api/upload/animation', { method: 'POST', body: formData }); + input.value = ''; + chargerAnimationsCustom(); + afficherStatut('Animation importee', 'succes'); +} + +async function supprimerAnimation(nom) { + if (!confirm(`Supprimer l'animation "${nom}" ?`)) return; + await fetch(`/api/animations/custom/${encodeURIComponent(nom)}`, { method: 'DELETE' }); + chargerAnimationsCustom(); +} + +function testerAnimationPreview() { const chiffre = document.getElementById('preview-anim-chiffre'); - let count = 3; + const video = document.getElementById('preview-anim-video'); - function afficher() { - if (animationChoisie === 'emoji') { - chiffre.textContent = EMOJI_MAP[count] || count; - } else { - chiffre.textContent = count; + // Verifier s'il y a des animations custom actives + const customActives = []; + document.querySelectorAll('#liste-animations-custom .anim-item input[type="checkbox"]:checked').forEach(cb => { + customActives.push(cb.dataset.animNom); + }); + + // Verifier les CSS actives + const cssActives = []; + document.querySelectorAll('#liste-animations .anim-item input[type="checkbox"]:checked').forEach(cb => { + cssActives.push(cb.dataset.animId); + }); + + const toutes = [...cssActives.map(a => ({type: 'css', id: a})), ...customActives.map(a => ({type: 'custom', nom: a}))]; + if (toutes.length === 0) return; + + const choix = toutes[Math.floor(Math.random() * toutes.length)]; + + if (choix.type === 'custom') { + // Jouer la video/gif + chiffre.classList.add('cache'); + video.classList.remove('cache'); + video.src = `/assets/animations/${choix.nom}`; + video.currentTime = 0; + video.play(); + video.onended = () => { + video.classList.add('cache'); + chiffre.classList.remove('cache'); + }; + // Pour les GIF, masquer apres 3s + if (choix.nom.endsWith('.gif')) { + setTimeout(() => { + video.classList.add('cache'); + chiffre.classList.remove('cache'); + }, 3000); } - // Reset : retirer toute classe d'animation - chiffre.className = 'anim-chiffre'; - // Force reflow pour que le navigateur enregistre le retrait - void chiffre.offsetWidth; - // Appliquer la nouvelle animation - chiffre.classList.add('anim-' + animationChoisie); - - count--; - if (count > 0) setTimeout(afficher, 1000); + } else { + // Animation CSS + video.classList.add('cache'); + chiffre.classList.remove('cache'); + let count = 3; + function afficher() { + if (choix.id === 'emoji') { + chiffre.textContent = EMOJI_MAP[count] || count; + } else { + chiffre.textContent = count; + } + chiffre.className = 'anim-chiffre'; + void chiffre.offsetWidth; + chiffre.classList.add('anim-' + choix.id); + count--; + if (count > 0) setTimeout(afficher, 1000); + } + afficher(); } - - afficher(); } // === FONCTIONNALITES === diff --git a/frontend/js/camera.js b/frontend/js/camera.js index 79f4793..dc22268 100644 --- a/frontend/js/camera.js +++ b/frontend/js/camera.js @@ -76,32 +76,83 @@ function getNombrePhotos() { return 1; } +function choisirAnimationAleatoire() { + // Construire la liste de toutes les animations actives (CSS + custom) + const cssActives = config.camera?.animations_css_actives || ['classique']; + const customActives = config.animations_custom?.actives || []; + + const pool = [ + ...cssActives.map(id => ({ type: 'css', id })), + ...customActives.map(nom => ({ type: 'custom', nom })), + ]; + + if (pool.length === 0) return { type: 'css', id: 'classique' }; + return pool[Math.floor(Math.random() * pool.length)]; +} + async function compteARebours() { const conteneur = document.getElementById('compte-a-rebours'); const chiffre = document.getElementById('chiffre-car'); const duree = config.camera?.compte_a_rebours || 3; - const anim = config.camera?.animation_compte_a_rebours || 'classique'; - conteneur.classList.remove('cache'); + // Choisir une animation au hasard parmi les actives + const anim = choisirAnimationAleatoire(); - for (let i = duree; i > 0; i--) { - // Contenu selon le type d'animation - if (anim === 'emoji') { - chiffre.textContent = EMOJI_CAR[i] || i; - } else { - chiffre.textContent = i; + if (anim.type === 'custom') { + // Jouer une video/gif en plein ecran + await jouerAnimationCustom(anim.nom, duree); + } else { + // Animation CSS classique + conteneur.classList.remove('cache'); + + for (let i = duree; i > 0; i--) { + if (anim.id === 'emoji') { + chiffre.textContent = EMOJI_CAR[i] || i; + } else { + chiffre.textContent = i; + } + + chiffre.className = 'anim-chiffre'; + void chiffre.offsetWidth; + chiffre.classList.add('anim-' + anim.id); + + await pause(1000); } - // Appliquer l'animation - chiffre.className = 'anim-chiffre anim-' + anim; - chiffre.style.animation = 'none'; - chiffre.offsetHeight; - chiffre.className = 'anim-chiffre anim-' + anim; + conteneur.classList.add('cache'); + } +} - await pause(1000); +async function jouerAnimationCustom(nom, duree) { + // Creer un overlay plein ecran pour la video/gif + const overlay = document.createElement('div'); + overlay.className = 'video-countdown-overlay'; + + const ext = nom.split('.').pop().toLowerCase(); + + if (ext === 'gif') { + const img = document.createElement('img'); + img.src = `/assets/animations/${nom}`; + overlay.appendChild(img); + document.body.appendChild(overlay); + await pause(duree * 1000); + } else { + const video = document.createElement('video'); + video.src = `/assets/animations/${nom}`; + video.muted = true; + video.playsInline = true; + video.autoplay = true; + overlay.appendChild(video); + document.body.appendChild(overlay); + + // Attendre la fin de la video ou le timeout + await Promise.race([ + new Promise(resolve => { video.onended = resolve; }), + pause(duree * 1000 + 2000), + ]); } - conteneur.classList.add('cache'); + overlay.remove(); } function afficherFlash() {