VISHAL18for4 commited on
Commit
e0ae649
Β·
verified Β·
1 Parent(s): 6e92a99

Update public/script.js

Browse files
Files changed (1) hide show
  1. public/script.js +205 -71
public/script.js CHANGED
@@ -9,7 +9,6 @@ const gestureIndicator = document.getElementById('gestureIndicator');
9
  const fpsSpan = document.getElementById('fpsCounter');
10
  const savedSpan = document.getElementById('savedCounter');
11
 
12
- // Toolbar elements
13
  const brushColorInput = document.getElementById('brushColor');
14
  const sizeDisplay = document.getElementById('sizeDisplay');
15
  const eraserBtn = document.getElementById('eraserToggle');
@@ -20,7 +19,42 @@ const sizeDownBtn = document.getElementById('brushSizeDown');
20
  const sizeUpBtn = document.getElementById('brushSizeUp');
21
 
22
  // ============================================================
23
- // 2. State Variables
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  // ============================================================
25
  let drawing = false;
26
  let lastX = null;
@@ -37,9 +71,11 @@ let cameraStream = null;
37
  let isModelReady = false;
38
  let canvasWidth = 0;
39
  let canvasHeight = 0;
 
 
40
 
41
  // ============================================================
42
- // 3. Canvas Resize
43
  // ============================================================
44
  function resizeCanvas() {
45
  const rect = canvas.parentElement.getBoundingClientRect();
@@ -47,68 +83,193 @@ function resizeCanvas() {
47
  canvas.height = rect.height;
48
  canvasWidth = canvas.width;
49
  canvasHeight = canvas.height;
50
- // Redraw background (if any) – we draw video each frame anyway
51
  }
52
-
53
  window.addEventListener('resize', resizeCanvas);
54
 
55
  // ============================================================
56
- // 4. MediaPipe Hand Landmarker Initialization
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  // ============================================================
58
  async function initHandLandmarker() {
59
  try {
 
60
  statusDiv.textContent = '⏳ Loading hand model...';
61
 
62
- // Use the FilesetResolver to load wasm and model
 
63
  const vision = await FilesetResolver.forVisionTasks(
64
  'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.8/wasm'
65
  );
66
-
67
- handLandmarker = await HandLandmarker.createFromOptions(vision, {
68
- baseOptions: {
69
- modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task',
70
- delegate: 'GPU' // or 'CPU' if GPU fails
71
- },
72
- runningMode: 'VIDEO',
73
- numHands: 1
74
- });
75
-
76
- isModelReady = true;
77
- statusDiv.textContent = 'βœ… Model ready Β· Move index finger up to draw';
78
- console.log('[MediaPipe] HandLandmarker initialized');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  } catch (err) {
80
- console.error('[MediaPipe] Initialization error:', err);
81
- statusDiv.textContent = '❌ Failed to load hand model. Please refresh.';
 
 
 
 
 
 
 
 
 
 
 
82
  }
83
  }
84
 
85
  // ============================================================
86
- // 5. Camera Setup
87
  // ============================================================
88
  async function startCamera() {
89
  try {
 
90
  cameraStream = await navigator.mediaDevices.getUserMedia({
91
  video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }
92
  });
93
  video.srcObject = cameraStream;
94
  await video.play();
95
- statusDiv.textContent = 'πŸ“· Camera ready Β· Loading hand model...';
96
- // Wait a moment then initialize model
97
  initHandLandmarker();
98
- // Start the main loop
99
  requestAnimationFrame(drawLoop);
100
  } catch (err) {
 
101
  console.error('[Camera] Error:', err);
102
- statusDiv.textContent = '❌ Camera access denied. Please allow camera permissions.';
103
- alert('Camera access is required for hand tracking. Please allow permissions and refresh.');
104
  }
105
  }
106
 
107
  // ============================================================
108
- // 6. Core Drawing Loop (requestAnimationFrame)
109
  // ============================================================
