Spaces:
Sleeping
Sleeping
| const video = document.getElementById('videoElement'); | |
| const canvas = document.getElementById('canvasElement'); | |
| const ctx = canvas.getContext('2d'); | |
| const overlayCanvas = document.getElementById('overlayCanvas'); | |
| const overlayCtx = overlayCanvas.getContext('2d'); | |
| const statusBadge = document.getElementById('status-badge'); | |
| const attendanceList = document.getElementById('attendance-list'); | |
| const confirmedList = document.getElementById('confirmed-list'); | |
| const terminalLogs = document.getElementById('terminal-logs'); | |
| const debugCrops = document.getElementById('debug-crops'); | |
| const cropCount = document.getElementById('crop-count'); | |
| let ws; | |
| let isProcessing = false; | |
| let frameCount = 0; | |
| let lastFpsTime = Date.now(); | |
| let reconnectAttempts = 0; | |
| let pingInterval; | |
| let currentlyVisible = {}; | |
| let confirmedPeople = new Set(); | |
| const VISIBILITY_TIMEOUT_MS = 8000; | |
| // Calculate dynamic WebSocket URL for Hugging Face Spaces | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws`; | |
| function logToTerminal(msg) { | |
| const div = document.createElement('div'); | |
| const time = new Date().toISOString().split('T')[1].slice(0, 12); | |
| div.innerHTML = `<span class="text-slate-500">[${time}]</span> <span class="${msg.includes('ERROR') ? 'text-red-400' : 'text-slate-300'}">${msg}</span>`; | |
| terminalLogs.appendChild(div); | |
| if (terminalLogs.children.length > 20) terminalLogs.removeChild(terminalLogs.firstChild); | |
| terminalLogs.scrollTop = terminalLogs.scrollHeight; | |
| } | |
| function drawFaceBoxes(faces) { | |
| overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); | |
| if (!faces) return; | |
| faces.forEach(face => { | |
| if (face.box) { | |
| let { x, y, w, h } = face.box; | |
| // Mirror math for canvas | |
| const mirroredX = overlayCanvas.width - x - w; | |
| const color = face.status === 'match' ? '#10b981' : (face.status === 'fail' ? '#f59e0b' : '#ef4444'); | |
| overlayCtx.strokeStyle = color; | |
| overlayCtx.lineWidth = 3; | |
| overlayCtx.strokeRect(mirroredX, y, w, h); | |
| overlayCtx.fillStyle = color; | |
| overlayCtx.font = 'bold 12px monospace'; | |
| const label = `${face.name} (${face.score}%)`; | |
| overlayCtx.fillText(label, mirroredX, y > 20 ? y - 10 : y + 20); | |
| } | |
| }); | |
| } | |
| function updateDebugCrops(faces) { | |
| // Always clear previous crops | |
| debugCrops.innerHTML = ''; | |
| if (!faces || faces.length === 0) { | |
| debugCrops.innerHTML = '<div class="text-xs text-slate-600 italic">Waiting for detection...</div>'; | |
| cropCount.textContent = '0 faces'; | |
| return; | |
| } | |
| cropCount.textContent = `${faces.length} faces`; | |
| faces.forEach(face => { | |
| if (face.crop) { | |
| const container = document.createElement('div'); | |
| container.className = 'relative flex-shrink-0'; | |
| const img = document.createElement('img'); | |
| img.src = face.crop; | |
| img.className = `w-16 h-16 rounded border-2 ${face.status === 'match' ? 'border-emerald-500' : (face.status === 'fail' ? 'border-amber-500' : 'border-slate-700')} object-cover`; | |
| const badge = document.createElement('div'); | |
| badge.className = 'absolute bottom-0 left-0 right-0 bg-black/70 text-[8px] text-center py-0.5 truncate'; | |
| badge.textContent = face.status === 'match' ? 'MATCH' : (face.score > 0 ? `${face.score}%` : '???'); | |
| container.appendChild(img); | |
| container.appendChild(badge); | |
| debugCrops.appendChild(container); | |
| } | |
| }); | |
| } | |
| function connectWebSocket() { | |
| ws = new WebSocket(wsUrl); | |
| ws.onopen = () => { | |
| reconnectAttempts = 0; | |
| statusBadge.textContent = '🟢 System Active'; | |
| statusBadge.className = 'px-4 py-2 rounded-full text-sm font-semibold bg-emerald-500/20 text-emerald-400 border border-emerald-500/50'; | |
| document.getElementById('ws-status').textContent = 'Connected'; | |
| logToTerminal("WebSocket Connected. Running Visual Debugger..."); | |
| attendanceList.innerHTML = '<div class="text-center text-slate-500 mt-10 text-sm">Camera is empty.</div>'; | |
| currentlyVisible = {}; | |
| pingInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'heartbeat' })); }, 10000); | |
| }; | |
| ws.onmessage = (event) => { | |
| const data = JSON.parse(event.data); | |
| if (data.type === 'attendance') { | |
| currentlyVisible[data.name] = Date.now(); | |
| renderPresenceList(); | |
| addConfirmedEntry(data.name, data.time); | |
| } else if (data.type === 'ready') { | |
| isProcessing = false; | |
| if(data.debug) logToTerminal(data.debug); | |
| if(data.faces) { | |
| drawFaceBoxes(data.faces); | |
| updateDebugCrops(data.faces); | |
| } | |
| } | |
| }; | |
| ws.onclose = () => { | |
| isProcessing = false; | |
| clearInterval(pingInterval); | |
| statusBadge.textContent = '🔴 Disconnected'; | |
| statusBadge.className = 'px-4 py-2 rounded-full text-sm font-semibold bg-red-500/20 text-red-400 border border-red-500/50'; | |
| document.getElementById('ws-status').textContent = 'Offline'; | |
| const timeout = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); | |
| logToTerminal(`ERROR: Lost connection. Reconnect in ${timeout/1000}s...`); | |
| reconnectAttempts++; | |
| setTimeout(connectWebSocket, timeout); | |
| }; | |
| } | |
| function addConfirmedEntry(name, time) { | |
| if (confirmedPeople.has(name)) return; | |
| if (confirmedPeople.size === 0) confirmedList.innerHTML = ''; | |
| confirmedPeople.add(name); | |
| const div = document.createElement('div'); | |
| div.className = 'bg-emerald-500/10 p-3 rounded-xl border border-emerald-500/20 flex justify-between items-center animate-[slideIn_0.3s_ease-out]'; | |
| div.innerHTML = ` | |
| <div class="flex items-center gap-3"> | |
| <div class="w-8 h-8 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-xs font-bold">${confirmedPeople.size}</div> | |
| <div> | |
| <div class="font-semibold text-slate-200 capitalize tracking-wide">${name.replace(/_/g, ' ')}</div> | |
| <div class="text-[9px] text-emerald-500 uppercase font-bold tracking-wider">Confirmed Identity</div> | |
| </div> | |
| </div> | |
| <span class="text-[10px] text-emerald-400 font-mono bg-emerald-400/5 px-2 py-1 rounded border border-emerald-400/10">${time}</span> | |
| `; | |
| confirmedList.insertBefore(div, confirmedList.firstChild); | |
| } | |
| setInterval(() => { | |
| const now = Date.now(); | |
| let hasChanges = false; | |
| for (const name in currentlyVisible) { | |
| if (now - currentlyVisible[name] > VISIBILITY_TIMEOUT_MS) { | |
| delete currentlyVisible[name]; | |
| hasChanges = true; | |
| } | |
| } | |
| if (hasChanges) renderPresenceList(); | |
| }, 1000); | |
| function renderPresenceList() { | |
| const names = Object.keys(currentlyVisible); | |
| if (names.length === 0) { | |
| attendanceList.innerHTML = '<div class="text-center text-slate-500 mt-10 text-sm animate-pulse">Camera is empty.</div>'; | |
| return; | |
| } | |
| attendanceList.innerHTML = ''; | |
| const timeStr = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', second:'2-digit'}); | |
| names.forEach(name => { | |
| const div = document.createElement('div'); | |
| div.className = 'bg-slate-800/80 p-3 rounded-xl border border-emerald-500/50 flex justify-between items-center animate-[slideIn_0.3s_ease-out] shadow-[0_0_10px_rgba(16,185,129,0.1)]'; | |
| div.innerHTML = ` | |
| <div class="flex items-center gap-3"> | |
| <div class="w-10 h-10 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-lg shadow-inner">🟢</div> | |
| <div> | |
| <div class="font-semibold text-slate-200 capitalize tracking-wide">${name.replace(/_/g, ' ')}</div> | |
| <div class="text-[10px] text-emerald-400 uppercase font-bold tracking-wider">Present Now</div> | |
| </div> | |
| </div> | |
| <span class="text-xs text-emerald-400 font-mono bg-emerald-400/10 px-2 py-1 rounded border border-emerald-400/20">${timeStr}</span> | |
| `; | |
| attendanceList.appendChild(div); | |
| }); | |
| } | |
| async function startCamera() { | |
| try { | |
| // Lowered to 640x480 (VGA) for ultra-fast AI processing without losing accuracy | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { width: 640, height: 480, facingMode: "user" } | |
| }); | |
| video.srcObject = stream; | |
| video.onloadedmetadata = () => { | |
| canvas.width = video.videoWidth; canvas.height = video.videoHeight; | |
| overlayCanvas.width = video.videoWidth; overlayCanvas.height = video.videoHeight; | |
| requestAnimationFrame(processFrame); | |
| }; | |
| } catch (err) { | |
| statusBadge.textContent = '⚠️ Camera Denied'; | |
| logToTerminal("ERROR: Camera access denied."); | |
| } | |
| } | |
| function processFrame() { | |
| frameCount++; | |
| if (Date.now() - lastFpsTime >= 1000) { document.getElementById('fps-counter').textContent = frameCount; frameCount = 0; lastFpsTime = Date.now(); } | |
| if (ws && ws.readyState === WebSocket.OPEN && !isProcessing) { | |
| ctx.drawImage(video, 0, 0, canvas.width, canvas.height); | |
| isProcessing = true; | |
| // Lowered to 0.7 quality to minimize network latency on Hugging Face proxy | |
| ws.send(JSON.stringify({ type: 'frame', image: canvas.toDataURL('image/jpeg', 0.7) })); | |
| } | |
| requestAnimationFrame(processFrame); | |
| } | |
| connectWebSocket(); | |
| startCamera(); | |