Spaces:
Sleeping
Sleeping
| // src/utils/VideoManager.js | |
| export class VideoManager { | |
| constructor(callbacks) { | |
| // callbacks 用于通知 React 组件更新界面 | |
| // 例如: onStatusChange, onFrameData, onSessionStart, onSessionEnd | |
| this.callbacks = callbacks || {}; | |
| this.videoElement = null; | |
| this.canvasElement = null; | |
| this.ctx = null; | |
| this.captureCanvas = null; | |
| this.captureCtx = null; | |
| this.stream = null; | |
| this.ws = null; | |
| this.isStreaming = false; | |
| this.sessionId = null; | |
| this.frameRate = 30; | |
| this.frameInterval = null; | |
| this.renderLoopId = null; | |
| // 状态平滑处理 | |
| this.currentStatus = false; | |
| this.statusBuffer = []; | |
| this.bufferSize = 5; | |
| // 检测数据 | |
| this.latestDetectionData = null; | |
| this.lastConfidence = 0; | |
| this.detectionHoldMs = 30; | |
| // 通知系统 | |
| this.notificationEnabled = true; | |
| this.notificationThreshold = 30; // 默认30秒 | |
| this.unfocusedStartTime = null; | |
| this.lastNotificationTime = null; | |
| this.notificationCooldown = 60000; // 通知冷却时间60秒 | |
| } | |
| // 初始化:接收 React 传递过来的 video 和 canvas 引用 | |
| async initCamera(videoRef, canvasRef) { | |
| try { | |
| this.stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| width: { ideal: 640 }, | |
| height: { ideal: 480 }, | |
| facingMode: 'user' | |
| } | |
| }); | |
| this.videoElement = videoRef; | |
| this.videoElement.srcObject = this.stream; | |
| this.videoElement.autoplay = true; | |
| this.videoElement.playsInline = true; | |
| this.canvasElement = canvasRef; | |
| this.canvasElement.width = 640; | |
| this.canvasElement.height = 480; | |
| this.ctx = this.canvasElement.getContext('2d'); | |
| // 用于截图发送给后端的隐藏 Canvas | |
| this.captureCanvas = document.createElement('canvas'); | |
| this.captureCanvas.width = 640; | |
| this.captureCanvas.height = 480; | |
| this.captureCtx = this.captureCanvas.getContext('2d'); | |
| await this.videoElement.play(); | |
| this.startRenderLoop(); | |
| return true; | |
| } catch (error) { | |
| console.error('Camera init error:', error); | |
| throw error; | |
| } | |
| } | |
| connectWebSocket() { | |
| // 自动判断是加密的 wss 还是普通的 ws | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| // 自动获取当前的主机地址 (比如 huggingface.co/...) | |
| const host = window.location.host; | |
| // 拼接完整的 WebSocket 地址 | |
| // 注意:这里假设后端路由是 /ws/video | |
| const wsUrl = `${protocol}//${host}/ws/video`; | |
| console.log("Connecting to WebSocket:", wsUrl); // 方便调试 | |
| this.ws = new WebSocket(wsUrl); | |
| this.ws.onopen = () => { | |
| console.log('WebSocket connected'); | |
| this.startSession(); | |
| }; | |
| this.ws.onmessage = (event) => { | |
| const data = JSON.parse(event.data); | |
| this.handleServerMessage(data); | |
| }; | |
| this.ws.onerror = (error) => { | |
| console.error('WebSocket error:', error); | |
| }; | |
| this.ws.onclose = () => { | |
| console.log('WebSocket closed'); | |
| }; | |
| } | |
| async startStreaming() { | |
| if (!this.stream) return; | |
| this.isStreaming = true; | |
| // 请求通知权限 | |
| await this.requestNotificationPermission(); | |
| // 加载通知设置 | |
| await this.loadNotificationSettings(); | |
| this.connectWebSocket(); | |
| this.frameInterval = setInterval(() => { | |
| if (this.ws && this.ws.readyState === WebSocket.OPEN) { | |
| this.captureAndSendFrame(); | |
| } | |
| }, 1000 / this.frameRate); | |
| } | |
| async requestNotificationPermission() { | |
| if ('Notification' in window && Notification.permission === 'default') { | |
| try { | |
| await Notification.requestPermission(); | |
| } catch (error) { | |
| console.error('Failed to request notification permission:', error); | |
| } | |
| } | |
| } | |
| async loadNotificationSettings() { | |
| try { | |
| const response = await fetch('/api/settings'); | |
| const settings = await response.json(); | |
| if (settings) { | |
| this.notificationEnabled = settings.notification_enabled ?? true; | |
| this.notificationThreshold = settings.notification_threshold ?? 30; | |
| } | |
| } catch (error) { | |
| console.error('Failed to load notification settings:', error); | |
| } | |
| } | |
| sendNotification(title, message) { | |
| if (!this.notificationEnabled) return; | |
| if ('Notification' in window && Notification.permission === 'granted') { | |
| try { | |
| const notification = new Notification(title, { | |
| body: message, | |
| icon: '/vite.svg', | |
| badge: '/vite.svg', | |
| tag: 'focus-guard-distraction', | |
| requireInteraction: false | |
| }); | |
| // 3秒后自动关闭 | |
| setTimeout(() => notification.close(), 3000); | |
| } catch (error) { | |
| console.error('Failed to send notification:', error); | |
| } | |
| } | |
| } | |
| captureAndSendFrame() { | |
| if (!this.videoElement || !this.captureCanvas || !this.captureCtx) return; | |
| this.captureCtx.drawImage(this.videoElement, 0, 0, 640, 480); | |
| const imageData = this.captureCanvas.toDataURL('image/jpeg', 0.8); | |
| const base64Data = imageData.split(',')[1]; | |
| if (this.ws && this.ws.readyState === WebSocket.OPEN) { | |
| this.ws.send(JSON.stringify({ | |
| type: 'frame', | |
| image: base64Data | |
| })); | |
| } | |
| } | |
| handleServerMessage(data) { | |
| switch (data.type) { | |
| case 'detection': | |
| this.updateStatus(data.focused); | |
| this.latestDetectionData = { | |
| detections: data.detections || [], | |
| confidence: data.confidence || 0, | |
| focused: data.focused, | |
| timestamp: performance.now() | |
| }; | |
| this.lastConfidence = data.confidence || 0; | |
| // 通知 React 更新时间轴状态 | |
| if(this.callbacks.onStatusUpdate) { | |
| this.callbacks.onStatusUpdate(this.currentStatus); | |
| } | |
| break; | |
| case 'session_started': | |
| this.sessionId = data.session_id; | |
| console.log('Session started:', this.sessionId); | |
| // 通知 React session已启动 | |
| if(this.callbacks.onSessionStart) { | |
| this.callbacks.onSessionStart(this.sessionId); | |
| } | |
| break; | |
| case 'session_ended': | |
| console.log('Session ended:', data.summary); | |
| // 通知 React 显示弹窗 | |
| if(this.callbacks.onSessionEnd) { | |
| this.callbacks.onSessionEnd(data.summary); | |
| } | |
| break; | |
| } | |
| } | |
| updateStatus(newFocused) { | |
| this.statusBuffer.push(newFocused); | |
| if (this.statusBuffer.length > this.bufferSize) { | |
| this.statusBuffer.shift(); | |
| } | |
| if (this.statusBuffer.length < this.bufferSize) return false; | |
| const focusedCount = this.statusBuffer.filter(f => f).length; | |
| const focusedRatio = focusedCount / this.statusBuffer.length; | |
| const previousStatus = this.currentStatus; | |
| if (focusedRatio >= 0.75) { | |
| this.currentStatus = true; | |
| } else if (focusedRatio <= 0.25) { | |
| this.currentStatus = false; | |
| } | |
| // 通知逻辑 | |
| this.handleNotificationLogic(previousStatus, this.currentStatus); | |
| } | |
| handleNotificationLogic(previousStatus, currentStatus) { | |
| const now = Date.now(); | |
| // 如果从专注变为不专注,记录开始时间 | |
| if (previousStatus && !currentStatus) { | |
| this.unfocusedStartTime = now; | |
| } | |
| // 如果从不专注变为专注,清除计时 | |
| if (!previousStatus && currentStatus) { | |
| this.unfocusedStartTime = null; | |
| } | |
| // 如果持续不专注 | |
| if (!currentStatus && this.unfocusedStartTime) { | |
| const unfocusedDuration = (now - this.unfocusedStartTime) / 1000; // 秒 | |
| // 检查是否超过阈值且不在冷却期 | |
| if (unfocusedDuration >= this.notificationThreshold) { | |
| const canSendNotification = !this.lastNotificationTime || | |
| (now - this.lastNotificationTime) >= this.notificationCooldown; | |
| if (canSendNotification) { | |
| this.sendNotification( | |
| '⚠️ Focus Alert', | |
| `You've been distracted for ${Math.floor(unfocusedDuration)} seconds. Get back to work!` | |
| ); | |
| this.lastNotificationTime = now; | |
| } | |
| } | |
| } | |
| } | |
| startRenderLoop() { | |
| if (this.renderLoopId) return; | |
| const render = () => { | |
| if (this.videoElement && this.ctx) { | |
| // 1. 绘制视频底图 | |
| this.ctx.drawImage(this.videoElement, 0, 0, 640, 480); | |
| const now = performance.now(); | |
| const latest = this.latestDetectionData; | |
| const hasFresh = latest && (now - latest.timestamp) <= this.detectionHoldMs; | |
| // 2. 绘制检测框 (Logic preserved) | |
| if (hasFresh && latest.detections.length > 0) { | |
| latest.detections.forEach(det => { | |
| const [x1, y1, x2, y2] = det.bbox; | |
| this.ctx.strokeStyle = this.currentStatus ? '#00FF00' : '#FF0000'; | |
| this.ctx.lineWidth = 3; | |
| this.ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); | |
| this.ctx.fillStyle = this.currentStatus ? '#00FF00' : '#FF0000'; | |
| this.ctx.font = '16px Nunito'; | |
| const label = `${det.class_name} ${(det.confidence * 100).toFixed(1)}%`; | |
| this.ctx.fillText(label, x1, y1 - 5); | |
| }); | |
| } | |
| // 3. 绘制状态文字 | |
| const statusText = this.currentStatus ? 'FOCUSED' : 'NOT FOCUSED'; | |
| this.ctx.fillStyle = this.currentStatus ? '#00FF00' : '#FF0000'; | |
| this.ctx.font = 'bold 24px Nunito'; | |
| this.ctx.fillText(statusText, 10, 30); | |
| this.ctx.font = '16px Nunito'; | |
| this.ctx.fillText(`Confidence: ${(this.lastConfidence * 100).toFixed(1)}%`, 10, 55); | |
| } | |
| this.renderLoopId = requestAnimationFrame(render); | |
| }; | |
| this.renderLoopId = requestAnimationFrame(render); | |
| } | |
| stopRenderLoop() { | |
| if (this.renderLoopId) { | |
| cancelAnimationFrame(this.renderLoopId); | |
| this.renderLoopId = null; | |
| } | |
| } | |
| startSession() { | |
| if (this.ws && this.ws.readyState === WebSocket.OPEN) { | |
| this.ws.send(JSON.stringify({ type: 'start_session' })); | |
| } | |
| } | |
| stopStreaming() { | |
| this.isStreaming = false; | |
| this.stopRenderLoop(); | |
| if (this.frameInterval) clearInterval(this.frameInterval); | |
| if (this.ws) { | |
| this.ws.send(JSON.stringify({ type: 'end_session' })); | |
| this.ws.close(); | |
| this.ws = null; | |
| } | |
| if (this.stream) { | |
| this.stream.getTracks().forEach(track => track.stop()); | |
| this.stream = null; | |
| } | |
| // 清理 Canvas | |
| if (this.ctx) this.ctx.clearRect(0, 0, 640, 480); | |
| // 清理通知状态 | |
| this.unfocusedStartTime = null; | |
| this.lastNotificationTime = null; | |
| } | |
| setFrameRate(rate) { | |
| this.frameRate = Math.max(1, Math.min(60, rate)); | |
| if (this.isStreaming && this.frameInterval) { | |
| clearInterval(this.frameInterval); | |
| this.frameInterval = setInterval(() => { | |
| if (this.ws && this.ws.readyState === WebSocket.OPEN) { | |
| this.captureAndSendFrame(); | |
| } | |
| }, 1000 / this.frameRate); | |
| } | |
| } | |
| } |