Spaces:
Sleeping
Sleeping
| // src/utils/VideoManagerLocal.js | |
| // 本地视频处理版本 - 使用 WebSocket + Canvas,不依赖 WebRTC | |
| export class VideoManagerLocal { | |
| constructor(callbacks) { | |
| this.callbacks = callbacks || {}; | |
| this.localVideoElement = null; // 显示本地摄像头 | |
| this.displayVideoElement = null; // 显示处理后的视频 | |
| this.canvas = null; | |
| this.stream = null; | |
| this.ws = null; | |
| this.isStreaming = false; | |
| this.sessionId = null; | |
| this.sessionStartTime = null; | |
| this.frameRate = 15; // 降低帧率以减少网络负载 | |
| this.captureInterval = null; | |
| // 状态平滑处理 | |
| this.currentStatus = false; | |
| this.statusBuffer = []; | |
| this.bufferSize = 3; | |
| // 检测数据 | |
| this.latestDetectionData = null; | |
| this.lastConfidence = 0; | |
| // 通知系统 | |
| this.notificationEnabled = true; | |
| this.notificationThreshold = 30; | |
| this.unfocusedStartTime = null; | |
| this.lastNotificationTime = null; | |
| this.notificationCooldown = 60000; | |
| // 性能统计 | |
| this.stats = { | |
| framesSent: 0, | |
| framesProcessed: 0, | |
| avgLatency: 0, | |
| lastLatencies: [] | |
| }; | |
| } | |
| // 初始化摄像头 | |
| async initCamera(localVideoRef, displayCanvasRef) { | |
| try { | |
| console.log('Initializing local camera...'); | |
| this.stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| width: { ideal: 640 }, | |
| height: { ideal: 480 }, | |
| facingMode: 'user' | |
| }, | |
| audio: false | |
| }); | |
| this.localVideoElement = localVideoRef; | |
| this.displayCanvas = displayCanvasRef; | |
| // 显示本地视频流 | |
| if (this.localVideoElement) { | |
| this.localVideoElement.srcObject = this.stream; | |
| this.localVideoElement.play(); | |
| } | |
| // 创建用于截图的 canvas | |
| this.canvas = document.createElement('canvas'); | |
| this.canvas.width = 640; | |
| this.canvas.height = 480; | |
| console.log('Local camera initialized'); | |
| return true; | |
| } catch (error) { | |
| console.error('Camera init error:', error); | |
| throw error; | |
| } | |
| } | |
| // 开始流式处理 | |
| async startStreaming() { | |
| if (!this.stream) { | |
| throw new Error('Camera not initialized'); | |
| } | |
| if (this.isStreaming) { | |
| console.warn('Already streaming'); | |
| return; | |
| } | |
| console.log('Starting WebSocket streaming...'); | |
| this.isStreaming = true; | |
| // 请求通知权限 | |
| await this.requestNotificationPermission(); | |
| await this.loadNotificationSettings(); | |
| // 建立 WebSocket 连接 | |
| await this.connectWebSocket(); | |
| // 开始定期截图并发送 | |
| this.startCapture(); | |
| console.log('Streaming started'); | |
| } | |
| // 建立 WebSocket 连接 | |
| async connectWebSocket() { | |
| return new Promise((resolve, reject) => { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws/video`; | |
| console.log('Connecting to WebSocket:', wsUrl); | |
| this.ws = new WebSocket(wsUrl); | |
| this.ws.onopen = () => { | |
| console.log('WebSocket connected'); | |
| // 发送开始会话请求 | |
| this.ws.send(JSON.stringify({ type: 'start_session' })); | |
| resolve(); | |
| }; | |
| this.ws.onmessage = (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| this.handleServerMessage(data); | |
| } catch (e) { | |
| console.error('Failed to parse message:', e); | |
| } | |
| }; | |
| this.ws.onerror = (error) => { | |
| console.error('WebSocket error:', error); | |
| reject(error); | |
| }; | |
| this.ws.onclose = () => { | |
| console.log('WebSocket disconnected'); | |
| if (this.isStreaming) { | |
| console.log('Attempting to reconnect...'); | |
| setTimeout(() => this.connectWebSocket(), 2000); | |
| } | |
| }; | |
| }); | |
| } | |
| // 开始截图并发送 | |
| startCapture() { | |
| const interval = 1000 / this.frameRate; // 转换为毫秒 | |
| this.captureInterval = setInterval(() => { | |
| if (!this.isStreaming || !this.ws || this.ws.readyState !== WebSocket.OPEN) { | |
| return; | |
| } | |
| try { | |
| // 从视频流截取一帧 | |
| const ctx = this.canvas.getContext('2d'); | |
| ctx.drawImage(this.localVideoElement, 0, 0, this.canvas.width, this.canvas.height); | |
| // 转换为 JPEG base64(压缩质量 0.8) | |
| const imageData = this.canvas.toDataURL('image/jpeg', 0.8); | |
| const base64Data = imageData.split(',')[1]; | |
| // 发送到服务器 | |
| const timestamp = Date.now(); | |
| this.ws.send(JSON.stringify({ | |
| type: 'frame', | |
| image: base64Data, | |
| timestamp: timestamp | |
| })); | |
| this.stats.framesSent++; | |
| } catch (error) { | |
| console.error('Capture error:', error); | |
| } | |
| }, interval); | |
| console.log(`Capturing at ${this.frameRate} FPS`); | |
| } | |
| // 处理服务器消息 | |
| handleServerMessage(data) { | |
| switch (data.type) { | |
| case 'session_started': | |
| this.sessionId = data.session_id; | |
| this.sessionStartTime = Date.now(); | |
| console.log('Session started:', this.sessionId); | |
| if (this.callbacks.onSessionStart) { | |
| this.callbacks.onSessionStart(this.sessionId); | |
| } | |
| break; | |
| case 'detection': | |
| this.stats.framesProcessed++; | |
| // 计算延迟 | |
| if (data.timestamp) { | |
| const latency = Date.now() - data.timestamp; | |
| this.stats.lastLatencies.push(latency); | |
| if (this.stats.lastLatencies.length > 10) { | |
| this.stats.lastLatencies.shift(); | |
| } | |
| this.stats.avgLatency = | |
| this.stats.lastLatencies.reduce((a, b) => a + b, 0) / | |
| this.stats.lastLatencies.length; | |
| } | |
| // 更新状态 | |
| this.updateStatus(data.focused); | |
| this.latestDetectionData = { | |
| detections: data.detections || [], | |
| confidence: data.confidence || 0, | |
| focused: data.focused, | |
| timestamp: performance.now() | |
| }; | |
| this.lastConfidence = data.confidence || 0; | |
| if (this.callbacks.onStatusUpdate) { | |
| this.callbacks.onStatusUpdate(this.currentStatus); | |
| } | |
| // 在 display canvas 上绘制结果 | |
| this.drawDetectionResult(data); | |
| break; | |
| case 'session_ended': | |
| console.log('Received session_ended message'); | |
| console.log('Session summary:', data.summary); | |
| if (this.callbacks.onSessionEnd) { | |
| console.log('Calling onSessionEnd callback'); | |
| this.callbacks.onSessionEnd(data.summary); | |
| } else { | |
| console.warn('No onSessionEnd callback registered'); | |
| } | |
| this.sessionId = null; | |
| this.sessionStartTime = null; | |
| break; | |
| case 'error': | |
| console.error('Server error:', data.message); | |
| break; | |
| default: | |
| console.log('Unknown message type:', data.type); | |
| } | |
| } | |
| // 在 canvas 上绘制检测结果 | |
| drawDetectionResult(data) { | |
| if (!this.displayCanvas) return; | |
| const ctx = this.displayCanvas.getContext('2d'); | |
| // 绘制当前帧 | |
| ctx.drawImage(this.localVideoElement, 0, 0, this.displayCanvas.width, this.displayCanvas.height); | |
| // 绘制检测框 | |
| if (data.detections && data.detections.length > 0) { | |
| data.detections.forEach(det => { | |
| const [x1, y1, x2, y2] = det.bbox; | |
| const color = data.focused ? '#00FF00' : '#FF0000'; | |
| // 绘制边框 | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 3; | |
| ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); | |
| // 绘制标签 | |
| const label = `${det.class_name || 'person'} ${(det.confidence * 100).toFixed(1)}%`; | |
| ctx.fillStyle = color; | |
| ctx.font = '16px Arial'; | |
| ctx.fillText(label, x1, Math.max(20, y1 - 5)); | |
| }); | |
| } | |
| // 绘制状态文字 | |
| const statusText = data.focused ? 'FOCUSED' : 'NOT FOCUSED'; | |
| const color = data.focused ? '#00FF00' : '#FF0000'; | |
| ctx.fillStyle = color; | |
| ctx.font = 'bold 24px Arial'; | |
| ctx.fillText(statusText, 10, 30); | |
| ctx.font = '16px Arial'; | |
| ctx.fillText(`Confidence: ${(data.confidence * 100).toFixed(1)}%`, 10, 55); | |
| // 显示性能统计 | |
| ctx.font = '12px Arial'; | |
| ctx.fillStyle = '#FFFFFF'; | |
| ctx.fillText(`FPS: ${this.frameRate} | Latency: ${this.stats.avgLatency.toFixed(0)}ms`, 10, this.displayCanvas.height - 10); | |
| } | |
| 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; | |
| } | |
| } | |
| } | |
| } | |
| 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 | |
| }); | |
| setTimeout(() => notification.close(), 3000); | |
| } catch (error) { | |
| console.error('Failed to send notification:', error); | |
| } | |
| } | |
| } | |
| async stopStreaming() { | |
| console.log('Stopping streaming...'); | |
| this.isStreaming = false; | |
| // 停止截图 | |
| if (this.captureInterval) { | |
| clearInterval(this.captureInterval); | |
| this.captureInterval = null; | |
| } | |
| // 发送结束会话请求并等待响应 | |
| if (this.ws && this.ws.readyState === WebSocket.OPEN && this.sessionId) { | |
| const sessionId = this.sessionId; | |
| // 等待 session_ended 消息 | |
| const waitForSessionEnd = new Promise((resolve) => { | |
| const originalHandler = this.ws.onmessage; | |
| const timeout = setTimeout(() => { | |
| this.ws.onmessage = originalHandler; | |
| console.log('Session end timeout, proceeding anyway'); | |
| resolve(); | |
| }, 2000); | |
| this.ws.onmessage = (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| if (data.type === 'session_ended') { | |
| clearTimeout(timeout); | |
| this.handleServerMessage(data); | |
| this.ws.onmessage = originalHandler; | |
| resolve(); | |
| } else { | |
| // 仍然处理其他消息 | |
| this.handleServerMessage(data); | |
| } | |
| } catch (e) { | |
| console.error('Failed to parse message:', e); | |
| } | |
| }; | |
| }); | |
| console.log('Sending end_session request for session:', sessionId); | |
| this.ws.send(JSON.stringify({ | |
| type: 'end_session', | |
| session_id: sessionId | |
| })); | |
| // 等待响应或超时 | |
| await waitForSessionEnd; | |
| } | |
| // 延迟关闭 WebSocket 确保消息发送完成 | |
| await new Promise(resolve => setTimeout(resolve, 200)); | |
| // 关闭 WebSocket | |
| if (this.ws) { | |
| this.ws.close(); | |
| this.ws = null; | |
| } | |
| // 停止摄像头 | |
| if (this.stream) { | |
| this.stream.getTracks().forEach(track => track.stop()); | |
| this.stream = null; | |
| } | |
| // 清空视频 | |
| if (this.localVideoElement) { | |
| this.localVideoElement.srcObject = null; | |
| } | |
| // 清空 canvas | |
| if (this.displayCanvas) { | |
| const ctx = this.displayCanvas.getContext('2d'); | |
| ctx.clearRect(0, 0, this.displayCanvas.width, this.displayCanvas.height); | |
| } | |
| // 清理状态 | |
| this.unfocusedStartTime = null; | |
| this.lastNotificationTime = null; | |
| console.log('Streaming stopped'); | |
| console.log('Stats:', this.stats); | |
| } | |
| setFrameRate(rate) { | |
| this.frameRate = Math.max(1, Math.min(30, rate)); | |
| console.log(`Frame rate set to ${this.frameRate} FPS`); | |
| // 重启截图(如果正在运行) | |
| if (this.isStreaming && this.captureInterval) { | |
| clearInterval(this.captureInterval); | |
| this.startCapture(); | |
| } | |
| } | |
| getStats() { | |
| return { | |
| ...this.stats, | |
| isStreaming: this.isStreaming, | |
| sessionId: this.sessionId, | |
| currentStatus: this.currentStatus, | |
| lastConfidence: this.lastConfidence | |
| }; | |
| } | |
| } | |