Spaces:
Running
Running
| <html lang="fr"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>SnapCam - Effets Photo</title> | |
| <!-- Importation de la bibliothèque d'icônes Phosphor Icons --> | |
| <script src="https://unpkg.com/@phosphor-icons/web"></script> | |
| <style> | |
| /* --- RESET & BASE --- */ | |
| :root { | |
| --primary-color: #FFFC00; /* Jaune Snapchat */ | |
| --accent-color: #ffffff; | |
| --bg-dark: #000000; | |
| --overlay-bg: rgba(0, 0, 0, 0.6); | |
| --glass-bg: rgba(20, 20, 20, 0.8); | |
| --font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| --transition-speed: 0.3s; | |
| } | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| -webkit-tap-highlight-color: transparent; | |
| } | |
| body { | |
| font-family: var(--font-family); | |
| background-color: var(--bg-dark); | |
| color: var(--accent-color); | |
| height: 100dvh; /* Dynamic Viewport Height */ | |
| width: 100vw; | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| position: relative; | |
| } | |
| /* --- HEADER --- */ | |
| header { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| padding: 15px 20px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| z-index: 100; | |
| background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent); | |
| pointer-events: none; /* Laisser passer les clics vers la vidéo si besoin */ | |
| } | |
| .brand { | |
| font-weight: 800; | |
| font-size: 1.2rem; | |
| letter-spacing: 1px; | |
| pointer-events: auto; | |
| text-shadow: 0 2px 4px rgba(0,0,0,0.5); | |
| } | |
| .anycoder-link { | |
| font-size: 0.8rem; | |
| color: rgba(255, 255, 255, 0.7); | |
| text-decoration: none; | |
| pointer-events: auto; | |
| transition: color 0.2s; | |
| } | |
| .anycoder-link:hover { | |
| color: var(--primary-color); | |
| } | |
| /* --- MAIN CAMERA VIEW --- */ | |
| main { | |
| flex: 1; | |
| position: relative; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| background: #111; | |
| } | |
| video { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| transform: scaleX(-1); /* Miroir par défaut (caméra frontale) */ | |
| transition: filter 0.2s ease; | |
| } | |
| /* Canvas caché pour la capture */ | |
| canvas { | |
| display: none; | |
| } | |
| /* Effet Flash lors de la prise de photo */ | |
| #flash-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: white; | |
| opacity: 0; | |
| pointer-events: none; | |
| z-index: 50; | |
| transition: opacity 0.1s ease-out; | |
| } | |
| /* --- CONTROLS (BOTTOM) --- */ | |
| .controls-container { | |
| position: absolute; | |
| bottom: 0; | |
| width: 100%; | |
| padding: 20px; | |
| background: linear-gradient(to top, rgba(0,0,0,0.9), transparent); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| z-index: 100; | |
| padding-bottom: max(20px, env(safe-area-inset-bottom)); | |
| } | |
| /* --- FILTER SCROLLER --- */ | |
| .filter-scroller { | |
| display: flex; | |
| gap: 15px; | |
| overflow-x: auto; | |
| padding-bottom: 10px; | |
| scrollbar-width: none; /* Firefox */ | |
| -ms-overflow-style: none; /* IE 10+ */ | |
| } | |
| .filter-scroller::-webkit-scrollbar { | |
| display: none; /* Chrome/Safari */ | |
| } | |
| .filter-item { | |
| flex: 0 0 auto; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| cursor: pointer; | |
| opacity: 0.7; | |
| transition: transform 0.2s, opacity 0.2s; | |
| } | |
| .filter-item.active { | |
| opacity: 1; | |
| transform: scale(1.1); | |
| } | |
| .filter-preview { | |
| width: 60px; | |
| height: 60px; | |
| border-radius: 50%; | |
| border: 2px solid transparent; | |
| background-color: #333; | |
| background-size: cover; | |
| background-position: center; | |
| margin-bottom: 5px; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| /* Un indicateur visuel pour le filtre actif */ | |
| .filter-item.active .filter-preview { | |
| border-color: var(--primary-color); | |
| } | |
| .filter-name { | |
| font-size: 0.75rem; | |
| text-shadow: 0 1px 2px black; | |
| } | |
| /* --- ACTION BAR (Shutter, Switch, Gallery) --- */ | |
| .action-bar { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 0 10px; | |
| } | |
| .btn-icon { | |
| background: none; | |
| border: none; | |
| color: white; | |
| font-size: 2rem; | |
| cursor: pointer; | |
| padding: 10px; | |
| border-radius: 50%; | |
| transition: background 0.2s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .btn-icon:hover { | |
| background: rgba(255, 255, 255, 0.1); | |
| } | |
| .btn-gallery { | |
| position: relative; | |
| } | |
| .last-photo-thumb { | |
| width: 40px; | |
| height: 40px; | |
| border-radius: 8px; | |
| border: 2px solid white; | |
| background-size: cover; | |
| background-position: center; | |
| display: block; | |
| } | |
| .placeholder-thumb { | |
| width: 40px; | |
| height: 40px; | |
| border-radius: 8px; | |
| border: 2px solid rgba(255,255,255,0.5); | |
| background: rgba(255,255,255,0.1); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: rgba(255,255,255,0.5); | |
| font-size: 1.2rem; | |
| } | |
| /* --- SHUTTER BUTTON --- */ | |
| .shutter-container { | |
| position: relative; | |
| width: 80px; | |
| height: 80px; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .shutter-outer { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| border-radius: 50%; | |
| border: 4px solid white; | |
| box-sizing: border-box; | |
| transition: transform 0.1s; | |
| } | |
| .shutter-inner { | |
| width: 65px; | |
| height: 65px; | |
| background-color: white; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| box-shadow: 0 0 15px rgba(0,0,0,0.3); | |
| } | |
| .shutter-inner:active { | |
| transform: scale(0.9); | |
| background-color: #ddd; | |
| } | |
| .shutter-inner:active + .shutter-outer { | |
| transform: scale(0.95); | |
| border-color: #ddd; | |
| } | |
| /* --- TOAST NOTIFICATION --- */ | |
| .toast { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%) scale(0.8); | |
| background: rgba(0, 0, 0, 0.85); | |
| color: white; | |
| padding: 15px 25px; | |
| border-radius: 30px; | |
| font-size: 1rem; | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); | |
| z-index: 200; | |
| text-align: center; | |
| backdrop-filter: blur(5px); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .toast.show { | |
| opacity: 1; | |
| transform: translate(-50%, -50%) scale(1); | |
| } | |
| .toast i { | |
| color: var(--primary-color); | |
| font-size: 1.5rem; | |
| } | |
| /* --- PERMISSION ERROR STATE --- */ | |
| .error-message { | |
| display: none; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: #111; | |
| z-index: 300; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| text-align: center; | |
| padding: 20px; | |
| } | |
| .error-message h2 { | |
| color: #ff4444; | |
| margin-bottom: 10px; | |
| } | |
| .error-message p { | |
| color: #ccc; | |
| max-width: 400px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Header --> | |
| <header> | |
| <div class="brand">SnapCam</div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="anycoder-link"> | |
| Built with anycoder | |
| </a> | |
| </header> | |
| <!-- Main Viewport --> | |
| <main> | |
| <video id="video" autoplay playsinline muted></video> | |
| <div id="flash-overlay"></div> | |
| <!-- Message d'erreur permissions --> | |
| <div id="error-screen" class="error-message"> | |
| <i class="ph ph-camera-slash" style="font-size: 3rem; color: #666; margin-bottom: 1rem;"></i> | |
| <h2>Accès Caméra Refusé</h2> | |
| <p>Veuillez autoriser l'accès à la caméra dans votre navigateur pour utiliser cette application.</p> | |
| </div> | |
| </main> | |
| <!-- Canvas pour le traitement image (caché) --> | |
| <canvas id="canvas"></canvas> | |
| <!-- Notification Toast --> | |
| <div id="toast" class="toast"> | |
| <i class="ph ph-download-simple"></i> | |
| <span>Photo enregistrée !</span> | |
| </div> | |
| <!-- Contrôles --> | |
| <div class="controls-container"> | |
| <!-- Sélecteur de Filtres --> | |
| <div class="filter-scroller" id="filter-list"> | |
| <!-- Les filtres seront générés par JS --> | |
| </div> | |
| <!-- Barre d'action (Shutter, Switch, Gallery) --> | |
| <div class="action-bar"> | |
| <!-- Bouton Galerie (Miniature) --> | |
| <button class="btn-icon btn-gallery" id="btn-gallery" aria-label="Galerie"> | |
| <div class="placeholder-thumb"> | |
| <i class="ph ph-image"></i> | |
| </div> | |
| </button> | |
| <!-- Bouton Shutter --> | |
| <div class="shutter-container"> | |
| <div class="shutter-inner" id="btn-capture"></div> | |
| <div class="shutter-outer"></div> | |
| </div> | |
| <!-- Bouton Switch Caméra --> | |
| <button class="btn-icon" id="btn-switch" aria-label="Changer de caméra"> | |
| <i class="ph ph-arrows-clockwise"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <script> | |
| /** | |
| * Logique de l'application SnapCam | |
| * Gère la caméra, les filtres CSS, la capture canvas et le téléchargement. | |
| */ | |
| // --- Configuration & État --- | |
| const state = { | |
| stream: null, | |
| facingMode: 'user', // 'user' (front) ou 'environment' (back) | |
| currentFilter: 'none', | |
| filters: [ | |
| { name: 'Normal', css: 'none', thumb: '' }, | |
| { name: 'Noir & Blanc', css: 'grayscale(100%) contrast(1.2)', thumb: 'filter: grayscale(100%);' }, | |
| { name: 'Sépia', css: 'sepia(100%)', thumb: 'filter: sepia(100%);' }, | |
| { name: 'Vintage', css: 'sepia(50%) contrast(1.2) brightness(0.9)', thumb: 'filter: sepia(50%) contrast(1.2);' }, | |
| { name: 'Froid', css: 'hue-rotate(180deg) saturate(1.5)', thumb: 'filter: hue-rotate(180deg) saturate(1.5);' }, | |
| { name: 'Chaud', css: 'sepia(30%) saturate(1.4) contrast(1.1)', thumb: 'filter: sepia(30%) saturate(1.4);' }, | |
| { name: 'Cyber', css: 'saturate(2) hue-rotate(20deg) contrast(1.2)', thumb: 'filter: saturate(2) hue-rotate(20deg);' }, | |
| { name: 'Inversé', css: 'invert(100%)', thumb: 'filter: invert(100%);' }, | |
| { name: 'Flou', css: 'blur(2px)', thumb: 'filter: blur(2px);' }, | |
| { name: 'Contraste', css: 'contrast(200%)', thumb: 'filter: contrast(200%);' } | |
| ] | |
| }; | |
| // --- Éléments DOM --- | |
| const video = document.getElementById('video'); | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const filterList = document.getElementById('filter-list'); | |
| const btnCapture = document.getElementById('btn-capture'); | |
| const btnSwitch = document.getElementById('btn-switch'); | |
| const btnGallery = document.getElementById('btn-gallery'); | |
| const flashOverlay = document.getElementById('flash-overlay'); | |
| const toast = document.getElementById('toast'); | |
| const errorScreen = document.getElementById('error-screen'); | |
| // --- Initialisation --- | |
| async function initCamera() { | |
| try { | |
| if (state.stream) { | |
| state.stream.getTracks().forEach(track => track.stop()); | |
| } | |
| const constraints = { | |
| video: { | |
| facingMode: state.facingMode, | |
| width: { ideal: 1920 }, // Essaye d'avoir une bonne résolution | |
| height: { ideal: 1080 } | |
| }, | |
| audio: false | |
| }; | |
| state.stream = await navigator.mediaDevices.getUserMedia(constraints); | |
| video.srcObject = state.stream; | |
| // Gérer le miroir (seulement pour la caméra frontale) | |
| if (state.facingMode === 'user') { | |
| video.style.transform = 'scaleX(-1)'; | |
| } else { | |
| video.style.transform = 'scaleX(1)'; | |
| } | |
| errorScreen.style.display = 'none'; | |
| } catch (err) { | |
| console.error("Erreur caméra:", err); | |
| errorScreen.style.display = 'flex'; | |
| } | |
| } | |
| // --- Génération des Filtres UI --- | |
| function renderFilters() { | |
| filterList.innerHTML = ''; | |
| state.filters.forEach((filter, index) => { | |
| const item = document.createElement('div'); | |
| item.className = `filter-item ${filter.css === state.currentFilter ? 'active' : ''}`; | |
| // Création de la miniature (placeholder coloré ou icon) | |
| const preview = document.createElement('div'); | |
| preview.className = 'filter-preview'; | |
| // On simule le filtre sur un fond dégradé pour la preview | |
| preview.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; | |
| // On applique le style CSS du filtre sur l'élément preview | |
| if (filter.thumb) { | |
| preview.style.cssText += filter.thumb; | |
| } | |
| const name = document.createElement('span'); | |
| name.className = 'filter-name'; | |
| name.innerText = filter.name; | |
| item.appendChild(preview); | |
| item.appendChild(name); | |
| // Clic pour changer le filtre | |
| item.addEventListener('click', () => { | |
| // Mise à jour UI | |
| document.querySelectorAll('.filter-item').forEach(el => el.classList.remove('active')); | |
| item.classList.add('active'); | |
| // Mise à jour État & Vidéo | |
| state.currentFilter = filter.css; | |
| video.style.filter = state.currentFilter; | |
| }); | |
| filterList.appendChild(item); | |
| }); | |
| } | |
| // --- Prise de Photo --- | |
| function takePhoto() { | |
| if (!state.stream) return; | |
| // 1. Effet Flash | |
| flashOverlay.style.opacity = 1; | |
| setTimeout(() => { flashOverlay.style.opacity = 0; }, 100); | |
| // 2. Configuration Canvas | |
| // On utilise les dimensions réelles de la vidéo | |
| const width = video.videoWidth; | |
| const height = video.videoHeight; | |
| canvas.width = width; | |
| canvas.height = height; | |
| // 3. Dessin | |
| // Si on est en mode "selfie" (front), on doit flipper le canvas horizontalement pour que le texte soit lisible | |
| // car la vidéo est flippée par CSS pour l'utilisateur, mais le stream raw n'est pas flippé. | |
| if (state.facingMode === 'user') { | |
| ctx.translate(width, 0); | |
| ctx.scale(-1, 1); | |
| } | |
| // Appliquer le filtre sur le contexte | |
| if (state.currentFilter !== 'none') { | |
| ctx.filter = state.currentFilter; | |
| } | |
| ctx.drawImage(video, 0, 0, width, height); | |
| // Reset transform pour éviter les bugs prochains dessins | |
| ctx.setTransform(1, 0, 0, 1, 0, 0); | |
| ctx.filter = 'none'; | |
| // 4. Export & Téléchargement | |
| const dataURL = canvas.toDataURL('image/jpeg', 0.95); // Qualité 95% | |
| // Création d'un lien temporaire | |
| const link = document.createElement('a'); | |
| const timestamp = new Date().getTime(); | |
| link.download = `snapcam_${timestamp}.jpg`; | |
| link.href = dataURL; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| // 5. Mise à jour Miniature Galerie | |
| updateGalleryThumbnail(dataURL); | |
| // 6. Feedback Toast | |
| showToast(); | |
| } | |
| // --- Mise à jour Miniature --- | |
| function updateGalleryThumbnail(dataUrl) { | |
| // Supprimer l'icône placeholder si elle existe | |
| if (btnGallery.querySelector('.placeholder-thumb')) { | |
| btnGallery.innerHTML = ''; | |
| } | |
| // Créer l'image | |
| const img = document.createElement('div'); | |
| img.className = 'last-photo-thumb'; | |
| img.style.backgroundImage = `url(${dataUrl})`; | |
| btnGallery.appendChild(img); | |
| } | |
| // --- Afficher Toast --- | |
| function showToast() { | |
| toast.classList.add('show'); | |
| setTimeout(() => { | |
| toast.classList.remove('show'); | |
| }, 2500); | |
| } | |
| // --- Changement de Caméra --- | |
| function switchCamera() { | |
| state.facingMode = state.facingMode === 'user' ? 'environment' : 'user'; | |
| initCamera(); | |
| } | |
| // --- Écouteurs d'événements --- | |
| btnCapture.addEventListener('click', takePhoto); | |
| btnSwitch.addEventListener('click', switchCamera); | |
| // Démarrage | |
| window.addEventListener('DOMContentLoaded', () => { | |
| renderFilters(); | |
| initCamera(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |