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

@@ -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 {

View File

@@ -257,16 +257,27 @@
<!-- Panneau Animations -->
<div class="admin-panneau" id="panneau-animations-admin">
<h3>Animation du compte a rebours</h3>
<p class="aide">Choisissez le style d'animation pour le 3... 2... 1...</p>
<h3>Animations CSS</h3>
<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>
<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">
<h4>Apercu</h4>
<div id="preview-animation" class="preview-animation-box">
<span id="preview-anim-chiffre" class="anim-chiffre">3</span>
<video id="preview-anim-video" class="preview-anim-video cache" muted playsinline></video>
</div>
<button class="btn-secondaire" onclick="testerAnimation()">Tester</button>
<button class="btn-secondaire" onclick="testerAnimationPreview()">Tester</button>
</div>
</div>

View File

@@ -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 = '<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);
}
}
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 ===

View File

@@ -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() {