概述
传统视频合成依赖后端 FFmpeg 命令行,用户需要上传图片、等待处理、再下载,整个流程重且延迟高。利用 @ffmpeg/ffmpeg 的 WebAssembly 编译版本,我们可以在浏览器中直接将图片序列合成为 MP4 视频,全程客户端完成,零服务端开销。本文覆盖:FFmpeg WASM 初始化、图片加载、合成参数控制(帧率/时长/分辨率)、进度回调、以及前端 vs 后端方案对比。
1. FFmpeg WASM 技术原理
FFmpeg 是 C/C++ 写的音视频处理库,通过 Emscripten 编译为 WebAssembly,可以在浏览器中运行。
C/C++ FFmpeg 源码
↓ Emscripten
FFmpeg.wasm
↓ 浏览器加载
JavaScript FFmpeg API
↓
虚拟文件系统 (MEMFS)
↓
创建图片序列 → 合成 → 输出 MP4 → ArrayBuffer → Blob → 下载
关键限制:
- WASM 版本功能为完整版子集(不包含所有编解码器)
- 内存受浏览器限制,不适合超长视频(建议 < 2 分钟)
- 首次加载需下载 ~31MB 的 WASM 文件
2. 安装与初始化
npm install @ffmpeg/ffmpeg @ffmpeg/util
// ===== ffmpeg-engine.ts =====
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
let ffmpeg: FFmpeg | null = null;
let isLoaded = false;
const BASE_URL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
export async function getFFmpeg(): Promise<FFmpeg> {
if (ffmpeg && isLoaded) return ffmpeg;
ffmpeg = new FFmpeg();
ffmpeg.on('log', ({ message }) => {
console.log('[FFmpeg]', message);
});
ffmpeg.on('progress', ({ progress, time }) => {
console.log(`[FFmpeg] ${Math.round(progress * 100)}% done`);
});
await ffmpeg.load({
coreURL: await toBlobURL(`${BASE_URL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${BASE_URL}/ffmpeg-core.wasm`, 'application/wasm'),
});
isLoaded = true;
return ffmpeg;
}
3. 图片序列合成视频
// ===== compose-video.ts =====
import { getFFmpeg } from './ffmpeg-engine';
import { fetchFile } from '@ffmpeg/util';
interface ComposeOptions {
images: string[]; // 图片 URL 或 Base64 数组
fps?: number; // 帧率,默认 24
outputWidth?: number; // 输出宽度
outputHeight?: number; // 输出高度
onProgress?: (percent: number) => void;
crf?: number; // 质量 (0-51, 越小越好),默认 23
}
export async function composeImagesToVideo(options: ComposeOptions): Promise<Blob> {
const { images, fps = 24, outputWidth = 1280, outputHeight = 720, onProgress, crf = 23 } = options;
if (images.length < 2) {
throw new Error('至少需要 2 张图片');
}
const ffmpeg = await getFFmpeg();
// === 步骤 1: 将所有图片写入 FFmpeg 虚拟文件系统 ===
for (let i = 0; i < images.length; i++) {
const data = await fetchFile(images[i]);
// 文件名格式:img0001.png, img0002.png ...
const paddedIndex = String(i + 1).padStart(4, '0');
await ffmpeg.writeFile(`img${paddedIndex}.png`, data);
onProgress?.(Math.round((i / images.length) * 30));
}
// === 步骤 2: 统一尺寸 + 编码为视频流 ===
// 先把所有图片 resize 成统一尺寸,再合成视频
const args = [
'-framerate', String(fps),
'-i', 'img%04d.png', // 输入序列
'-vf', `scale=${outputWidth}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2`,
'-c:v', 'libx264', // H.264 编码
'-crf', String(crf), // 质量
'-pix_fmt', 'yuv420p', // 兼容性最好的像素格式
'-preset', 'medium', // 编码速度
'-movflags', '+faststart', // 支持流式播放
'-y',
'output.mp4',
];
// 进度监听
ffmpeg.on('progress', ({ progress }) => {
onProgress?.(30 + Math.round(progress * 70));
});
await ffmpeg.exec(args);
// === 步骤 3: 读取输出文件 ===
const data = await ffmpeg.readFile('output.mp4');
const blob = new Blob([(data as Uint8Array).buffer], { type: 'video/mp4' });
// === 步骤 4: 清理虚拟文件系统 ===
for (let i = 0; i < images.length; i++) {
const paddedIndex = String(i + 1).padStart(4, '0');
await ffmpeg.deleteFile(`img${paddedIndex}.png`);
}
await ffmpeg.deleteFile('output.mp4');
return blob;
}
4. React 组件:图片选取 → 合成 → 下载
// ===== VideoComposer.tsx =====
import React, { useState } from 'react';
import { composeImagesToVideo } from './compose-video';
export default function VideoComposer() {
const [selectedImages, setSelectedImages] = useState<string[]>([]);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<'idle' | 'composing' | 'done' | 'error'>('idle');
const [videoUrl, setVideoUrl] = useState('');
const handleCompose = async () => {
if (selectedImages.length < 2) {
alert('至少选择 2 张图片');
return;
}
setStatus('composing');
setProgress(0);
try {
const blob = await composeImagesToVideo({
images: selectedImages,
fps: 12,
outputWidth: 1280,
outputHeight: 720,
onProgress: setProgress,
});
const url = URL.createObjectURL(blob);
setVideoUrl(url);
setStatus('done');
} catch (err: any) {
console.error(err);
setStatus('error');
}
};
const handleDownload = () => {
const a = document.createElement('a');
a.href = videoUrl;
a.download = `composed_${Date.now()}.mp4`;
a.click();
};
return (
<div className="video-composer">
<h2>图片合成视频</h2>
{/* 图片选择 */}
<input
type="file"
accept="image/*"
multiple
onChange={(e) => {
const files = Array.from(e.target.files || []);
Promise.all(
files.map((f) => new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(f);
}))
).then(setSelectedImages);
}}
/>
{/* 已选图片预览网格 */}
{selectedImages.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: 8, marginTop: 12 }}>
{selectedImages.map((img, i) => (
<img key={i} src={img} alt={`选中 ${i + 1}`} style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 4 }} />
))}
</div>
)}
<p>已选: {selectedImages.length} 张</p>
{/* 合成 */}
<button onClick={handleCompose} disabled={status === 'composing'}>
{status === 'composing' ? `合成中... ${progress}%` : '合成视频'}
</button>
{/* 进度 */}
{status === 'composing' && (
<div className="progress-bar-bg">
<div className="progress-bar-fill" style={{ width: `${progress}%` }} />
</div>
)}
{/* 结果 */}
{status === 'done' && (
<div className="result">
<video src={videoUrl} controls style={{ width: '100%', maxWidth: 480 }} />
<button onClick={handleDownload}>下载视频</button>
</div>
)}
{status === 'error' && <p style={{ color: 'red' }}>合成失败,请重试</p>}
</div>
);
}
5. 前端 WASM vs 后端方案对比
| 维度 | 前端 FFmpeg WASM | 后端 FFmpeg |
|---|---|---|
| 部署 | 无需服务端 | 需要服务器 + 安装 FFmpeg |
| 加载 | 首次 ~31MB WASM | 零 |
| 性能 | 受浏览器内存限制 | 无限制 |
| 适用场景 | 少量图片、短视频 | 大量图片、长视频、高清输出 |
| 离线能力 | 完全离线可用 | 需要网络 |
| 稳定性 | 浏览器差异 | 生产级稳定 |
选型原则:小文件/轻量场景用前端 WASM(无需后端、即合即下),大文件/生产环境用后端合成(稳、快、内存不受限)。
6. 多媒体素材管理扩展
与视频合成配套的素材管理模块:
interface MediaItem {
id: string;
type: 'image' | 'video';
url: string;
createTime: number;
}
// 关键设计:
// 1. 网格式展示 - CSS Grid 自适应列数
// 2. selectedIds 数组管理选中状态(不给每个 item 加 selected,性能更好)
// 3. 批量操作:删除、下载、合成视频
// 4. 双击全屏预览(图片用 img,视频用 video + controls)
// 5. 合成后的视频自动加入素材库
面试核心要点
问:FFmpeg WASM 是怎么在浏览器里跑的?
FFmpeg 是 C/C++ 写的,通过 Emscripten 编译为 WebAssembly 字节码。浏览器加载 .wasm 文件后,JS 通过 FFmpeg API 操作虚拟文件系统(MEMFS):把图片 writeFile 进去,exec 命令行参数,readFile 拿出 MP4。
问:大量素材如何优化?
- 虚拟列表(react-window)只渲染可视区域
- 图片懒加载(IntersectionObserver)
- 缩略图使用压缩版本
- 选中状态用数组管理
- 大量数据使用 IndexedDB 存储
问:图片合成视频,前端做还是后端做?
小文件用前端 WASM(轻量、离线、零服务端成本),大文件用后端合成(稳定、不受浏览器内存限制)。实际项目中我会根据图片数量、分辨率和服务端资源灵活选择。
总结
- FFmpeg WASM 把 C 语言的音视频处理能力搬进浏览器
- 虚拟文件系统 是核心抽象:writeFile → exec → readFile
- 合成流程:图片序列 → resize 统一尺寸 → H.264 编码 → MP4 输出
- 前端方案 适合少量/轻量场景,后端方案适合生产级处理
- 多媒体素材管理需配套网格展示、批量操作、全屏预览
Comments 留言讨论
还没有评论,来抢个沙发,聊聊你的看法~