FGR / src /utils /VideoManager.js
Madoka0416's picture
Upload 30 files
0bc0dd7 verified
// 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);
}
}
}