项目:视频预览功能多种方案总结,最优客户端canvas 和 服务端ffmpeg

1,118 阅读6分钟

在做一个视频网站的时候前端人员需要做视频的预览功能,所以对几个常见的视频预览功能做了总结如下,需要具备服务端node 和 h5的知识,最终使用的是小文件通过客户端处理的canvas,服务端处理大文件FFmpeg

目录

  1. 方案概览
  2. 相关 API 介绍
  3. 服务端提取方案 (FFmpeg)
  4. 前端 Canvas 提取方案
  5. Web Worker 并行处理方案
  6. MediaRecorder API 方案
  7. requestVideoFrameCallback 方案
  8. 使用建议
  9. 注意事项
  10. 参考文献

方案概览

下面是各个方案的对比图:

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 在服务器端对视频进行处理,每秒提取一帧作为关键帧。

实现步骤

  1. 安装依赖
npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg
  1. 配置 FFmpeg
const ffmpeg = require('fluent-ffmpeg');
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
ffmpeg.setFfmpegPath(ffmpegPath);
  1. 创建输出目录
const outputDir = path.join(__dirname, 'frames');
if (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir, { recursive: true });
}
  1. 提取关键帧
ffmpeg(videoPath)
    .outputOptions([
        '-vf', 'fps=1',  // 每秒一帧
        '-frame_pts', '1' // 添加时间戳
    ])
    .output('frame-%d.jpg')
    .run();

优点

  1. 可处理大型视频文件
  2. 提取效果稳定
  3. 可自定义提取参数(fps、分辨率等)

缺点

  1. 需要服务器资源
  2. 依赖 FFmpeg 库
  3. 处理时间较长

完整案例

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 服务请求的帧图片数据

image.png

通过 鼠标事件实现 帧图片的预览

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 的方法,以下是几种常用方案:

  1. 直接转换方案
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();
    });
}
  1. 高质量 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();
    });
}
  1. 优化大小的 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 在浏览器端对视频进行处理,通过设置视频时间点来获取对应帧画面。

实现步骤

  1. 创建视频和画布元素
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
  1. 加载视频
video.src = videoUrl;
await new Promise(resolve => {
    video.addEventListener('loadedmetadata', resolve);
});
  1. 设置画布尺寸
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
  1. 捕获帧
video.currentTime = timePoint;
await new Promise(resolve => {
    video.addEventListener('seeked', () => {
        ctx.drawImage(video, 0, 0);
        resolve(canvas.toDataURL('image/jpeg'));
    }, { once: true });
});

优点

  1. 无需服务器支持
  2. 实时处理
  3. 适合小型视频文件

缺点

  1. 浏览器性能限制
  2. 不适合大文件
  3. 可能存在跨域问题

完整案例

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 池?

实现步骤

  1. 创建 Worker 文件
// frame-worker.js
self.onmessage = async function(e) {
    const { imageData } = e.data;
    // 处理图像数据
};
  1. 初始化 Worker
const worker = new Worker('frame-worker.js');
worker.onmessage = (e) => {
    // 处理返回的帧数据
};
  1. 发送帧数据
const imageData = ctx.getImageData(0, 0, width, height);
worker.postMessage({ imageData }, [imageData.data.buffer]);
  1. 并行处理控制
const batchSize = 3;
for (let i = 0; i < batchSize; i++) {
    // 并行处理多个帧
}

优点

  1. 不阻塞主线程
  2. 可显示处理进度
  3. 性能更好

缺点

  1. 实现复杂
  2. 浏览器兼容性问题
  3. 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池

  1. 多 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帧
  1. 单 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 池

串行并行

  1. 串行处理:
// 串行:一个接一个按顺序处理
async function 串行处理() {
    for(let i = 0; i < frameCount; i++) {
        await 处理第i帧();  // 等待当前帧处理完才处理下一帧
    }
}
  1. 并行处理:
// 并行:同时处理多个任务
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 捕获视频流,并将其转换为图片帧。

实现步骤

  1. 获取视频流
const stream = video.captureStream();
  1. 创建录制器
const recorder = new MediaRecorder(stream, {
    mimeType: 'video/webm;codecs=vp9'
});
  1. 设置数据处理
recorder.ondataavailable = async (event) => {
    if (event.data.size > 0) {
        // 处理视频数据
    }
};
  1. 开始录制
video.play();
recorder.start(100); // 每100ms触发一次

优点

  1. 原生API支持
  2. 可实时处理
  3. 性能较好

缺点

  1. 浏览器兼容性
  2. 格式转换开销
  3. 不适合精确帧提取

完整案例

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。

实现步骤

  1. 检查浏览器支持
if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
    // 支持 requestVideoFrameCallback
}
  1. 注册回调函数
video.requestVideoFrameCallback((now, metadata) => {
    // now: 当前时间戳
    // metadata: 包含帧相关信息
    console.log('Frame metadata:', metadata);
});
  1. 持续捕获帧
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);
}

优点

  1. 精确的帧捕获时机
  2. 提供详细的帧元数据
  3. 性能优化,减少不必要的渲染

缺点

  1. 浏览器支持有限
  2. 需要视频播放状态
  3. 可能需要降级处理

完整案例

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);
    }
}

使用建议

  1. 小型视频文件(<50MB)

    • 推荐使用前端 Canvas 方案
    • 适合快速预览和简单处理
    • 示例:视频缩略图生成
  2. 大型视频文件(>50MB)

    • 推荐使用服务端 FFmpeg 方案
    • 确保稳定性和性能
    • 示例:视频转码和帧提取服务
  3. 实时处理需求

    • 推荐使用 MediaRecorder API 方案
    • 适合视频流处理
    • 示例:实时视频预览
  4. 复杂处理需求

    • 推荐使用 Web Worker 并行处理方案
    • 适合需要显示进度的场景
    • 示例:批量视频处理

注意事项

  1. 内存管理

    • 及时清理不需要的帧数据
    • 使用 URL.revokeObjectURL() 释放资源
    • 大量帧处理时考虑分批处理
  2. 错误处理

    • 添加适当的错误处理和重试机制
    • 处理跨域问题
    • 考虑浏览器兼容性
  3. 性能优化

    • 使用适当的图片格式和压缩率
    • 控制并发处理数量
    • 添加进度反馈
  4. 用户体验

    • 添加加载提示
    • 显示处理进度
    • 提供预览功能

示例代码

完整的示例代码可以参考项目中的以下文件:

  • server/common.js - 服务端实现
  • common/CanvasVideoPreview.js - Canvas 实现
  • common/MediaRecorderApiCanvasVideo.js - MediaRecorder 实现
  • common/frame-worker.js - Web Worker 实现

参考文献

  1. FFmpeg 文档

  2. HTML5 Canvas

  3. Web Worker

  4. MediaRecorder

  5. 相关研究论文

    • 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
  6. 技术博客