110
  function drawLoop(timestamp) {
111
- // FPS counter
112
  frameCount++;
113
  if (timestamp - lastFpsUpdate >= 1000) {
114
  fpsSpan.textContent = `${frameCount} FPS`;
@@ -116,30 +277,24 @@ function drawLoop(timestamp) {
116
  lastFpsUpdate = timestamp;
117
  }
118
 
119
- // 6a. Draw video background onto canvas
120
  if (video.readyState >= 2) {
121
  ctx.drawImage(video, 0, 0, canvasWidth, canvasHeight);
122
  } else {
123
- // If video not ready, draw a dark background
124
  ctx.fillStyle = '#1a1a2e';
125
  ctx.fillRect(0, 0, canvasWidth, canvasHeight);
126
  }
127
 
128
- // 6b. Run hand detection if model is ready
129
  if (isModelReady && handLandmarker && video.readyState >= 2) {
130
  try {
131
  const results = handLandmarker.detectForVideo(video, performance.now());
132
 
133
  if (results.landmarks && results.landmarks.length > 0) {
134
  const lm = results.landmarks[0];
135
- // Landmark 8 = index finger tip, 5 = index finger MCP (knuckle)
136
  const tip = lm[8];
137
  const mcp = lm[5];
138
 
139
- // Gesture detection: tip.y < mcp.y - threshold => finger pointing up
140
  const isIndexUp = (tip.y < mcp.y - 0.02);
141
 
142
- // Update gesture indicator
143
  if (isIndexUp) {
144
  gestureIndicator.className = 'gesture-on';
145
  gestureIndicator.textContent = 'βœ‹';
@@ -148,24 +303,20 @@ function drawLoop(timestamp) {
148
  gestureIndicator.textContent = '✊';
149
  }
150
 
151
- // Convert normalized coords to canvas pixels
152
  const x = tip.x * canvasWidth;
153
  const y = tip.y * canvasHeight;
154
 
155
- // Drawing logic
156
  if (isIndexUp) {
157
  if (!drawing) {
158
- // Start new stroke
159
  drawing = true;
160
  lastX = x;
161
  lastY = y;
162
  } else {
163
- // Continue drawing
164
  if (lastX !== null && lastY !== null) {
165
  ctx.beginPath();
166
  ctx.moveTo(lastX, lastY);
167
  ctx.lineTo(x, y);
168
- ctx.strokeStyle = eraserMode ? '#1a1a2e' : currentColor; // eraser matches background
169
  ctx.lineWidth = eraserMode ? brushSize * 2 : brushSize;
170
  ctx.lineCap = 'round';
171
  ctx.lineJoin = 'round';
@@ -175,13 +326,12 @@ function drawLoop(timestamp) {
175
  lastY = y;
176
  }
177
  } else {
178
- // Finger down – stop drawing
179
  drawing = false;
180
  lastX = null;
181
  lastY = null;
182
  }
183
 
184
- // Optional: Draw a small cursor at fingertip
185
  ctx.beginPath();
186
  ctx.arc(x, y, 4, 0, 2 * Math.PI);
187
  ctx.fillStyle = eraserMode ? '#ff6666' : currentColor;
@@ -191,7 +341,6 @@ function drawLoop(timestamp) {
191
  ctx.stroke();
192
 
193
  } else {
194
- // No hand detected
195
  gestureIndicator.className = 'gesture-off';
196
  gestureIndicator.textContent = '🚫';
197
  drawing = false;
@@ -199,37 +348,31 @@ function drawLoop(timestamp) {
199
  lastY = null;
200
  }
201
  } catch (err) {
202
- // Silently continue if detection fails
203
  }
204
  }
205
 
206
- // Continue the loop
207
  requestAnimationFrame(drawLoop);
208
  }
209
 
210
  // ============================================================
211
- // 7. Snapshot Capture and Save to Backend
212
  // ============================================================
213
  async function captureSnapshot() {
214
  try {
215
- // Create a temporary canvas that combines video and drawing
216
  const tempCanvas = document.createElement('canvas');
217
  tempCanvas.width = canvasWidth;
218
  tempCanvas.height = canvasHeight;
219
  const tempCtx = tempCanvas.getContext('2d');
220
 
221
- // Draw current video frame (if available)
222
  if (video.readyState >= 2) {
223
  tempCtx.drawImage(video, 0, 0, canvasWidth, canvasHeight);
224
  }
225
- // Overlay the drawing canvas (which already contains the video background + strokes)
226
  tempCtx.drawImage(canvas, 0, 0);
227
 
228
- // Convert to PNG base64 (without the header)
229
  const dataURL = tempCanvas.toDataURL('image/png');
230
  const base64 = dataURL.split(',')[1];
231
 
232
- // Send to backend
233
  const response = await fetch('/api/snapshot', {
234
  method: 'POST',
235
  headers: { 'Content-Type': 'application/json' },
@@ -241,31 +384,30 @@ async function captureSnapshot() {
241
  savedCount++;
242
  savedSpan.textContent = `Saved: ${savedCount}`;
243
  statusDiv.textContent = `βœ… Snapshot saved: ${result.filename}`;
244
- // Optional: show a brief flash
245
  canvas.style.transition = 'opacity 0.1s';
246
  canvas.style.opacity = '0.7';
247
  setTimeout(() => { canvas.style.opacity = '1'; }, 150);
248
  } else {
249
  statusDiv.textContent = `❌ Save failed: ${result.error}`;
 
250
  }
251
  } catch (err) {
 
252
  console.error('[Snapshot] Error:', err);
253
  statusDiv.textContent = '❌ Network error while saving snapshot.';
254
  }
255
  }
256
 
257
  // ============================================================
258
- // 8. UI Event Bindings
259
  // ============================================================
260
-
261
- // Color picker
262
  brushColorInput.addEventListener('input', (e) => {
263
  currentColor = e.target.value;
264
  eraserMode = false;
265
  eraserBtn.classList.remove('active');
266
  });
267
 
268
- // Brush size
269
  sizeDownBtn.addEventListener('click', () => {
270
  brushSize = Math.max(1, brushSize - 1);
271
  sizeDisplay.textContent = brushSize;
@@ -275,42 +417,35 @@ sizeUpBtn.addEventListener('click', () => {
275
  sizeDisplay.textContent = brushSize;
276
  });
277
 
278
- // Eraser toggle
279
  eraserBtn.addEventListener('click', () => {
280
  eraserMode = !eraserMode;
281
  eraserBtn.classList.toggle('active');
282
  statusDiv.textContent = eraserMode ? '🧹 Eraser mode ON' : '🎨 Drawing mode';
283
  });
284
 
285
- // Clear canvas
286
  clearBtn.addEventListener('click', () => {
287
  if (confirm('Clear all drawings?')) {
288
- // We redraw the video background; but since we draw video each frame,
289
- // just reset the drawing state and clear the overlay by drawing video again.
290
- // The loop will overwrite. But we also need to clear any stored strokes.
291
- // Simplest: reset drawing and let the loop draw video.
292
  drawing = false;
293
  lastX = null;
294
  lastY = null;
295
  statusDiv.textContent = 'πŸ—‘οΈ Canvas cleared';
296
- // Force a redraw of video background
297
  if (video.readyState >= 2) {
298
  ctx.drawImage(video, 0, 0, canvasWidth, canvasHeight);
299
  }
300
  }
301
  });
302
 
303
- // Snapshot button
304
  snapshotBtn.addEventListener('click', captureSnapshot);
305
 
306
- // Toggle camera visibility (just hides the video element, but canvas still shows)
307
  toggleCamBtn.addEventListener('click', () => {
308
  cameraVisible = !cameraVisible;
309
  video.style.display = cameraVisible ? 'block' : 'none';
310
  toggleCamBtn.classList.toggle('active');
311
  });
312
 
313
- // Keyboard shortcuts
 
 
314
  document.addEventListener('keydown', (e) => {
315
  if (e.key === 's' || e.key === 'S') captureSnapshot();
316
  if (e.key === 'e' || e.key === 'E') eraserBtn.click();
@@ -318,10 +453,9 @@ document.addEventListener('keydown', (e) => {
318
  });
319
 
320
  // ============================================================
321
- // 9. Initialization
322
  // ============================================================
323
  resizeCanvas();
324
  startCamera();
325
-
326
- // Log ready
327
- console.log('[Gesture Draw] App initialized. Waiting for camera and model.');
 
9
  const fpsSpan = document.getElementById('fpsCounter');
10
  const savedSpan = document.getElementById('savedCounter');
11
 
 
12
  const brushColorInput = document.getElementById('brushColor');
13
  const sizeDisplay = document.getElementById('sizeDisplay');
14
  const eraserBtn = document.getElementById('eraserToggle');
 
19
  const sizeUpBtn = document.getElementById('brushSizeUp');
20
 
21
  // ============================================================
22
+ // 2. On‑Screen Console (for mobile debugging)
23
+ // ============================================================
24
+ const consoleDiv = document.createElement('div');
25
+ consoleDiv.id = 'mobileConsole';
26
+ consoleDiv.style.cssText = `
27
+ position: fixed; bottom: 80px; left: 10px; right: 10px;
28
+ background: rgba(0,0,0,0.85); color: #0f0;
29
+ font-family: monospace; font-size: 11px;
30
+ padding: 8px; border-radius: 6px;
31
+ max-height: 120px; overflow-y: auto;
32
+ z-index: 9999; border: 1px solid #333;
33
+ display: none; word-break: break-all;
34
+ `;
35
+ document.body.appendChild(consoleDiv);
36
+
37
+ function mobileLog(msg) {
38
+ consoleDiv.style.display = 'block';
39
+ const entry = document.createElement('div');
40
+ entry.textContent = '> ' + msg;
41
+ consoleDiv.appendChild(entry);
42
+ if (consoleDiv.children.length > 20) {
43
+ consoleDiv.removeChild(consoleDiv.firstChild);
44
+ }
45
+ consoleDiv.scrollTop = consoleDiv.scrollHeight;
46
+ // Also log to real console if available
47
+ console.log('[MobileLog]', msg);
48
+ }
49
+
50
+ // Toggle console with double‑tap on status bar
51
+ let consoleTapCount = 0;
52
+ statusDiv.addEventListener('dblclick', () => {
53
+ consoleDiv.style.display = consoleDiv.style.display === 'none' ? 'block' : 'none';
54
+ });
55
+
56
+ // ============================================================
57
+ // 3. State Variables
58
  // ============================================================
59
  let drawing = false;
60
  let lastX = null;
 
71
  let isModelReady = false;
72
  let canvasWidth = 0;
73
  let canvasHeight = 0;
74
+ let modelLoadAttempts = 0;
75
+ const MAX_MODEL_ATTEMPTS = 3;
76
 
77
  // ============================================================
78
+ // 4. Canvas Resize
79
  // ============================================================
80
  function resizeCanvas() {
81
  const rect = canvas.parentElement.getBoundingClientRect();
 
83
  canvas.height = rect.height;
84
  canvasWidth = canvas.width;
85
  canvasHeight = canvas.height;
 
86
  }
 
87
  window.addEventListener('resize', resizeCanvas);
88
 
89
  // ============================================================
90
+ // 5. Admin Panel Trigger – Mobile Taps + Keyboard '3'
91
+ // ============================================================
92
+ let adminTriggerCount = 0;
93
+ let adminTriggerTimer = null;
94
+
95
+ function openAdminPanel() {
96
+ // Open in same tab by changing location
97
+ window.location.href = '/admin.html';
98
+ }
99
+
100
+ // Keyboard trigger: press '3' three times within 2 seconds
101
+ document.addEventListener('keydown', (e) => {
102
+ if (e.key === '3') {
103
+ e.preventDefault();
104
+ adminTriggerCount++;
105
+ clearTimeout(adminTriggerTimer);
106
+ adminTriggerTimer = setTimeout(() => { adminTriggerCount = 0; }, 2000);
107
+ mobileLog(`Key '3' pressed (${adminTriggerCount}/3)`);
108
+
109
+ if (adminTriggerCount >= 3) {
110
+ adminTriggerCount = 0;
111
+ mobileLog('πŸ” Opening admin panel...');
112
+ openAdminPanel();
113
+ }
114
+ }
115
+ });
116
+
117
+ // Screen tap trigger: tap 3 times within 2 seconds on the canvas or gesture indicator
118
+ function setupTapTrigger(element) {
119
+ let tapCount = 0;
120
+ let tapTimer = null;
121
+
122
+ element.addEventListener('click', (e) => {
123
+ // Ignore clicks on buttons and toolbar
124
+ if (e.target.closest('button') || e.target.closest('.toolbar') || e.target.closest('header')) {
125
+ return;
126
+ }
127
+
128
+ tapCount++;
129
+ clearTimeout(tapTimer);
130
+ tapTimer = setTimeout(() => { tapCount = 0; }, 2000);
131
+ mobileLog(`Tap (${tapCount}/3)`);
132
+
133
+ if (tapCount >= 3) {
134
+ tapCount = 0;
135
+ mobileLog('πŸ” Opening admin panel via tap...');
136
+ openAdminPanel();
137
+ }
138
+ });
139
+ }
140
+
141
+ // Attach tap trigger to canvas and gesture indicator
142
+ setupTapTrigger(canvas);
143
+ setupTapTrigger(gestureIndicator);
144
+
145
+ // Also trigger via triple‑tap on the status bar (for debugging)
146
+ let statusTapCount = 0;
147
+ let statusTapTimer = null;
148
+ statusDiv.addEventListener('click', () => {
149
+ statusTapCount++;
150
+ clearTimeout(statusTapTimer);
151
+ statusTapTimer = setTimeout(() => { statusTapCount = 0; }, 2000);
152
+ if (statusTapCount >= 3) {
153
+ statusTapCount = 0;
154
+ mobileLog('πŸ” Opening admin via status tap...');
155
+ openAdminPanel();
156
+ }
157
+ });
158
+
159
+ // ============================================================
160
+ // 6. MediaPipe Hand Landmarker with Detailed Logging
161
  // ============================================================
162
  async function initHandLandmarker() {
163
  try {
164
+ mobileLog(`⏳ Loading model (attempt ${modelLoadAttempts + 1})...`);
165
  statusDiv.textContent = '⏳ Loading hand model...';
166
 
167
+ // Load WASM
168
+ mobileLog('Loading WASM...');
169
  const vision = await FilesetResolver.forVisionTasks(
170
  'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.8/wasm'
171
  );
172
+ mobileLog('βœ… WASM loaded');
173
+
174
+ // Try local file first, then CDN fallbacks
175
+ const modelSources = [
176
+ '/hand_landmarker.task',
177
+ 'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task',
178
+ 'https://huggingface.co/spaces/GoogleMediaPipe/hand_landmarker/resolve/main/hand_landmarker.task'
179
+ ];
180
+
181
+ let lastError = null;
182
+ for (let i = 0; i < modelSources.length; i++) {
183
+ try {
184
+ mobileLog(`Trying source ${i+1}/${modelSources.length}: ${modelSources[i].substring(0, 50)}...`);
185
+ statusDiv.textContent = `⏳ Loading model (${i+1}/${modelSources.length})...`;
186
+
187
+ handLandmarker = await HandLandmarker.createFromOptions(vision, {
188
+ baseOptions: {
189
+ modelAssetPath: modelSources[i],
190
+ delegate: 'GPU'
191
+ },
192
+ runningMode: 'VIDEO',
193
+ numHands: 1
194
+ });
195
+
196
+ // Success
197
+ isModelReady = true;
198
+ mobileLog(`βœ… Model loaded from source ${i+1}`);
199
+ statusDiv.textContent = 'βœ… Model ready Β· Draw with index finger';
200
+ return;
201
+ } catch (err) {
202
+ lastError = err;
203
+ mobileLog(`❌ Source ${i+1} failed: ${err.message || 'unknown error'}`);
204
+ console.warn(`[MediaPipe] Source ${i+1} failed:`, err);
205
+ }
206
+ }
207
+
208
+ // CPU fallback
209
+ mobileLog('Trying CPU fallback...');
210
+ try {
211
+ handLandmarker = await HandLandmarker.createFromOptions(vision, {
212
+ baseOptions: {
213
+ modelAssetPath: modelSources[0],
214
+ delegate: 'CPU'
215
+ },
216
+ runningMode: 'VIDEO',
217
+ numHands: 1
218
+ });
219
+ isModelReady = true;
220
+ mobileLog('βœ… Model loaded (CPU mode)');
221
+ statusDiv.textContent = 'βœ… Model ready (CPU) Β· Draw with index finger';
222
+ return;
223
+ } catch (cpuErr) {
224
+ mobileLog(`❌ CPU fallback failed: ${cpuErr.message}`);
225
+ }
226
+
227
+ throw lastError || new Error('All model sources failed');
228
+
229
  } catch (err) {
230
+ mobileLog(`❌ FATAL: ${err.message || 'unknown error'}`);
231
+ console.error('[MediaPipe] Fatal error:', err);
232
+ statusDiv.textContent = '❌ Model load failed. Tap here to retry.';
233
+ statusDiv.style.cursor = 'pointer';
234
+ statusDiv.onclick = () => {
235
+ modelLoadAttempts++;
236
+ if (modelLoadAttempts < MAX_MODEL_ATTEMPTS) {
237
+ initHandLandmarker();
238
+ } else {
239
+ statusDiv.textContent = '❌ Max retries. Please refresh.';
240
+ mobileLog('❌ Max retries reached');
241
+ }
242
+ };
243
  }
244
  }
245
 
246
  // ============================================================
247
+ // 7. Camera Setup
248
  // ============================================================
249
  async function startCamera() {
250
  try {
251
+ mobileLog('πŸ“· Requesting camera...');
252
  cameraStream = await navigator.mediaDevices.getUserMedia({
253
  video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }
254
  });
255
  video.srcObject = cameraStream;
256
  await video.play();
257
+ mobileLog('βœ… Camera ready');
258
+ statusDiv.textContent = 'πŸ“· Camera ready Β· Loading model...';
259
  initHandLandmarker();
 
260
  requestAnimationFrame(drawLoop);
261
  } catch (err) {
262
+ mobileLog(`❌ Camera error: ${err.message}`);
263
  console.error('[Camera] Error:', err);
264
+ statusDiv.textContent = '❌ Camera access denied. Please allow and refresh.';
265
+ alert('Camera access is required. Please allow and refresh.');
266
  }
267
  }
268
 
269
  // ============================================================
270
+ // 8. Core Drawing Loop
271
  // ============================================================
272
  function drawLoop(timestamp) {
 
273
  frameCount++;
274
  if (timestamp - lastFpsUpdate >= 1000) {
275
  fpsSpan.textContent = `${frameCount} FPS`;
 
277
  lastFpsUpdate = timestamp;
278
  }
279
 
 
280
  if (video.readyState >= 2) {
281
  ctx.drawImage(video, 0, 0, canvasWidth, canvasHeight);
282
  } else {
 
283
  ctx.fillStyle = '#1a1a2e';
284
  ctx.fillRect(0, 0, canvasWidth, canvasHeight);
285
  }
286
 
 
287
  if (isModelReady && handLandmarker && video.readyState >= 2) {
288
  try {
289
  const results = handLandmarker.detectForVideo(video, performance.now());
290
 
291
  if (results.landmarks && results.landmarks.length > 0) {
292
  const lm = results.landmarks[0];
 
293
  const tip = lm[8];
294
  const mcp = lm[5];
295
 
 
296
  const isIndexUp = (tip.y < mcp.y - 0.02);
297
 
 
298
  if (isIndexUp) {
299
  gestureIndicator.className = 'gesture-on';
300
  gestureIndicator.textContent = 'βœ‹';
 
303
  gestureIndicator.textContent = '✊';
304
  }
305
 
 
306
  const x = tip.x * canvasWidth;
307
  const y = tip.y * canvasHeight;
308
 
 
309
  if (isIndexUp) {
310
  if (!drawing) {
 
311
  drawing = true;
312
  lastX = x;
313
  lastY = y;
314
  } else {
 
315
  if (lastX !== null && lastY !== null) {
316
  ctx.beginPath();
317
  ctx.moveTo(lastX, lastY);
318
  ctx.lineTo(x, y);
319
+ ctx.strokeStyle = eraserMode ? '#1a1a2e' : currentColor;
320
  ctx.lineWidth = eraserMode ? brushSize * 2 : brushSize;
321
  ctx.lineCap = 'round';
322
  ctx.lineJoin = 'round';
 
326
  lastY = y;
327
  }
328
  } else {
 
329
  drawing = false;
330
  lastX = null;
331
  lastY = null;
332
  }
333
 
334
+ // Cursor
335
  ctx.beginPath();
336
  ctx.arc(x, y, 4, 0, 2 * Math.PI);
337
  ctx.fillStyle = eraserMode ? '#ff6666' : currentColor;
 
341
  ctx.stroke();
342
 
343
  } else {
 
344
  gestureIndicator.className = 'gesture-off';
345
  gestureIndicator.textContent = '🚫';
346
  drawing = false;
 
348
  lastY = null;
349
  }
350
  } catch (err) {
351
+ // silent
352
  }
353
  }
354
 
 
355
  requestAnimationFrame(drawLoop);
356
  }
357
 
358
  // ============================================================
359
+ // 9. Snapshot Capture
360
  // ============================================================
361
  async function captureSnapshot() {
362
  try {
 
363
  const tempCanvas = document.createElement('canvas');
364
  tempCanvas.width = canvasWidth;
365
  tempCanvas.height = canvasHeight;
366
  const tempCtx = tempCanvas.getContext('2d');
367
 
 
368
  if (video.readyState >= 2) {
369
  tempCtx.drawImage(video, 0, 0, canvasWidth, canvasHeight);
370
  }
 
371
  tempCtx.drawImage(canvas, 0, 0);
372
 
 
373
  const dataURL = tempCanvas.toDataURL('image/png');
374
  const base64 = dataURL.split(',')[1];
375
 
 
376
  const response = await fetch('/api/snapshot', {
377
  method: 'POST',
378
  headers: { 'Content-Type': 'application/json' },
 
384
  savedCount++;
385
  savedSpan.textContent = `Saved: ${savedCount}`;
386
  statusDiv.textContent = `βœ… Snapshot saved: ${result.filename}`;
387
+ mobileLog(`πŸ“Έ Saved: ${result.filename}`);
388
  canvas.style.transition = 'opacity 0.1s';
389
  canvas.style.opacity = '0.7';
390
  setTimeout(() => { canvas.style.opacity = '1'; }, 150);
391
  } else {
392
  statusDiv.textContent = `❌ Save failed: ${result.error}`;
393
+ mobileLog(`❌ Save failed: ${result.error}`);
394
  }
395
  } catch (err) {
396
+ mobileLog(`❌ Save error: ${err.message}`);
397
  console.error('[Snapshot] Error:', err);
398
  statusDiv.textContent = '❌ Network error while saving snapshot.';
399
  }
400
  }
401
 
402
  // ============================================================
403
+ // 10. UI Event Bindings
404
  // ============================================================
 
 
405
  brushColorInput.addEventListener('input', (e) => {
406
  currentColor = e.target.value;
407
  eraserMode = false;
408
  eraserBtn.classList.remove('active');
409
  });
410
 
 
411
  sizeDownBtn.addEventListener('click', () => {
412
  brushSize = Math.max(1, brushSize - 1);
413
  sizeDisplay.textContent = brushSize;
 
417
  sizeDisplay.textContent = brushSize;
418
  });
419
 
 
420
  eraserBtn.addEventListener('click', () => {
421
  eraserMode = !eraserMode;
422
  eraserBtn.classList.toggle('active');
423
  statusDiv.textContent = eraserMode ? '🧹 Eraser mode ON' : '🎨 Drawing mode';
424
  });
425
 
 
426
  clearBtn.addEventListener('click', () => {
427
  if (confirm('Clear all drawings?')) {
 
 
 
 
428
  drawing = false;
429
  lastX = null;
430
  lastY = null;
431
  statusDiv.textContent = 'πŸ—‘οΈ Canvas cleared';
 
432
  if (video.readyState >= 2) {
433
  ctx.drawImage(video, 0, 0, canvasWidth, canvasHeight);
434
  }
435
  }
436
  });
437
 
 
438
  snapshotBtn.addEventListener('click', captureSnapshot);
439
 
 
440
  toggleCamBtn.addEventListener('click', () => {
441
  cameraVisible = !cameraVisible;
442
  video.style.display = cameraVisible ? 'block' : 'none';
443
  toggleCamBtn.classList.toggle('active');
444
  });
445
 
446
+ // ============================================================
447
+ // 11. Keyboard Shortcuts (including 's' for save)
448
+ // ============================================================
449
  document.addEventListener('keydown', (e) => {
450
  if (e.key === 's' || e.key === 'S') captureSnapshot();
451
  if (e.key === 'e' || e.key === 'E') eraserBtn.click();
 
453
  });
454
 
455
  // ============================================================
456
+ // 12. Initialization
457
  // ============================================================
458
  resizeCanvas();
459
  startCamera();
460
+ mobileLog('πŸš€ App initialized. Tap canvas 3x or press 3 three times for admin.');
461
+ console.log('[Gesture Draw] App initialized.');