Files
photobooth/frontend/js/camera.js
Jules b871fc4c3b Preview push 30fps : thread de fond + diffusion WS sans polling
Avant : frontend poll WS toutes les 100ms -> gphoto2 bloquant -> lag
Apres : thread dedie capture en continu a 30fps, pusher asyncio
        diffuse a 20fps a tous les clients, frontend dit juste
        preview_start / preview_stop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:21:29 +02:00

350 lines
10 KiB
JavaScript

/* Module capture photo - Preview live, compte a rebours, declenchement */
let previewActif = false;
let previewTimeoutId = null;
const PREVIEW_TIMEOUT_MS = 3000;
const EMOJI_CAR = { 3: '🤪', 2: '😱', 1: '🔥' };
// --- Preview live ---
function afficherErreurCapture(titre, detail = '') {
arreterPreview();
const el = document.getElementById('preview-erreur-camera');
if (!el) { allerA('accueil'); return; }
el.querySelector('p').textContent = titre;
el.querySelector('span').textContent = detail || 'Retour dans 4 secondes...';
el.classList.remove('cache');
setTimeout(() => {
el.classList.add('cache');
allerA('accueil');
}, 4000);
}
function afficherErreurPreview(visible) {
const el = document.getElementById('preview-erreur-camera');
if (!el) return;
if (visible) {
el.querySelector('p').textContent = 'Appareil photo deconnecte';
el.querySelector('span').textContent = 'Reconnexion en cours...';
el.classList.remove('cache');
} else {
el.classList.add('cache');
}
}
function resetPreviewTimeout() {
if (previewTimeoutId) clearTimeout(previewTimeoutId);
previewTimeoutId = setTimeout(() => {
if (previewActif) afficherErreurPreview(true);
}, PREVIEW_TIMEOUT_MS);
}
function lancerPreview() {
if (previewActif) return;
previewActif = true;
afficherErreurPreview(false);
wsEnvoyer({ type: 'preview_start' });
resetPreviewTimeout();
}
function arreterPreview() {
if (!previewActif) return;
previewActif = false;
wsEnvoyer({ type: 'preview_stop' });
if (previewInterval) {
clearInterval(previewInterval);
previewInterval = null;
}
if (previewTimeoutId) {
clearTimeout(previewTimeoutId);
previewTimeoutId = null;
}
afficherErreurPreview(false);
}
wsOnMessage('preview', (msg) => {
if (!previewActif) return;
const img = document.getElementById('img-preview');
if (img) img.src = msg.image;
afficherErreurPreview(false);
resetPreviewTimeout();
});
// Erreur camera pendant la preview
wsOnMessage('camera_erreur', () => {
if (previewActif) afficherErreurPreview(true);
});
wsOnMessage('camera_ok', () => {
afficherErreurPreview(false);
});
// --- Capture ---
async function lancerCapture() {
photosSession = [];
photoImpression = null;
document.getElementById('photo-resultat').src = '';
document.getElementById('photo-partage').src = '';
majCompteurAccueil();
// Verifier l'etat de la camera avant de commencer
try {
const statut = await apiGet('/api/camera/statut');
if (!statut.connectee || statut.mode === 'erreur') {
afficherErreurCapture('Appareil photo non disponible', 'Verifiez la connexion USB du DSLR');
return;
}
} catch (e) {
afficherErreurCapture('Impossible de joindre le serveur', 'Verifiez que le photobooth est bien demarre');
return;
}
const nbPhotos = getNombrePhotos();
const compteurEl = document.getElementById('capture-compteur');
for (let i = 0; i < nbPhotos; i++) {
lancerPreview();
await pause(500);
if (nbPhotos > 1) {
compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`;
}
await compteARebours();
// Si la camera a signale une erreur pendant le compte a rebours, on arrete
const erreurVisible = !document.getElementById('preview-erreur-camera').classList.contains('cache');
if (erreurVisible) {
arreterPreview();
afficherErreurCapture('Appareil photo deconnecte', 'Reconnectez le DSLR et reessayez');
return;
}
arreterPreview();
await pause(300);
afficherFlash();
// Tenter la capture avec retry
let resultat = null;
let erreurReseau = false;
for (let r = 0; r < 3; r++) {
try {
resultat = await apiPost('/api/capturer');
} catch (e) {
erreurReseau = true;
await pause(500);
continue;
}
erreurReseau = false;
if (!resultat.erreur) break;
if (resultat.erreur === 'Limite de photos atteinte') {
afficherErreurCapture('Limite de photos atteinte', 'La session est terminee');
return;
}
await pause(500);
}
if (erreurReseau) {
afficherErreurCapture('Erreur reseau', 'Le serveur ne repond pas — redemarrez le photobooth');
return;
}
if (!resultat || resultat.erreur) {
const msg = resultat?.erreur || 'Erreur inconnue';
afficherErreurCapture('Echec de la capture', msg.includes('Echec') ? 'Verifiez le DSLR et reessayez' : msg);
return;
}
if (resultat.nom) {
photosSession.push(resultat.nom);
}
}
await traiterCapture();
}
function getNombrePhotos() {
if (modeActuel === 'simple') return 1;
if (modeActuel === 'multi') return config.multi_shot?.nombre_photos || 4;
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;
// Choisir une animation au hasard parmi les actives
const anim = choisirAnimationAleatoire();
if (anim.type === 'custom') {
// Jouer la video/gif en plein ecran, shoot des qu'elle finit
await jouerAnimationCustom(anim.nom);
return; // pas de compte a rebours CSS
} 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);
}
conteneur.classList.add('cache');
}
}
async function jouerAnimationCustom(nom) {
// 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');
// Ajouter un timestamp pour forcer le rechargement (relancer le GIF depuis le debut)
img.src = `/assets/animations/${nom}?t=${Date.now()}`;
overlay.appendChild(img);
document.body.appendChild(overlay);
// Pour un GIF, on attend sa duree naturelle
// Comme on ne peut pas detecter la fin d'un GIF, on utilise un fallback de 3s
// L'utilisateur peut configurer la duree dans le nom du fichier (ex: countdown_5s.gif)
const match = nom.match(/(\d+)s/);
const dureeGif = match ? parseInt(match[1]) * 1000 : 3000;
await pause(dureeGif);
} else {
// Video MP4/WebM : on attend la fin exacte de la video
const video = document.createElement('video');
video.src = `/assets/animations/${nom}`;
video.muted = true;
video.playsInline = true;
overlay.appendChild(video);
document.body.appendChild(overlay);
await new Promise(resolve => {
video.onended = resolve;
video.onerror = resolve;
video.play().catch(resolve);
});
}
overlay.remove();
}
function afficherFlash() {
const flash = document.getElementById('flash-blanc');
flash.classList.remove('cache');
flash.style.animation = 'none';
flash.offsetHeight;
flash.style.animation = 'flash 0.3s ease-out forwards';
setTimeout(() => flash.classList.add('cache'), 400);
}
let photoImpression = null; // Nom du fichier d'impression (2 bandes sur 10x15)
async function traiterCapture() {
if (photosSession.length === 0) {
afficherErreurCapture('Aucune photo capturee', 'Une erreur inattendue s\'est produite');
return;
}
photoImpression = null;
try {
if (modeActuel === 'multi') {
const mode = config.multi_shot?.mode || 'strip';
let resultat;
if (mode === 'strip') {
resultat = await apiPost('/api/strip', { photos: photosSession });
if (resultat.erreur) {
afficherErreurCapture('Erreur creation de la planche', resultat.erreur);
return;
}
if (resultat.impression) photoImpression = resultat.impression;
} else {
resultat = await apiPost('/api/collage', { photos: photosSession });
if (resultat.erreur) {
afficherErreurCapture('Erreur creation du collage', resultat.erreur);
return;
}
}
if (!resultat.nom) {
afficherErreurCapture('Erreur traitement des photos', 'Le fichier final n\'a pas ete genere');
return;
}
photoFinale = resultat.nom;
afficherPreviewPhoto('/data/exports/' + resultat.nom);
} else {
photoFinale = photosSession[0];
afficherPreviewPhoto('/data/photos/' + photosSession[0]);
}
} catch (e) {
afficherErreurCapture('Erreur reseau', 'Impossible de traiter les photos');
return;
}
allerA('preview');
}
function afficherPreviewPhoto(chemin) {
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
if (modeActuel === 'simple' && config.fonctionnalites?.filtres) {
chargerFiltres();
}
if (config.fonctionnalites?.overlays) {
chargerOverlays();
}
if (config.fonctionnalites?.chroma_key) {
chargerFonds();
}
}
// --- Compteur accueil ---
async function majCompteurAccueil() {
const etat = await apiGet('/api/compteur');
const el = document.getElementById('compteur-accueil');
if (etat.actif) {
el.classList.remove('cache');
document.getElementById('compteur-restant').textContent = etat.restantes;
document.getElementById('compteur-limite').textContent = etat.limite;
} else {
el.classList.add('cache');
}
}
function pause(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}