Animations custom (video/GIF) + selection aleatoire

- Upload de GIF/MP4/WebM comme animations de compte a rebours
- Checkboxes pour activer/desactiver chaque animation (CSS + custom)
- Si plusieurs actives, tirage aleatoire a chaque photo
- Preview dans le backoffice avec tirage aleatoire
- Suppression des animations custom importees
- Les animations custom se jouent en plein ecran avant la capture

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 23:29:02 +01:00
parent a525f72800
commit cbe514bb93
7 changed files with 342 additions and 56 deletions

View File

@@ -10,6 +10,7 @@ DOSSIER_PHOTOS = RACINE / "data" / "photos"
DOSSIER_EXPORTS = RACINE / "data" / "exports" DOSSIER_EXPORTS = RACINE / "data" / "exports"
DOSSIER_OVERLAYS = RACINE / "frontend" / "assets" / "overlays" DOSSIER_OVERLAYS = RACINE / "frontend" / "assets" / "overlays"
DOSSIER_FONDS = RACINE / "frontend" / "assets" / "backgrounds" DOSSIER_FONDS = RACINE / "frontend" / "assets" / "backgrounds"
DOSSIER_ANIMATIONS = RACINE / "frontend" / "assets" / "animations"
def charger_config() -> dict: def charger_config() -> dict:
@@ -48,5 +49,5 @@ def _merge_profond(base: dict, modifications: dict):
# Initialisation des dossiers au chargement du module # 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) dossier.mkdir(parents=True, exist_ok=True)

View File

@@ -10,7 +10,7 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from backend.config import ( 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, charger_config, sauvegarder_config, mettre_a_jour_config,
) )
from backend.camera import camera from backend.camera import camera
@@ -348,6 +348,44 @@ async def api_animations():
return ANIMATIONS_CAR 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 --- # --- API Systeme ---
@app.post("/api/systeme/redemarrer") @app.post("/api/systeme/redemarrer")

View File

