Files
photobooth/frontend/js/camera.js
Jules f49d5511ef Pellicule : marge de coupe plus large + warmup LiveView apres capture
La coupe physique du K60 n'est jamais parfaitement centree, donc une marge
noire trop fine fait perdre la pellicule d'un cote du strip dedouble. Marge
de coupe (perforations) elargie de 34 a 55px (~4.6mm) de chaque cote.

Ajoute aussi un warmup LiveView (_chauffer_liveview) juste apres la remontee
du miroir post-capture : consomme les ~1-2s de stabilisation pendant le
spinner de traitement plutot que de laisser le preview du countdown suivant
figé sur l'ancienne photo.
2026-06-21 23:47:06 +02:00

436 lines
14 KiB
JavaScript

/* Module capture photo - Preview live, compte a rebours, declenchement */
let previewActif = false;
let previewTimeoutId = null;
let previewInterval = null;
const PREVIEW_TIMEOUT_MS = 3000;
let captureAbortee = false;
let captureEnCours = false; // true pendant tout le flux lancerCapture()
const EMOJI_CAR = { 3: '🤪', 2: '😱', 1: '🔥' };
// --- Preview live ---
function afficherErreurCapture(titre, detail = '') {
arreterPreview();
document.getElementById('capture-en-cours')?.classList.add('cache');
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);
// Flou le temps que la 1ere frame fraiche arrive (le miroir DSLR doit remonter
// apres la capture precedente) : evite l'impression d'image figee sur l'ancienne photo.
document.getElementById('img-preview')?.classList.add('preview-chargement');
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);
document.getElementById('cadre-guide-recadrage')?.classList.add('cache');
}
wsOnMessage('preview', (msg) => {
if (!previewActif) return;
const img = document.getElementById('img-preview');
if (img) {
img.onload = () => {
img.classList.remove('preview-chargement');
positionnerGuideRecadrage();
};
img.src = msg.image;
}
afficherErreurPreview(false);
resetPreviewTimeout();
});
// --- Cadre de visee : montre en live la zone qui sera gardee apres recadrage ---
// (la pellicule/le collage recadrent chaque photo au centre selon un ratio different du plein cadre camera)
function getRatioCelluleAttendu() {
if (modeActuel !== 'multi') return null;
const nb = getNombrePhotos();
const mode = config.multi_shot?.mode || 'strip';
if (mode === 'strip') {
// Reproduit creer_strip() dans backend/collage.py (bande portrait 600x1800, photos empilees)
const bandeLargeur = 600, bandeHauteur = 1800, marge = 10, sep = 12, perfLargeur = 55;
const zonePhotosLargeur = bandeLargeur - perfLargeur * 2;
const espacePhotos = bandeHauteur - marge * 2 - sep * (nb - 1);
const hauteurCellule = Math.floor(espacePhotos / nb);
return zonePhotosLargeur / hauteurCellule;
} else {
// Reproduit creer_collage() dans backend/collage.py
const colonnes = 2;
const largeurPage = 1200, hauteurPage = 1800, margeC = 25;
const lignes = Math.ceil(nb / colonnes);
const largeurCellule = Math.floor((largeurPage - margeC * (colonnes + 1)) / colonnes);
const hauteurCellule = Math.floor((hauteurPage - margeC * (lignes + 1)) / lignes);
return largeurCellule / hauteurCellule;
}
}
function positionnerGuideRecadrage() {
const guide = document.getElementById('cadre-guide-recadrage');
const img = document.getElementById('img-preview');
const container = document.getElementById('preview-live');
if (!guide || !img || !container) return;
const ratioCible = getRatioCelluleAttendu();
if (!ratioCible || !img.naturalWidth || !img.naturalHeight) {
guide.classList.add('cache');
return;
}
const cw = container.clientWidth, ch = container.clientHeight;
const ratioImg = img.naturalWidth / img.naturalHeight;
const ratioContainer = cw / ch;
// Boite affichee de l'image dans le conteneur (object-fit: contain)
let imgW, imgH, imgTop, imgLeft;
if (ratioImg > ratioContainer) {
imgW = cw; imgH = cw / ratioImg;
imgLeft = 0; imgTop = (ch - imgH) / 2;
} else {
imgH = ch; imgW = ch * ratioImg;
imgTop = 0; imgLeft = (cw - imgW) / 2;
}
// Boite du recadrage final, centree dans l'image affichee (meme logique que _crop_center cote backend)
let guideW, guideH;
if (ratioImg > ratioCible) {
guideH = imgH;
guideW = imgH * ratioCible;
} else {
guideW = imgW;
guideH = imgW / ratioCible;
}
guide.style.left = `${imgLeft + (imgW - guideW) / 2}px`;
guide.style.top = `${imgTop + (imgH - guideH) / 2}px`;
guide.style.width = `${guideW}px`;
guide.style.height = `${guideH}px`;
guide.classList.remove('cache');
}
window.addEventListener('resize', positionnerGuideRecadrage);
// Erreur camera pendant preview idle : retour accueil
// Si on est en cours de capture (compte à rebours ou attente API), on ignore :
// l'API /api/capturer retournera elle-même une erreur si le DSLR est vraiment mort.
wsOnMessage('camera_erreur', () => {
arreterPreview();
if (!captureEnCours) {
captureAbortee = true;
if (ecranActuel === 'capture') {
afficherErreurCapture('Appareil photo déconnecté', 'Reconnexion en cours...');
}
}
});
wsOnMessage('camera_ok', () => {
captureAbortee = false;
afficherErreurPreview(false);
});
// --- Capture ---
async function lancerCapture() {
captureEnCours = true;
captureAbortee = false;
photosSession = [];
photoImpression = null;
formatImpression = 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') {
captureEnCours = false;
afficherErreurCapture('Appareil photo non disponible', 'Verifiez la connexion USB du DSLR');
return;
}
} catch (e) {
captureEnCours = false;
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++) {
if (nbPhotos > 1) {
compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`;
}
lancerPreview(); // Miroir live pendant le compte à rebours
await compteARebours();
arreterPreview();
await pause(150);
const promesseCapture = apiPost('/api/capturer').catch(() => null);
let resultat = await promesseCapture;
document.getElementById('capture-en-cours').classList.add('cache');
let erreurReseau = false;
if (!resultat) {
erreurReseau = true;
} else if (resultat.erreur === 'Limite de photos atteinte') {
captureEnCours = false;
afficherErreurCapture('Limite de photos atteinte', 'La session est terminee');
return;
}
if (erreurReseau) {
captureEnCours = false;
afficherErreurCapture('Erreur reseau', 'Le serveur ne repond pas — redemarrez le photobooth');
return;
}
if (!resultat || resultat.erreur) {
captureEnCours = false;
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);
}
}
captureEnCours = false;
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; // Fichier à imprimer (strip ou photo finale)
let formatImpression = null; // Format CUPS à utiliser (ex: "10x15-2up")
async function traiterCapture() {
if (photosSession.length === 0) {
afficherErreurCapture('Aucune photo capturee', 'Une erreur inattendue s\'est produite');
return;
}
photoImpression = null;
formatImpression = 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;
if (resultat.format_impression) formatImpression = resultat.format_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;
afficherCadreOverlay(cadreChoisi);
if (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));
}