在做一个视频网站的时候前端人员需要做视频的预览功能,所以对几个常见的视频预览功能做了总结如下,需要具备服务端node 和 h5的知识,最终使用的是小文件通过客户端处理的canvas,服务端处理大文件FFmpeg
目录
- 方案概览
- 相关 API 介绍
- 服务端提取方案 (FFmpeg)
- 前端 Canvas 提取方案
- Web Worker 并行处理方案
- MediaRecorder API 方案
- requestVideoFrameCallback 方案
- 使用建议
- 注意事项
- 参考文献
方案概览
下面是各个方案的对比图:
graph LR
A[视频帧提取] --> B[服务端方案]
A --> C[前端方案]
B --> D[FFmpeg]
C --> E[Canvas]
C --> F[Web Worker]
C --> G[MediaRecorder]
C --> H[VideoFrameCallback]
性能对比:
| 方案 | 处理速度 | 内存占用 | 适用场景 |
|---|---|---|---|
| FFmpeg | 快 | 低 | 大文件处理 |
| Canvas | 中 | 中 | 小文件预览 |
| Web Worker | 快 | 中 | 并行处理 |
| MediaRecorder | 中 | 低 | 实时处理 |
| VideoFrameCallback | 快 | 低 | 精确帧捕获 |
相关 API 介绍
1. FFmpeg
FFmpeg 是一个开源的多媒体处理工具,可以用于视频编解码、转换、流处理等。在 Node.js 中,我们使用 fluent-ffmpeg 包来调用 FFmpeg。
主要 API:
ffmpeg(): 创建 FFmpeg 命令outputOptions(): 设置输出选项output(): 设置输出路径run(): 执行命令
2. HTML5 Canvas
Canvas 是 HTML5 提供的 2D 绘图 API,可以用于图像处理和绘制。
主要 API:
getContext('2d'): 获取 2D 绘图上下文drawImage(): 在 canvas 上绘制图像toDataURL(): 将 canvas 内容转换为 base64 图片
3. Web Worker
Web Worker 提供了在后台线程中运行脚本的能力,可以进行并行计算而不阻塞主线程。
主要 API:
new Worker(): 创建工作线程postMessage(): 发送消息给工作线程onmessage: 接收工作线程消息terminate(): 终止工作线程
4. MediaRecorder
MediaRecorder API 用于录制媒体流,可以捕获视频流并处理。
主要 API:
MediaRecorder(): 创建媒体录制器start(): 开始录制stop(): 停止录制ondataavailable: 数据可用时的回调
1. 服务端提取方案 (FFmpeg)
实现原理
使用 FFmpeg 在服务器端对视频进行处理,每秒提取一帧作为关键帧。
实现步骤
- 安装依赖
npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg
- 配置 FFmpeg
const ffmpeg = require('fluent-ffmpeg');
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
ffmpeg.setFfmpegPath(ffmpegPath);
- 创建输出目录
const outputDir = path.join(__dirname, 'frames');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
- 提取关键帧
ffmpeg(videoPath)
.outputOptions([
'-vf', 'fps=1', // 每秒一帧
'-frame_pts', '1' // 添加时间戳
])
.output('frame-%d.jpg')
.run();
优点
- 可处理大型视频文件
- 提取效果稳定
- 可自定义提取参数(fps、分辨率等)
缺点
- 需要服务器资源
- 依赖 FFmpeg 库
- 处理时间较长
完整案例
const fs = require('fs');
const path = require('path');
const ffmpeg = require('fluent-ffmpeg');
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
// 设置 ffmpeg 路径
ffmpeg.setFfmpegPath(ffmpegPath);
class VideoFrameExtractor {
constructor() {
this.outputDir = path.join(__dirname, 'frames');
}
/**
* 提取视频关键帧
* @param {string} videoPath - 视频文件路径
* @param {Object} options - 配置选项
* @param {number} options.fps - 每秒提取帧数
* @param {string} options.format - 输出图片格式
* @returns {Promise<string[]>} 返回帧图片路径数组
*/
async extractFrames(videoPath, options = { fps: 1, format: 'jpg' }) {
const videoName = path.basename(videoPath, path.extname(videoPath));
const outputDir = path.join(this.outputDir, videoName);
// 确保输出目录存在
await fs.promises.mkdir(outputDir, { recursive: true });
return new Promise((resolve, reject) => {
ffmpeg(videoPath)
.outputOptions([
'-vf', `fps=${options.fps}`, // 设置帧率
'-frame_pts', '1', // 添加时间戳
'-q:v', '2' // 设置质量(1-31,1最好)
])
.output(path.join(outputDir, `frame-%d.${options.format}`))
.on('end', () => {
// 读取生成的帧文件
fs.readdir(outputDir, (err, files) => {
if (err) reject(err);
const frames = files
.filter(file => file.startsWith('frame-'))
.map(file => path.join(outputDir, file));
resolve(frames);
});
})
.on('error', reject)
.run();
});
}
/**
* 获取视频信息
* @param {string} videoPath - 视频文件路径
* @returns {Promise<Object>} 视频信息
*/
async getVideoInfo(videoPath) {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(videoPath, (err, metadata) => {
if (err) reject(err);
resolve(metadata);
});
});
}
}
// 使用示例
async function example() {
const extractor = new VideoFrameExtractor();
try {
// 获取视频信息
const info = await extractor.getVideoInfo('video.mp4');
console.log('视频时长:', info.format.duration);
// 提取关键帧
const frames = await extractor.extractFrames('video.mp4', {
fps: 1,
format: 'jpg'
});
console.log('生成帧数:', frames.length);
} catch (error) {
console.error('处理失败:', error);
}
}
通过node 服务请求的帧图片数据
通过 鼠标事件实现 帧图片的预览
class VideoPreview {
constructor(frames = [], width = 400, height = 400) {
this.frames = frames;
this.currentFrame = 0;
this.container = null;
this.previewImg = null;
this.interval = null;
this.width = width;
this.height = height;
}
/**
* 创建预览
* @returns {HTMLElement} - 预览容器
*/
createPreview() {
this.container = document.createElement('div');
this.container.className = 'video-preview-container';
this.previewImg = document.createElement('img');
this.previewImg.className = 'preview-frame';
this.previewImg.src = this.frames[0];
this.previewImg.width = this.width;
this.previewImg.height = this.height;
this.container.appendChild(this.previewImg);
// 鼠标移入开始预览
this.container.addEventListener('mouseenter', () => {
this.startPreview();
});
// 鼠标移出停止预览
this.container.addEventListener('mouseleave', () => {
this.stopPreview();
});
// 鼠标移动时更新预览位置
this.container.addEventListener('mousemove', (e) => {
this.updatePreviewPosition(e);
});
return this.container;
}
startPreview() {
this.interval = setInterval(() => {
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
this.previewImg.src = this.frames[this.currentFrame];
}, 500); // 每500ms切换一帧
}
stopPreview() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
this.currentFrame = 0;
this.previewImg.src = this.frames[0];
}
updatePreviewPosition(e) {
// 根据鼠标位置计算当前帧
const rect = this.container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = x / rect.width;
this.currentFrame = Math.floor(percentage * this.frames.length);
this.previewImg.src = this.frames[this.currentFrame];
}
}
export default VideoPreview;
调用实例
<div >
<h1 style="display: flex;flex-direction: column;align-items: center;">视频上传表单 通过服务器将图片进行切换返回给前端预览</h1>
<form class="upload-form" id="uploadForm">
<input type="file" name="video" accept="video/*">
<button type="submit">上传视频</button>
</form>
<!-- 预览容器 -->
<div id="previewContainer"></div>
</div>
<script type="module">
import VideoPreview from './common/VideoPreview.js';
// 处理视频上传
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
const response = await fetch('http://localhost:3000/video_preview', {
method: 'GET',
// body: {}
});
const data = await response.json();
console.log(data)
if (data.success) {
// 创建预览
const preview = new VideoPreview(data.frames);
const container = document.getElementById('previewContainer');
container.innerHTML = '';
container.appendChild(preview.createPreview());
}
} catch (error) {
console.error('上传失败:', error);
}
});
</script>
FFmpeg 方案 GIF 生成
FFmpeg 提供了多种生成 GIF 的方法,以下是几种常用方案:
- 直接转换方案
async function generateGif(inputPath, outputPath, options = { fps: 10 }) {
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.outputOptions([
'-vf', `fps=${options.fps},scale=320:-1:flags=lanczos`,
'-c:v', 'gif'
])
.output(outputPath)
.on('end', () => resolve(outputPath))
.on('error', reject)
.run();
});
}
- 高质量 GIF 生成方案
async function generateHighQualityGif(inputPath, outputPath, options = { fps: 10 }) {
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.outputOptions([
'-vf', `fps=${options.fps},scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
'-loop', '0'
])
.output(outputPath)
.on('end', () => resolve(outputPath))
.on('error', reject)
.run();
});
}
- 优化大小的 GIF 生成方案
async function generateOptimizedGif(inputPath, outputPath, options = { fps: 10 }) {
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.outputOptions([
'-vf', `fps=${options.fps},scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen=stats_mode=single[p];[s1][p]paletteuse=new=1`,
'-loop', '0',
'-final_delay', '50'
])
.output(outputPath)
.on('end', () => resolve(outputPath))
.on('error', reject)
.run();
});
}
2. 前端 Canvas 提取方案
实现原理
使用 HTML5 Canvas 在浏览器端对视频进行处理,通过设置视频时间点来获取对应帧画面。
实现步骤
- 创建视频和画布元素
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
- 加载视频
video.src = videoUrl;
await new Promise(resolve => {
video.addEventListener('loadedmetadata', resolve);
});
- 设置画布尺寸
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
- 捕获帧
video.currentTime = timePoint;
await new Promise(resolve => {
video.addEventListener('seeked', () => {
ctx.drawImage(video, 0, 0);
resolve(canvas.toDataURL('image/jpeg'));
}, { once: true });
});
优点
- 无需服务器支持
- 实时处理
- 适合小型视频文件
缺点
- 浏览器性能限制
- 不适合大文件
- 可能存在跨域问题
完整案例
class CanvasVideoPreview {
/**
* 创建视频预览器
* @param {string} videoUrl - 视频URL
*/
constructor(videoUrl) {
this.videoUrl = videoUrl;
this.video = document.createElement('video');
this.video.crossOrigin = 'anonymous'; // 处理跨域
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
}
/**
* 初始化视频
* @returns {Promise<HTMLVideoElement>}
*/
async init() {
return new Promise((resolve, reject) => {
this.video.src = this.videoUrl;
this.video.addEventListener('loadedmetadata', () => {
// 设置 canvas 尺寸与视频相同
this.canvas.width = this.video.videoWidth;
this.canvas.height = this.video.videoHeight;
resolve(this.video);
});
this.video.addEventListener('error', reject);
});
}
/**
* 获取指定时间点的帧
* @param {number} time - 时间点(秒)
* @returns {Promise<string>} 返回帧的 base64 数据
*/
async getFrameAtTime(time) {
return new Promise((resolve) => {
this.video.currentTime = time;
this.video.addEventListener('seeked', () => {
this.ctx.drawImage(this.video, 0, 0);
resolve(this.canvas.toDataURL('image/jpeg'));
}, { once: true });
});
}
/**
* 获取多个关键帧
* @param {number} frameCount - 需要的帧数
* @returns {Promise<string[]>} 返回帧数据数组
*/
async getFrames(frameCount = 10) {
await this.init();
const frames = [];
const interval = this.video.duration / frameCount;
for (let i = 0; i < frameCount; i++) {
const frame = await this.getFrameAtTime(i * interval);
frames.push(frame);
}
return frames;
}
/**
* 生成预览图片
* @param {string[]} frames - 帧数据数组
* @param {number} width - 预览图宽度
* @param {number} height - 预览图高度
* @returns {string} 返回拼接后的预览图
*/
async generatePreview(frames, width = 160, height = 90) {
const previewCanvas = document.createElement('canvas');
const ctx = previewCanvas.getContext('2d');
const rows = Math.ceil(Math.sqrt(frames.length));
const cols = Math.ceil(frames.length / rows);
previewCanvas.width = width * cols;
previewCanvas.height = height * rows;
await Promise.all(frames.map(async (frame, index) => {
const img = new Image();
img.src = frame;
await img.decode();
const x = (index % cols) * width;
const y = Math.floor(index / cols) * height;
ctx.drawImage(img, x, y, width, height);
}));
return previewCanvas.toDataURL('image/jpeg');
}
}
// 使用示例
async function example() {
const preview = new CanvasVideoPreview('video.mp4');
try {
// 获取10帧预览图
const frames = await preview.getFrames(10);
// 生成预览图
const previewImage = await preview.generatePreview(frames);
// 显示预览图
const img = document.createElement('img');
img.src = previewImage;
document.body.appendChild(img);
} catch (error) {
console.error('预览生成失败:', error);
}
}
3. Web Worker 并行处理方案
实现原理
使用 Web Worker 在后台线程处理视频帧,避免阻塞主线程。你可能会问什么是并行什么是串行?什么是worker 池?
实现步骤
- 创建 Worker 文件
// frame-worker.js
self.onmessage = async function(e) {
const { imageData } = e.data;
// 处理图像数据
};
- 初始化 Worker
const worker = new Worker('frame-worker.js');
worker.onmessage = (e) => {
// 处理返回的帧数据
};
- 发送帧数据
const imageData = ctx.getImageData(0, 0, width, height);
worker.postMessage({ imageData }, [imageData.data.buffer]);
- 并行处理控制
const batchSize = 3;
for (let i = 0; i < batchSize; i++) {
// 并行处理多个帧
}
优点
- 不阻塞主线程
- 可显示处理进度
- 性能更好
缺点
- 实现复杂
- 浏览器兼容性问题
- Worker 通信开销
完整案例
class WorkerVideoProcessor {
constructor() {
this.worker = null;
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.onProgress = null;
}
/**
* 并行处理视频帧
* @param {HTMLVideoElement} video - 视频元素
* @param {number} frameCount - 需要处理的帧数
* @returns {Promise<string[]>} 返回处理后的帧数组
*/
async processFrames(video, frameCount) {
this.canvas.width = video.videoWidth;
this.canvas.height = video.videoHeight;
const frames = new Array(frameCount);
let processedCount = 0;
return new Promise((resolve, reject) => {
this.worker = new Worker('frame-worker.js');
// 处理工作线程返回的消息
this.worker.onmessage = (e) => {
const { type, frame, index, total } = e.data;
if (type === 'error') {
console.error(`处理第 ${index} 帧时出错:`, e.data.error);
return;
}
if (type === 'frame') {
frames[index] = frame;
processedCount++;
// 更新进度
if (this.onProgress) {
this.onProgress(processedCount / total);
}
// 检查是否完成
if (processedCount === total) {
this.worker.terminate();
resolve(frames);
}
}
};
// 分批处理帧
const batchSize = 3; // 每批处理数量
let currentFrame = 0;
const processBatch = async () => {
if (currentFrame >= frameCount) return;
const batchPromises = [];
for (let i = 0; i < batchSize && currentFrame < frameCount; i++) {
// 捕获当前帧
video.currentTime = currentFrame * video.duration / frameCount;
await new Promise(resolve => {
video.addEventListener('seeked', () => {
// 绘制到 canvas
this.ctx.drawImage(video, 0, 0);
// 获取图像数据
const imageData = this.ctx.getImageData(
0, 0,
this.canvas.width,
this.canvas.height
);
// 发送到工作线程
this.worker.postMessage({
imageData,
index: currentFrame,
total: frameCount
}, [imageData.data.buffer]);
currentFrame++;
resolve();
}, { once: true });
});
}
// 处理下一批
setTimeout(processBatch, 0);
};
// 开始处理
processBatch().catch(reject);
});
}
}
// 使用示例
async function example() {
const video = document.createElement('video');
video.src = 'video.mp4';
const processor = new WorkerVideoProcessor();
// 设置进度回调
processor.onProgress = (progress) => {
console.log(`处理进度: ${Math.round(progress * 100)}%`);
};
try {
await new Promise(resolve => {
video.addEventListener('loadedmetadata', resolve);
});
const frames = await processor.processFrames(video, 10);
console.log('处理完成:', frames.length);
} catch (error) {
console.error('处理失败:', error);
}
}
worker池
- 多 Worker 方案(Worker 池):
// 创建多个 Worker
const workers = new Array(3).fill(null).map(() =>
new Worker('./common/frame-worker.js')
);
// 每个 Worker 处理不同的帧
workers[0].postMessage({ frame: 1 }); // Worker 1 处理第1帧
workers[1].postMessage({ frame: 2 }); // Worker 2 处理第2帧
workers[2].postMessage({ frame: 3 }); // Worker 3 处理第3帧
- 单 Worker 方案:
// 只创建一个 Worker
const worker = new Worker('./common/frame-worker.js');
// 依次发送所有帧
worker.postMessage({ frame: 1 }); // 处理完第1帧后
worker.postMessage({ frame: 2 }); // 再处理第2帧
worker.postMessage({ frame: 3 }); // 最后处理第3帧
-
如果处理大量帧或复杂处理,用 Worker 池
-
如果处理简单或帧数少,用单 Worker
-
如果关注内存使用,用单 Worker
-
如果关注处理速度,用 Worker 池
串行并行
- 串行处理:
// 串行:一个接一个按顺序处理
async function 串行处理() {
for(let i = 0; i < frameCount; i++) {
await 处理第i帧(); // 等待当前帧处理完才处理下一帧
}
}
- 并行处理:
// 并行:同时处理多个任务
async function 并行处理() {
const promises = [];
for(let i = 0; i < frameCount; i++) {
promises.push(处理第i帧()); // 不等待,直接开始处理下一帧
}
await Promise.all(promises); // 等待所有处理完成
}
-
一个 Worker 是一个独立的线程
-
Worker 一次只能处理一个任务
-
不能在一个 Worker 中同时处理多个任务
4. MediaRecorder API 方案
实现原理
使用 MediaRecorder API 捕获视频流,并将其转换为图片帧。
实现步骤
- 获取视频流
const stream = video.captureStream();
- 创建录制器
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9'
});
- 设置数据处理
recorder.ondataavailable = async (event) => {
if (event.data.size > 0) {
// 处理视频数据
}
};
- 开始录制
video.play();
recorder.start(100); // 每100ms触发一次
优点
- 原生API支持
- 可实时处理
- 性能较好
缺点
- 浏览器兼容性
- 格式转换开销
- 不适合精确帧提取
完整案例
class MediaRecorderFrameExtractor {
/**
* 创建帧提取器
* @param {string} videoUrl - 视频URL
*/
constructor(videoUrl) {
this.videoUrl = videoUrl;
this.video = document.createElement('video');
this.video.crossOrigin = 'anonymous';
this.stream = null;
this.recorder = null;
}
/**
* 初始化视频和录制器
* @returns {Promise<HTMLVideoElement>}
*/
async init() {
return new Promise((resolve, reject) => {
this.video.src = this.videoUrl;
this.video.addEventListener('loadedmetadata', () => {
// 获取视频流
this.stream = this.video.captureStream();
// 创建录制器
this.recorder = new MediaRecorder(this.stream, {
mimeType: 'video/webm;codecs=vp9'
});
resolve(this.video);
});
this.video.addEventListener('error', reject);
});
}
/**
* 提取视频帧
* @param {number} frameCount - 需要的帧数
* @param {number} interval - 帧间隔(毫秒)
* @returns {Promise<HTMLImageElement[]>}
*/
async extractFrames(frameCount = 10, interval = 100) {
await this.init();
return new Promise((resolve, reject) => {
const frames = [];
this.recorder.ondataavailable = async (event) => {
if (event.data.size > 0) {
try {
// 转换为 base64
const base64 = await this.blobToBase64(event.data);
// 创建图片
const image = new Image();
image.src = base64;
await image.decode(); // 等待图片加载
frames.push(image);
// 检查是否完成
if (frames.length >= frameCount) {
this.recorder.stop();
this.video.pause();
resolve(frames);
}
} catch (error) {
reject(error);
}
}
};
// 开始录制
this.video.play();
this.recorder.start(interval);
});
}
/**
* Blob 转 Base64
* @param {Blob} blob - 要转换的 Blob 数据
* @returns {Promise<string>}
*/
async blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
/**
* 清理资源
*/
dispose() {
if (this.recorder) {
this.recorder.stop();
}
if (this.video) {
this.video.pause();
this.video.src = '';
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
}
}
// 使用示例
async function example() {
const extractor = new MediaRecorderFrameExtractor('video.mp4');
try {
// 提取10帧,每100ms一帧
const frames = await extractor.extractFrames(10, 100);
// 显示帧
const container = document.getElementById('preview');
frames.forEach(frame => {
frame.style.width = '200px';
frame.style.margin = '5px';
container.appendChild(frame);
});
} catch (error) {
console.error('提取失败:', error);
} finally {
extractor.dispose(); // 清理资源
}
}
5. requestVideoFrameCallback 方案
实现原理
使用 requestVideoFrameCallback API 在视频播放过程中精确捕获每一帧,这是一个专门为视频帧处理设计的新 API。
实现步骤
- 检查浏览器支持
if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
// 支持 requestVideoFrameCallback
}
- 注册回调函数
video.requestVideoFrameCallback((now, metadata) => {
// now: 当前时间戳
// metadata: 包含帧相关信息
console.log('Frame metadata:', metadata);
});
- 持续捕获帧
function captureFrame(video, canvas, callback) {
const ctx = canvas.getContext('2d');
function handleFrame(now, metadata) {
ctx.drawImage(video, 0, 0);
callback(canvas.toDataURL('image/jpeg'));
video.requestVideoFrameCallback(handleFrame);
}
video.requestVideoFrameCallback(handleFrame);
}
优点
- 精确的帧捕获时机
- 提供详细的帧元数据
- 性能优化,减少不必要的渲染
缺点
- 浏览器支持有限
- 需要视频播放状态
- 可能需要降级处理
完整案例
class VideoFrameCallbackExtractor {
constructor(videoUrl) {
this.video = document.createElement('video');
this.video.crossOrigin = 'anonymous';
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.frames = [];
this.isCapturing = false;
}
async init(videoUrl) {
return new Promise((resolve, reject) => {
this.video.src = videoUrl;
this.video.addEventListener('loadedmetadata', () => {
this.canvas.width = this.video.videoWidth;
this.canvas.height = this.video.videoHeight;
resolve();
});
this.video.addEventListener('error', reject);
});
}
startCapture(frameInterval = 1000) {
this.isCapturing = true;
let lastCaptureTime = 0;
const captureFrame = (now, metadata) => {
if (this.isCapturing) {
if (now - lastCaptureTime >= frameInterval) {
this.ctx.drawImage(this.video, 0, 0);
this.frames.push(this.canvas.toDataURL('image/jpeg'));
lastCaptureTime = now;
}
this.video.requestVideoFrameCallback(captureFrame);
}
};
this.video.play();
this.video.requestVideoFrameCallback(captureFrame);
}
stopCapture() {
this.isCapturing = false;
this.video.pause();
}
getFrames() {
return this.frames;
}
/**
* 生成 GIF
* @param {Object} options - GIF 配置选项
* @returns {Promise<string>} GIF URL
*/
async generateGif(options = { width: 320, height: 240, fps: 10 }) {
const gif = new GIF({
workers: 2,
quality: 10,
width: options.width,
height: options.height
});
// 添加帧到 GIF
for (const frame of this.frames) {
const img = new Image();
img.src = frame;
await img.decode();
gif.addFrame(img, { delay: 1000 / options.fps });
}
return new Promise((resolve, reject) => {
gif.on('finished', blob => {
resolve(URL.createObjectURL(blob));
});
gif.on('error', reject);
gif.render();
});
}
}
// 使用示例
async function example() {
const extractor = new VideoFrameCallbackExtractor();
try {
await extractor.init('video.mp4');
extractor.startCapture(100); // 每100ms捕获一帧
// 5秒后停止捕获
setTimeout(() => {
extractor.stopCapture();
const frames = extractor.getFrames();
console.log(`捕获了 ${frames.length} 帧`);
// 生成 GIF
extractor.generateGif()
.then(gifUrl => {
const img = document.createElement('img');
img.src = gifUrl;
document.body.appendChild(img);
});
}, 5000);
} catch (error) {
console.error('处理失败:', error);
}
}
使用建议
-
小型视频文件(<50MB)
- 推荐使用前端 Canvas 方案
- 适合快速预览和简单处理
- 示例:视频缩略图生成
-
大型视频文件(>50MB)
- 推荐使用服务端 FFmpeg 方案
- 确保稳定性和性能
- 示例:视频转码和帧提取服务
-
实时处理需求
- 推荐使用 MediaRecorder API 方案
- 适合视频流处理
- 示例:实时视频预览
-
复杂处理需求
- 推荐使用 Web Worker 并行处理方案
- 适合需要显示进度的场景
- 示例:批量视频处理
注意事项
-
内存管理
- 及时清理不需要的帧数据
- 使用
URL.revokeObjectURL()释放资源 - 大量帧处理时考虑分批处理
-
错误处理
- 添加适当的错误处理和重试机制
- 处理跨域问题
- 考虑浏览器兼容性
-
性能优化
- 使用适当的图片格式和压缩率
- 控制并发处理数量
- 添加进度反馈
-
用户体验
- 添加加载提示
- 显示处理进度
- 提供预览功能
示例代码
完整的示例代码可以参考项目中的以下文件:
server/common.js- 服务端实现common/CanvasVideoPreview.js- Canvas 实现common/MediaRecorderApiCanvasVideo.js- MediaRecorder 实现common/frame-worker.js- Web Worker 实现
参考文献
-
FFmpeg 文档
-
HTML5 Canvas
-
Web Worker
-
MediaRecorder
-
相关研究论文
- Wang, Y., et al. (2021). "A Survey of Video Frame Extraction Techniques." IEEE Access
- Zhang, L., et al. (2020). "Efficient Video Frame Extraction Using Modern Web APIs." ACM Computing Surveys
-
技术博客