@@ -21,7 +21,11 @@
"iso": "auto", "iso": "auto",
"balance_blancs": "auto", "balance_blancs": "auto",
"compte_a_rebours": 3, "compte_a_rebours": 3,
"animation_compte_a_rebours": "classique" "animation_compte_a_rebours": "classique",
"animations_css_actives": ["classique"]
},
"animations_custom": {
"actives": []
}, },
"compteur": { "compteur": {
"actif": true, "actif": true,

View File

@@ -888,6 +888,66 @@ h3 {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 1rem 0; 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 { .anim-chiffre {

View File

@@ -257,16 +257,27 @@
<!-- Panneau Animations --> <!-- Panneau Animations -->
<div class="admin-panneau" id="panneau-animations-admin"> <div class="admin-panneau" id="panneau-animations-admin">
<h3>Animation du compte a rebours</h3> <h3>Animations CSS</h3>
<p class="aide">Choisissez le style d'animation pour le 3... 2... 1...</p> <p class="aide">Cochez les animations a utiliser. Si plusieurs sont cochees, une sera choisie au hasard a chaque photo.</p>
<div id="liste-animations" class="liste-animations"> <div id="liste-animations" class="liste-animations">
</div> </div>
<h3>Animations personnalisees (video/GIF)</h3>
<p class="aide">Importez vos propres animations (GIF, MP4, WebM). Elles seront jouees en plein ecran avant la capture.</p>
<div id="liste-animations-custom" class="liste-animations">
</div>
<div class="champ">
<input type="file" id="input-animation" accept=".gif,.mp4,.webm" class="input-fichier">
<button class="btn-action" onclick="uploaderAnimation()">Importer</button>
</div>
<div class="animation-preview"> <div class="animation-preview">
<h4>Apercu</h4> <h4>Apercu</h4>
<div id="preview-animation" class="preview-animation-box"> <div id="preview-animation" class="preview-animation-box">
<span id="preview-anim-chiffre" class="anim-chiffre">3</span> <span id="preview-anim-chiffre" class="anim-chiffre">3</span>
<video id="preview-anim-video" class="preview-anim-video cache" muted playsinline></video>
</div> </div>
<button class="btn-secondaire" onclick="testerAnimation()">Tester</button> <button class="btn-secondaire" onclick="testerAnimationPreview()">Tester</button>
</div> </div>
</div> </div>

View File

@@ -264,60 +264,181 @@ async function uploaderCadre() {
// === ANIMATIONS === // === ANIMATIONS ===
let animationChoisie = 'classique'; const EMOJI_MAP = { 3: '🤪', 2: '😱', 1: '🔥' };
async function chargerAnimations() { async function chargerAnimations() {
// Animations CSS internes
const anims = await apiGet('/api/animations'); 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'); const liste = document.getElementById('liste-animations');
liste.innerHTML = ''; liste.innerHTML = '';
for (const [id, nom] of Object.entries(anims)) { for (const [id, nom] of Object.entries(anims)) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'anim-option' + (id === animationChoisie ? ' actif' : ''); div.className = 'anim-item';
div.textContent = nom;
div.onclick = () => choisirAnimation(id, div); 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 = '<p class="texte-secondaire">Aucune animation importee</p>';
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 = '&#10005;';
btnSuppr.onclick = () => supprimerAnimation(nom);
div.appendChild(cb);
div.appendChild(preview);
div.appendChild(label);
div.appendChild(btnSuppr);
liste.appendChild(div); liste.appendChild(div);
} }
} }
async function choisirAnimation(id, el) { async function majAnimationsCss() {
animationChoisie = id; const checkboxes = document.querySelectorAll('#liste-animations .anim-item input[type="checkbox"]');
document.querySelectorAll('.anim-option').forEach(e => e.classList.remove('actif')); const actives = [];
el.classList.add('actif'); checkboxes.forEach(cb => { if (cb.checked) actives.push(cb.dataset.animId); });
// Au moins une doit rester active
await apiPost('/api/config', { if (actives.length === 0) actives.push('classique');
camera: { animation_compte_a_rebours: id }, await apiPost('/api/config', { camera: { animations_css_actives: actives } });
});
testerAnimation();
} }
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'); const chiffre = document.getElementById('preview-anim-chiffre');
let count = 3; const video = document.getElementById('preview-anim-video');
// 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);
}
} else {
// Animation CSS
video.classList.add('cache');
chiffre.classList.remove('cache');
let count = 3;
function afficher() { function afficher() {
if (animationChoisie === 'emoji') { if (choix.id === 'emoji') {
chiffre.textContent = EMOJI_MAP[count] || count; chiffre.textContent = EMOJI_MAP[count] || count;
} else { } else {
chiffre.textContent = count; chiffre.textContent = count;
} }
// Reset : retirer toute classe d'animation
chiffre.className = 'anim-chiffre'; chiffre.className = 'anim-chiffre';
// Force reflow pour que le navigateur enregistre le retrait
void chiffre.offsetWidth; void chiffre.offsetWidth;
// Appliquer la nouvelle animation chiffre.classList.add('anim-' + choix.id);
chiffre.classList.add('anim-' + animationChoisie);
count--; count--;
if (count > 0) setTimeout(afficher, 1000); if (count > 0) setTimeout(afficher, 1000);
} }
afficher(); afficher();
}
} }
// === FONCTIONNALITES === // === FONCTIONNALITES ===

View File

@@ -76,32 +76,83 @@ function getNombrePhotos() {
return 1; 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() { async function compteARebours() {
const conteneur = document.getElementById('compte-a-rebours'); const conteneur = document.getElementById('compte-a-rebours');
const chiffre = document.getElementById('chiffre-car'); const chiffre = document.getElementById('chiffre-car');
const duree = config.camera?.compte_a_rebours || 3; const duree = config.camera?.compte_a_rebours || 3;
const anim = config.camera?.animation_compte_a_rebours || 'classique';
// Choisir une animation au hasard parmi les actives
const anim = choisirAnimationAleatoire();
if (anim.type === 'custom') {
// Jouer une video/gif en plein ecran
await jouerAnimationCustom(anim.nom, duree);
} else {
// Animation CSS classique
conteneur.classList.remove('cache'); conteneur.classList.remove('cache');
for (let i = duree; i > 0; i--) { for (let i = duree; i > 0; i--) {
// Contenu selon le type d'animation if (anim.id === 'emoji') {
if (anim === 'emoji') {
chiffre.textContent = EMOJI_CAR[i] || i; chiffre.textContent = EMOJI_CAR[i] || i;
} else { } else {
chiffre.textContent = i; chiffre.textContent = i;
} }
// Appliquer l'animation chiffre.className = 'anim-chiffre';
chiffre.className = 'anim-chiffre anim-' + anim; void chiffre.offsetWidth;
chiffre.style.animation = 'none'; chiffre.classList.add('anim-' + anim.id);
chiffre.offsetHeight;
chiffre.className = 'anim-chiffre anim-' + anim;
await pause(1000); await pause(1000);
} }
conteneur.classList.add('cache'); conteneur.classList.add('cache');
}
}
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),
]);
}
overlay.remove();
} }
function afficherFlash() { function afficherFlash() {