/* camera.js - Enhanced for Hugging Face Spaces deployment Features: - Robust session management and authentication handling - Enhanced error handling for network timeouts and server errors - Improved retry mechanisms for unreliable connections - Better resource cleanup and camera management - Live liveness detection preview for attendance - Fallback handling when models are unavailable - Progressive image quality for better performance on cloud platforms */ document.addEventListener('DOMContentLoaded', function () { // Configuration const CONFIG = { REQUEST_TIMEOUT: 45000, // 45 seconds for cloud deployment RETRY_ATTEMPTS: 3, RETRY_DELAY: 1000, PREVIEW_FPS: 1.5, // Reduced for cloud hosting stability MAX_CONSECUTIVE_ERRORS: 3, IMAGE_QUALITY: { preview: 0.4, capture: 0.8, registration: 0.9 } }; // Global state management let globalState = { sessionValid: true, modelsAvailable: true, networkOnline: navigator.onLine }; // Enhanced request function with retry logic and session handling async function makeRequest(url, options = {}, retries = CONFIG.RETRY_ATTEMPTS) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), CONFIG.REQUEST_TIMEOUT); try { const response = await fetch(url, { ...options, signal: controller.signal, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', ...options.headers } }); clearTimeout(timeoutId); if (!response.ok) { // Handle specific HTTP status codes if (response.status === 401 || response.status === 403) { globalState.sessionValid = false; throw new Error('Session expired. Please login again.'); } else if (response.status === 503) { throw new Error('Service temporarily unavailable. Please try again later.'); } else { throw new Error(`Server error (${response.status}). Please try again.`); } } const data = await response.json(); // Handle application-level redirects and session issues if (data.redirect || (data.message && data.message.includes('Not logged in'))) { globalState.sessionValid = false; throw new Error('Session expired. Redirecting to login...'); } // Check for model availability issues if (data.message && data.message.includes('model not available')) { globalState.modelsAvailable = false; } return data; } catch (error) { clearTimeout(timeoutId); // Handle session expiration if (!globalState.sessionValid || error.message.includes('Session expired')) { setTimeout(() => { window.location.href = '/login.html'; }, 2000); throw error; } // Handle network errors with retry logic if (retries > 0 && ( error.name === 'AbortError' || error.name === 'TypeError' || error.message.includes('fetch') )) { console.warn(`Request failed, retrying... (${CONFIG.RETRY_ATTEMPTS - retries + 1}/${CONFIG.RETRY_ATTEMPTS})`); await new Promise(resolve => setTimeout(resolve, CONFIG.RETRY_DELAY)); return makeRequest(url, options, retries - 1); } // Enhanced error messages for user if (error.name === 'AbortError') { throw new Error('Request timed out. Please check your internet connection and try again.'); } else if (error.name === 'TypeError') { throw new Error('Network error. Please check your internet connection.'); } throw error; } } // Enhanced loading indicator with progress function showLoading(element, message = 'Processing...', showSpinner = true) { if (!element) return; const spinner = showSpinner ? `
Loading...
` : ''; element.innerHTML = `
${spinner} ${message}
`; element.disabled = true; } function hideLoading(element, message = '', isError = false) { if (!element) return; element.innerHTML = message; element.disabled = false; if (isError) { element.classList.add('btn-outline-danger'); setTimeout(() => { element.classList.remove('btn-outline-danger'); }, 3000); } } // Enhanced camera access with progressive fallback async function getCameraStream(constraints = {}) { const configurations = [ // High quality for registration/login { video: { width: { ideal: 640, max: 1280 }, height: { ideal: 480, max: 720 }, facingMode: 'user', frameRate: { ideal: 30, max: 60 } } }, // Medium quality fallback { video: { width: { ideal: 480, max: 640 }, height: { ideal: 360, max: 480 }, facingMode: 'user', frameRate: { ideal: 15, max: 30 } } }, // Basic fallback { video: { facingMode: 'user' } }, // Last resort - any camera { video: true } ]; // Merge user constraints with defaults if (Object.keys(constraints).length > 0) { configurations.unshift(constraints); } for (let i = 0; i < configurations.length; i++) { try { console.log(`Trying camera configuration ${i + 1}/${configurations.length}`); const stream = await navigator.mediaDevices.getUserMedia(configurations[i]); console.log('Camera access successful with configuration:', configurations[i]); return stream; } catch (error) { console.warn(`Camera configuration ${i + 1} failed:`, error); if (i === configurations.length - 1) { throw new Error('Unable to access camera. Please ensure camera permissions are granted and no other application is using the camera.'); } } } } // Check system capabilities async function checkSystemCapabilities() { try { // Check camera availability const devices = await navigator.mediaDevices.enumerateDevices(); const hasCamera = devices.some(device => device.kind === 'videoinput'); if (!hasCamera) { throw new Error('No camera detected on this device.'); } // Check server health const healthData = await makeRequest('/health-check'); globalState.modelsAvailable = healthData.models && (healthData.models.yolo_loaded || healthData.models.antispoof_loaded); return { camera: hasCamera, models: globalState.modelsAvailable, database: healthData.database_connected }; } catch (error) { console.warn('System capability check failed:', error); return { camera: true, models: false, database: false }; // Optimistic defaults } } // Reusable camera section for Registration/Login with enhanced error handling function setupCameraSection(config) { const elements = { video: document.getElementById(config.videoId), canvas: document.getElementById(config.canvasId), startBtn: document.getElementById(config.startCameraBtnId), captureBtn: document.getElementById(config.captureImageBtnId), retakeBtn: document.getElementById(config.retakeImageBtnId), overlay: document.getElementById(config.cameraOverlayId), imageInput: document.getElementById(config.faceImageInputId), actionBtn: document.getElementById(config.actionBtnId) }; // Validate required elements const requiredElements = ['video', 'canvas', 'startBtn', 'captureBtn', 'retakeBtn', 'imageInput']; const missingElements = requiredElements.filter(key => !elements[key]); if (missingElements.length > 0) { console.warn(`Skipping camera section - missing elements: ${missingElements.join(', ')}`); return; } let stream = null; let isCapturing = false; // Enhanced start camera with capability check elements.startBtn.addEventListener('click', async function () { if (isCapturing) return; try { isCapturing = true; showLoading(elements.startBtn, 'Checking camera...'); // Quick capability check const capabilities = await checkSystemCapabilities(); if (!capabilities.camera) { throw new Error('No camera available on this device.'); } showLoading(elements.startBtn, 'Starting camera...'); stream = await getCameraStream({ video: { width: { ideal: 400, max: 640 }, height: { ideal: 300, max: 480 }, facingMode: 'user' } }); elements.video.srcObject = stream; await elements.video.play(); // Update UI elements.startBtn.classList.add('d-none'); elements.captureBtn.classList.remove('d-none'); elements.retakeBtn.classList.add('d-none'); if (elements.overlay) elements.overlay.classList.add('d-none'); elements.video.classList.remove('d-none'); elements.canvas.classList.add('d-none'); if (elements.actionBtn) elements.actionBtn.disabled = true; } catch (err) { console.error('Error starting camera:', err); alert(err.message || 'Could not access the camera. Please check permissions.'); hideLoading(elements.startBtn, 'Start Camera', true); } finally { isCapturing = false; if (!stream) { hideLoading(elements.startBtn, 'Start Camera'); } else { hideLoading(elements.startBtn, 'Camera Active'); } } }); // Enhanced capture with quality options elements.captureBtn.addEventListener('click', function () { try { if (!stream || elements.video.readyState < 2) { alert('Camera not ready. Please wait a moment and try again.'); return; } const context = elements.canvas.getContext('2d'); elements.canvas.width = elements.video.videoWidth || 400; elements.canvas.height = elements.video.videoHeight || 300; // Draw with better quality context.drawImage(elements.video, 0, 0, elements.canvas.width, elements.canvas.height); // Use appropriate quality based on use case const quality = config.isRegistration ? CONFIG.IMAGE_QUALITY.registration : CONFIG.IMAGE_QUALITY.capture; const imageDataURL = elements.canvas.toDataURL('image/jpeg', quality); elements.imageInput.value = imageDataURL; // Update UI if (elements.overlay) elements.overlay.classList.remove('d-none'); elements.captureBtn.classList.add('d-none'); elements.retakeBtn.classList.remove('d-none'); elements.video.classList.add('d-none'); elements.canvas.classList.remove('d-none'); if (elements.actionBtn) elements.actionBtn.disabled = false; // Stop camera stream if (stream) { stream.getTracks().forEach(track => track.stop()); stream = null; } } catch (err) { console.error('Error capturing image:', err); alert('Failed to capture image. Please try again.'); } }); // Enhanced retake with cleanup elements.retakeBtn.addEventListener('click', async function () { try { showLoading(elements.retakeBtn, 'Restarting...'); // Clear canvas and input const context = elements.canvas.getContext('2d'); context.clearRect(0, 0, elements.canvas.width, elements.canvas.height); elements.imageInput.value = ''; // Restart camera stream = await getCameraStream({ video: { width: { ideal: 400, max: 640 }, height: { ideal: 300, max: 480 }, facingMode: 'user' } }); elements.video.srcObject = stream; await elements.video.play(); // Update UI if (elements.overlay) elements.overlay.classList.add('d-none'); elements.captureBtn.classList.remove('d-none'); elements.retakeBtn.classList.add('d-none'); elements.video.classList.remove('d-none'); elements.canvas.classList.add('d-none'); if (elements.actionBtn) elements.actionBtn.disabled = true; } catch (err) { console.error('Error restarting camera:', err); alert(err.message || 'Error restarting camera.'); } finally { hideLoading(elements.retakeBtn, 'Retake'); } }); // Cleanup on page unload window.addEventListener('beforeunload', () => { if (stream) { stream.getTracks().forEach(track => track.stop()); } }); } // Enhanced attendance section with robust liveness preview function setupAttendanceSection(config) { const elements = { video: document.getElementById(config.videoId), canvas: document.getElementById(config.canvasId), startBtn: document.getElementById(config.startCameraBtnId), captureBtn: document.getElementById(config.captureImageBtnId), retakeBtn: document.getElementById(config.retakeImageBtnId), markBtn: document.getElementById(config.markBtnId), overlayImg: document.getElementById(config.overlayImgId), statusEl: document.getElementById(config.statusId), // Form fields programEl: document.getElementById(config.programId), semesterEl: document.getElementById(config.semesterId), courseEl: document.getElementById(config.courseId), studentIdEl: document.getElementById(config.studentIdInputId) }; // Validate core elements if (!elements.video || !elements.canvas || !elements.startBtn || !elements.captureBtn || !elements.retakeBtn || !elements.markBtn) { console.warn('Skipping attendance section - missing core elements'); return; } let stream = null; let capturedDataUrl = ''; let previewState = { active: false, busy: false, timer: null, consecutiveErrors: 0, lastSuccessTime: 0 }; // Preview canvas for efficiency const previewCanvas = document.createElement('canvas'); const previewCtx = previewCanvas.getContext('2d'); function setStatus(msg, type = 'info') { if (!elements.statusEl) return; elements.statusEl.textContent = msg || ''; elements.statusEl.classList.remove('text-success', 'text-danger', 'text-warning', 'text-info'); switch (type) { case 'error': elements.statusEl.classList.add('text-danger'); break; case 'warning': elements.statusEl.classList.add('text-warning'); break; case 'success': elements.statusEl.classList.add('text-success'); break; default: elements.statusEl.classList.add('text-info'); } } function clearOverlay() { if (elements.overlayImg) { elements.overlayImg.src = ''; elements.overlayImg.classList.add('d-none'); } } async function startCamera() { try { showLoading(elements.startBtn, 'Initializing camera...'); setStatus('Starting camera system...'); // Check system capabilities first const capabilities = await checkSystemCapabilities(); if (!capabilities.camera) { throw new Error('No camera detected. Please ensure a camera is connected.'); } if (!capabilities.models) { setStatus('Warning: Face recognition models may not be fully available', 'warning'); } showLoading(elements.startBtn, 'Accessing camera...'); stream = await getCameraStream({ video: { width: { ideal: 640, max: 1280 }, height: { ideal: 480, max: 720 }, facingMode: 'user' } }); elements.video.srcObject = stream; await elements.video.play(); // Configure preview canvas previewCanvas.width = 480; // Reduced for better network performance previewCanvas.height = Math.round(previewCanvas.width * (elements.video.videoHeight || 480) / (elements.video.videoWidth || 640)); // Update UI elements.startBtn.classList.add('d-none'); elements.captureBtn.classList.remove('d-none'); elements.retakeBtn.classList.add('d-none'); elements.markBtn.disabled = true; elements.video.classList.remove('d-none'); elements.canvas.classList.add('d-none'); clearOverlay(); setStatus('Camera active. Starting liveness detection...'); // Wait for video stabilization before starting preview setTimeout(() => { if (stream && elements.video.readyState >= 2) { startLivenessPreview(); } }, 1500); } catch (err) { console.error('Error starting camera:', err); setStatus(`Camera error: ${err.message}`, 'error'); alert(err.message || 'Could not access camera. Please check permissions.'); } finally { hideLoading(elements.startBtn, 'Start Camera'); } } function startLivenessPreview() { if (previewState.active) return; previewState.active = true; previewState.consecutiveErrors = 0; previewState.lastSuccessTime = Date.now(); // Reduced FPS for cloud hosting stability const intervalMs = Math.round(1000 / CONFIG.PREVIEW_FPS); previewState.timer = setInterval(async () => { if (!previewState.active || previewState.busy || !stream || elements.video.readyState < 2) { return; } previewState.busy = true; try { // Capture frame at lower quality for preview previewCtx.drawImage(elements.video, 0, 0, previewCanvas.width, previewCanvas.height); const frameDataUrl = previewCanvas.toDataURL('image/jpeg', CONFIG.IMAGE_QUALITY.preview); const data = await makeRequest('/liveness-preview', { method: 'POST', body: JSON.stringify({ face_image: frameDataUrl }) }); // Reset error counter on success previewState.consecutiveErrors = 0; previewState.lastSuccessTime = Date.now(); // Update overlay if (elements.overlayImg && data.overlay) { elements.overlayImg.src = data.overlay; elements.overlayImg.classList.remove('d-none'); } // Enhanced status with confidence indicators if (typeof data.live === 'boolean' && typeof data.live_prob === 'number') { const confidence = data.live_prob; let confidenceText = confidence >= 0.9 ? 'Excellent' : confidence >= 0.8 ? 'Good' : confidence >= 0.7 ? 'Fair' : 'Poor'; const statusType = data.live ? (confidence >= 0.8 ? 'success' : 'warning') : 'error'; setStatus( `${data.live ? 'LIVE' : 'SPOOF'} detected - Confidence: ${confidenceText} (${confidence.toFixed(2)})`, statusType ); } else if (data.message) { setStatus(data.message, data.success ? 'info' : 'warning'); } } catch (error) { previewState.consecutiveErrors++; const timeSinceLastSuccess = Date.now() - previewState.lastSuccessTime; console.warn(`Preview error ${previewState.consecutiveErrors}/${CONFIG.MAX_CONSECUTIVE_ERRORS}:`, error); if (previewState.consecutiveErrors >= CONFIG.MAX_CONSECUTIVE_ERRORS || timeSinceLastSuccess > 30000) { setStatus('Liveness preview temporarily unavailable. You can still capture and mark attendance.', 'warning'); stopLivenessPreview(); } else if (previewState.consecutiveErrors === 1) { setStatus('Connecting to liveness service...', 'warning'); } } finally { previewState.busy = false; } }, intervalMs); } function stopLivenessPreview() { previewState.active = false; if (previewState.timer) { clearInterval(previewState.timer); previewState.timer = null; } previewState.busy = false; previewState.consecutiveErrors = 0; } function captureAttendanceFrame() { try { if (!stream || elements.video.readyState < 2) { setStatus('Camera not ready. Please wait and try again.', 'error'); return; } // Stop preview during capture stopLivenessPreview(); const ctx = elements.canvas.getContext('2d'); elements.canvas.width = elements.video.videoWidth || 640; elements.canvas.height = elements.video.videoHeight || 480; ctx.drawImage(elements.video, 0, 0, elements.canvas.width, elements.canvas.height); capturedDataUrl = elements.canvas.toDataURL('image/jpeg', CONFIG.IMAGE_QUALITY.capture); // Update UI elements.captureBtn.classList.add('d-none'); elements.retakeBtn.classList.remove('d-none'); elements.markBtn.disabled = false; elements.video.classList.add('d-none'); elements.canvas.classList.remove('d-none'); setStatus('Image captured successfully. Ready to mark attendance.', 'success'); // Stop camera if (stream) { stream.getTracks().forEach(track => track.stop()); stream = null; } } catch (err) { console.error('Error capturing frame:', err); setStatus('Failed to capture image. Please try again.', 'error'); } } async function retakeAttendanceFrame() { try { showLoading(elements.retakeBtn, 'Restarting camera...'); // Clear previous capture const ctx = elements.canvas.getContext('2d'); ctx.clearRect(0, 0, elements.canvas.width, elements.canvas.height); capturedDataUrl = ''; clearOverlay(); // Restart camera await startCamera(); } catch (err) { console.error('Error during retake:', err); setStatus('Error restarting camera. Please refresh the page.', 'error'); } finally { hideLoading(elements.retakeBtn, 'Retake'); } } async function markAttendance() { try { if (!capturedDataUrl) { setStatus('Please capture an image first.', 'error'); return; } // Validate form fields const payload = { student_id: (elements.studentIdEl && elements.studentIdEl.value) || null, program: (elements.programEl && elements.programEl.value) || '', semester: (elements.semesterEl && elements.semesterEl.value) || '', course: (elements.courseEl && elements.courseEl.value) || '', face_image: capturedDataUrl, }; if (!payload.program || !payload.semester || !payload.course) { setStatus('Please fill in Program, Semester, and Course fields.', 'error'); return; } showLoading(elements.markBtn, 'Processing attendance...'); setStatus('Analyzing face and marking attendance... This may take a moment.'); const data = await makeRequest('/mark-attendance', { method: 'POST', body: JSON.stringify(payload) }); // Show final overlay with detection results if (elements.overlayImg && data.overlay) { elements.overlayImg.src = data.overlay; elements.overlayImg.classList.remove('d-none'); } if (data.success) { setStatus('✅ ' + (data.message || 'Attendance marked successfully!'), 'success'); // Auto-refresh after successful attendance setTimeout(() => { setStatus('Refreshing page...', 'info'); window.location.reload(); }, 3000); } else { const errorMessage = data.message || 'Failed to mark attendance.'; setStatus('❌ ' + errorMessage, 'error'); // Provide helpful suggestions based on error type if (errorMessage.includes('already marked')) { setStatus('Attendance already marked for this course today.', 'warning'); } else if (errorMessage.includes('not recognized')) { setStatus('Face not recognized. Please ensure good lighting and try again.', 'warning'); } else if (errorMessage.includes('SPOOF')) { setStatus('Spoof detection activated. Please ensure you are physically present and try again.', 'warning'); } } } catch (err) { console.error('markAttendance error:', err); let errorMsg = 'An error occurred while marking attendance.'; if (err.message.includes('Session expired')) { errorMsg = 'Session expired. You will be redirected to login.'; } else if (err.message.includes('timed out')) { errorMsg = 'Request timed out. Please check your connection and try again.'; } else if (err.message.includes('model not available')) { errorMsg = 'Face recognition service is temporarily unavailable. Please try again later.'; } setStatus('❌ ' + errorMsg, 'error'); } finally { hideLoading(elements.markBtn, 'Mark Attendance'); } } // Event listeners elements.startBtn.addEventListener('click', startCamera); elements.captureBtn.addEventListener('click', captureAttendanceFrame); elements.retakeBtn.addEventListener('click', retakeAttendanceFrame); elements.markBtn.addEventListener('click', markAttendance); // Cleanup handlers window.addEventListener('beforeunload', () => { stopLivenessPreview(); if (stream) { stream.getTracks().forEach(track => track.stop()); } }); // Handle page visibility changes document.addEventListener('visibilitychange', () => { if (document.hidden) { if (previewState.active) { stopLivenessPreview(); } } else if (!document.hidden && stream && elements.video.readyState >= 2 && !previewState.active) { setTimeout(() => { startLivenessPreview(); }, 1000); } }); } // Enhanced auto face login with progressive quality function setupAutoFaceLogin() { const autoLoginBtn = document.getElementById('autoFaceLoginBtn'); const roleSelect = document.getElementById('faceRole'); if (!autoLoginBtn) return; autoLoginBtn.addEventListener('click', async function() { try { autoLoginBtn.disabled = true; showLoading(autoLoginBtn, 'Initializing...'); const role = roleSelect ? roleSelect.value : 'student'; showLoading(autoLoginBtn, 'Accessing camera...'); const stream = await getCameraStream({ video: { width: { ideal: 640, max: 1280 }, height: { ideal: 480, max: 720 }, facingMode: 'user' } }); // Create temporary elements const tempVideo = document.createElement('video'); tempVideo.srcObject = stream; tempVideo.muted = true; showLoading(autoLoginBtn, 'Preparing camera...'); await tempVideo.play(); // Wait for stabilization await new Promise(resolve => setTimeout(resolve, 2000)); showLoading(autoLoginBtn, 'Capturing image...'); const tempCanvas = document.createElement('canvas'); const ctx = tempCanvas.getContext('2d'); tempCanvas.width = tempVideo.videoWidth || 640; tempCanvas.height = tempVideo.videoHeight || 480; ctx.drawImage(tempVideo, 0, 0, tempCanvas.width, tempCanvas.height); const imageDataURL = tempCanvas.toDataURL('image/jpeg', CONFIG.IMAGE_QUALITY.capture); // Cleanup camera stream.getTracks().forEach(track => track.stop()); showLoading(autoLoginBtn, 'Recognizing face...'); const data = await makeRequest('/auto-face-login', { method: 'POST', body: JSON.stringify({ face_image: imageDataURL, face_role: role }) }); if (data.success) { showLoading(autoLoginBtn, 'Login successful! Redirecting...', false); setTimeout(() => { window.location.href = data.redirect_url; }, 1500); } else { throw new Error(data.message || 'Face recognition failed. Please try again.'); } } catch (err) { console.error('Auto face login error:', err); alert(err.message || 'Auto face login failed. Please try manual login.'); hideLoading(autoLoginBtn, 'Auto Face Login', true); } finally { if (autoLoginBtn.innerHTML === autoLoginBtn.textContent) { autoLoginBtn.disabled = false; } } }); } // Initialize all camera sections console.log('Initializing camera systems...'); // Student Registration (enhanced quality) setupCameraSection({ videoId: 'videoStudent', canvasId: 'canvasStudent', startCameraBtnId: 'startCameraStudent', captureImageBtnId: 'captureImageStudent', retakeImageBtnId: 'retakeImageStudent', cameraOverlayId: 'cameraOverlayStudent', faceImageInputId: 'face_image_student', actionBtnId: 'registerBtnStudent', isRegistration: true }); // Teacher Registration (enhanced quality) setupCameraSection({ videoId: 'videoTeacher', canvasId: 'canvasTeacher', startCameraBtnId: 'startCameraTeacher', captureImageBtnId: 'captureImageTeacher', retakeImageBtnId: 'retakeImageTeacher', cameraOverlayId: 'cameraOverlayTeacher', faceImageInputId: 'face_image_teacher', actionBtnId: 'registerBtnTeacher', isRegistration: true }); // Face Login setupCameraSection({ videoId: 'video', canvasId: 'canvas', startCameraBtnId: 'startCamera', captureImageBtnId: 'captureImage', retakeImageBtnId: 'retakeImage', cameraOverlayId: 'cameraOverlay', faceImageInputId: 'face_image', actionBtnId: 'faceLoginBtn' }); // Attendance (with liveness preview) setupAttendanceSection({ videoId: 'videoAttendance', canvasId: 'canvasAttendance', startCameraBtnId: 'startCameraAttendance', captureImageBtnId: 'captureImageAttendance', retakeImageBtnId: 'retakeImageAttendance', markBtnId: 'markAttendanceBtn', overlayImgId: 'attendanceOverlayImg', statusId: 'attendanceStatus', programId: 'program', semesterId: 'semester', courseId: 'course', studentIdInputId: 'student_id' }); // Auto face login setupAutoFaceLogin(); // Network status monitoring window.addEventListener('online', () => { globalState.networkOnline = true; console.log('Network connection restored'); const statusElements = document.querySelectorAll('[id*="Status"]'); statusElements.forEach(el => { if (el.textContent.includes('connection')) { el.textContent = 'Connection restored'; el.className = 'text-success'; } }); }); window.addEventListener('offline', () => { globalState.networkOnline = false; console.log('Network connection lost'); const statusElements = document.querySelectorAll('[id*="Status"]'); statusElements.forEach(el => { el.textContent = 'No internet connection - Please check your network'; el.className = 'text-danger'; }); }); // System capability check on load checkSystemCapabilities().then(capabilities => { console.log('System capabilities:', capabilities); if (!capabilities.camera) { console.warn('No camera detected'); } if (!capabilities.models) { console.warn('Face recognition models may not be fully available'); } }).catch(err => { console.warn('Failed to check system capabilities:', err); }); console.log('Camera system initialization complete'); });