Webrtc uvc Camera
精准定位性能瓶颈:知道每次点击按钮后发生了什么,哪一步最耗时优化初始化速度:避免localStorage阻塞,异步化设备枚举选择合适的本地服务器:理解不同方案的优劣和适用场景预防内存泄漏:知道在哪里可能泄漏,如何检测和修复实现高级特性:PWA离线、WebSocket实时传输等
第一部分:Web前端应用层架构分析
为了更直观地展示整个前端应用的技术栈、模块划分和核心数据流,梳理了以下架构图:
1. 模块化架构与功能解耦分析
index.html
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>USB摄像头监控系统</title> <script src="https://cdn.tailwindcss.com"></script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="styles.css"> <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> </head> <body class="bg-gray-100 min-h-screen"> <!-- 导航栏 --> <nav class="bg-white shadow-lg"> <div class="max-w-7xl mx-auto px-4"> <div class="flex justify-between items-center py-4"> <div class="flex items-center space-x-2"> <i class="fas fa-camera text-blue-500 text-2xl"></i> <h1 class="text-2xl font-bold text-gray-800">USB摄像头监控系统</h1> </div> <div class="flex items-center space-x-4"> <span id="deviceStatusText" class="text-sm text-gray-600"> <i class="fas fa-circle text-green-500 mr-1"></i> <span>设备就绪: /dev/video0</span> </span> <button id="settingsBtn" class="p-2 text-gray-600 hover:text-blue-500 transition-colors"> <i class="fas fa-cog text-xl"></i> </button> </div> </div> </div> </nav> <!-- 主内容区域 --> <main class="max-w-7xl mx-auto px-4 py-8"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <!-- 视频监控区域 --> <div class="lg:col-span-2"> <div class="bg-white rounded-2xl shadow-xl overflow-hidden"> <div class="video-container p-4"> <div class="bg-black rounded-xl overflow-hidden aspect-video relative"> <video id="videoPreview" class="w-full h-full object-cover" autoplay muted></video> <div id="statusOverlay" class="absolute top-4 left-4 bg-black bg-opacity-50 text-white px-3 py-1 rounded-full text-sm"> <i class="fas fa-circle text-green-500 mr-1"></i> <span>就绪</span> </div> <div id="recordingIndicator" class="absolute top-4 right-4 bg-red-500 text-white px-3 py-1 rounded-full text-sm hidden"> <i class="fas fa-circle mr-1"></i> <span>录制中</span> </div> </div> </div> <!-- 控制面板 --> <div class="p-6 bg-white"> <div class="flex flex-wrap gap-4 justify-center"> <button id="startBtn" class="btn-primary px-6 py-3 rounded-full text-white font-semibold flex items-center space-x-2"> <i class="fas fa-play"></i> <span>启动摄像头</span> </button> <button id="captureBtn" class="bg-green-500 hover:bg-green-600 px-6 py-3 rounded-full text-white font-semibold flex items-center space-x-2 transition-all disabled:opacity-50" disabled> <i class="fas fa-camera"></i> <span>拍摄截图</span> </button> <button id="recordBtn" class="bg-red-500 hover:bg-red-600 px-6 py-3 rounded-full text-white font-semibold flex items-center space-x-2 transition-all disabled:opacity-50" disabled> <i class="fas fa-video"></i> <span>开始录制</span> </button> <button id="stopBtn" class="bg-gray-500 hover:bg-gray-600 px-6 py-3 rounded-full text-white font-semibold flex items-center space-x-2 transition-all"> <i class="fas fa-stop"></i> <span>停止</span> </button> </div> <!-- 设置选项 --> <div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4"> <div> <label class="block text-sm font-medium text-gray-700 mb-2">视频分辨率</label> <select id="resolutionSelect" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> <option value="640x480">640x480 (VGA)</option> <option value="1280x720" selected>1280x720 (HD)</option> <option value="1920x1080">1920x1080 (Full HD)</option> </select> </div> <div> <label class="block text-sm font-medium text-gray-700 mb-2">帧率设置</label> <select id="frameRateSelect" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> <option value="15">15 FPS</option> <option value="30" selected>30 FPS</option> <option value="60">60 FPS</option> </select> </div> </div> </div> </div> </div> <!-- 侧边栏 - 截图管理 --> <div class="lg:col-span-1"> <div class="bg-white rounded-2xl shadow-xl p-6 h-full"> <div class="flex items-center justify-between mb-6"> <h2 class="text-xl font-bold text-gray-800 flex items-center"> <i class="fas fa-images text-purple-500 mr-2"></i> 截图库 </h2> <span id="screenshotCount" class="bg-blue-100 text-blue-800 text-sm px-3 py-1 rounded-full">0 张</span> </div> <div id="screenshotList" class="space-y-4 max-h-96 overflow-y-auto pr-2"> <div class="text-center py-8 text-gray-500"> <i class="fas fa-camera text-4xl mb-2 opacity-50"></i> <p>暂无截图</p> <p class="text-sm">点击"拍摄截图"按钮开始收集</p> </div> </div> <div class="mt-6 space-y-2"> <button id="clearAllBtn" class="w-full bg-gray-100 hover:bg-gray-200 py-2 rounded-lg text-gray-700 transition-colors disabled:opacity-50" disabled> <i class="fas fa-trash mr-2"></i>清空所有截图 </button> <button id="exportAllBtn" class="w-full bg-blue-100 hover:bg-blue-200 py-2 rounded-lg text-blue-700 transition-colors disabled:opacity-50" disabled> <i class="fas fa-download mr-2"></i>导出全部截图 </button> </div> </div> </div> </div> <!-- 统计信息 --> <div class="mt-8 grid grid-cols-1 md:grid-cols-4 gap-6"> <div class="bg-white rounded-xl p-6 shadow-lg"> <div class="flex items-center justify-between"> <div> <p class="text-gray-600 text-sm">运行时间</p> <p id="uptime" class="text-2xl font-bold text-gray-800">00:00:00</p> </div> <i class="fas fa-clock text-3xl text-blue-500"></i> </div> </div> <div class="bg-white rounded-xl p-6 shadow-lg"> <div class="flex items-center justify-between"> <div> <p class="text-gray-600 text-sm">截图数量</p> <p id="totalCaptures" class="text-2xl font-bold text-green-800">0</p> </div> <i class="fas fa-camera text-3xl text-green-500"></i> </div> </div> <div class="bg-white rounded-xl p-6 shadow-lg"> <div class="flex items-center justify-between"> <div> <p class="text-gray-600 text-sm">录制状态</p> <p id="recordStatus" class="text-2xl font-bold text-red-800">未开始</p> </div> <i class="fas fa-video text-3xl text-red-500"></i> </div> </div> <div class="bg-white rounded-xl p-6 shadow-lg"> <div class="flex items-center justify-between"> <div> <p class="text-gray-600 text-sm">设备状态</p> <p id="deviceStatus" class="text-2xl font-bold text-purple-800">就绪</p> </div> <i class="fas fa-plug text-3xl text-purple-500"></i> </div> </div> </div> </main> <!-- 模态框 - 设置 --> <div id="settingsModal" class="modal-overlay hidden"> <div class="modal-content"> <div class="modal-header"> <h3 class="modal-title">系统设置</h3> <button id="closeSettings" class="modal-close-btn"> <i class="fas fa-times"></i> </button> </div> <div class="modal-body"> <div class="space-y-4"> <div> <label class="form-label">摄像头设备</label> <select id="cameraDeviceSelect" class="form-select"> <option value="default">默认摄像头</option> </select> </div> <div> <label class="form-label">视频质量</label> <select id="videoQuality" class="form-select"> <option value="high">高质量</option> <option value="medium" selected>平衡</option> <option value="low">低质量</option> </select> </div> <div class="form-checkbox-group"> <input type="checkbox" id="autoStart" class="form-checkbox" checked> <label for="autoStart" class="form-checkbox-label">页面加载时自动启动摄像头</label> </div> <div class="form-checkbox-group"> <input type="checkbox" id="motionDetection" class="form-checkbox"> <label for="motionDetection" class="form-checkbox-label">启用运动检测</label> </div> <div class="form-checkbox-group"> <input type="checkbox" id="autoSave" class="form-checkbox" checked> <label for="autoSave" class="form-checkbox-label">自动保存截图到本地存储</label> </div> </div> </div> <div class="modal-footer"> <button id="cancelSettings" class="btn-secondary">取消</button> <button id="saveSettings" class="btn-primary">保存设置</button> </div> </div> </div> <!-- 截图查看器模态框 --> <div id="screenshotViewerModal" class="modal-overlay hidden"> <div class="modal-content max-w-4xl"> <div class="modal-header"> <h3 class="modal-title">查看截图</h3> <button id="closeViewer" class="modal-close-btn"> <i class="fas fa-times"></i> </button> </div> <div class="modal-body"> <div id="viewerContent" class="text-center"> <img id="fullSizeScreenshot" class="max-w-full max-h-96 mx-auto rounded-lg" src="" alt="全尺寸截图"> <div class="mt-4 text-sm text-gray-600"> <p id="screenshotInfo"></p> </div> </div> </div> <div class="modal-footer"> <button id="downloadScreenshot" class="btn-primary"> <i class="fas fa-download mr-2"></i>下载图片 </button> <button id="closeViewerBtn" class="btn-secondary">关闭</button> </div> </div> </div> <!-- 通知容器 --> <div id="notificationContainer" class="fixed top-4 right-4 z-50 space-y-2"></div> <script src="app.js" type="module"></script> </body> </html>
app.js
/**
* USB摄像头监控系统 - 主应用模块 (app.js)
* 重新设计版本 - 解决黑屏、卡顿问题
* 采用模块化设计,逐行注解说明
*/
// ============================================
// 工具类:性能监控器
// ============================================
class PerformanceMonitor {
constructor() {
this.markers = new Map(); // 性能标记点
this.frameStats = {
count: 0,
lastTime: performance.now(),
fps: 0,
avgProcessingTime: 0
};
this.memoryStats = {
screenshots: 0,
totalSize: 0
};
this.isMonitoring = false;
this.monitorInterval = null;
}
/**
* 开始性能监控
*/
startMonitoring() {
if (this.isMonitoring) return;
this.isMonitoring = true;
console.log('[性能监控] 开始监控系统性能');
// 每2秒更新一次性能数据
this.monitorInterval = setInterval(() => {
this.updateFrameRate();
this.logMemoryUsage();
}, 2000);
}
/**
* 停止性能监控
*/
stopMonitoring() {
if (!this.isMonitoring) return;
this.isMonitoring = false;
clearInterval(this.monitorInterval);
console.log('[性能监控] 停止监控');
}
/**
* 标记性能测量点开始
* @param {string} name - 标记名称
*/
markStart(name) {
this.markers.set(name, {
startTime: performance.now(),
endTime: null,
duration: null
});
}
/**
* 标记性能测量点结束并计算时长
* @param {string} name - 标记名称
* @returns {number} 执行时长(毫秒)
*/
markEnd(name) {
const marker = this.markers.get(name);
if (!marker) {
console.warn(`[性能监控] 未找到标记: ${name}`);
return 0;
}
marker.endTime = performance.now();
marker.duration = marker.endTime - marker.startTime;
// 记录平均处理时间
if (name.includes('frame')) {
this.frameStats.avgProcessingTime =
(this.frameStats.avgProcessingTime * 0.7 + marker.duration * 0.3);
}
// 如果耗时超过阈值,发出警告
if (marker.duration > 16.67) { // 超过60fps的一帧时间
console.warn(`[性能警告] ${name} 耗时 ${marker.duration.toFixed(2)}ms`);
}
return marker.duration;
}
/**
* 更新帧率统计
*/
updateFrameRate() {
const now = performance.now();
const elapsed = now - this.frameStats.lastTime;
if (elapsed >= 1000) {
this.frameStats.fps = Math.round(
(this.frameStats.count * 1000) / elapsed
);
this.frameStats.count = 0;
this.frameStats.lastTime = now;
// 更新UI显示(如果存在)
this.updateFpsDisplay();
}
}
/**
* 增加帧计数
*/
incrementFrameCount() {
this.frameStats.count++;
}
/**
* 更新FPS显示
*/
updateFpsDisplay() {
const fpsElement = document.getElementById('fpsDisplay');
if (fpsElement) {
fpsElement.textContent = `${this.frameStats.fps} FPS`;
fpsElement.className = this.frameStats.fps < 20 ? 'fps-low' :
this.frameStats.fps > 25 ? 'fps-good' : 'fps-normal';
}
}
/**
* 记录内存使用情况
*/
logMemoryUsage() {
const memory = this.memoryStats;
console.log(`[内存使用] 截图: ${memory.screenshots}张, 大小: ${(memory.totalSize / 1024).toFixed(2)}KB`);
}
/**
* 更新内存统计
* @param {number} screenshotCount - 截图数量
* @param {number} totalSize - 总大小(字节)
*/
updateMemoryStats(screenshotCount, totalSize) {
this.memoryStats.screenshots = screenshotCount;
this.memoryStats.totalSize = totalSize;
}
/**
* 获取性能报告
* @returns {Object} 性能报告
*/
getReport() {
return {
fps: this.frameStats.fps,
avgFrameTime: this.frameStats.avgProcessingTime.toFixed(2),
memory: { ...this.memoryStats }
};
}
}
// ============================================
// 工具类:截图管理器
// ============================================
class ScreenshotManager {
constructor() {
this.screenshots = [];
this.maxCount = 100; // 最大保存数量
this.storageKey = 'camera_screenshots_v2';
this.loadFromStorage();
}
/**
* 从本地存储加载截图
*/
loadFromStorage() {
try {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
this.screenshots = JSON.parse(stored);
console.log(`[截图管理] 从存储加载 ${this.screenshots.length} 张截图`);
}
} catch (error) {
console.error('[截图管理] 加载存储失败:', error);
this.screenshots = [];
}
}
/**
* 保存到本地存储
*/
saveToStorage() {
try {
const data = JSON.stringify(this.screenshots.slice(-50)); // 只保存最近50张
localStorage.setItem(this.storageKey, data);
} catch (error) {
console.error('[截图管理] 保存到存储失败:', error);
}
}
/**
* 添加截图
* @param {Object} screenshot - 截图数据
* @param {string} screenshot.dataUrl - 图片数据URL
* @param {number} screenshot.width - 宽度
* @param {number} screenshot.height - 高度
* @param {string} screenshot.format - 格式(jpeg/png)
* @returns {string} 截图ID
*/
addScreenshot(screenshot) {
const screenshotData = {
id: `ss_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
dataUrl: screenshot.dataUrl,
width: screenshot.width,
height: screenshot.height,
format: screenshot.format || 'jpeg',
timestamp: Date.now(),
dateString: new Date().toLocaleString('zh-CN'),
size: Math.floor(screenshot.dataUrl.length * 0.75) // 估算字节数
};
// 添加到开头(最新在前)
this.screenshots.unshift(screenshotData);
// 限制数量
if (this.screenshots.length > this.maxCount) {
this.screenshots = this.screenshots.slice(0, this.maxCount);
}
// 异步保存到存储
setTimeout(() => this.saveToStorage(), 0);
console.log(`[截图管理] 新增截图: ${screenshotData.id} (${screenshotData.width}x${screenshotData.height})`);
return screenshotData.id;
}
/**
* 获取截图
* @param {string} id - 截图ID
* @returns {Object|null} 截图数据
*/
getScreenshot(id) {
return this.screenshots.find(ss => ss.id === id) || null;
}
/**
* 删除截图
* @param {string} id - 截图ID
* @returns {boolean} 是否删除成功
*/
deleteScreenshot(id) {
const initialLength = this.screenshots.length;
this.screenshots = this.screenshots.filter(ss => ss.id !== id);
const deleted = initialLength > this.screenshots.length;
if (deleted) {
this.saveToStorage();
console.log(`[截图管理] 删除截图: ${id}`);
}
return deleted;
}
/**
* 清空所有截图
*/
clearAll() {
const count = this.screenshots.length;
this.screenshots = [];
localStorage.removeItem(this.storageKey);
console.log(`[截图管理] 清空所有截图,共 ${count} 张`);
}
/**
* 获取截图数量
* @returns {number} 截图数量
*/
getCount() {
return this.screenshots.length;
}
/**
* 获取所有截图
* @returns {Array} 截图数组
*/
getAll() {
return [...this.screenshots]; // 返回副本
}
/**
* 批量导出截图
* @returns {Promise<Blob>} ZIP文件Blob
*/
async exportToZip() {
if (this.screenshots.length === 0) {
throw new Error('没有可导出的截图');
}
// 动态导入JSZip库(如果已全局加载则跳过)
let JSZip;
if (typeof window.JSZip !== 'undefined') {
JSZip = window.JSZip;
} else {
// 这里可以添加动态加载JSZip的代码
throw new Error('JSZip库未加载,请确保在页面中引入');
}
const zip = new JSZip();
const folder = zip.folder('screenshots');
// 添加截图文件
this.screenshots.forEach((ss, index) => {
const base64Data = ss.dataUrl.split(',')[1];
const filename = `screenshot_${index + 1}_${ss.width}x${ss.height}.${ss.format}`;
folder.file(filename, base64Data, { base64: true });
});
// 添加元数据文件
const metadata = {
exportDate: new Date().toISOString(),
totalScreenshots: this.screenshots.length,
appVersion: '2.0.0',
resolutions: this.screenshots.map(ss => `${ss.width}x${ss.height}`)
};
folder.file('metadata.json', JSON.stringify(metadata, null, 2));
return await zip.generateAsync({ type: 'blob' });
}
}
// ============================================
// 核心类:摄像头控制器
// ============================================
class CameraController {
constructor(videoElement) {
// 视频元素引用
this.videoElement = videoElement;
// 媒体流状态
this.mediaStream = null;
this.videoTrack = null;
this.isStreaming = false;
// 录制状态
this.mediaRecorder = null;
this.recordedChunks = [];
this.isRecording = false;
// 配置
this.constraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
// 不指定设备ID,让浏览器选择
facingMode: 'environment'
},
audio: false
};
// 支持的约束优先级(根据v4l2输出)
this.formatPriority = [
{ width: 1280, height: 720, frameRate: 30 },
{ width: 640, height: 480, frameRate: 30 },
{ width: 1280, height: 1024, frameRate: 30 },
{ width: 1920, height: 1080, frameRate: 15 }
];
console.log('[摄像头控制器] 初始化完成');
}
/**
* 获取摄像头设备列表
* @returns {Promise<Array>} 设备列表
*/
async getDevices() {
try {
// 先请求权限,否则可能无法获取设备标签
await navigator.mediaDevices.getUserMedia({ video: true });
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(device => device.kind === 'videoinput');
console.log(`[摄像头控制器] 找到 ${videoDevices.length} 个视频设备`);
return videoDevices;
} catch (error) {
console.error('[摄像头控制器] 获取设备失败:', error);
return [];
}
}
/**
* 启动摄像头
* @param {Object} options - 启动选项
* @param {string} options.deviceId - 设备ID
* @param {number} options.width - 宽度
* @param {number} options.height - 高度
* @param {number} options.frameRate - 帧率
* @returns {Promise<MediaStream>} 媒体流
*/
async start(options = {}) {
console.log('[摄像头控制器] 启动摄像头...');
// 停止现有的流
if (this.isStreaming) {
this.stop();
}
try {
// 构建约束条件
const constraints = this.buildConstraints(options);
console.log('[摄像头控制器] 使用约束:', JSON.stringify(constraints.video));
// 请求摄像头访问权限
this.mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
console.log('[摄像头控制器] 摄像头访问权限已获取');
// 获取视频轨道
const tracks = this.mediaStream.getVideoTracks();
if (tracks.length === 0) {
throw new Error('未获取到视频轨道');
}
this.videoTrack = tracks[0];
this.isStreaming = true;
// 获取实际的视频设置
const settings = this.videoTrack.getSettings();
console.log('[摄像头控制器] 实际视频设置:', settings);
// 将流连接到视频元素
this.videoElement.srcObject = this.mediaStream;
// 等待视频元素就绪
await this.waitForVideoReady();
console.log('[摄像头控制器] 摄像头启动成功');
return this.mediaStream;
} catch (error) {
console.error('[摄像头控制器] 启动失败:', error);
this.handleStartError(error);
throw error;
}
}
/**
* 构建约束条件
* @param {Object} options - 用户选项
* @returns {Object} 媒体约束
*/
buildConstraints(options) {
const constraints = JSON.parse(JSON.stringify(this.constraints));
// 应用用户选项
if (options.deviceId) {
constraints.video.deviceId = { exact: options.deviceId };
}
if (options.width && options.height) {
constraints.video.width = { ideal: options.width };
constraints.video.height = { ideal: options.height };
}
if (options.frameRate) {
constraints.video.frameRate = { ideal: options.frameRate };
}
return constraints;
}
/**
* 等待视频元素就绪
* @returns {Promise<void>}
*/
waitForVideoReady() {
return new Promise((resolve, reject) => {
// 设置超时
const timeout = setTimeout(() => {
reject(new Error('视频加载超时'));
}, 5000);
// 检查视频是否已有元数据
if (this.videoElement.readyState >= 1) { // HAVE_METADATA
clearTimeout(timeout);
resolve();
return;
}
// 监听元数据加载事件
const onLoadedMetadata = () => {
clearTimeout(timeout);
this.videoElement.removeEventListener('loadedmetadata', onLoadedMetadata);
this.videoElement.removeEventListener('error', onError);
resolve();
};
// 监听错误事件
const onError = (error) => {
clearTimeout(timeout);
this.videoElement.removeEventListener('loadedmetadata', onLoadedMetadata);
this.videoElement.removeEventListener('error', onError);
reject(error);
};
this.videoElement.addEventListener('loadedmetadata', onLoadedMetadata);
this.videoElement.addEventListener('error', onError);
});
}
/**
* 处理启动错误
* @param {Error} error - 错误对象
*/
handleStartError(error) {
let userMessage = '摄像头启动失败';
switch (error.name) {
case 'NotAllowedError':
userMessage = '请允许摄像头访问权限';
break;
case 'NotFoundError':
userMessage = '未找到摄像头设备';
break;
case 'NotReadableError':
userMessage = '摄像头被其他程序占用';
break;
case 'OverconstrainedError':
userMessage = '请求的视频参数不被支持';
break;
case 'TypeError':
userMessage = '无效的参数';
break;
}
console.error(`[摄像头控制器] 用户错误: ${userMessage}`, error);
}
/**
* 停止摄像头
*/
stop() {
console.log('[摄像头控制器] 停止摄像头...');
// 停止录制
if (this.isRecording) {
this.stopRecording();
}
// 停止所有轨道
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => {
track.stop();
console.log(`[摄像头控制器] 停止轨道: ${track.kind}`);
});
this.mediaStream = null;
this.videoTrack = null;
}
// 清空视频元素
if (this.videoElement) {
this.videoElement.srcObject = null;
}
this.isStreaming = false;
console.log('[摄像头控制器] 摄像头已停止');
}
/**
* 捕获当前帧
* @param {Object} options - 捕获选项
* @param {string} options.format - 图片格式(image/jpeg, image/png)
* @param {number} options.quality - JPEG质量(0-1)
* @returns {Promise<string>} 图片数据URL
*/
captureFrame(options = {}) {
if (!this.isStreaming) {
throw new Error('摄像头未启动');
}
return new Promise((resolve, reject) => {
try {
// 创建画布
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 设置画布尺寸与视频一致
canvas.width = this.videoElement.videoWidth;
canvas.height = this.videoElement.videoHeight;
// 绘制当前帧
ctx.drawImage(this.videoElement, 0, 0);
// 生成图片数据
const format = options.format || 'image/jpeg';
const quality = options.quality || 0.92;
const dataUrl = canvas.toDataURL(format, quality);
console.log(`[摄像头控制器] 捕获帧: ${canvas.width}x${canvas.height} (${format})`);
resolve(dataUrl);
} catch (error) {
console.error('[摄像头控制器] 捕获帧失败:', error);
reject(error);
}
});
}
/**
* 开始录制视频
* @returns {boolean} 是否成功开始
*/
startRecording() {
if (!this.isStreaming || this.isRecording) {
return false;
}
try {
// 重置录制数据
this.recordedChunks = [];
// 创建媒体录制器
const mimeType = this.getSupportedMimeType();
const options = { mimeType };
this.mediaRecorder = new MediaRecorder(this.mediaStream, options);
// 设置数据可用回调
this.mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.recordedChunks.push(event.data);
console.log(`[摄像头控制器] 录制数据块: ${event.data.size}字节`);
}
};
// 设置录制停止回调
this.mediaRecorder.onstop = () => {
console.log('[摄像头控制器] 录制停止,准备保存');
this.saveRecording();
};
// 开始录制
this.mediaRecorder.start(1000); // 每1秒收集一次数据
this.isRecording = true;
console.log('[摄像头控制器] 开始录制视频');
return true;
} catch (error) {
console.error('[摄像头控制器] 开始录制失败:', error);
return false;
}
}
/**
* 停止录制视频
*/
stopRecording() {
if (!this.isRecording || !this.mediaRecorder) {
return;
}
try {
if (this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop();
}
this.isRecording = false;
console.log('[摄像头控制器] 停止录制');
} catch (error) {
console.error('[摄像头控制器] 停止录制失败:', error);
}
}
/**
* 获取支持的MIME类型
* @returns {string} MIME类型
*/
getSupportedMimeType() {
const mimeTypes = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm;codecs=h264',
'video/webm',
'video/mp4'
];
for (const mimeType of mimeTypes) {
if (MediaRecorder.isTypeSupported(mimeType)) {
console.log(`[摄像头控制器] 使用MIME类型: ${mimeType}`);
return mimeType;
}
}
console.warn('[摄像头控制器] 未找到支持的MIME类型,使用默认值');
return '';
}
/**
* 保存录制视频
*/
saveRecording() {
if (this.recordedChunks.length === 0) {
console.warn('[摄像头控制器] 没有录制数据');
return;
}
try {
// 合并数据块
const blob = new Blob(this.recordedChunks, {
type: this.mediaRecorder.mimeType || 'video/webm'
});
// 创建下载链接
const url = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `recording_${timestamp}.webm`;
// 触发下载
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 释放URL
setTimeout(() => URL.revokeObjectURL(url), 1000);
console.log(`[摄像头控制器] 视频已保存: ${filename} (${blob.size}字节)`);
} catch (error) {
console.error('[摄像头控制器] 保存录制失败:', error);
}
}
/**
* 获取当前状态
* @returns {Object} 状态信息
*/
getStatus() {
return {
isStreaming: this.isStreaming,
isRecording: this.isRecording,
videoTrack: this.videoTrack ? {
label: this.videoTrack.label,
settings: this.videoTrack.getSettings()
} : null,
stream: this.mediaStream ? {
active: this.mediaStream.active,
id: this.mediaStream.id
} : null
};
}
/**
* 切换摄像头状态
* @returns {Promise<boolean>} 切换后的状态
*/
async toggle() {
if (this.isStreaming) {
this.stop();
return false;
} else {
await this.start();
return true;
}
}
}
// ============================================
// 主应用类:USB摄像头监控系统
// ============================================
class CameraMonitorApp {
constructor() {
console.log('[主应用] 开始初始化 CameraMonitorApp...');
// -----------------------------
// 核心组件初始化
// -----------------------------
// 1. 获取视频元素 - 用于显示摄像头画面
this.videoElement = document.getElementById('videoPreview');
if (!this.videoElement) {
throw new Error('未找到视频元素 #videoPreview');
}
// 2. 初始化性能监控器 - 监控帧率、内存等性能指标
this.performance = new PerformanceMonitor();
// 3. 初始化截图管理器 - 管理截图的存储、加载、删除
this.screenshotManager = new ScreenshotManager();
// 4. 初始化摄像头控制器 - 控制摄像头启停、录制等核心功能
this.cameraController = new CameraController(this.videoElement);
// -----------------------------
// 应用状态变量
// -----------------------------
// 当前应用状态:'idle'(空闲), 'starting'(启动中), 'streaming'(流传输中), 'recording'(录制中)
this.appState = 'idle';
// 应用配置:从本地存储加载或使用默认值
this.config = {
autoStart: false, // 是否自动启动摄像头
defaultResolution: '1280x720', // 默认分辨率
defaultFrameRate: 30, // 默认帧率
videoQuality: 0.92, // 视频/截图质量 (0-1)
enableNotifications: true, // 是否启用通知
maxScreenshots: 100 // 最大截图数量
};
// 可用摄像头设备列表
this.availableDevices = [];
// 当前选中的摄像头设备ID
this.selectedDeviceId = null;
// 运行时间计数器(秒)
this.uptimeSeconds = 0;
this.uptimeInterval = null;
console.log('[主应用] 基础组件初始化完成');
}
/**
* 初始化应用 - 入口方法
* 顺序执行所有初始化步骤,确保依赖关系正确
*/
async initialize() {
console.log('[主应用] 开始完整初始化流程...');
try {
// 第一步:加载配置
// 从localStorage加载用户保存的配置,如果不存在则使用默认配置
await this.loadConfiguration();
// 第二步:初始化UI组件
// 设置所有界面元素的状态和事件监听器
this.initializeUIComponents();
// 第三步:更新设备列表
// 获取系统中可用的摄像头设备
await this.updateDeviceList();
// 第四步:加载截图数据
// 从本地存储加载之前保存的截图
this.loadScreenshots();
// 第五步:恢复应用状态
// 根据配置恢复上次的应用状态(如自动启动)
this.restoreAppState();
// 第六步:开始性能监控
// 启动帧率监控和性能统计
this.performance.startMonitoring();
// 第七步:更新运行时间显示
// 启动计时器,每秒更新运行时间
this.startUptimeCounter();
console.log('[主应用] 应用初始化完成,当前状态:', this.appState);
// 显示初始化完成通知
this.showNotification('应用初始化完成', 'success');
} catch (error) {
console.error('[主应用] 初始化失败:', error);
this.showNotification(`初始化失败: ${error.message}`, 'error');
throw error;
}
}
/**
* 从本地存储加载配置
* 如果本地没有保存的配置,则使用默认配置
*/
async loadConfiguration() {
console.log('[主应用] 加载配置...');
try {
// 尝试从localStorage读取配置
const savedConfig = localStorage.getItem('cameraMonitorConfig');
if (savedConfig) {
// 解析保存的配置
const parsedConfig = JSON.parse(savedConfig);
// 合并配置:保存的配置覆盖默认配置
this.config = {
...this.config, // 默认配置
...parsedConfig // 用户保存的配置
};
console.log('[主应用] 配置加载成功:', this.config);
} else {
console.log('[主应用] 无保存的配置,使用默认配置');
}
// 更新截图管理器最大数量限制
this.screenshotManager.maxCount = this.config.maxScreenshots;
} catch (error) {
console.warn('[主应用] 配置加载失败,使用默认配置:', error);
// 配置加载失败不影响应用运行,继续使用默认配置
}
}
/**
* 保存配置到本地存储
* 将当前配置以JSON格式保存到localStorage
*/
saveConfiguration() {
try {
// 将配置对象转换为JSON字符串
const configJson = JSON.stringify(this.config);
// 保存到localStorage
localStorage.setItem('cameraMonitorConfig', configJson);
console.log('[主应用] 配置已保存');
} catch (error) {
console.error('[主应用] 保存配置失败:', error);
this.showNotification('保存配置失败,存储空间可能不足', 'error');
}
}
/**
* 初始化所有UI组件
* 设置初始状态、绑定事件监听器、更新显示
*/
initializeUIComponents() {
console.log('[主应用] 初始化UI组件...');
// -----------------------------
// 控制按钮初始化
// -----------------------------
// 启动/停止按钮
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
if (startBtn) {
// 绑定点击事件,使用箭头函数保持this指向
startBtn.addEventListener('click', () => this.startCamera());
console.log('[主应用] 启动按钮初始化完成');
}
if (stopBtn) {
stopBtn.addEventListener('click', () => this.stopCamera());
console.log('[主应用] 停止按钮初始化完成');
}
// 截图按钮
const captureBtn = document.getElementById('captureBtn');
if (captureBtn) {
captureBtn.addEventListener('click', () => this.captureScreenshot());
// 初始状态禁用,摄像头启动后启用
captureBtn.disabled = true;
console.log('[主应用] 截图按钮初始化完成');
}
// 录制按钮
const recordBtn = document.getElementById('recordBtn');
if (recordBtn) {
recordBtn.addEventListener('click', () => this.toggleRecording());
recordBtn.disabled = true;
console.log('[主应用] 录制按钮初始化完成');
}
// 清空截图按钮
const clearAllBtn = document.getElementById('clearAllBtn');
if (clearAllBtn) {
clearAllBtn.addEventListener('click', () => this.clearAllScreenshots());
console.log('[主应用] 清空截图按钮初始化完成');
}
// 导出截图按钮
const exportAllBtn = document.getElementById('exportAllBtn');
if (exportAllBtn) {
exportAllBtn.addEventListener('click', () => this.exportAllScreenshots());
console.log('[主应用] 导出截图按钮初始化完成');
}
// 设置按钮
const settingsBtn = document.getElementById('settingsBtn');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => this.showSettingsModal());
console.log('[主应用] 设置按钮初始化完成');
}
// -----------------------------
// 设置相关元素初始化
// -----------------------------
// 分辨率选择框
const resolutionSelect = document.getElementById('resolutionSelect');
if (resolutionSelect) {
// 设置默认选中值
resolutionSelect.value = this.config.defaultResolution;
// 监听变化事件
resolutionSelect.addEventListener('change', (e) => {
this.config.defaultResolution = e.target.value;
this.saveConfiguration();
this.showNotification(`分辨率设置为: ${e.target.value}`, 'info');
});
console.log('[主应用] 分辨率选择框初始化完成');
}
// 帧率选择框
const frameRateSelect = document.getElementById('frameRateSelect');
if (frameRateSelect) {
frameRateSelect.value = this.config.defaultFrameRate;
frameRateSelect.addEventListener('change', (e) => {
this.config.defaultFrameRate = parseInt(e.target.value);
this.saveConfiguration();
this.showNotification(`帧率设置为: ${e.target.value} FPS`, 'info');
});
console.log('[主应用] 帧率选择框初始化完成');
}
// 设置模态框关闭按钮
const closeSettings = document.getElementById('closeSettings');
if (closeSettings) {
closeSettings.addEventListener('click', () => this.hideSettingsModal());
console.log('[主应用] 设置模态框关闭按钮初始化完成');
}
// 设置保存按钮
const saveSettings = document.getElementById('saveSettings');
if (saveSettings) {
saveSettings.addEventListener('click', () => this.applySettings());
console.log('[主应用] 设置保存按钮初始化完成');
}
// -----------------------------
// 视频元素初始化
// -----------------------------
// 设置视频元素属性,优化播放性能
this.videoElement.muted = true; // 静音,避免自动播放策略限制
this.videoElement.playsInline = true; // 在移动端内联播放
this.videoElement.setAttribute('playsinline', 'true'); // 兼容性属性
// 监听视频元素错误事件
this.videoElement.addEventListener('error', (e) => {
console.error('[主应用] 视频元素错误:', e);
this.showNotification('视频播放错误,请检查摄像头', 'error');
});
// 监听视频元素加载元数据完成事件
this.videoElement.addEventListener('loadedmetadata', () => {
console.log('[主应用] 视频元数据加载完成:', {
width: this.videoElement.videoWidth,
height: this.videoElement.videoHeight,
duration: this.videoElement.duration
});
});
console.log('[主应用] UI组件初始化完成');
}
/**
* 更新可用的摄像头设备列表
* 获取系统所有视频输入设备,并更新设备选择器
*/
async updateDeviceList() {
console.log('[主应用] 更新设备列表...');
try {
// 通过摄像头控制器获取设备列表
this.availableDevices = await this.cameraController.getDevices();
// 获取设备选择器DOM元素
const deviceSelect = document.getElementById('cameraDeviceSelect');
if (!deviceSelect) {
console.warn('[主应用] 未找到设备选择器元素');
return;
}
// 保存当前选中的设备ID
const currentSelection = deviceSelect.value;
// 清空现有选项(保留第一个默认选项)
deviceSelect.innerHTML = '<option value="default">默认摄像头</option>';
// 添加所有找到的设备到选择器
this.availableDevices.forEach((device, index) => {
// 创建option元素
const option = document.createElement('option');
option.value = device.deviceId;
// 设置显示文本:如果有设备标签则使用标签,否则使用序号
const label = device.label || `摄像头 ${index + 1}`;
option.textContent = label;
// 添加到选择器
deviceSelect.appendChild(option);
});
// 恢复之前的选中状态,如果设备仍然存在
if (currentSelection && this.availableDevices.some(d => d.deviceId === currentSelection)) {
deviceSelect.value = currentSelection;
} else if (this.availableDevices.length > 0) {
// 否则选择第一个设备
deviceSelect.value = this.availableDevices[0].deviceId;
}
// 保存选中的设备ID到配置
this.selectedDeviceId = deviceSelect.value === 'default' ? null : deviceSelect.value;
console.log(`[主应用] 设备列表更新完成,找到 ${this.availableDevices.length} 个设备`);
} catch (error) {
console.error('[主应用] 更新设备列表失败:', error);
// 失败不影响主要功能,继续使用默认设备
}
}
/**
* 从本地存储加载截图数据
* 显示截图数量和预览
*/
loadScreenshots() {
console.log('[主应用] 加载截图数据...');
// 截图管理器已经在构造函数中加载了数据
// 这里只需要更新UI显示
const count = this.screenshotManager.getCount();
// 更新截图数量显示
const countElement = document.getElementById('screenshotCount');
if (countElement) {
countElement.textContent = `${count} 张`;
}
// 更新统计信息中的截图数量
const totalCaptures = document.getElementById('totalCaptures');
if (totalCaptures) {
totalCaptures.textContent = count;
}
// 更新截图列表显示
this.updateScreenshotList();
// 更新内存统计
const totalSize = this.screenshotManager.getAll().reduce((sum, ss) => sum + (ss.size || 0), 0);
this.performance.updateMemoryStats(count, totalSize);
console.log(`[主应用] 截图数据加载完成,共 ${count} 张,总大小 ${totalSize} 字节`);
}
/**
* 恢复应用状态
* 根据配置决定是否自动启动摄像头等
*/
restoreAppState() {
console.log('[主应用] 恢复应用状态...');
// 检查是否启用自动启动
if (this.config.autoStart) {
console.log('[主应用] 配置了自动启动,将在500ms后启动摄像头');
// 延迟启动,确保页面完全加载
setTimeout(() => {
this.startCamera().catch(error => {
console.warn('[主应用] 自动启动失败:', error);
});
}, 500);
}
// 更新UI状态显示
this.updateUIState();
console.log('[主应用] 应用状态恢复完成');
}
/**
* 启动运行时间计数器
* 每秒更新运行时间显示
*/
startUptimeCounter() {
console.log('[主应用] 启动运行时间计数器...');
// 清除已有的计时器
if (this.uptimeInterval) {
clearInterval(this.uptimeInterval);
}
// 重置运行时间
this.uptimeSeconds = 0;
// 创建新的计时器,每秒更新一次
this.uptimeInterval = setInterval(() => {
this.uptimeSeconds++;
this.updateUptimeDisplay();
}, 1000);
// 立即更新一次显示
this.updateUptimeDisplay();
console.log('[主应用] 运行时间计数器已启动');
}
/**
* 更新运行时间显示
* 将秒数转换为 HH:MM:SS 格式
*/
updateUptimeDisplay() {
const uptimeElement = document.getElementById('uptime');
if (!uptimeElement) return;
// 计算小时、分钟、秒
const hours = Math.floor(this.uptimeSeconds / 3600);
const minutes = Math.floor((this.uptimeSeconds % 3600) / 60);
const seconds = this.uptimeSeconds % 60;
// 格式化为两位数
const format = (num) => num.toString().padStart(2, '0');
// 更新显示
uptimeElement.textContent = `${format(hours)}:${format(minutes)}:${format(seconds)}`;
}
/**
* 启动摄像头
* 核心功能:请求摄像头权限、开始视频流、更新UI
*/
async startCamera() {
console.log('[主应用] 用户请求启动摄像头...');
// 检查是否已经在流传输状态
if (this.appState === 'streaming' || this.appState === 'recording') {
console.log('[主应用] 摄像头已经在运行中');
this.showNotification('摄像头已经在运行中', 'info');
return;
}
// 更新应用状态
this.appState = 'starting';
this.updateUIState();
// 显示启动状态
this.showNotification('正在启动摄像头...', 'info');
try {
// 性能监控:标记开始时间
this.performance.markStart('camera_start');
// -----------------------------
// 解析分辨率设置
// -----------------------------
// 从配置中获取分辨率字符串(如 "1280x720")
const resolution = this.config.defaultResolution;
let width, height;
if (resolution && resolution.includes('x')) {
// 分割字符串获取宽高
const [w, h] = resolution.split('x').map(Number);
width = w;
height = h;
console.log(`[主应用] 使用配置的分辨率: ${width}x${height}`);
} else {
// 默认分辨率
width = 1280;
height = 720;
console.log(`[主应用] 使用默认分辨率: ${width}x${height}`);
}
// -----------------------------
// 构建摄像头启动选项
// -----------------------------
const options = {
deviceId: this.selectedDeviceId, // 选中的设备ID
width: width, // 视频宽度
height: height, // 视频高度
frameRate: this.config.defaultFrameRate // 帧率
};
console.log('[主应用] 摄像头启动选项:', options);
// -----------------------------
// 启动摄像头控制器
// -----------------------------
// 调用摄像头控制器的start方法
await this.cameraController.start(options);
// 性能监控:记录启动耗时
const duration = this.performance.markEnd('camera_start');
console.log(`[主应用] 摄像头启动耗时: ${duration.toFixed(2)}ms`);
// -----------------------------
// 更新应用状态
// -----------------------------
this.appState = 'streaming';
// 更新UI状态(启用按钮、更新状态显示等)
this.updateUIState();
// 显示成功通知
this.showNotification('摄像头启动成功', 'success');
// 性能监控:开始帧率统计
this.startFrameRateMonitoring();
console.log('[主应用] 摄像头启动流程完成');
} catch (error) {
// -----------------------------
// 错误处理
// -----------------------------
console.error('[主应用] 启动摄像头失败:', error);
// 恢复应用状态
this.appState = 'idle';
this.updateUIState();
// 显示错误通知
this.showNotification(`启动失败: ${error.message}`, 'error');
// 重新抛出错误,允许调用者处理
throw error;
}
}
/**
* 停止摄像头
* 停止视频流、更新UI状态
*/
async stopCamera() {
console.log('[主应用] 用户请求停止摄像头...');
// 检查是否在运行状态
if (this.appState !== 'streaming' && this.appState !== 'recording') {
console.log('[主应用] 摄像头未在运行');
return;
}
// 显示停止状态
this.showNotification('正在停止摄像头...', 'info');
try {
// 性能监控:标记开始时间
this.performance.markStart('camera_stop');
// 停止摄像头控制器
this.cameraController.stop();
// 停止帧率监控
this.stopFrameRateMonitoring();
// 更新应用状态
this.appState = 'idle';
// 更新UI状态
this.updateUIState();
// 性能监控:记录停止耗时
const duration = this.performance.markEnd('camera_stop');
console.log(`[主应用] 摄像头停止耗时: ${duration.toFixed(2)}ms`);
// 显示成功通知
this.showNotification('摄像头已停止', 'info');
console.log('[主应用] 摄像头停止流程完成');
} catch (error) {
console.error('[主应用] 停止摄像头失败:', error);
this.showNotification('停止摄像头时发生错误', 'error');
}
}
/**
* 开始帧率监控
* 使用requestAnimationFrame监控实际帧率
*/
startFrameRateMonitoring() {
console.log('[主应用] 开始帧率监控...');
// 定义帧率监控函数
const monitorFrameRate = () => {
// 只在摄像头运行时监控
if (this.appState === 'streaming' || this.appState === 'recording') {
// 增加帧计数
this.performance.incrementFrameCount();
// 继续下一帧监控
requestAnimationFrame(monitorFrameRate);
}
};
// 启动监控
requestAnimationFrame(monitorFrameRate);
console.log('[主应用] 帧率监控已启动');
}
/**
* 停止帧率监控
*/
stopFrameRateMonitoring() {
console.log('[主应用] 停止帧率监控');
// 帧率监控通过appState控制,无需额外操作
}
/**
* 更新UI状态
* 根据当前应用状态更新所有UI元素的显示和状态
*/
updateUIState() {
console.log(`[主应用] 更新UI状态,当前状态: ${this.appState}`);
// -----------------------------
// 控制按钮状态
// -----------------------------
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const captureBtn = document.getElementById('captureBtn');
const recordBtn = document.getElementById('recordBtn');
if (this.appState === 'starting') {
// 启动中状态
if (startBtn) {
startBtn.disabled = true;
startBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 启动中...';
}
if (stopBtn) stopBtn.style.display = 'none';
if (captureBtn) captureBtn.disabled = true;
if (recordBtn) recordBtn.disabled = true;
} else if (this.appState === 'streaming' || this.appState === 'recording') {
// 运行中状态
if (startBtn) startBtn.style.display = 'none';
if (stopBtn) {
stopBtn.style.display = 'inline-flex';
stopBtn.disabled = false;
}
if (captureBtn) captureBtn.disabled = false;
if (recordBtn) recordBtn.disabled = false;
// 如果是录制状态,更新录制按钮样式
if (this.appState === 'recording' && recordBtn) {
recordBtn.innerHTML = '<i class="fas fa-stop"></i> 停止录制';
recordBtn.classList.add('recording');
} else if (recordBtn) {
recordBtn.innerHTML = '<i class="fas fa-video"></i> 开始录制';
recordBtn.classList.remove('recording');
}
} else {
// 空闲状态
if (startBtn) {
startBtn.style.display = 'inline-flex';
startBtn.disabled = false;
startBtn.innerHTML = '<i class="fas fa-play"></i> 启动摄像头';
}
if (stopBtn) stopBtn.style.display = 'none';
if (captureBtn) captureBtn.disabled = true;
if (recordBtn) {
recordBtn.disabled = true;
recordBtn.classList.remove('recording');
recordBtn.innerHTML = '<i class="fas fa-video"></i> 开始录制';
}
}
// -----------------------------
// 状态覆盖层显示
// -----------------------------
const statusOverlay = document.getElementById('statusOverlay');
if (statusOverlay) {
switch (this.appState) {
case 'starting':
statusOverlay.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 启动中...';
break;
case 'streaming':
statusOverlay.innerHTML = '<i class="fas fa-circle text-green-500"></i> 实时监控中';
break;
case 'recording':
statusOverlay.innerHTML = '<i class="fas fa-circle text-red-500"></i> 录制中';
break;
default:
statusOverlay.innerHTML = '<i class="fas fa-circle text-gray-500"></i> 已停止';
}
}
// -----------------------------
// 录制指示器显示
// -----------------------------
const recordingIndicator = document.getElementById('recordingIndicator');
if (recordingIndicator) {
if (this.appState === 'recording') {
recordingIndicator.classList.remove('hidden');
} else {
recordingIndicator.classList.add('hidden');
}
}
// -----------------------------
// 统计信息更新
// -----------------------------
const recordStatus = document.getElementById('recordStatus');
if (recordStatus) {
recordStatus.textContent = this.appState === 'recording' ? '录制中' : '未开始';
recordStatus.className = this.appState === 'recording' ? 'text-red-600' : 'text-gray-600';
}
const deviceStatus = document.getElementById('deviceStatus');
if (deviceStatus) {
deviceStatus.textContent =
this.appState === 'streaming' || this.appState === 'recording' ? '运行中' : '就绪';
deviceStatus.className =
this.appState === 'streaming' || this.appState === 'recording' ? 'text-green-600' : 'text-gray-600';
}
console.log('[主应用] UI状态更新完成');
}
/**
* 截图功能
* 捕获当前视频帧并保存
*/
async captureScreenshot() {
console.log('[主应用] 用户请求截图...');
// 检查摄像头状态
if (this.appState !== 'streaming' && this.appState !== 'recording') {
this.showNotification('请先启动摄像头', 'warning');
return;
}
// 性能监控:标记开始时间
this.performance.markStart('capture_screenshot');
try {
// 显示截图状态
this.showNotification('正在截图...', 'info');
// -----------------------------
// 捕获当前视频帧
// -----------------------------
// 使用摄像头控制器捕获帧
const dataUrl = await this.cameraController.captureFrame({
format: 'image/jpeg', // 使用JPEG格式,压缩率高
quality: this.config.videoQuality // 使用配置的质量参数
});
// 获取视频实际尺寸
const width = this.videoElement.videoWidth;
const height = this.videoElement.videoHeight;
console.log(`[主应用] 截图捕获完成: ${width}x${height}`);
// -----------------------------
// 保存截图
// -----------------------------
const screenshot = {
dataUrl: dataUrl, // 图片数据URL
width: width, // 图片宽度
height: height, // 图片高度
format: 'jpeg' // 图片格式
};
// 添加到截图管理器
const screenshotId = this.screenshotManager.addScreenshot(screenshot);
// -----------------------------
// 更新UI显示
// -----------------------------
// 更新截图列表
this.updateScreenshotList();
// 更新截图数量显示
const count = this.screenshotManager.getCount();
const countElement = document.getElementById('screenshotCount');
if (countElement) {
countElement.textContent = `${count} 张`;
}
// 更新统计信息
const totalCaptures = document.getElementById('totalCaptures');
if (totalCaptures) {
totalCaptures.textContent = count;
}
// 更新内存统计
const totalSize = this.screenshotManager.getAll().reduce((sum, ss) => sum + (ss.size || 0), 0);
this.performance.updateMemoryStats(count, totalSize);
// 更新按钮状态(如果有截图则启用清除/导出按钮)
this.updateScreenshotButtons();
// 性能监控:记录截图耗时
const duration = this.performance.markEnd('capture_screenshot');
console.log(`[主应用] 截图耗时: ${duration.toFixed(2)}ms`);
// 显示成功通知
this.showNotification(`截图保存成功 (${width}x${height})`, 'success');
console.log('[主应用] 截图流程完成,ID:', screenshotId);
} catch (error) {
console.error('[主应用] 截图失败:', error);
this.showNotification(`截图失败: ${error.message}`, 'error');
}
}
/**
* 更新截图列表显示
* 在侧边栏显示所有截图的缩略图
*/
updateScreenshotList() {
console.log('[主应用] 更新截图列表显示...');
const container = document.getElementById('screenshotList');
if (!container) {
console.warn('[主应用] 未找到截图列表容器');
return;
}
// 获取所有截图
const screenshots = this.screenshotManager.getAll();
const count = screenshots.length;
// 如果没有截图,显示空状态
if (count === 0) {
container.innerHTML = `
<div class="text-center py-8 text-gray-500">
<i class="fas fa-camera text-4xl mb-2 opacity-50"></i>
<p>暂无截图</p>
<p class="text-sm">点击"拍摄截图"按钮开始收集</p>
</div>
`;
console.log('[主应用] 截图列表为空,显示空状态');
return;
}
// 清空容器
container.innerHTML = '';
// 创建文档片段,提高DOM操作性能
const fragment = document.createDocumentFragment();
// 为每个截图创建显示项
screenshots.forEach((screenshot, index) => {
// 创建截图项容器
const item = document.createElement('div');
item.className = 'screenshot-item bg-gray-50 rounded-lg p-3 border border-gray-200 mb-3';
item.dataset.id = screenshot.id;
// 截图的HTML结构
item.innerHTML = `
<div class="flex items-center space-x-3">
<!-- 缩略图 -->
<img src="${screenshot.dataUrl}"
alt="摄像头截图 ${index + 1}"
class="w-16 h-16 object-cover rounded-lg cursor-pointer screenshot-thumb"
data-id="${screenshot.id}"
title="点击查看大图">
<!-- 截图信息 -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">
截图 ${screenshot.id.substring(3, 11)}...
</p>
<p class="text-xs text-gray-500 mt-1">
${screenshot.dateString}
</p>
<p class="text-xs text-gray-500">
${screenshot.width} × ${screenshot.height}
</p>
</div>
<!-- 操作按钮 -->
<div class="flex space-x-1">
<!-- 查看按钮 -->
<button class="p-2 text-blue-500 hover:bg-blue-100 rounded transition-colors screenshot-view"
data-id="${screenshot.id}"
title="查看大图">
<i class="fas fa-eye"></i>
</button>
<!-- 删除按钮 -->
<button class="p-2 text-red-500 hover:bg-red-100 rounded transition-colors screenshot-delete"
data-id="${screenshot.id}"
title="删除截图">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
// 添加到文档片段
fragment.appendChild(item);
});
// 一次性添加到容器
container.appendChild(fragment);
// 为缩略图添加点击事件(事件委托)
container.addEventListener('click', (e) => {
const target = e.target;
// 如果点击的是缩略图
if (target.classList.contains('screenshot-thumb')) {
const id = target.dataset.id;
this.viewScreenshot(id);
}
// 如果点击的是查看按钮
else if (target.classList.contains('screenshot-view') ||
target.closest('.screenshot-view')) {
const btn = target.classList.contains('screenshot-view') ? target : target.closest('.screenshot-view');
const id = btn.dataset.id;
this.viewScreenshot(id);
}
// 如果点击的是删除按钮
else if (target.classList.contains('screenshot-delete') ||
target.closest('.screenshot-delete')) {
const btn = target.classList.contains('screenshot-delete') ? target : target.closest('.screenshot-delete');
const id = btn.dataset.id;
this.deleteScreenshot(id);
}
});
console.log(`[主应用] 截图列表更新完成,显示 ${count} 张截图`);
}
/**
* 更新截图相关按钮状态
* 根据是否有截图来启用/禁用按钮
*/
updateScreenshotButtons() {
console.log('[主应用] 更新截图按钮状态...');
const count = this.screenshotManager.getCount();
const hasScreenshots = count > 0;
// 清空所有按钮
const clearAllBtn = document.getElementById('clearAllBtn');
if (clearAllBtn) {
clearAllBtn.disabled = !hasScreenshots;
}
// 导出全部按钮
const exportAllBtn = document.getElementById('exportAllBtn');
if (exportAllBtn) {
exportAllBtn.disabled = !hasScreenshots;
}
console.log(`[主应用] 截图按钮状态更新: 有截图=${hasScreenshots}`);
}
// ============================================
// CameraMonitorApp 类继续 - 剩余功能实现
// ============================================
/**
* 查看截图 - 在大图模态框中显示截图
* @param {string} id - 要查看的截图ID
*/
viewScreenshot(id) {
console.log(`[主应用] 用户请求查看截图: ${id}`);
// 从截图管理器获取截图数据
const screenshot = this.screenshotManager.getScreenshot(id);
if (!screenshot) {
console.warn(`[主应用] 未找到截图: ${id}`);
this.showNotification('截图不存在或已被删除', 'warning');
return;
}
// 获取模态框相关元素
const modal = document.getElementById('screenshotViewerModal');
const fullSizeImage = document.getElementById('fullSizeScreenshot');
const screenshotInfo = document.getElementById('screenshotInfo');
if (!modal || !fullSizeImage || !screenshotInfo) {
console.error('[主应用] 截图查看器元素缺失');
return;
}
// 设置大图
fullSizeImage.src = screenshot.dataUrl;
fullSizeImage.alt = `截图 ${id}`;
// 设置图片信息
screenshotInfo.textContent =
`拍摄时间: ${screenshot.dateString} | 分辨率: ${screenshot.width}x${screenshot.height} | 格式: ${screenshot.format.toUpperCase()}`;
// 设置下载按钮的ID
const downloadBtn = document.getElementById('downloadScreenshot');
if (downloadBtn) {
downloadBtn.dataset.screenshotId = id;
}
// 显示模态框
modal.classList.remove('hidden');
console.log(`[主应用] 截图查看器已打开: ${id}`);
}
/**
* 关闭截图查看器
*/
hideScreenshotViewer() {
console.log('[主应用] 关闭截图查看器');
const modal = document.getElementById('screenshotViewerModal');
if (modal) {
modal.classList.add('hidden');
}
}
/**
* 下载当前查看的截图
*/
downloadCurrentScreenshot() {
// 获取当前查看的截图ID
const downloadBtn = document.getElementById('downloadScreenshot');
if (!downloadBtn || !downloadBtn.dataset.screenshotId) {
console.warn('[主应用] 未找到当前查看的截图ID');
return;
}
const screenshotId = downloadBtn.dataset.screenshotId;
console.log(`[主应用] 用户请求下载截图: ${screenshotId}`);
// 获取截图数据
const screenshot = this.screenshotManager.getScreenshot(screenshotId);
if (!screenshot) {
this.showNotification('截图不存在', 'error');
return;
}
try {
// 创建下载链接
const link = document.createElement('a');
link.href = screenshot.dataUrl;
// 生成文件名: screenshot_时间戳_分辨率.格式
const timestamp = new Date(screenshot.timestamp)
.toISOString()
.replace(/[:.]/g, '-')
.substring(0, 19);
const filename = `screenshot_${timestamp}_${screenshot.width}x${screenshot.height}.${screenshot.format}`;
link.download = filename;
// 触发下载
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log(`[主应用] 截图下载完成: ${filename}`);
this.showNotification(`截图已下载: ${filename}`, 'success');
} catch (error) {
console.error('[主应用] 下载截图失败:', error);
this.showNotification('下载失败: ' + error.message, 'error');
}
}
/**
* 删除截图
* @param {string} id - 要删除的截图ID
*/
deleteScreenshot(id) {
console.log(`[主应用] 用户请求删除截图: ${id}`);
// 确认对话框
if (!confirm('确定要删除这张截图吗?此操作不可撤销。')) {
console.log('[主应用] 用户取消了删除操作');
return;
}
// 执行删除
const deleted = this.screenshotManager.deleteScreenshot(id);
if (deleted) {
// 更新UI
this.updateScreenshotList();
// 更新截图数量显示
const count = this.screenshotManager.getCount();
const countElement = document.getElementById('screenshotCount');
if (countElement) {
countElement.textContent = `${count} 张`;
}
// 更新统计信息
const totalCaptures = document.getElementById('totalCaptures');
if (totalCaptures) {
totalCaptures.textContent = count;
}
// 更新按钮状态
this.updateScreenshotButtons();
// 更新内存统计
const totalSize = this.screenshotManager.getAll().reduce((sum, ss) => sum + (ss.size || 0), 0);
this.performance.updateMemoryStats(count, totalSize);
console.log(`[主应用] 截图已删除: ${id}`);
this.showNotification('截图已删除', 'info');
} else {
console.warn(`[主应用] 删除失败,未找到截图: ${id}`);
this.showNotification('截图不存在或删除失败', 'error');
}
}
/**
* 清空所有截图
*/
clearAllScreenshots() {
console.log('[主应用] 用户请求清空所有截图');
const count = this.screenshotManager.getCount();
if (count === 0) {
this.showNotification('没有可清空的截图', 'info');
return;
}
// 确认对话框
if (!confirm(`确定要清空所有截图吗?将删除 ${count} 张截图,此操作不可撤销。`)) {
console.log('[主应用] 用户取消了清空操作');
return;
}
// 执行清空
this.screenshotManager.clearAll();
// 更新UI
this.updateScreenshotList();
// 更新截图数量显示
const countElement = document.getElementById('screenshotCount');
if (countElement) {
countElement.textContent = '0 张';
}
// 更新统计信息
const totalCaptures = document.getElementById('totalCaptures');
if (totalCaptures) {
totalCaptures.textContent = '0';
}
// 更新按钮状态
this.updateScreenshotButtons();
// 更新内存统计
this.performance.updateMemoryStats(0, 0);
console.log(`[主应用] 已清空所有截图,共 ${count} 张`);
this.showNotification(`已清空所有截图 (${count}张)`, 'info');
}
/**
* 导出所有截图为ZIP文件
*/
async exportAllScreenshots() {
console.log('[主应用] 用户请求导出所有截图');
const count = this.screenshotManager.getCount();
if (count === 0) {
this.showNotification('没有可导出的截图', 'warning');
return;
}
try {
// 显示导出进度
this.showNotification(`正在打包 ${count} 张截图...`, 'info');
// 禁用导出按钮,防止重复点击
const exportBtn = document.getElementById('exportAllBtn');
const originalText = exportBtn ? exportBtn.innerHTML : '';
if (exportBtn) {
exportBtn.disabled = true;
exportBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 打包中...';
}
// 性能监控:标记开始时间
this.performance.markStart('export_screenshots');
// -----------------------------
// 生成ZIP文件
// -----------------------------
const zipBlob = await this.screenshotManager.exportToZip();
// 性能监控:记录导出耗时
const duration = this.performance.markEnd('export_screenshots');
console.log(`[主应用] 截图导出耗时: ${duration.toFixed(2)}ms`);
// -----------------------------
// 创建下载链接
// -----------------------------
const url = URL.createObjectURL(zipBlob);
// 生成文件名: screenshots_时间戳.zip
const timestamp = new Date().toISOString()
.replace(/[:.]/g, '-')
.substring(0, 19);
const filename = `screenshots_${timestamp}.zip`;
// 创建下载链接并触发下载
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 释放URL对象(延迟执行)
setTimeout(() => {
URL.revokeObjectURL(url);
console.log(`[主应用] URL对象已释放: ${url.substring(0, 50)}...`);
}, 1000);
// 恢复按钮状态
if (exportBtn) {
setTimeout(() => {
exportBtn.disabled = false;
exportBtn.innerHTML = originalText;
}, 1000);
}
console.log(`[主应用] 截图导出完成: ${filename} (${(zipBlob.size / 1024 / 1024).toFixed(2)}MB)`);
this.showNotification(`导出成功: ${filename}`, 'success');
} catch (error) {
console.error('[主应用] 导出截图失败:', error);
// 恢复按钮状态
const exportBtn = document.getElementById('exportAllBtn');
if (exportBtn) {
exportBtn.disabled = false;
exportBtn.innerHTML = '<i class="fas fa-download mr-2"></i>导出全部截图';
}
let errorMessage = '导出失败';
if (error.message.includes('JSZip库未加载')) {
errorMessage = '导出需要JSZip库支持,请确保已加载该库';
} else {
errorMessage = `导出失败: ${error.message}`;
}
this.showNotification(errorMessage, 'error');
}
}
/**
* 切换录制状态
* 开始或停止视频录制
*/
toggleRecording() {
console.log('[主应用] 用户请求切换录制状态');
// 检查摄像头状态
if (this.appState !== 'streaming' && this.appState !== 'recording') {
this.showNotification('请先启动摄像头', 'warning');
return;
}
// 如果当前正在录制,则停止录制
if (this.appState === 'recording') {
this.stopRecording();
}
// 如果当前未在录制,则开始录制
else {
this.startRecording();
}
}
/**
* 开始录制视频
*/
startRecording() {
console.log('[主应用] 开始录制视频...');
try {
// 性能监控:标记开始时间
this.performance.markStart('start_recording');
// 通过摄像头控制器开始录制
const success = this.cameraController.startRecording();
if (success) {
// 更新应用状态
this.appState = 'recording';
// 更新UI状态
this.updateUIState();
// 性能监控:记录开始录制耗时
const duration = this.performance.markEnd('start_recording');
console.log(`[主应用] 开始录制耗时: ${duration.toFixed(2)}ms`);
this.showNotification('开始录制视频', 'info');
} else {
throw new Error('开始录制失败');
}
} catch (error) {
console.error('[主应用] 开始录制失败:', error);
this.showNotification('开始录制失败: ' + error.message, 'error');
}
}
/**
* 停止录制视频
*/
stopRecording() {
console.log('[主应用] 停止录制视频...');
try {
// 性能监控:标记开始时间
this.performance.markStart('stop_recording');
// 通过摄像头控制器停止录制
this.cameraController.stopRecording();
// 更新应用状态
this.appState = 'streaming';
// 更新UI状态
this.updateUIState();
// 性能监控:记录停止录制耗时
const duration = this.performance.markEnd('stop_recording');
console.log(`[主应用] 停止录制耗时: ${duration.toFixed(2)}ms`);
this.showNotification('录制已停止,视频正在保存...', 'info');
} catch (error) {
console.error('[主应用] 停止录制失败:', error);
this.showNotification('停止录制失败: ' + error.message, 'error');
}
}
/**
* 显示设置模态框
*/
showSettingsModal() {
console.log('[主应用] 显示设置模态框');
const modal = document.getElementById('settingsModal');
if (!modal) {
console.error('[主应用] 未找到设置模态框');
return;
}
// 更新模态框中的设置值
this.updateSettingsModalValues();
// 显示模态框
modal.classList.remove('hidden');
// 设置模态框显示时的动画效果
modal.style.opacity = '0';
setTimeout(() => {
modal.style.opacity = '1';
modal.style.transition = 'opacity 0.3s ease';
}, 10);
}
/**
* 更新设置模态框中的值
*/
updateSettingsModalValues() {
console.log('[主应用] 更新设置模态框值');
// 摄像头设备选择
const cameraDeviceSelect = document.getElementById('cameraDeviceSelect');
if (cameraDeviceSelect) {
cameraDeviceSelect.value = this.selectedDeviceId || 'default';
}
// 视频质量选择
const videoQuality = document.getElementById('videoQuality');
if (videoQuality) {
videoQuality.value = this.config.videoQuality === 0.92 ? 'high' :
this.config.videoQuality === 0.8 ? 'medium' : 'low';
}
// 自动启动复选框
const autoStart = document.getElementById('autoStart');
if (autoStart) {
autoStart.checked = this.config.autoStart;
}
// 运动检测复选框
const motionDetection = document.getElementById('motionDetection');
if (motionDetection) {
motionDetection.checked = this.config.enableMotionDetection || false;
}
// 自动保存复选框
const autoSave = document.getElementById('autoSave');
if (autoSave) {
autoSave.checked = this.config.autoSave !== false; // 默认为true
}
}
/**
* 隐藏设置模态框
*/
hideSettingsModal() {
console.log('[主应用] 隐藏设置模态框');
const modal = document.getElementById('settingsModal');
if (modal) {
// 添加淡出动画
modal.style.opacity = '0';
modal.style.transition = 'opacity 0.3s ease';
// 延迟隐藏,让动画完成
setTimeout(() => {
modal.classList.add('hidden');
modal.style.opacity = ''; // 清除内联样式
modal.style.transition = ''; // 清除内联样式
}, 300);
}
}
/**
* 应用设置(保存设置)
*/
applySettings() {
console.log('[主应用] 用户应用设置');
try {
// -----------------------------
// 从表单获取设置值
// -----------------------------
// 摄像头设备
const cameraDeviceSelect = document.getElementById('cameraDeviceSelect');
if (cameraDeviceSelect) {
this.selectedDeviceId = cameraDeviceSelect.value === 'default' ? null : cameraDeviceSelect.value;
console.log(`[主应用] 设置摄像头设备: ${this.selectedDeviceId || '默认'}`);
}
// 视频质量
const videoQuality = document.getElementById('videoQuality');
if (videoQuality) {
switch (videoQuality.value) {
case 'high':
this.config.videoQuality = 0.95;
break;
case 'medium':
this.config.videoQuality = 0.85;
break;
case 'low':
this.config.videoQuality = 0.75;
break;
default:
this.config.videoQuality = 0.92;
}
console.log(`[主应用] 设置视频质量: ${videoQuality.value} (${this.config.videoQuality})`);
}
// 自动启动
const autoStart = document.getElementById('autoStart');
if (autoStart) {
this.config.autoStart = autoStart.checked;
console.log(`[主应用] 设置自动启动: ${this.config.autoStart}`);
}
// 运动检测
const motionDetection = document.getElementById('motionDetection');
if (motionDetection) {
this.config.enableMotionDetection = motionDetection.checked;
console.log(`[主应用] 设置运动检测: ${this.config.enableMotionDetection}`);
// 如果启用了运动检测,可以在这里初始化相关功能
if (this.config.enableMotionDetection) {
this.initializeMotionDetection();
}
}
// 自动保存
const autoSave = document.getElementById('autoSave');
if (autoSave) {
this.config.autoSave = autoSave.checked;
console.log(`[主应用] 设置自动保存: ${this.config.autoSave}`);
}
// -----------------------------
// 保存配置
// -----------------------------
this.saveConfiguration();
// -----------------------------
// 关闭模态框并显示通知
// -----------------------------
this.hideSettingsModal();
this.showNotification('设置已保存', 'success');
} catch (error) {
console.error('[主应用] 应用设置失败:', error);
this.showNotification('保存设置失败: ' + error.message, 'error');
}
}
/**
* 初始化运动检测功能
* 注意:这是一个高级功能,需要额外的算法实现
*/
initializeMotionDetection() {
console.log('[主应用] 初始化运动检测功能');
// 这里可以添加运动检测的初始化代码
// 例如:设置画布、初始化图像处理算法等
// 由于运动检测是高级功能,这里只记录日志
console.warn('[主应用] 运动检测功能需要额外实现,当前为占位实现');
// 可以在这里添加后续的运动检测逻辑
// this.startMotionDetection();
}
/**
* 显示通知消息
* @param {string} message - 通知消息内容
* @param {string} type - 通知类型: 'success', 'error', 'warning', 'info'
*/
showNotification(message, type = 'info') {
// 如果配置中禁用了通知,则直接返回
if (!this.config.enableNotifications) {
return;
}
console.log(`[主应用] 显示通知: [${type}] ${message}`);
// 获取通知容器
const container = document.getElementById('notificationContainer');
if (!container) {
console.warn('[主应用] 未找到通知容器,跳过显示通知');
return;
}
// 确定图标和颜色
let icon, bgColor, textColor;
switch (type) {
case 'success':
icon = 'fa-check-circle';
bgColor = 'bg-green-500';
textColor = 'text-white';
break;
case 'error':
icon = 'fa-exclamation-circle';
bgColor = 'bg-red-500';
textColor = 'text-white';
break;
case 'warning':
icon = 'fa-exclamation-triangle';
bgColor = 'bg-yellow-500';
textColor = 'text-yellow-900';
break;
default: // info
icon = 'fa-info-circle';
bgColor = 'bg-blue-500';
textColor = 'text-white';
}
// 创建通知元素
const notification = document.createElement('div');
notification.className = `notification ${bgColor} ${textColor} px-4 py-3 rounded-lg shadow-lg mb-2 transform translate-x-full opacity-0 transition-all duration-300`;
notification.innerHTML = `
<div class="flex items-center">
<i class="fas ${icon} mr-2"></i>
<span>${message}</span>
</div>
`;
// 添加到容器
container.appendChild(notification);
// 触发入场动画
requestAnimationFrame(() => {
notification.classList.remove('translate-x-full', 'opacity-0');
notification.classList.add('translate-x-0', 'opacity-100');
});
// 设置自动移除
const duration = type === 'error' ? 5000 : 3000; // 错误消息显示5秒,其他3秒
setTimeout(() => {
// 触发退场动画
notification.classList.remove('translate-x-0', 'opacity-100');
notification.classList.add('translate-x-full', 'opacity-0');
// 动画完成后移除元素
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}, duration);
}
/**
* 获取应用状态信息
* @returns {Object} 应用状态对象
*/
getAppStatus() {
return {
appState: this.appState,
uptime: this.uptimeSeconds,
screenshotCount: this.screenshotManager.getCount(),
cameraStatus: this.cameraController.getStatus(),
performance: this.performance.getReport(),
config: { ...this.config } // 返回副本,避免外部修改
};
}
/**
* 销毁应用,释放资源
* 在页面卸载或应用关闭时调用
*/
destroy() {
console.log('[主应用] 销毁应用,释放资源...');
// 停止摄像头
if (this.appState === 'streaming' || this.appState === 'recording') {
this.stopCamera();
}
// 停止运行时间计数器
if (this.uptimeInterval) {
clearInterval(this.uptimeInterval);
this.uptimeInterval = null;
}
// 停止性能监控
this.performance.stopMonitoring();
// 保存配置
this.saveConfiguration();
// 清理截图管理器的临时数据
// 注意:这里不清除本地存储的数据
console.log('[主应用] 应用已销毁');
}
/**
* 调试方法:打印详细状态信息
* 用于开发调试
*/
debug() {
console.group('[主应用] 调试信息');
// 应用状态
console.log('应用状态:', this.appState);
console.log('运行时间:', this.uptimeSeconds, '秒');
// 摄像头状态
const cameraStatus = this.cameraController.getStatus();
console.log('摄像头状态:', cameraStatus);
// 截图信息
console.log('截图数量:', this.screenshotManager.getCount());
// 配置信息
console.log('应用配置:', this.config);
// 性能信息
const perfReport = this.performance.getReport();
console.log('性能报告:', perfReport);
// 视频元素状态
if (this.videoElement) {
console.log('视频元素:', {
readyState: this.videoElement.readyState,
videoWidth: this.videoElement.videoWidth,
videoHeight: this.videoElement.videoHeight,
srcObject: this.videoElement.srcObject ? '已设置' : '未设置'
});
}
console.groupEnd();
}
}
// ============================================
// 全局辅助函数
// ============================================
/**
* 检查浏览器兼容性
* @returns {Object} 兼容性检查结果
*/
function checkBrowserCompatibility() {
console.log('[全局] 检查浏览器兼容性...');
const compatibility = {
mediaDevices: !!navigator.mediaDevices,
getUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
mediaRecorder: !!window.MediaRecorder,
localstorage: !!window.localStorage,
canvas: !!window.HTMLCanvasElement,
webrtc: !!(window.RTCPeerConnection || window.webkitRTCPeerConnection)
};
// 检查所有必需功能
const required = ['mediaDevices', 'getUserMedia', 'localstorage', 'canvas'];
const missing = required.filter(feature => !compatibility[feature]);
if (missing.length > 0) {
console.error('[全局] 浏览器不兼容,缺少功能:', missing);
return {
supported: false,
missing: missing,
details: compatibility
};
}
console.log('[全局] 浏览器兼容性检查通过:', compatibility);
return {
supported: true,
details: compatibility
};
}
/**
* 初始化应用
* 页面加载完成后调用
*/
async function initializeApplication() {
console.log('[全局] 开始初始化摄像头监控应用...');
// 检查浏览器兼容性
const compatibility = checkBrowserCompatibility();
if (!compatibility.supported) {
const errorMsg = `浏览器不兼容,缺少功能: ${compatibility.missing.join(', ')}。请使用现代浏览器如 Chrome、Firefox 或 Edge。`;
console.error('[全局] ' + errorMsg);
alert(errorMsg);
return;
}
try {
// 创建应用实例
window.app = new CameraMonitorApp();
// 初始化应用
await window.app.initialize();
// 将调试方法暴露给全局
window.debugApp = () => window.app.debug();
console.log('[全局] 应用初始化完成,实例已保存到 window.app');
// 显示欢迎消息
setTimeout(() => {
if (window.app && window.app.showNotification) {
window.app.showNotification('摄像头监控系统已就绪', 'success');
}
}, 1000);
} catch (error) {
console.error('[全局] 应用初始化失败:', error);
// 显示错误信息
const errorElement = document.createElement('div');
errorElement.className = 'fixed inset-0 flex items-center justify-center bg-red-50 z-50';
errorElement.innerHTML = `
<div class="bg-white p-6 rounded-lg shadow-xl max-w-md">
<h2 class="text-xl font-bold text-red-600 mb-4">应用初始化失败</h2>
<p class="text-gray-700 mb-4">${error.message}</p>
<p class="text-sm text-gray-500 mb-4">请检查浏览器控制台获取详细信息。</p>
<button οnclick="location.reload()" class="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600">
重新加载页面
</button>
</div>
`;
document.body.appendChild(errorElement);
}
}
/**
* 页面卸载前的清理工作
*/
function handlePageUnload() {
console.log('[全局] 页面正在卸载,执行清理...');
if (window.app && typeof window.app.destroy === 'function') {
window.app.destroy();
}
}
// ============================================
// 页面事件监听器
// ============================================
// DOM内容加载完成后初始化应用
document.addEventListener('DOMContentLoaded', () => {
console.log('[全局] DOMContentLoaded 事件触发');
// 延迟初始化,确保所有DOM元素都已加载
setTimeout(initializeApplication, 100);
});
// 页面卸载前的清理
window.addEventListener('beforeunload', handlePageUnload);
// 页面可见性变化处理(标签页切换)
document.addEventListener('visibilitychange', () => {
console.log(`[全局] 页面可见性变化: ${document.visibilityState}`);
if (window.app) {
// 如果页面隐藏且摄像头正在运行,暂停视频以节省资源
if (document.hidden && window.app.appState === 'streaming') {
window.app.videoElement.pause();
}
// 如果页面恢复显示且摄像头正在运行,恢复视频播放
else if (!document.hidden && window.app.appState === 'streaming') {
window.app.videoElement.play().catch(e => {
console.warn('[全局] 恢复视频播放失败:', e);
});
}
}
});
// ============================================
// 全局错误处理
// ============================================
// 全局未捕获的Promise错误处理
window.addEventListener('unhandledrejection', (event) => {
console.error('[全局] 未处理的Promise错误:', event.reason);
// 显示错误通知
if (window.app && window.app.showNotification) {
window.app.showNotification(`发生错误: ${event.reason.message}`, 'error');
}
});
// 全局JavaScript错误处理
window.addEventListener('error', (event) => {
console.error('[全局] JavaScript错误:', event.error);
// 如果不是来自应用本身的错误,显示通知
if (window.app && window.app.showNotification &&
!event.message.includes('CameraMonitorApp')) {
window.app.showNotification('发生JavaScript错误,请查看控制台', 'error');
}
// 阻止错误冒泡到浏览器默认处理
event.preventDefault();
});
// ============================================
// 全局实用函数
// ============================================
/**
* 下载数据为文件
* @param {Blob|string} data - 要下载的数据
* @param {string} filename - 文件名
*/
window.downloadFile = function(data, filename) {
try {
// 创建Blob(如果是字符串)
const blob = typeof data === 'string' ?
new Blob([data], { type: 'application/octet-stream' }) : data;
// 创建下载URL
const url = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.href = url;
link.download = filename;
// 触发下载
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 清理URL
setTimeout(() => URL.revokeObjectURL(url), 1000);
console.log(`[全局] 文件下载完成: ${filename}`);
} catch (error) {
console.error('[全局] 下载文件失败:', error);
throw error;
}
};
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @returns {string} 格式化后的文件大小
*/
window.formatFileSize = function(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
};
/**
* 获取查询参数
* @param {string} name - 参数名
* @returns {string|null} 参数值
*/
window.getQueryParam = function(name) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(name);
};
// ============================================
// 导出模块(如果使用模块系统)
// ============================================
// 注意:这是一个直接执行的脚本文件,不是ES6模块
// 如果需要在模块系统中使用,可以取消下面的注释
/*
if (typeof module !== 'undefined' && module.exports) {
// Node.js环境
module.exports = {
CameraMonitorApp,
CameraController,
ScreenshotManager,
PerformanceMonitor,
checkBrowserCompatibility
};
} else if (typeof define === 'function' && define.amd) {
// AMD环境
define([], function() {
return {
CameraMonitorApp,
CameraController,
ScreenshotManager,
PerformanceMonitor,
checkBrowserCompatibility
};
});
}
*/
// ============================================
// 应用初始化完成标志
// ============================================
console.log('[全局] app.js 加载完成,等待DOMContentLoaded事件...');
// 立即执行的代码
(function() {
// 检查当前环境
const isLocalhost = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1';
const isHttps = window.location.protocol === 'https:';
// 如果不是localhost且不是HTTPS,显示警告
if (!isLocalhost && !isHttps) {
console.warn('[全局] 警告: 摄像头功能通常需要在HTTPS或localhost环境下工作');
// 可以在这里添加一个非侵入式的警告
setTimeout(() => {
const warning = document.createElement('div');
warning.className = 'fixed bottom-4 left-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 rounded shadow-lg max-w-sm z-40';
warning.innerHTML = `
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="ml-3">
<p class="text-sm">
摄像头功能在非HTTPS环境下可能受限。建议在localhost或HTTPS环境下使用。
</p>
</div>
</div>
`;
document.body.appendChild(warning);
// 10秒后自动移除警告
setTimeout(() => {
if (warning.parentNode) {
warning.parentNode.removeChild(warning);
}
}, 10000);
}, 2000);
}
})();
styles.css
/* 自定义样式 */
.video-container {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.btn-primary {
background: linear-gradient(45deg, #4facfe 0%, #00f2fe 100%);
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.btn-secondary {
background: #6b7280;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.3s ease;
}
.btn-secondary:hover {
background: #4b5563;
}
.screenshot-item {
transition: all 0.3s ease;
}
.screenshot-item:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.recording {
animation: pulse 1.5s infinite;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
backdrop-filter: blur(4px);
}
.modal-overlay.hidden {
display: none;
}
.modal-content {
background: white;
border-radius: 1rem;
padding: 1.5rem;
max-width: 32rem;
width: 100%;
margin: 1rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.modal-title {
font-size: 1.25rem;
font-weight: bold;
color: #1f2937;
}
.modal-close-btn {
color: #6b7280;
padding: 0.5rem;
border-radius: 0.5rem;
transition: all 0.2s ease;
}
.modal-close-btn:hover {
color: #374151;
background: #f3f4f6;
}
.modal-body {
margin-bottom: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
/* 表单样式 */
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
}
.form-select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background: white;
color: #374151;
transition: all 0.2s ease;
}
.form-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-checkbox-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-checkbox {
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
border: 1px solid #d1d5db;
background: white;
cursor: pointer;
}
.form-checkbox:checked {
background-color: #3b82f6;
border-color: #3b82f6;
}
.form-checkbox-label {
font-size: 0.875rem;
color: #374151;
cursor: pointer;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 响应式调整 */
@media (max-width: 768px) {
.modal-content {
margin: 0.5rem;
padding: 1rem;
}
.modal-footer {
flex-direction: column;
}
.modal-footer button {
width: 100%;
}
}
/* 暗色模式支持 */
@media (prefers-color-scheme: dark) {
body {
background-color: #111827;
color: #f3f4f6;
}
.modal-content {
background: #1f2937;
color: #f3f4f6;
}
.modal-title {
color: #f3f4f6;
}
.form-select {
background: #374151;
border-color: #4b5563;
color: #f3f4f6;
}
.form-checkbox {
background: #374151;
border-color: #4b5563;
}
}
/* 视频元素硬件加速优化 */
#videoPreview {
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000;
will-change: transform;
}
/* 性能统计面板 */
#performanceStats {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px 15px;
border-radius: 8px;
font-family: monospace;
font-size: 12px;
z-index: 1000;
}
.stats-item {
display: flex;
justify-content: space-between;
margin: 5px 0;
}
.stats-label {
color: #aaa;
}
.stats-value {
color: #4CAF50;
font-weight: bold;
}
.stats-value.low-frame-rate {
color: #ff5252;
}
.stats-value.good-frame-rate {
color: #4CAF50;
}
/* 通知样式优化 */
.notification {
transition: all 0.3s ease;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* 控制按钮状态 */
.control-panel button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.control-panel button.loading {
position: relative;
color: transparent !important;
}
.control-panel button.loading::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: buttonSpin 0.6s linear infinite;
}
@keyframes buttonSpin {
to { transform: rotate(360deg); }
}
代码通过四个核心类实现了清晰的功能划分,这正是高内聚、低耦合思想的体现:
-
CameraMonitorApp(主控制器):作为协调中枢,它不直接操作硬件或数据,而是初始化并管理其他三个专业模块。它处理UI事件、协调startCamera、captureScreenshot等工作流,并维护应用整体状态(appState)。这确保了业务逻辑的集中和可维护性。 -
CameraController(硬件交互层):这是与浏览器媒体API交互的唯一通道,实现了与虚拟“UVC驱动层”(浏览器封装)的对接。它封装了getUserMedia、MediaRecorder等所有底层操作,将原始视频流转化为MediaStream对象。这种设计将复杂的硬件交互细节隔离,使上层应用无需关心具体实现。 -
ScreenshotManager(数据管理层):独立负责截图数据的存取、序列化和持久化。它处理dataUrl、localStorage和ZIP打包,将数据操作逻辑从UI和硬件控制中彻底解耦。 -
PerformanceMonitor(辅助工具层):专注于性能度量,通过高精度时间API监控帧率和操作耗时,为其他模块提供诊断支持而不干扰其核心功能。
2. 视频数据流与大小分析
数据流是理解此应用的关键。视频数据从摄像头到最终文件,经历了多次形态和位置转换:
-
原始采集与封装 (
CameraController.start()):-
来源: 物理USB摄像头 (通过OS,如
/dev/video0)。 -
转换: 浏览器通过
navigator.mediaDevices.getUserMedia()获取原始视频帧。 -
形态/大小: 被封装为
MediaStream对象。数据仍在浏览器内核管理的缓冲区中,大小取决于分辨率、帧率和色彩深度(例如,1280x720 @30fps, YUV420格式,理论原始数据流约为1280*720*1.5*30 ≈ 39.6 MB/s)。
-
-
渲染与显示 (
Video元素):-
流向:
MediaStream被赋值给HTMLVideoElement.srcObject。 -
形态: 由浏览器引擎进行解码(如果是MJPEG/H264)并渲染到页面。此过程数据在GPU内存中。
-
-
帧捕获与编码 (
CameraController.captureFrame()):-
关键函数:
ctx.drawImage(this.videoElement, 0, 0); // 从视频元素拷贝帧到Canvas const dataUrl = canvas.toDataURL('image/jpeg', quality); // Canvas编码为JPEG -
形态/大小转换: GPU帧 -> Canvas位图 -> JPEG编码的Base64字符串。一张1280x720的高质量JPEG截图,
dataUrl字符串长度约为(文件字节数 * 4/3),一个100KB的图片会产生约133KB的字符串。
-
-
持久化存储 (
ScreenshotManager):-
存储:
dataUrl连同元数据(ID、时间戳、分辨率)以JSON格式存入localStorage。 -
大小限制: 受
localStorage单域名存储限制(通常5-10MB)。代码通过maxCount和只保存最近50张的策略进行自我管理。 -
导出: 使用
JSZip库,将dataUrl解码回二进制,打包成ZIP文件供下载,恢复为紧凑的二进制格式。
-
3. 与底层技术栈的关联映射
尽管这是一个Web应用,但其功能依赖于浏览器对底层操作系统能力的封装:
-
对“Web UVC驱动”的调用: 当
getUserMedia({video: true})被调用时,浏览器(以Chrome为例)会:-
通过
Linux内核 -> V4L2层枚举/dev/video*设备。 -
通过UVC驱动与摄像头协商格式、分辨率、帧率(对应
constraints对象)。 -
建立内存映射,开始将视频帧数据从内核空间读取到用户空间的浏览器进程。
-
-
对“内核V4L2框架”的间接使用: 应用中的 分辨率选择、帧率设置 最终会转化为对
MediaStream的约束,并由浏览器翻译成对V4L2的VIDIOC_S_PARM、VIDIOC_S_FMT等ioctl调用。内核中相关源码主要位于drivers/media/usb/uvc/目录。 -
数据流路径对应:
-
/dev/video0 -> V4L2缓冲区: 对应CameraController中getUserMedia()成功后的原始数据获取。 -
V4L2缓冲区 -> 用户空间: 对应浏览器内部将数据填充到MediaStream轨道。 -
用户空间 -> 渲染管线: 对应HTMLVideoElement的播放和Canvas的drawImage操作。
-
如何用于复杂应用场景
基于当前高内聚、模块化的设计,此应用可以轻松扩展至以下复杂场景:
| 应用场景 | 所需扩展模块/修改 | 设计依据与优势 |
|---|---|---|
| 多摄像头监控看板 | 创建多个CameraController实例,CameraMonitorApp统一调度,UI分屏显示。 |
控制器与UI解耦,便于实例化管理。 |
| AI图像分析(如人脸识别) | 新增AIAnalyzer类,从Canvas或直接处理Video帧,结果通过事件通知主应用。 |
数据流清晰(视频帧易于获取),不影响现有截图/录制功能。 |
| 云端录像与存储 | 新增CloudStorage类,将MediaRecorder的数据块或ScreenshotManager的图片直接上传。 |
ScreenshotManager的存储后端可插拔,MediaRecorder数据块易于拦截。 |
| 实时视频通讯(P2P) | 利用现有MediaStream,集成WebRTC的RTCPeerConnection。 |
硬件访问已封装,流可复用,只需增加信令模块。 |
| 工业质检(自动触发) | 扩展PerformanceMonitor为EventMonitor,分析帧内容,自动调用captureScreenshot。 |
事件驱动架构,功能模块可通过标准接口被触发。 |
第二部分:浏览器中间件与第三方库层分析
1. 架构位置与作用
此层位于用户态,是Web前端与内核UVC驱动之间的翻译器和协议处理器。其核心职责包括:
-
硬件抽象:将底层不同的摄像头硬件统一为Web标准API
-
协议转换:在Web API调用与Linux V4L2系统调用之间进行转换
-
数据桥接:在内核空间视频缓冲区与用户态JavaScript对象之间建立通道
2. 浏览器媒体子系统架构树形分析




3. 关键组件详细分析
3.1 WebRTC/MediaDevices API实现分解
当CameraController.getUserMedia()被调用时,浏览器内部执行以下流程:
Chromium/Blink实现路径树形分析:
media/webrtc/ (Chromium源码目录) ├── media_stream_manager.cc │ ├── MediaStreamManager::MakeMediaAccessRequest() │ │ └── 1. 权限检查 (WebRTC权限API) │ │ └── 2. 设备枚举 (从OS获取设备列表) │ │ └── 3. 约束处理 (处理width/height/frameRate参数) │ │ ├── video_capture/ │ ├── video_capture_impl.cc │ │ └── VideoCaptureImpl::StartCapture() │ │ ├── 4. 创建V4L2接口 │ │ ├── 5. 协商视频格式 │ │ └── 6. 分配视频缓冲区 │ │ └── media_stream_video_capturer_source.cc └── MediaStreamVideoCapturerSource::StartCaptureImpl() └── 7. 启动视频捕获线程
约束处理的具体实现:
// 对应 constraints 对象的处理
void ProcessVideoConstraints(const MediaConstraints& constraints) {
// 1. 解析理想值 (对应 {ideal: 1280})
int ideal_width = constraints.basic().width.ideal();
// 2. 查询设备能力
std::vector<media::VideoCaptureFormat> formats;
GetDeviceSupportedFormats(device_id, &formats);
// 3. 匹配最佳格式 (参考formatPriority)
media::VideoCaptureFormat best_format;
for (const auto& format : formats) {
// 优先匹配MJPEG/YUYV等UVC常用格式
if (format.pixel_format == media::PIXEL_FORMAT_MJPEG) {
best_format = format;
break;
}
}
// 4. 设置V4L2参数
v4l2_format v4l2_fmt = {};
v4l2_fmt.fmt.pix.width = best_format.frame_size.width();
v4l2_fmt.fmt.pix.height = best_format.frame_size.height();
v4l2_fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_MJPEG; // UVC常用
ioctl(fd, VIDIOC_S_FMT, &v4l2_fmt);
}
3.2 第三方库依赖分析
应用直接依赖的库:
第三方库依赖树:
├── Tailwind CSS (UI框架)
│ └── 仅影响表现层,无数据流影响
│
├── JSZip (ZIP打包库)
│ └── 数据流:Base64字符串 → 二进制Blob → ZIP文件
│ ├── 函数调用链:
│ │ ScreenshotManager.exportToZip()
│ │ ├── new JSZip()
│ │ ├── zip.file(filename, base64Data, {base64: true})
│ │ └── zip.generateAsync({type: 'blob'})
│ └── 内存转换:
│ Base64字符串 (约133KB/图片)
│ → Uint8Array二进制 (约100KB/图片)
│ → ZIP压缩数据 (约70-90KB/图片)
│
└── Font Awesome (图标)
└── 纯UI资源
浏览器内部间接依赖的第三方媒体库:
在Linux桌面环境,浏览器通常依赖以下多媒体框架:
浏览器媒体后端树: ├── Chromium (Linux) │ ├── 主要:V4L2直接接口 │ ├── 备选:libv4l2 (Video4Linux2兼容库) │ └── 编码:libvpx (VP8/VP9), libx264 (H.264) │ ├── Firefox (Linux) │ ├── GStreamer媒体框架 ←─┐ │ │ ├── gstreamer1.0-plugins-good │ │ │ │ ├── v4l2src (V4L2源) │ │ │ │ ├── jpegdec (MJPEG解码) │数据流关联 │ │ │ └── videoconvert (格式转换)│ │ │ └── gstreamer1.0-plugins-ugly │ │ │ └── x264enc (H.264编码) │ │ └── PulseAudio (音频) │ │ │ └── 数据流对应关系: │ MediaRecorder API调用 ↓ → GStreamer pipeline构建 ↓ → V4L2设备访问 ↓ → 内核UVC驱动 ←─┘
4. 数据缓冲区与内存管理分析
4.1 视频数据缓冲区流转
从内核到JavaScript的完整数据路径:
缓冲区流转层级树: ├── 层级1:内核空间缓冲区 (drivers/media/usb/uvc/uvc_video.c) │ ├── uvc_video.c::uvc_alloc_urb_buffers() │ │ └── 分配USB URB (USB Request Block) │ │ ├── 大小:根据UVC帧格式计算 │ │ │ ├── MJPEG: 可变大小 (按质量) │ │ │ ├── YUYV: 固定大小 = width × height × 2 │ │ │ └── H.264: 可变大小 (NAL单元) │ │ └── 数量:通常3-5个 (双/三缓冲) │ │ ├── 层级2:V4L2缓冲区 (drivers/media/v4l2-core/videobuf2-core.c) │ ├── vb2_queue_init() │ │ ├── 内存模式: │ │ │ ├── MMAP (内存映射):内核分配,用户空间映射 │ │ │ ├── USERPTR (用户指针):用户空间分配 │ │ │ └── DMABUF (DMA缓冲区):硬件加速 │ │ └── 缓冲区数量:4-10个 (避免丢帧) │ │ ├── 层级3:浏览器媒体缓冲区 │ ├── media/base/video_frame.cc (Chromium) │ │ ├── VideoFrame::WrapExternalData() │ │ │ └── 包装V4L2缓冲区数据 │ │ ├── 格式转换: │ │ │ ├── MJPEG → I420 (软件解码) │ │ │ ├── YUYV → I420 (色彩空间转换) │ │ │ └── 可启用硬件加速 │ │ └── 内存管理: │ │ ├── SharedMemory (进程间共享) │ │ └── GpuMemoryBuffer (GPU内存) │ │ └── 层级4:JavaScript对象 ├── ImageData (Canvas API) │ └── 从VideoFrame提取RGBA数据 ├── ArrayBuffer (二进制数据) │ └── 用于MediaRecorder数据块 └── dataURL (Base64编码) └── 字符串存储,内存开销约+33%
4.2 缓冲区大小计算示例
以应用默认设置(1280x720 @30fps)为例:
原始数据大小:
-
YUYV格式:
1280 × 720 × 2字节 × 30fps = 52.9 MB/s -
MJPEG格式:压缩比约10:1 →
~5.3 MB/s -
H.264格式:压缩比约50:1 →
~1.1 MB/s
浏览器内存占用:
-
解码后I420帧:
1280 × 720 × 1.5字节 = 1.35 MB/帧 -
缓冲队列(4帧):
1.35 MB × 4 = 5.4 MB -
Canvas RGBA缓冲:
1280 × 720 × 4字节 = 3.53 MB -
总计峰值:
~9-10 MB RAM
5. 性能优化机制分析
5.1 零拷贝数据路径(Zero-Copy)
现代浏览器针对视频处理优化的数据路径:
// 理想的数据流(Chromium中的实现示例)
class ZeroCopyVideoFramePool {
public:
scoped_refptr<VideoFrame> GetFrame() {
// 1. 从V4L2获取DMABUF句柄
int dmabuf_fd = v4l2_buffer.memory == V4L2_MEMORY_DMABUF ?
v4l2_buffer.m.fd : -1;
// 2. 导入到GPU内存(如果支持)
if (dmabuf_fd >= 0 && HasGpuAcceleration()) {
gpu::GpuMemoryBufferHandle gmb_handle;
gmb_handle.type = gpu::GpuMemoryBufferType::NATIVE_PIXMAP;
gmb_handle.native_pixmap_handle.planes.push_back(
gfx::NativePixmapPlane(stride, offset, dmabuf_fd));
// 3. 创建GPU纹理,避免CPU拷贝
return VideoFrame::WrapExternalGpuMemoryBuffer(...);
}
// 4. 回退到共享内存路径
return VideoFrame::WrapExternalSharedMemory(...);
}
};
5.2 硬件加速支持矩阵
| 功能 | Linux支持状态 | 使用的API/驱动 | 性能影响 |
|---|---|---|---|
| MJPEG硬件解码 | 中等(依赖Intel VA-API/NVIDIA VDPAU) | libva, VDPAU |
CPU占用降低30-50% |
| H.264硬件编码 | 良好(Intel QuickSync, NVIDIA NVENC) | VA-API, NVENC |
编码速度提升5-10倍 |
| 3D加速渲染 | 优秀(所有现代GPU) | OpenGL/WebGL | Canvas绘制提速 |
| 内存零拷贝 | 中等(需要DMABUF支持) | DRM/DMA-BUF | 内存带宽降低 |
6. 错误处理与兼容性机制
6.1 格式回退策略
当首选格式不支持时的浏览器内部处理:
// 对应CameraController.formatPriority逻辑
void NegotiateVideoFormat(int fd, const FormatPreference& prefs) {
// 1. 查询设备支持的所有格式
v4l2_fmtdesc fmt_desc = {};
fmt_desc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
std::vector<uint32_t> supported_formats;
while (ioctl(fd, VIDIOC_ENUM_FMT, &fmt_desc) == 0) {
supported_formats.push_back(fmt_desc.pixelformat);
fmt_desc.index++;
}
// 2. 按优先级尝试格式(对应formatPriority)
const uint32_t format_priority[] = {
V4L2_PIX_FMT_H264, // 首选H.264
V4L2_PIX_FMT_MJPEG, // 次选MJPEG
V4L2_PIX_FMT_YUYV, // 后备YUYV
V4L2_PIX_FMT_RGB24 // 最后RGB
};
// 3. 协商最佳匹配
for (auto preferred_fmt : format_priority) {
if (std::find(supported_formats.begin(),
supported_formats.end(),
preferred_fmt) != supported_formats.end()) {
SetFormat(fd, preferred_fmt);
return;
}
}
// 4. 无支持格式,抛出OverconstrainedError
throw OverconstrainedError("No supported video format found");
}
7. 安全与权限模型
7.1 浏览器安全层架构
浏览器摄像头安全层: ├── 权限提示层 (Permission UI) │ ├── 显示设备选择器 │ └── 请求用户明确授权 │ ├── 来源检查层 (Origin Security) │ ├── HTTPS要求 (非localhost) │ ├── 同源策略检查 │ └── 权限持久化存储 │ ├── 进程隔离层 (Sandboxing) │ ├── 媒体进程隔离 (Chromium的--no-sandbox异常) │ ├── GPU进程隔离 │ └── Seccomp过滤器限制系统调用 │ └── 硬件访问控制 ├── udev规则检查 (如/dev/video*权限) ├── SELinux/AppArmor策略 └── 用户组权限 (video组)
8. 实际部署的依赖关系
在Linux系统部署Web UVC应用时,需要的系统级依赖:
# Ubuntu/Debian 系统依赖 sudo apt-get install -y \ # 浏览器媒体后端 gstreamer1.0-plugins-good \ gstreamer1.0-plugins-ugly \ gstreamer1.0-libav \ libgstreamer-plugins-base1.0-dev \ # V4L2工具和库 v4l-utils \ libv4l-dev \ # 硬件加速支持 libva-dev \ vainfo \ # 视频编码库 libx264-dev \ libvpx-dev \ # 用户组权限 usermod -a -G video $USER
9. 性能监控与调试接口
PerformanceMonitor类可以扩展以下浏览器内部指标:
// 扩展性能监控以接入浏览器内部指标
class AdvancedPerformanceMonitor extends PerformanceMonitor {
async getMediaMetrics() {
if (!performance.mediaMetrics) return null;
// 获取浏览器内部媒体统计(实验性API)
const metrics = await performance.mediaMetrics.getMetrics();
return {
// 视频解码性能
decodeFps: metrics.video_decode_fps,
decodeLatency: metrics.video_decode_latency,
// 编码性能(录制时)
encodeFps: metrics.video_encode_fps,
encodeBitrate: metrics.video_encode_bitrate,
// 缓冲区健康度
bufferLevel: metrics.buffer_level,
bufferStarvation: metrics.buffer_starvation_count,
// 帧丢弃统计(卡顿指标)
droppedFrames: metrics.dropped_frames,
corruptedFrames: metrics.corrupted_frames
};
}
// 获取V4L2设备信息(通过扩展或本地服务)
async getV4L2Info() {
// 可通过WebSocket连接到本地后台服务获取
const response = await fetch('http://localhost:3000/v4l2-info');
return response.json();
}
}
总结:中间件层的关键作用
这一层作为桥梁,实现了以下关键转换:
-
API转换:Web标准API → Linux V4L2系统调用
-
数据格式转换:UVC设备原始数据 → 浏览器标准格式 → Canvas/WebGL可用格式
-
内存模型转换:内核DMA缓冲区 → 用户空间内存 → 进程间共享内存 → JavaScript可访问对象
-
同步机制转换:硬件中断/轮询 → 事件循环/回调 → Promise/async-await
第三部分:Linux内核UVC驱动与V4L2框架分析
1. 整体内核架构与数据流总览
2. UVC驱动详细源码分析
2.1 设备初始化和探测过程
完整探测调用链树形分析:
drivers/media/usb/uvc/ (内核UVC驱动源码) ├── uvc_driver.c │ ├── uvc_probe() ←─ USB设备插入时调用 │ │ ├── 1. 分配UVC设备结构体 │ │ │ ├── struct uvc_device *dev = kzalloc() │ │ │ ├── 大小: sizeof(struct uvc_device) ≈ 2KB │ │ │ └── 包含所有设备状态和控制信息 │ │ │ │ │ ├── 2. 解析UVC描述符 │ │ │ ├── uvc_parse_control() ←─ 关键函数 │ │ │ │ ├── 读取USB设备描述符 │ │ │ │ │ ├── 接口关联描述符(IAD) │ │ │ │ │ ├── 视频控制接口(VC) │ │ │ │ │ ├── 视频流接口(VS) │ │ │ │ │ └── 单元/终端描述符 │ │ │ │ │ │ │ │ │ ├── 构建内部表示结构 │ │ │ │ │ ├── struct uvc_entity *entity │ │ │ │ │ │ ├── 类型: UVC_ITT_CAMERA, UVC_OTT_VENDOR等 │ │ │ │ │ │ └── 大小: 每个实体约200-500字节 │ │ │ │ │ │ │ │ │ │ │ ├── struct uvc_control *ctrl │ │ │ │ │ │ ├── 支持的控制: 亮度/对比度/饱和度等 │ │ │ │ │ │ └── 大小: 每个控制约100字节 │ │ │ │ │ │ │ │ │ │ │ └── struct uvc_format_desc *format │ │ │ │ │ ├── 支持的格式: MJPEG, YUYV, H264等 │ │ │ │ │ └── 每个格式描述约50字节 │ │ │ │ │ │ │ │ │ └── 枚举所有支持的分辨率 │ │ │ │ ├── uvc_frame_uncompressed (未压缩格式) │ │ │ │ ├── uvc_frame_mjpeg (MJPEG格式) │ │ │ │ └── uvc_frame_frame_based (H264等) │ │ │ │ │ │ │ └── 建立实体链接图 │ │ │ ├── 输入终端 → 处理单元 → 输出终端 │ │ │ └── 形成视频处理管线 │ │ │ │ │ ├── 3. 初始化视频流接口 │ │ │ ├── uvc_video_init() ←─ 视频流核心初始化 │ │ │ │ ├── 初始化URB(USB Request Block)列表 │ │ │ │ │ ├── 数量: UVC_URBS (默认5个) │ │ │ │ │ ├── 每个大小: UVC_MAX_PACKETS * size │ │ │ │ │ └── 总缓冲: 通常1-4MB │ │ │ │ │ │ │ │ │ ├── 设置USB端点 │ │ │ │ │ ├── 查找批量(Bulk)或同步(Isochronous)端点 │ │ │ │ │ └── 配置端点参数 │ │ │ │ │ │ │ │ │ └── 启动URB提交循环 │ │ │ │ ├── usb_submit_urb() 异步提交 │ │ │ │ └── 建立数据流管道 │ │ │ │ │ │ ├── 4. 向V4L2注册设备 │ │ │ ├── uvc_register_video() ←─ V4L2接口注册 │ │ │ │ ├── video_register_device() ←─ 创建/dev/videoX │ │ │ │ │ ├── 分配次设备号 │ │ │ │ │ ├── 创建设备节点 │ │ │ │ │ └── 设置文件操作结构 │ │ │ │ │ │ │ │ │ ├── 设置V4L2能力标志 │ │ │ │ │ ├── V4L2_CAP_VIDEO_CAPTURE │ │ │ │ │ ├── V4L2_CAP_STREAMING │ │ │ │ │ └── V4L2_CAP_DEVICE_CAPS │ │ │ │ │ │ │ │ │ └── 填充v4l2_file_operations │ │ │ │ ├── .open = uvc_v4l2_open │ │ │ │ ├── .release = uvc_v4l2_release │ │ │ │ ├── .ioctl = video_ioctl2 │ │ │ │ └── .poll = v4l2_poll │ │ │ │ │ │ └── 5. 创建设备控制接口 │ │ ├── uvc_ctrl_init() ←─ 控制接口初始化 │ │ │ ├── 创建sysfs属性文件 │ │ │ │ ├── /sys/class/video4linux/videoX/ │ │ │ │ └── 暴露控制参数 │ │ │ │ │ │ │ ├── 初始化控制映射表 │ │ │ │ ├── V4L2_CID_BRIGHTNESS → UVC_PU_BRIGHTNESS_CONTROL │ │ │ │ ├── V4L2_CID_CONTRAST → UVC_PU_CONTRAST_CONTROL │ │ │ │ └── 建立控制传递通道 │ │ │ │ │ │ │ └── 设置控制回调函数 │ │ │ ├── .s_ctrl = uvc_ctrl_set_ctrl │ │ │ └── .g_ctrl = uvc_ctrl_get_ctrl │ │ │ └── uvc_video.c (视频数据处理核心) └── uvc_video_decode() ←─ 数据解码函数
2.2 视频数据流处理核心
uvc_video.c 关键函数详细分析:
/* drivers/media/usb/uvc/uvc_video.c - 核心数据流处理 */
/*
* uvc_video_complete() - URB完成回调函数
* @urb: 完成的USB请求块
*
* 这是视频数据流的心脏,每个URB传输完成后被USB核心调用
* 函数逐行注解:
*/
static void uvc_video_complete(struct urb *urb)
{
struct uvc_video_queue *queue = urb->context;
struct uvc_buffer *buf;
unsigned long flags;
/* 1. 获取当前流的状态 */
spin_lock_irqsave(&queue->irqlock, flags);
/* 2. 检查URB状态: 成功、错误或取消 */
switch (urb->status) {
case 0: /* 成功传输 */
/* 3. 从队列获取当前活动缓冲区 */
buf = uvc_queue_get_current_buffer(queue);
if (likely(buf != NULL)) {
/* 4. 计算接收到的数据大小 */
unsigned int len = urb->actual_length;
/* 5. 处理视频数据包 */
uvc_video_decode_data(uvc, buf, urb->transfer_buffer, len);
/* 6. 检查帧是否完成 */
if (uvc->completed_size >= uvc->frame_size) {
/* 7. 帧完成,标记缓冲区就绪 */
buf->state = UVC_BUF_STATE_READY;
buf->bytesused = uvc->completed_size;
/* 8. 唤醒等待数据的进程 */
wake_up(&buf->wait);
/* 9. 准备下一个缓冲区 */
uvc_queue_next_buffer(queue, buf);
uvc->completed_size = 0;
}
}
break;
case -ESHUTDOWN: /* 设备断开 */
case -ENODEV:
case -EPROTO: /* 协议错误 */
/* 处理错误状态 */
uvc_queue_cancel(queue, urb->status == -ESHUTDOWN);
break;
}
/* 10. 重新提交URB以继续数据流 */
if (queue->streaming && !queue->flags & UVC_QUEUE_DISCONNECTED) {
urb->interval = uvc->streaming_ep->desc.bInterval;
usb_submit_urb(urb, GFP_ATOMIC);
}
spin_unlock_irqrestore(&queue->irqlock, flags);
}
/*
* uvc_video_decode_data() - 解码视频数据
* @uvc: UVC设备实例
* @buf: 目标V4L2缓冲区
* @data: 原始USB数据
* @len: 数据长度
*
* 处理不同格式的视频数据解码
*/
static void uvc_video_decode_data(struct uvc_device *uvc,
struct uvc_buffer *buf, const u8 *data, int len)
{
/* 根据格式选择解码器 */
switch (uvc->streaming->format) {
case UVC_FORMAT_MJPEG:
/* MJPEG格式处理 */
uvc_video_decode_mjpeg(uvc, buf, data, len);
break;
case UVC_FORMAT_UNCOMPRESSED:
/* 未压缩格式(YUYV, RGB等) */
uvc_video_decode_uncompressed(uvc, buf, data, len);
break;
case UVC_FORMAT_H264:
/* H.264格式处理 */
uvc_video_decode_h264(uvc, buf, data, len);
break;
}
}
/*
* uvc_video_decode_mjpeg() - MJPEG解码函数
* 这是最常见UVC摄像头的格式
*/
static void uvc_video_decode_mjpeg(struct uvc_device *uvc,
struct uvc_buffer *buf, const u8 *data, int len)
{
/* 1. 检查JPEG帧头 */
if (len < 2 || data[0] != 0xff || data[1] != 0xd8) {
uvc_trace(UVC_TRACE_FRAME, "Invalid JPEG header\n");
return;
}
/* 2. 查找帧尾(0xff 0xd9) */
const u8 *end = data + len;
const u8 *sof = memchr(data, 0xff, len);
/* 3. 复制数据到V4L2缓冲区 */
memcpy(buf->mem + buf->bytesused, data, len);
buf->bytesused += len;
/* 4. 检查SOF(帧开始)标记 */
if (sof && sof + 1 < end && sof[1] == 0xc0) {
/* 5. 解析JPEG帧头获取分辨率 */
uvc_parse_jpeg_header(uvc, sof);
}
}
3. V4L2核心框架分析
3.1 V4L2 ioctl调用树形分析
drivers/media/v4l2-core/ (V4L2核心源码) ├── v4l2-ioctl.c (用户空间接口核心) │ ├── video_ioctl2() ←─ 所有V4L2 ioctl的入口 │ │ ├── 1. 参数验证和复制 │ │ │ ├── copy_from_user() / copy_to_user() │ │ │ └── 安全检查 │ │ │ │ │ ├── 2. 分发到具体处理函数 │ │ │ ├── 命令分类: VIDIOC_xxx │ │ │ └── 根据文件操作结构调用 │ │ │ │ │ └── 3. 调用驱动注册的处理函数 │ │ ├── vdev->ioctl_ops->xxx() │ │ └── 对应UVC驱动的操作 │ │ │ └── v4l_ioctl_handler[] (ioctl处理程序表) │ ├── [VIDIOC_QUERYCAP] = v4l_querycap │ ├── [VIDIOC_ENUM_FMT] = v4l_enum_fmt │ ├── [VIDIOC_S_FMT] = v4l_s_fmt │ ├── [VIDIOC_G_FMT] = v4l_g_fmt │ ├── [VIDIOC_REQBUFS] = v4l_reqbufs │ ├── [VIDIOC_QUERYBUF] = v4l_querybuf │ ├── [VIDIOC_QBUF] = v4l_qbuf │ ├── [VIDIOC_DQBUF] = v4l_dqbuf │ ├── [VIDIOC_STREAMON] = v4l_streamon │ └── [VIDIOC_STREAMOFF] = v4l_streamoff │ ├── v4l2-dev.c (设备管理) │ └── v4l2_open() / v4l2_release() │ ├── 打开设备时调用驱动open │ └── 关闭时清理资源 │ └── videobuf2-core.c (缓冲区管理) └── 四种内存模型实现: ├── 1. videobuf2-vmalloc.c │ ├── vmalloc分配器 │ └── 小缓冲区,简单应用 │ ├── 2. videobuf2-dma-contig.c │ ├── 连续DMA内存 │ └── 硬件编解码要求 │ ├── 3. videobuf2-dma-sg.c │ ├── 分散/聚集DMA │ └── 大缓冲区,高性能 │ └── 4. videobuf2-v4l2.c ├── V4L2兼容层 └── 用户空间接口
3.2 关键ioctl流程详述
VIDIOC_S_FMT (设置格式) - 对应分辨率设置:
/* 用户空间调用ioctl(fd, VIDIOC_S_FMT, &format)时的内核路径 */
static int v4l_s_fmt(const struct v4l2_ioctl_ops *ops,
struct file *file, void *fh, void *arg)
{
struct v4l2_format *fmt = arg;
struct video_device *vdev = video_devdata(file);
int ret;
/* 1. 格式参数验证 */
ret = v4l_enforce_validation(vdev, fmt);
if (ret)
return ret;
/* 2. 调用驱动特定的s_fmt函数 */
if (ops->vidioc_s_fmt_vid_cap) {
ret = ops->vidioc_s_fmt_vid_cap(file, fh, fmt);
} else if (ops->vidioc_s_fmt) {
ret = ops->vidioc_s_fmt(file, fh, fmt);
}
/* 3. UVC驱动的具体实现 (drivers/media/usb/uvc/uvc_v4l2.c) */
static int uvc_v4l2_set_format(struct file *file, void *fh,
struct v4l2_format *fmt)
{
struct uvc_streaming *stream = video_drvdata(file);
struct uvc_format *format;
struct uvc_frame *frame;
unsigned int i;
/* 3.1 查找请求的格式 */
format = uvc_format_by_guid(stream,
fmt->fmt.pix.pixelformat);
if (!format)
return -EINVAL;
/* 3.2 查找支持的分辨率 */
for (i = 0; i < format->nframes; ++i) {
frame = &format->frame[i];
if (frame->wWidth == fmt->fmt.pix.width &&
frame->wHeight == fmt->fmt.pix.height)
break;
}
if (i == format->nframes)
return -EINVAL;
/* 3.3 设置格式到硬件 */
return uvc_video_set_format(stream, format, frame);
}
/* 4. 更新内部状态 */
vdev->current_format = *fmt;
return ret;
}
VIDIOC_REQBUFS (请求缓冲区) - 内存分配关键:
/* 用户空间调用ioctl(fd, VIDIOC_REQBUFS, &reqbufs) */
static int v4l_reqbufs(const struct v4l2_ioctl_ops *ops,
struct file *file, void *fh, void *arg)
{
struct v4l2_requestbuffers *reqbufs = arg;
struct vb2_queue *q;
int ret;
/* 1. 获取视频缓冲区队列 */
q = v4l2_get_vb2_queue(file);
/* 2. 验证参数 */
if (reqbufs->type != V4L2_BUF_TYPE_VIDEO_CAPTURE)
return -EINVAL;
/* 3. 设置缓冲区内存类型 */
switch (reqbufs->memory) {
case V4L2_MEMORY_MMAP:
q->memory = VB2_MEMORY_MMAP;
break;
case V4L2_MEMORY_USERPTR:
q->memory = VB2_MEMORY_USERPTR;
break;
case V4L2_MEMORY_DMABUF:
q->memory = VB2_MEMORY_DMABUF;
break;
default:
return -EINVAL;
}
/* 4. 分配缓冲区 (关键步骤) */
ret = vb2_reqbufs(q, reqbufs);
/* 5. UVC驱动的具体实现 */
static int uvc_request_buffers(struct uvc_video_queue *queue,
struct v4l2_requestbuffers *rb)
{
unsigned int size;
/* 5.1 计算每个缓冲区大小 */
size = queue->format->framesize.width *
queue->format->framesize.height *
queue->format->bpp;
/* 5.2 调用videobuf2分配 */
return vb2_reqbufs(&queue->queue, rb);
}
return ret;
}
4. 内存映射(MMAP)机制分析
从用户空间mmap到内核缓冲区的完整路径:
用户空间mmap(/dev/video0)调用链: ├── 1. 用户空间调用: mmap(addr, length, prot, flags, fd, offset) │ └── 进入内核: SYSCALL_DEFINE6(mmap_pgoff, ...) │ ├── 2. 文件系统层处理 │ └── video_fops.mmap = uvc_v4l2_mmap │ ├── 3. V4L2通用mmap处理 (v4l2_mmap) │ ├── 获取vb2_queue结构 │ ├── 验证映射参数 │ └── 调用vb2_mmap │ ├── 4. videobuf2 mmap实现 (vb2_mmap) │ ├── 根据缓冲区索引查找vb2_buffer │ ├── 调用内存分配器的mmap方法 │ └── 建立页表映射 │ ├── 5. 具体分配器实现 (以vmalloc为例) │ └── vb2_vmalloc_mmap() │ ├── 获取vmalloc分配的虚拟地址 │ ├── remap_vmalloc_range() ←─ 关键函数 │ └── 建立用户空间到内核缓冲区的直接映射 │ └── 6. 映射完成 ├── 用户空间获得缓冲区的直接指针 └── 零拷贝数据访问
关键的内存映射实现代码:
/* drivers/media/common/videobuf2/videobuf2-vmalloc.c */
static int vb2_vmalloc_mmap(void *buf_priv, struct vm_area_struct *vma)
{
struct vb2_vmalloc_buf *buf = buf_priv;
/* 1. 验证映射参数 */
if (vma->vm_end - vma->vm_start > buf->size) {
pr_err("mmap size exceeds buffer size\n");
return -EINVAL;
}
/* 2. 设置VMA标志 */
vma->vm_ops = &vb2_common_vm_ops;
vma->vm_private_data = buf;
vma->vm_flags |= VM_DONTEXPAND | VM_DONTDUMP;
/* 3. 建立映射 - 关键步骤 */
if (remap_vmalloc_range(vma, buf->vaddr, 0)) {
pr_err("remap_vmalloc_range failed\n");
return -EAGAIN;
}
/* 4. 增加缓冲区引用计数 */
atomic_inc(&buf->refcount);
return 0;
}
/* remap_vmalloc_range内部实现 (mm/vmalloc.c) */
int remap_vmalloc_range(struct vm_area_struct *vma, void *addr,
unsigned long pgoff)
{
/* 将vmalloc分配的内核虚拟地址映射到用户空间 */
unsigned long uaddr = (unsigned long)vma->vm_start;
unsigned long usize = vma->vm_end - vma->vm_start;
/* 遍历页表,建立映射 */
for (; usize > 0; uaddr += PAGE_SIZE, addr += PAGE_SIZE,
usize -= PAGE_SIZE, pgoff++) {
/* 获取内核页帧号 */
pfn = vmalloc_to_pfn(addr);
/* 映射到用户空间 */
err = vm_insert_page(vma, uaddr, pfn_to_page(pfn));
if (err)
return err;
}
return 0;
}
5. 视频数据缓冲区在内核中的完整生命周期
/* 缓冲区状态机与数据流 */
enum vb2_buffer_state {
VB2_BUF_STATE_DEQUEUED, /* 缓冲区在用户空间 */
VB2_BUF_STATE_PREPARING, /* 正在准备 */
VB2_BUF_STATE_QUEUED, /* 已排队等待填充 */
VB2_BUF_STATE_ACTIVE, /* 正在被硬件使用 */
VB2_BUF_STATE_DONE, /* 数据就绪,等待读取 */
VB2_BUF_STATE_ERROR, /* 错误状态 */
};
/* 缓冲区流转过程:
*
* 1. REQBUFS: 分配N个缓冲区 (状态: DEQUEUED)
* ├── 每个缓冲区大小 = width × height × bpp
* └── 总内存 = N × 缓冲区大小
*
* 2. QBUF: 用户空间将缓冲区提交给驱动 (状态: QUEUED)
* ├── 缓冲区加入硬件队列
* └── 等待数据填充
*
* 3. 硬件填充数据 (状态: ACTIVE)
* ├── UVC驱动接收USB数据
* ├── 解码视频帧
* └── 写入缓冲区
*
* 4. 数据就绪 (状态: DONE)
* ├── 设置bytesused字段
* └── 触发可读事件
*
* 5. DQBUF: 用户空间读取数据 (状态: DEQUEUED)
* ├── 复制数据或通过mmap直接访问
* └── 缓冲区返回用户空间控制
*
* 6. 循环2-5,形成环形缓冲区
*/
6. 性能关键指标与优化点
6.1 内核空间性能计数器
/* 可监控的内核性能指标 */
struct uvc_perf_stats {
/* 数据流统计 */
unsigned long frames_received; /* 接收的总帧数 */
unsigned long frames_completed; /* 完整处理的帧数 */
unsigned long frames_dropped; /* 丢弃的帧数 */
/* 时间统计 */
ktime_t last_frame_time; /* 上一帧时间 */
u64 avg_frame_interval; /* 平均帧间隔(ns) */
/* 缓冲区统计 */
unsigned int buffer_hit_count; /* 缓冲区命中次数 */
unsigned int buffer_miss_count; /* 缓冲区未命中次数 */
/* USB传输统计 */
unsigned long urb_submit_count; /* URB提交次数 */
unsigned long urb_complete_count; /* URB完成次数 */
unsigned long urb_error_count; /* URB错误次数 */
/* DMA统计 */
dma_addr_t dma_alloc_size; /* DMA分配的总大小 */
unsigned int dma_map_count; /* DMA映射次数 */
};
/* 通过debugfs暴露统计信息 */
static int uvc_debugfs_stats_show(struct seq_file *s, void *v)
{
struct uvc_device *uvc = s->private;
seq_printf(s, "UVC Performance Statistics:\n");
seq_printf(s, " Frames received: %lu\n",
uvc->stats.frames_received);
seq_printf(s, " Frames completed: %lu\n",
uvc->stats.frames_completed);
seq_printf(s, " Frame drop rate: %.2f%%\n",
(uvc->stats.frames_dropped * 100.0) /
max(uvc->stats.frames_received, 1UL));
seq_printf(s, " Average FPS: %.2f\n",
1e9 / max(uvc->stats.avg_frame_interval, 1ULL));
seq_printf(s, " Buffer hit ratio: %.2f%%\n",
(uvc->stats.buffer_hit_count * 100.0) /
max(uvc->stats.buffer_hit_count +
uvc->stats.buffer_miss_count, 1UL));
return 0;
}
6.2 内核优化配置参数
# 针对UVC视频流优化的内核启动参数 # 添加到 /etc/default/grub 的 GRUB_CMDLINE_LINUX # USB相关优化 usbcore.autosuspend=-1 # 禁用USB自动挂起 usbcore.usbfs_memory_mb=256 # 增加USB文件系统内存 usbhid.mousepoll=0 # 减少USB HID轮询干扰 # 内存和调度优化 vm.dirty_ratio=10 # 减少脏页比例 vm.dirty_background_ratio=5 # 后台脏页比例 vm.swappiness=10 # 减少交换倾向 # 网络和文件系统优化(如果涉及网络传输) net.core.rmem_max=26214400 # 增加接收缓冲区 net.core.wmem_max=26214400 # 增加发送缓冲区 # 实时性优化(对低延迟重要) kernel.sched_rt_runtime_us=950000 kernel.sched_rt_period_us=1000000
7. 故障排查与调试接口
7.1 内核调试接口使用
# 1. 查看UVC设备信息 cat /sys/kernel/debug/uvcvideo/*/status # 2. 监控V4L2缓冲区状态 v4l2-ctl --device=/dev/video0 --list-formats v4l2-ctl --device=/dev/video0 --get-fmt-video v4l2-ctl --device=/dev/video0 --get-input v4l2-ctl --device=/dev/video0 --set-fmt-video=width=1280,height=720 # 3. USB传输监控 cat /sys/kernel/debug/usb/devices # 查看USB设备树 lsusb -v -d 046d:0825 # 查看特定设备详细信息 cat /sys/kernel/debug/usb/uvcvideo/*/streaming # UVC流状态 # 4. 性能事件追踪 perf record -e 'uvc:*' -a # 追踪UVC事件 perf record -e 'sched:*' -a # 追踪调度事件 perf record -e 'irq:*' -a # 追踪中断事件 # 5. Ftrace内核函数追踪 echo 1 > /sys/kernel/debug/tracing/events/uvc/enable echo function_graph > /sys/kernel/debug/tracing/current_tracer echo uvc_video_complete > /sys/kernel/debug/tracing/set_graph_function cat /sys/kernel/debug/tracing/trace_pipe # 查看实时追踪
7.2 内核日志解析
# 典型的内核日志消息及其含义 dmesg | grep -i uvc # 正常初始化日志 [ 1.234567] uvcvideo: Found UVC 1.00 device Webcam C930e (046d:0825) [ 1.234568] uvcvideo: UVC non compliance - GET_DEF(PROBE) not supported [ 1.234569] uvcvideo: 2 video input terminals found [ 1.234570] uvcvideo: 3 video output terminals found [ 1.234571] uvcvideo: 1 camera processing unit found [ 1.234572] input: UVC Camera (046d:0825) as /devices/.../input/input15 # 流开始日志 [ 5.678901] uvcvideo: 4 video streaming interfaces found [ 5.678902] uvcvideo: Streaming with bandwidth 49152 bytes/interval [ 5.678903] uvcvideo: 6 supported frame sizes for MJPEG [ 5.678904] uvcvideo: 1280x720 selected # 错误日志 [ 10.123456] uvcvideo: Failed to submit URB (-ENOMEM) [ 10.123457] uvcvideo: Frame 42 too small (1024 bytes) [ 10.123458] uvcvideo: VS request failed (-71)
8. 硬件特定优化(不同UVC芯片组)
不同UVC摄像头芯片的内核驱动特性:
| 芯片组 | 驱动模块 | 特性支持 | 优化参数 |
|---|---|---|---|
| Realtek | uvcvideo通用 | 基础UVC 1.0 | 默认参数即可 |
| Sunplus | uvcvideo + 补丁 | 可能需要quirks | 设置uvcvideo.quirks=128 |
| Microdia | uvcvideo + gspca | 老式摄像头 | 优先使用gspca驱动 |
| Sonix | sn9c20x专用驱动 | 较高性能 | 启用DMA缓冲 |
| Logitech | uvcvideo + leds | LED控制支持 | 自动曝光优化 |
总结:内核层的关键作用与性能瓶颈
通过深入内核源码分析,可以看到:
-
数据路径优化:从USB中断到用户空间mmap的完整零拷贝路径已高度优化
-
内存管理复杂性:V4L2的四种内存模型适应不同硬件能力
-
实时性挑战:USB传输的异步性与视频流的同步性需要精细平衡
-
硬件多样性:UVC标准的灵活性导致不同设备的quirks处理
常见性能瓶颈点:
-
USB带宽不足(特别是USB2.0上的高清视频)
-
内核-用户空间上下文切换开销
-
内存复制操作(如果未使用零拷贝)
-
调度延迟导致帧丢失
优化建议:
-
优先使用MMAP内存模型
-
适当增加缓冲区数量(4-8个)
-
确保使用USB3.0端口
-
调整内核调度参数以减少延迟
第四部分:运行与调试
一:立即启动本地服务器并测试
1. 启动一个简易的Web服务器: 在终端项目目录下,运行以下命令:
python3 -m http.server 8080
看到输出 Serving HTTP on 0.0.0.0 port 8080 ... 说明服务器已启动。
2. 打开浏览器测试: 保持终端运行,打开Chrome或Firefox,访问:
http://localhost:8080
二:工作流程树形解析
【用户执行命令】python3 -m http.server 8080
├─ 【Python解释器】加载并执行 `http.server` 模块
│ ├─ 【开源库:socketserver】创建网络服务器框架
│ │ └─ 调用【Linux系统调用:socket()】创建监听套接字(AF_INET, SOCK_STREAM)
│ ├─ 调用【Linux系统调用:bind()】将套接字绑定到 0.0.0.0:8080
│ ├─ 调用【Linux系统调用:listen()】开始监听连接
│ └─ 进入循环,调用【Linux系统调用:accept()】等待客户端连接
│
├─ 【用户操作】浏览器访问 http://localhost:8080
│ ├─ 【浏览器】解析URL,向操作系统发起对 `localhost:8080` 的TCP连接
│ │ └─ 触发【Linux系统调用:connect()】
│ ├─ 【Python http.server】`accept()` 返回,建立连接
│ │ ├─ 创建新线程/进程处理请求(`ThreadingMixIn`)
│ │ ├─ 调用【Linux系统调用:recv()】读取HTTP请求头(GET / HTTP/1.1 ...)
│ │ ├─ 解析请求,定位到 `/home/tang/.../index.html` 文件
│ │ │ └─ 调用【Linux系统调用:open()、read()】读取文件内容
│ │ ├─ 构建HTTP响应头(Content-Type: text/html)
│ │ └─ 调用【Linux系统调用:send()】将响应头和文件内容发回浏览器
│ └─ 【浏览器】接收数据,解析HTML,开始渲染页面
│
├─ 【页面加载】浏览器解析到 <video id="videoPreview">
│ └─ 创建空的HTMLMediaElement对象
│
├─ 【页面加载】浏览器解析到 <script src="app.js">
│ └─ 再次向 `http://localhost:8080` 发起HTTP请求获取JS文件
│ ├─ (重复上述 socket/recv/send 过程)
│ └─ 获取后执行JavaScript代码
│
├─ 【应用初始化】执行 app.js 中的 CameraMonitorApp 类构造函数
│ ├─ 初始化 CameraController、ScreenshotManager
│ ├─ 调用 initializeEventListeners() 绑定按钮事件
│ │ └─ 【开源库:浏览器DOM API】document.getElementById(...).addEventListener(...)
│ └─ 至此,静态页面变为可交互应用
│
└─ 【核心交互】用户点击“启动摄像头”按钮
├─ 触发绑定的 onclick 事件,调用 app.startCamera()
├─ CameraController.startCamera() 执行
│ ├─ 调用【浏览器Web API】navigator.mediaDevices.getUserMedia({ video: {...} })
│ │ ├─ 【浏览器内部】检查页面来源(localhost)安全性(安全上下文)
│ │ ├─ 弹出摄像头权限请求窗口(用户点击“允许”)
│ │ └─ 【浏览器】通过底层进程与操作系统交互
│ │ ├─ 【Linux桌面环境(如GNOME)】可能介入权限管理(通过Portals等)
│ │ └─ 最终,浏览器进程调用【Linux系统调用:ioctl()】
│ │ └─ 与 /dev/video0 设备通信,指令为 V4L2(Video4Linux2)协议
│ ├─ 浏览器获取到视频流(MediaStream对象)
│ └─ 将流赋值给 videoElement.srcObject
│ └─ 【浏览器渲染引擎】解码MJPG/YUYV帧,逐帧绘制到 <video> 标签区域
└─ 用户看到实时视频,调试成功。
-
V4L2 (Video for Linux 2)
-
角色:Linux内核的视频捕获框架。
-
工作原理:当浏览器(通过
ioctl)与/dev/video0交互时,内核的V4L2子系统将调用对应的摄像头驱动(很可能是uvcvideo,一个开源的内核驱动模块,用于支持USB Video Class标准摄像头)。驱动通过USB总线控制硬件,采集压缩的MJPG或原始的YUYV数据,再通过V4L2框架返回给用户空间(浏览器)。之前运行的v4l2-ctl正是与这一层交互的用户空间工具。
-
-
浏览器引擎(以Chromium为例)
-
角色:整个Web应用的运行容器和技术栈整合者。
-
工作原理:
-
网络栈:处理HTTP(S)请求,使用类似
libcurl的开源库。 -
渲染引擎(Blink):解析HTML/CSS,布局页面。
-
JavaScript引擎(V8):执行
app.js。 -
WebRTC/MediaStream:这是实现
getUserMediaAPI的关键内部组件。它作为中间层,将标准化的JavaScript API调用,翻译成对特定操作系统(Linux)媒体子系统的调用。
-
-
-
Linux桌面环境的门户(Portal)系统
-
角色:在沙盒化应用(如Flatpak Snap或某些浏览器)中统一管理权限。
-
工作原理:当浏览器运行在沙盒中时,它不能直接访问
/dev/video0。此时,它会通过D-Bus向xdg-desktop-portal等服务发起请求,该服务会弹出系统的权限对话框,并在用户授权后,通过一个特殊的文件描述符将摄像头访问权“管道”传递给浏览器。这是一个重要的抽象层。
-
三: 问题诊断的实际应用
理解了这个树形流程,“按键无反应”问题可能发生在从下至上的多个环节:
-
叶子节点问题(用户层):
app.js代码中getUserMedia的约束条件(constraints)与摄像头能力不匹配(但根据v4l2-ctl的输出,大概率是匹配的)。 -
分支节点问题(权限/沙盒):浏览器或系统门户没有弹出权限请求,或请求被阻止。
-
树干问题(驱动/内核):已经用
v4l2-ctl和cheese排除了这一层。
因此,当前最有效的调试步骤,依然是回到浏览器控制台(Console),执行上一步讨论的诊断命令。这相当于从树顶(JavaScript代码) 直接探测到哪个分支节点出现了阻塞。当执行 navigator.mediaDevices.getUserMedia(...).catch(e => console.error(e)) 时,捕获的 e.name 和 e.message 就是系统反馈、关于哪个环节失败的精确错误码。
如果在之前的操作中已经看到了具体的错误信息(例如 NotAllowedError 或 NotFoundError),结合这个树形图,就能立刻明白问题对应在哪一层,以及该如何解决。请尝试并提供错误信息,可以进行最精确的定位。
四:运行结果

第四部分:拓展HTML/JS/CSS相互调用树形分析与Web本地服务架构
1. HTML/JS/CSS调用关系深度树形分析
1.1 应用启动阶段的硬核调用链
应用初始化调用树(冷启动 - DOMContentLoaded事件触发):
┌─ index.html (骨架加载)
│ ├── 同步加载: <script src="https://cdn.tailwindcss.com"></script>
│ │ └── 立即执行: tailwindcss 解析并注入 3MB+ 的CSS规则到内存
│ │ └── 生成CSSOM树: 约8000+个CSS规则类
│ │
│ ├── 同步加载: <link rel="stylesheet" href="https://cdnjs.cloudflare.com...">
│ │ └── Font Awesome CSS: 添加1800+图标类到CSSOM
│ │
│ ├── 同步加载: <link rel="stylesheet" href="styles.css">
│ │ └── 自定义CSS: 注入关键动画和布局规则
│ │ ├── 关键帧: @keyframes pulse, @keyframes slideIn
│ │ ├── GPU加速类: transform: translateZ(0)
│ │ └── 模态框过渡: transition: all 0.3s ease
│ │
│ └── 异步加载: <script src="app.js" type="module"></script> ←─ 关键!type="module"
│ ├── ES6模块特性: 延迟执行,在DOM解析后执行
│ ├── 依赖解析: 无import语句,因此立即执行主体代码
│ └── 执行入口: 底部注释"app.js加载完成,等待DOMContentLoaded事件..."
│
├─ DOMContentLoaded 事件触发 (约100ms后)
│ └── 主初始化流程: initializeApplication()
│ ├── 1. 浏览器兼容性核验: checkBrowserCompatibility()
│ │ ├── 硬性检查: mediaDevices, getUserMedia, localStorage, canvas
│ │ └── 软性检查: MediaRecorder, WebRTC (仅警告)
│ │
│ ├── 2. 创建应用核弹头: window.app = new CameraMonitorApp()
│ │ ├── 构造函数调用链:
│ │ │ ├── new PerformanceMonitor() ←─ 性能监控启动
│ │ │ ├── new ScreenshotManager() ←─ 立即加载localStorage
│ │ │ │ └── loadFromStorage(): 读取camera_screenshots_v2键
│ │ │ │ ├── JSON.parse可能阻塞: 大数据量时主线程卡顿
│ │ │ │ └── 内存分配: 每个截图约 (width*height*0.75 + 500)字节
│ │ │ │
│ │ │ ├── new CameraController(videoElement) ←─ 视频核心初始化
│ │ │ │ └── 设置formatPriority: 硬编码分辨率优先级
│ │ │ │
│ │ │ └── 应用状态初始化: appState = 'idle'
│ │ │
│ │ └── 配置初始化: config = {...}
│ │
│ ├── 3. 完整初始化链: await app.initialize()
│ │ └── 7步初始化过程(同步阻塞链):
│ │ ├── 1. loadConfiguration() ←─ 阻塞读取localStorage
│ │ ├── 2. initializeUIComponents() ←─ 事件监听器绑定风暴
│ │ │ ├── 14个addEventListener同步绑定
│ │ │ ├── 8个DOM元素属性直接设置
│ │ │ └── 潜在性能炸弹: 如果DOM元素不存在则静默失败
│ │ │
│ │ ├── 3. updateDeviceList() ←─ 异步但await阻塞
│ │ │ ├── navigator.mediaDevices.getUserMedia({video:true}) ←─ 权限弹窗!
│ │ │ └── navigator.mediaDevices.enumerateDevices()
│ │ │
│ │ ├── 4. loadScreenshots() ←─ 二次读取localStorage
│ │ ├── 5. restoreAppState() ←─ 配置检查
│ │ ├── 6. performance.startMonitoring() ←─ 启动2秒轮询定时器
│ │ └── 7. startUptimeCounter() ←─ 启动1秒轮询定时器
│ │
│ └── 4. 全局暴露: window.debugApp = () => window.app.debug()
│
└─ window.onload 事件 (所有资源加载完成)
└── 此时视频流尚未启动,除非autoStart为true
1.2 事件驱动的硬核调用网分析
按钮点击事件调用网(startBtn点击为例):
┌─ DOM事件捕获阶段 (从window向下)
│ └── 无监听器
│
├─ 目标阶段 (startBtn元素)
│ └── startBtn.addEventListener('click', () => this.startCamera())
│ ├── this绑定: 箭头函数确保this指向CameraMonitorApp实例
│ └── 闭包捕获: 完整app上下文被闭包引用
│
└─ 事件冒泡阶段 (向上到document)
└── 无监听器
startCamera()执行调用链(硬核剖析):
1. 状态检查: if (this.appState === 'streaming') → return
2. 状态设置: this.appState = 'starting'
3. UI状态更新: this.updateUIState() ←─ 触发DOM重排/重绘风暴
├── 4个按钮样式修改: display, disabled, innerHTML
├── 状态覆盖层更新: innerHTML替换
├── 录制指示器: classList.add/remove('hidden')
└── 统计信息更新: textContent修改
4. 分辨率解析: const [w, h] = resolution.split('x').map(Number)
└── 可能异常: 如果resolution格式错误则w/h为NaN
5. 构建选项: options对象创建
├── deviceId: this.selectedDeviceId (可能为null)
├── width/height: 解析后的数值
└── frameRate: this.config.defaultFrameRate
6. 调用摄像头控制器: await this.cameraController.start(options)
├── 内部调用: this.cameraController.start() ←─ 真正的硬件交互
│ ├── this.buildConstraints(options) ←─ 转换为MediaStreamConstraints
│ │ └── 输出: { video: { deviceId: {exact: id}, width: {ideal: w}, ... } }
│ │
│ ├── navigator.mediaDevices.getUserMedia(constraints) ←─ 系统级调用
│ │ ├── 浏览器内部: 权限对话框弹出(如果未授权)
│ │ ├── 系统调用: 访问/dev/video0设备文件
│ │ └── 返回Promise: 可能拒绝(NotAllowedError等)
│ │
│ ├── 视频轨道获取: const tracks = this.mediaStream.getVideoTracks()
│ ├── 视频元素连接: this.videoElement.srcObject = this.mediaStream
│ └── 等待就绪: this.waitForVideoReady() ←─ 5秒超时Promise
│
└── 应用状态更新: this.appState = 'streaming'
7. 性能监控: this.performance.markEnd('camera_start')
8. UI二次更新: this.updateUIState() ←─ 再次触发DOM操作
9. 帧率监控启动: this.startFrameRateMonitoring()
└── requestAnimationFrame递归调用 ←─ 每16.67ms执行一次
├── 性能影响: 即使摄像头关闭也会在下次启动时创建新循环
└── 内存泄漏风险: 没有对应的cancelAnimationFrame
1.3 CSS对JavaScript的隐形调用与性能影响
CSS动画与JS性能的隐形调用关系:
┌─ CSS动画触发JS事件(开发者常忽略的细节)
│ ├── 动画开始: animationstart 事件
│ ├── 动画迭代: animationiteration 事件
│ └── 动画结束: animationend 事件
│
├─ CSS变换触发的JS重排/重绘链
│ └── 代码中存在的隐形性能问题:
│ ├── .screenshot-item:hover { transform: scale(1.02); }
│ │ └── hover时: 触发GPU层合成,但布局未变 ←─ 性能良好
│ │
│ ├── .btn-primary:hover { transform: translateY(-2px); }
│ │ └── 同上,GPU加速 ←─ 性能良好
│ │
│ └── modal隐藏/显示: classList.add/remove('hidden')
│ ├── display: none 与 opacity: 0 切换
│ ├── 关键问题: 同时修改opacity和transition
│ └── 触发: 样式重计算 → 布局 → 绘制 → 合成 全链路
│
└─ Tailwind CSS的硬核性能影响
├── 文件大小: 开发版本3MB+,生产版本压缩后约50KB
├── 解析时间: 浏览器需要解析约8000个CSS规则
├── 内存占用: CSSOM树占用约2-5MB内存
└── 关键优化缺失: 未使用PurgeCSS移除未使用的CSS
2. Web本地服务与访问机制架构树形分析
2.1 本地开发服务器架构深度剖析
基于file://协议 vs HTTP本地服务器的硬核对比:
┌─ 方案A: 直接file://协议访问(代码当前状态)
│ ├── URL: file:///home/user/camera-app/index.html
│ ├── 优点: 零配置,直接打开
│ └── 致命限制:
│ ├── 1. Cross-Origin限制: JavaScript无法访问本地文件
│ │ └── 截图保存到localStorage正常,但无法直接保存为文件
│ │
│ ├── 2. 摄像头访问限制: 现代浏览器限制file://访问媒体设备
│ │ └── Chrome策略: file://需要--allow-file-access-from-files标志
│ │
│ ├── 3. Service Worker不支持: 无法实现离线缓存
│ ├── 4. 某些API限制: 如WebUSB、Web蓝牙
│ └── 5. 相对路径问题: <link href="styles.css">可能404
│
┌─ 方案B: Python简易HTTP服务器(推荐开发用)
│ ├── 启动: python3 -m http.server 8080
│ ├── 架构: 单线程同步HTTP/1.1服务器
│ └── 处理流程:
│ ┌─ 浏览器请求: GET http://localhost:8080/index.html
│ │ └── TCP握手: localhost:8080 (环回接口,0.1ms延迟)
│ │
│ ├─ Python http.server处理链:
│ │ ├── 1. socketserver.TCPServer接收连接
│ │ ├── 2. SimpleHTTPRequestHandler.handle()
│ │ │ ├── 解析HTTP头: GET /index.html HTTP/1.1
│ │ │ ├── 安全检查: 路径遍历防护
│ │ │ ├── 文件系统映射: /index.html → ./index.html
│ │ │ ├── MIME类型推断: .html → text/html
│ │ │ └── 发送响应: 文件内容 + HTTP头
│ │ │
│ │ └── 3. 连接保持: 默认HTTP/1.1 keep-alive
│ │
│ └─ 浏览器接收响应:
│ ├── 解析HTML,发现外部资源
│ ├── 并发请求: styles.css, app.js, CDN资源
│ └── 关键: 所有资源同源(localhost:8080),无CORS限制
│
┌─ 方案C: Node.js + Express生产级架构
│ ├── 核心架构:
│ │ server.js
│ │ ├── const express = require('express')
│ │ ├── const app = express()
│ │ ├── app.use(express.static('public')) ←─ 静态文件服务
│ │ ├── app.get('/api/screenshots', ...) ←─ 自定义API
│ │ └── app.listen(3000)
│ │
│ └── 高级特性支持:
│ ├── WebSocket支持: 实时视频流传输
│ ├── 文件上传API: 替代localStorage的截图存储
│ ├── 用户认证: 多用户摄像头访问控制
│ └── 数据库集成: 截图元数据持久化
│
┌─ 方案D: 现代化Vite开发服务器(终极方案)
│ ├── 启动: npx vite --host localhost --port 3000
│ ├── 核心优势: 原生ES模块支持,HMR热更新
│ └── 架构亮点:
│ ├── 基于ESBuild: 比Webpack快10-100倍
│ ├── 按需编译: 只编译请求的模块
│ ├── 即时HMR: 修改CSS/JS无需刷新页面
│ └── 生产构建: Rollup打包,自动代码分割
2.2 浏览器-服务器通信机制硬核分析
HTTP请求-响应全链路剖析(以加载index.html为例): ┌─ 浏览器发起请求阶段 │ ├── 1. URL解析: http://localhost:8080/index.html │ │ ├── 协议: http │ │ ├── 主机: localhost (解析为127.0.0.1) │ │ ├── 端口: 8080 │ │ └── 路径: /index.html │ │ │ ├── 2. HTTP请求构造 │ │ └── 原始请求数据: │ │ GET /index.html HTTP/1.1 │ │ Host: localhost:8080 │ │ User-Agent: Mozilla/5.0... │ │ Accept: text/html,application/xhtml+xml... │ │ Accept-Encoding: gzip, deflate │ │ Connection: keep-alive │ │ │ └── 3. TCP连接建立 │ ├── SYN → SYN-ACK → ACK 三次握手 │ └── 本地环回,延迟<1ms │ ├─ 服务器处理阶段 │ └── Python http.server处理时序: │ ┌─ 0ms: accept() 接受连接 │ ├─ 1ms: recv() 读取请求头 (约500字节) │ ├─ 2ms: parse_request() 解析HTTP请求 │ ├─ 3ms: translate_path() 路径安全转换 │ ├─ 4ms: os.stat() 检查文件是否存在 │ ├─ 5ms: guess_type() 根据扩展名猜测MIME类型 │ ├─ 6ms: open() 打开文件 (磁盘IO,约1-10ms) │ ├─ 16ms: read() 读取文件内容 (约10KB) │ ├─ 17ms: send_response(200) 发送状态行 │ ├─ 18ms: send_header() 发送Content-Type等头部 │ ├─ 19ms: end_headers() 空行结束头部 │ └─ 20ms: write() 发送文件内容 │ ├─ 浏览器接收与解析阶段 │ ├── 21ms: 接收HTTP响应头 │ │ └── 解析关键头: │ │ HTTP/1.1 200 OK │ │ Content-Type: text/html; charset=utf-8 │ │ Content-Length: 10240 │ │ │ ├── 22ms: 开始接收响应体 │ ├── 23ms: HTML解析器启动 (预解析扫描<link>/<script>) │ │ ├── 发现: <link rel="stylesheet" href="styles.css"> │ │ │ └── 高优先级请求: 立即发起CSS请求 │ │ │ │ │ ├── 发现: <script src="app.js" type="module"> │ │ │ └── 低优先级: 延迟到HTML解析后执行 │ │ │ │ │ └── 发现: CDN资源 (tailwindcss, fontawesome) │ │ └── DNS预解析 + 预连接 │ │ │ ├── 25ms: DOM树构建完成 │ └── 30ms: 触发DOMContentLoaded事件 │ └─ 资源加载瀑布流分析 ├── 主文档: index.html (20ms) ├── CSS阻塞资源: │ ├── styles.css (本地, 5ms) │ ├── tailwindcss (CDN, 50-100ms) ←─ 网络延迟主导 │ └── fontawesome (CDN, 50-100ms) │ ├── JavaScript执行: │ ├── app.js (本地, 2ms加载, 5ms解析) │ ├── 初始化执行: 约100-200ms (包括localStorage读取) │ └── 摄像头设备枚举: 约500-1000ms (系统调用慢) │ └── 图片资源: └── 无阻塞,异步加载
2.3 CORS与安全机制硬核分析
本地开发中的CORS陷阱与解决方案:
┌─ 问题场景: 应用需要从不同端口获取数据
│ ├── 前端: http://localhost:3000
│ └── API服务器: http://localhost:8080
│
├─ 浏览器CORS检查流程(硬核版本):
│ ├── 简单请求检查:
│ │ ├── 方法: GET, HEAD, POST
│ │ ├── 头部限制: 仅允许安全头部
│ │ └── Content-Type: 仅 text/plain, multipart/form-data, application/x-www-form-urlencoded
│ │
│ ├── 预检请求 (Preflight) 触发条件:
│ │ ├── 非简单方法: PUT, DELETE, PATCH
│ │ ├── 自定义头部: X-API-Key, Authorization
│ │ └── 非标准Content-Type: application/json
│ │
│ └── CORS错误示例(会遇到的):
│ Access to fetch at 'http://localhost:8080/api/screenshots' from origin
│ 'http://localhost:3000' has been blocked by CORS policy:
│ No 'Access-Control-Allow-Origin' header is present on the requested resource.
│
├─ 解决方案1: 开发服务器代理(Vite示例)
│ └── vite.config.js
│ export default {
│ server: {
│ proxy: {
│ '/api': {
│ target: 'http://localhost:8080',
│ changeOrigin: true,
│ rewrite: (path) => path.replace(/^\/api/, '')
│ }
│ }
│ }
│ }
│
├─ 解决方案2: 服务器添加CORS头部(Node.js示例)
│ └── Express中间件:
│ app.use((req, res, next) => {
│ res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
│ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
│ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
│ if (req.method === 'OPTIONS') return res.sendStatus(200);
│ next();
│ });
│
└─ 解决方案3: 浏览器禁用安全(仅开发)
└── Chrome启动参数:
chrome.exe --disable-web-security --user-data-dir=/tmp/chrome-test
⚠️ 高危: 不要在生产环境中使用
2.4 Service Worker与离线缓存架构
PWA离线缓存机制(摄像头应用可离线使用):
┌─ 注册Service Worker
│ └── app.js新增代码:
│ if ('serviceWorker' in navigator) {
│ navigator.serviceWorker.register('/sw.js')
│ .then(reg => console.log('SW registered:', reg))
│ .catch(err => console.log('SW registration failed:', err));
│ }
│
├─ Service Worker生命周期与缓存策略
│ └── sw.js 实现:
│ const CACHE_NAME = 'camera-app-v1';
│ const urlsToCache = [
│ '/', '/index.html', '/styles.css', '/app.js',
│ 'https://cdn.tailwindcss.com',
│ 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css'
│ ];
│
│ // 安装阶段 - 预缓存关键资源
│ self.addEventListener('install', event => {
│ event.waitUntil(
│ caches.open(CACHE_NAME)
│ .then(cache => cache.addAll(urlsToCache))
│ );
│ });
│
│ // 激活阶段 - 清理旧缓存
│ self.addEventListener('activate', event => {
│ event.waitUntil(
│ caches.keys().then(cacheNames => {
│ return Promise.all(
│ cacheNames.map(cacheName => {
│ if (cacheName !== CACHE_NAME) {
│ return caches.delete(cacheName);
│ }
│ })
│ );
│ })
│ );
│ });
│
│ // 拦截请求 - 缓存优先策略
│ self.addEventListener('fetch', event => {
│ event.respondWith(
│ caches.match(event.request)
│ .then(response => {
│ // 缓存命中
│ if (response) return response;
│
│ // 网络请求
│ return fetch(event.request).then(response => {
│ // 动态缓存(可选)
│ if (!response || response.status !== 200 ||
│ response.type !== 'basic') {
│ return response;
│ }
│
│ const responseToCache = response.clone();
│ caches.open(CACHE_NAME)
│ .then(cache => {
│ cache.put(event.request, responseToCache);
│ });
│
│ return response;
│ });
│ })
│ );
│ });
│
└─ 离线功能增强
├── 截图缓存: 即使离线,已保存截图仍可查看
├── 设置持久化: localStorage在离线时正常工作
└── 离线提示: 检测navigator.onLine状态
2.5 WebSocket实时通信架构(高级扩展)
视频流WebSocket传输架构(替代HTTP轮询):
┌─ 前端WebSocket客户端
│ └── CameraMonitorApp新增WebSocket模块:
│ class VideoWebSocket {
│ constructor(url) {
│ this.ws = new WebSocket(url);
│ this.setupEventHandlers();
│ }
│
│ setupEventHandlers() {
│ this.ws.onopen = () => console.log('WebSocket连接已建立');
│ this.ws.onmessage = (event) => this.handleMessage(event.data);
│ this.ws.onclose = () => console.log('WebSocket连接已关闭');
│ this.ws.onerror = (error) => console.error('WebSocket错误:', error);
│ }
│
│ // 发送控制命令
│ sendCommand(cmd, data) {
│ this.ws.send(JSON.stringify({ command: cmd, data }));
│ }
│
│ // 接收视频帧数据
│ handleMessage(data) {
│ if (typeof data === 'string') {
│ const msg = JSON.parse(data);
│ switch (msg.type) {
│ case 'video_frame':
│ this.displayVideoFrame(msg.frame);
│ break;
│ case 'status_update':
│ this.updateStatus(msg.status);
│ break;
│ }
│ } else {
│ // 二进制数据 - 视频帧
│ this.processBinaryFrame(data);
│ }
│ }
│ }
│
├─ 后端WebSocket服务器(Node.js + ws库)
│ └── websocket-server.js:
│ const WebSocket = require('ws');
│ const wss = new WebSocket.Server({ port: 8081 });
│
│ // V4L2摄像头捕获
│ const { spawn } = require('child_process');
│ const ffmpeg = spawn('ffmpeg', [
│ '-f', 'v4l2',
│ '-i', '/dev/video0',
│ '-f', 'mjpeg',
│ '-vf', 'scale=640:480',
│ '-qscale', '5',
│ 'pipe:1'
│ ]);
│
│ wss.on('connection', (ws) => {
│ console.log('客户端连接');
│
│ // 发送视频帧
│ ffmpeg.stdout.on('data', (data) => {
│ if (ws.readyState === WebSocket.OPEN) {
│ ws.send(data, { binary: true });
│ }
│ });
│
│ // 接收控制命令
│ ws.on('message', (message) => {
│ const cmd = JSON.parse(message);
│ handleCommand(cmd);
│ });
│
│ ws.on('close', () => console.log('客户端断开'));
│ });
│
└─ 性能优化策略
├── 帧率控制: 服务端限制发送频率
├── 压缩传输: MJPEG → WebP (减少50%带宽)
├── 自动降级: 网络差时降低分辨率
└── 重连机制: 断线自动重连 + 指数退避
3. 硬核性能优化与调试技术
3.1 使用Chrome DevTools进行调用链性能分析
// 在代码中添加性能标记
class PerformanceMonitor {
constructor() {
// 启用Chrome性能时间线标记
if (window.performance && performance.mark) {
this.useNativePerf = true;
}
}
markStart(name) {
if (this.useNativePerf) {
performance.mark(`${name}_start`);
}
// ...原有逻辑
}
markEnd(name) {
if (this.useNativePerf) {
performance.mark(`${name}_end`);
performance.measure(name, `${name}_start`, `${name}_end`);
// 获取所有测量结果
const measures = performance.getEntriesByName(name);
if (measures.length > 0) {
console.log(`[性能] ${name}: ${measures[0].duration.toFixed(2)}ms`);
// 如果超过阈值,记录到Performance Observer
if (measures[0].duration > 100) {
this.reportLongTask(name, measures[0].duration);
}
}
}
// ...原有逻辑
}
}
// Chrome DevTools Performance面板使用流程:
// 1. 打开DevTools → Performance面板
// 2. 点击Record按钮
// 3. 在页面执行操作(如点击"启动摄像头")
// 4. 点击Stop,分析性能火焰图
//
// 关键指标:
// - Long Tasks: >50ms的任务(阻塞主线程)
// - Layout Shifts: 意外布局偏移
// - Forced Reflows: 强制同步布局
3.2 内存泄漏检测与预防
// 内存泄漏检测代码
class MemoryLeakDetector {
constructor() {
this.snapshotCount = 0;
this.snapshots = [];
if (window.performance && performance.memory) {
this.monitorMemory();
}
}
monitorMemory() {
setInterval(() => {
const memory = performance.memory;
const usedJSHeapSize = memory.usedJSHeapSize / 1024 / 1024;
const totalJSHeapSize = memory.totalJSHeapSize / 1024 / 1024;
console.log(`[内存] 已使用: ${usedJSHeapSize.toFixed(2)}MB / 总堆: ${totalJSHeapSize.toFixed(2)}MB`);
// 检测内存泄漏模式
if (this.snapshots.length >= 2) {
const prev = this.snapshots[this.snapshots.length - 2];
const curr = this.snapshots[this.snapshots.length - 1];
if (curr.used > prev.used * 1.5) {
console.warn('[内存泄漏警告] 内存使用量快速增加!');
this.takeHeapSnapshot();
}
}
this.snapshots.push({
timestamp: Date.now(),
used: usedJSHeapSize,
total: totalJSHeapSize
});
// 保留最近10个快照
if (this.snapshots.length > 10) {
this.snapshots.shift();
}
}, 5000);
}
takeHeapSnapshot() {
// Chrome DevTools协议,需要--remote-debugging-port=9222
fetch('http://localhost:9222/json/version')
.then(response => response.json())
.then(data => {
console.log('准备堆快照...');
// 实际实现需要更复杂的Chrome DevTools协议通信
});
}
}
// 在CameraMonitorApp中集成
class CameraMonitorApp {
constructor() {
// ...原有代码
this.memoryDetector = new MemoryLeakDetector();
// 常见内存泄漏点修复
this.fixMemoryLeaks();
}
fixMemoryLeaks() {
// 1. 定时器清理
window.addEventListener('beforeunload', () => {
if (this.uptimeInterval) clearInterval(this.uptimeInterval);
if (this.performance.monitorInterval) clearInterval(this.performance.monitorInterval);
});
// 2. 事件监听器清理
this.cleanupEventListeners = () => {
// 记录所有添加的事件监听器,在destroy时移除
this.eventListeners.forEach(({element, type, handler}) => {
element.removeEventListener(type, handler);
});
this.eventListeners = [];
};
// 3. 闭包引用断开
this.videoElement = null;
this.mediaStream = null;
this.screenshotManager = null;
}
}
4. 安全与隐私硬核分析
4.1 摄像头权限安全模型
浏览器摄像头权限安全架构: ┌─ 权限请求流程(getUserMedia调用时) │ ├── 1. 安全检查链: │ │ ├── 来源安全: HTTPS或localhost │ │ ├── 页面可见性: document.hidden === false │ │ ├── 用户手势: 必须由点击等手势触发 │ │ └── 权限状态: 检查navigator.permissions API │ │ │ ├── 2. 权限提示显示: │ │ ├── 浏览器UI: 显示摄像头选择器 │ │ ├── 设备标签: 显示设备名称(如"Integrated Camera") │ │ └── 持久化选项: "记住此选择" │ │ │ └── 3. 系统级权限检查: │ ├── Linux: 检查/dev/video0权限 (video用户组) │ ├── udev规则: 可能限制特定设备 │ └── SELinux/AppArmor: 额外的强制访问控制 │ ├─ 隐私指示器(现代浏览器) │ ├── 地址栏图标: 摄像头/麦克风使用中 │ ├── 操作系统提示: 某些系统有全局指示器 │ └── 标签页标题: 可能显示录制状态 │ └─ 权限滥用防护 ├── 隐形iframe检测: 阻止隐藏的摄像头访问 ├── 自动暂停: 页面隐藏时自动暂停视频流 ├── 权限超时: 长时间未使用自动撤销 └── 用户控制: 可随时在浏览器设置中撤销权限
总结:从调用树到架构的全面掌控
-
精准定位性能瓶颈:知道每次点击按钮后发生了什么,哪一步最耗时
-
优化初始化速度:避免localStorage阻塞,异步化设备枚举
-
选择合适的本地服务器:理解不同方案的优劣和适用场景
-
预防内存泄漏:知道在哪里可能泄漏,如何检测和修复
-
实现高级特性:PWA离线、WebSocket实时传输等
最关键的一点:代码已经具备了良好的架构基础,但需要:
-
添加适当的错误边界和降级处理
-
实现更细粒度的性能监控
-
考虑生产环境的部署架构
-
添加自动化测试确保稳定性
从前端按钮点击到内核UVC驱动的完整调用链认知,可以精准优化任何环节的性能问题。
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐


所有评论(0)