简介:无需后端服务即可在浏览器中直接运行 FFmpeg,完成视频转码、格式处理等任务,这是 ffmpeg.js 的核心价值。它面向前端开发者与音视频处理爱好者,提供简单易用的 API,只需几行代码就能加载、写入、转码并读取文件,避免视频上传服务端的延迟与隐私风险。这套资源包共包含 122 个文件,压缩后约 3.44MB,以 zip 压缩包形式提供;其中 JavaScript 源码与 HTML 示例页面占比最高,配合大量 PNG 截图、Markdown 说明文档以及 Dockerfile、ESLint 等工程配置,既能帮助理解原理,也可直接改造复用,目录结构清晰,便于按需查阅。已有 6400 人浏览学习。包内示例覆盖摄像头实时处理、图片转视频、拼接与解复用等典型场景,并配有演示动图与完整转码流程代码,可让读者快速掌握 ffmpeg.js 的调用方式,并在浏览器端视频项目中落地实践。
1. 浏览器里的 FFmpeg:为什么说“无需后端服务”是个真需求
视频处理工具放到浏览器里跑,这件事以前听起来像玄学。但随着 ffmpeg.js 这类方案的出现,你只要写 JavaScript,就能在网页上直接完成视频转码、裁剪、抽帧、加水印,全程不碰服务器。我最早接触这个方向,是因为一个内部工具需要在一台不装任何环境的机器上处理短视频,业务方又不允许文件上传到外部接口。当时第一反应是“这不可能”,后来被现实教育:把 FFmpeg 编译成 WebAssembly 塞进浏览器,不仅能跑通,而且处理几百 MB 的视频完全可行。这篇文章就从原理、选型、代码实现到踩坑记录,把这条链路完整拆给你看。适合前端开发者、工具箱爱好者,以及任何不想为小功能单独部署后端服务的人。
2. 原理与选型:asm.js、WebAssembly 与 ffmpeg 的浏览器生存记
2.1 从 C 代码到浏览器可执行文件:中间到底发生了什么
FFmpeg 本身是 C 语言写成的庞大项目,涉及音视频编解码、封装格式、滤镜链等数千个源文件。浏览器不能直接执行 C 代码,必须借助编译链把 C/C++ 转成 JavaScript 或 WebAssembly。常见做法是用 Emscripten 这套工具链,把 FFmpeg 源码交叉编译成.wasm文件,同时生成一薄层 JavaScript glue code,用来做内存管理、调用宿主 API、把 FFmpeg 的输出映射到浏览器环境。
这里有个关键点:FFmpeg 在本地运行时是直接读写磁盘文件,但在浏览器里没有真正意义上的文件系统。所以 Emscripten 会模拟出一个内存文件系统,常见的是 MEMFS(memory file system)。你读入的视频文件要先写入这个虚拟文件系统,FFmpeg 才能像操作普通文件一样去读它。执行完命令,输出文件同样落在内存文件系统里,你再把它取出来,转成 Blob 或二进制流供浏览器下载。
理解这个模型,后面所有代码就好懂了。你做的其实就是三步:把客户端选好的文件塞进虚拟目录 → 调用 FFmpeg 命令处理 → 从虚拟目录导出结果文件。表面上看你是在调 API,实际上是在操作一个沙箱里的文件系统。
2.2 ffmpeg.js(asm.js)和 ffmpeg.wasm 怎么选:看兼容性和性能
市面上存在两个易混淆的名字:ffmpeg.js 和 ffmpeg.wasm。ffmpeg.js 是早期的实现,编译产物是 asm.js,这是一种经过优化的 JavaScript 子集,能在老浏览器里跑,但性能远不如 WebAssembly。ffmpeg.wasm 是后者的 WebAssembly 版本,处理速度更快,包体也更小,是目前的主流选择。
我的建议是:新项目一律用 ffmpeg.wasm,别碰 asm.js。除非你的目标用户里还有一大票不支持 WebAssembly 的老旧设备。截至现在,主流浏览器对 WebAssembly 的支持已经非常稳定,没有理由为个例牺牲性能。ffmpeg.wasm 的 npm 包名通常是@ffmpeg/ffmpeg,核心 wasm 文件通过@ffmpeg/core加载。注意这两个包是分离的,前者是 API 封装,后者是实际的编解码内核,初始化时要传入核心文件的路径。
如果你只是做个演示 Demo,也可以把 wasm 文件放到静态资源目录,用构造函数传入coreURL。但生产环境我一般会用 CDN 或自己的对象存储,避免 npm 包内置的 wasm 路径在打包时被 tree-shaking 或误处理。
2.3 加载体积与内存基线:用之前先看的参数表
浏览器端跑 FFmpeg 不是没有代价的。一个典型的 ffmpeg.wasm 核心,wasm 二进制体积在 25~35MB 左右,加上 JavaScript glue code 和 worker 脚本,首次加载量并不小。这意味着你需要做加载态,甚至考虑按需加载,而不是在首页强推。
内存是更现实的约束。WebAssembly 的线性内存默认是 1GB 上限,但 ffmpeg.wasm 在构建时往往把这个值设得更高,而且浏览器分配给单个 tab 的内存是有限的,不同设备差异极大。比如有些手机浏览器,你刚写入一个 200MB 的视频,内存可能就告警了。
以下是几个需要提前知道的基线参数:
| 项目 | 典型范围 | 说明 |
|---|---|---|
| 核心 wasm 大小 | 25~35MB | 含编解码器,具体看构建配置 |
| 初始堆内存 | 约 200MB | 随配置可调,但调太高会增加崩溃风险 |
| 单文件建议上限 | 500MB | 超过后极容易出现内存溢出或浏览器崩溃 |
| 可同时运行实例 | 1 个 | 单线程限制下,多个实例没有意义 |
| 转码耗时 | 约为本地 FFmpeg 的 60~80% | 无 SIMD 优化时会更慢 |
这些数字来自我实际测试过的常见构建,不同版本会有出入。但你可以把它当成预算表:如果业务要求处理 2GB 的视频,浏览器端方案大概率扛不住,要么做分段处理,要么回到后端方案。
3. 在项目中集成 ffmpeg.wasm:从引入包到完成第一个转码任务
3.1 依赖安装与初始化:用对路径才能避免“空白页面”
先安装依赖,这里我用 npm:
npm install @ffmpeg/ffmpeg @ffmpeg/core如果你是在浏览器直接用<script>标签,那就需要把两个包的 dist 目录里的文件复制到静态目录。无论哪种方式,核心一步是把 wasm 文件拿给FFmpeg构造函数:
import { FFmpeg } from '@ffmpeg/ffmpeg'; const baseURL = '/ffmpeg'; // 你的静态资源目录 const ffmpeg = new FFmpeg(); await ffmpeg.load({ coreURL: `${baseURL}/ffmpeg-core.js`, wasmURL: `${baseURL}/ffmpeg-core.wasm`, });这段代码里,coreURL指向的是 JavaScript glue 文件,wasmURL指向实际的 wasm 二进制。很多人在这一步就踩坑:只指定了wasmURL,漏了coreURL,或者路径写错,导致加载时 404,页面直接白屏。注意load()是异步方法,必须在用户交互之后调用,并显示加载进度,否则会因浏览器自动播放策略干扰。
初始化之后,你还需要一个 worker 来跑转码,这和普通 Web Worker 不太一样。常见做法是:
const worker = new Worker('/ffmpeg/worker.js'); ffmpeg.worker = worker;有些封装版本里,这个 worker 配置是自动的。但手动指定时,务必确认 worker.js 的路径和你静态目录里的一致,否则会遇到“postMessage 后没有任何响应”的翻车现场。
3.2 读取文件并执行转码命令:核心 API 与参数的逐条说明
先看一个把 MP4 转成 WebM 的完整流程:
async function convertToWebM(file) { // 1. 写入输入文件到内存文件系统 const inputName = 'input.mp4'; await ffmpeg.writeFile(inputName, await fetchFile(file)); // 2. 执行转码 await ffmpeg.exec([ '-i', inputName, '-c:v', 'libvpx', '-c:a', 'libopus', '-b:v', '1M', 'output.webm' ]); // 3. 读取输出 const data = await ffmpeg.readFile('output.webm'); return new Blob([data.buffer], { type: 'video/webm' }); }这里fetchFile(file)是把 File 对象转成 Uint8Array 的常见辅助函数,ffmpeg.writeFile等价于在虚拟文件系统里创建一个文件。ffmpeg.exec接收的参数和命令行 FFmpeg 参数几乎完全一致,只是不需要写ffmpeg前缀,直接是参数数组。readFile返回的是一个Uint8Array,但注意它的内部结构是 Emscripten 的FS.Stream返回类型,有的版本会返回FileData对象,你需要取.buffer或.data来构造 Blob,具体看你的包版本。
参数说明:
-i input.mp4:指定输入文件。文件名必须和前面writeFile时完全一致,包括扩展名和路径(如果有子目录)。-c:v libvpx:指定视频编码器为 VP8。要更高质量可以改libvpx-vp9,但速度会慢很多。-c:a libopus:音频编码器用 Opus,WebM 常见搭配。-b:v 1M:设置视频码率 1 Mbps。不写的话,编码器会使用默认码率,可能让输出体积暴涨。
执行期间,你可以通过监听progress事件拿到进度信息。但注意,ffmpeg.wasm 的进度事件不是每帧都触发,而是按“时间戳”跳变,有的版本甚至没有进度事件,只能靠死等。所以我会加一个 loading UI,并做好超时处理。
3.3 拿到输出文件:从“内存文件系统”导出结果的三个方式
转码完成后,readFile只是第一步,你还需要把数据真正交给用户。三种常见方式:
// 方式一:Blob + URL.createObjectURL const blob = new Blob([data.buffer], { type: 'video/webm' }); const url = URL.createObjectURL(blob); // 方式二:直接触发下载 const a = document.createElement('a'); a.href = url; a.download = 'output.webm'; a.click(); // 方式三:转成 ArrayBuffer 后做自定义处理 const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);方式一适合在页面里嵌视频预览。方式二是最常见的“点击下载”。方式三适合你后续要上传到某处,或者做二进制分析。这里有个坑:data对象可能不是纯粹的 Uint8Array,而是带偏移的视图。直接new Blob([data])在有些浏览器上会得到包含额外字节的内容。所以我总是用slice(data.byteOffset, ...)来裁剪,这是血泪换来的。
4. 常用命令改造:裁剪、截图、加音频流,照着改参数就行
4.1 裁剪视频片段:时间点参数要注意“秒”还是“毫秒”
很多人习惯用-ss和-t来裁剪,但在浏览器端跑 FFmpeg,时间基准和快捷键操作可能不一致。看这个例子:
// 裁剪从第10秒开始,持续15秒 await ffmpeg.exec([ '-i', 'input.mp4', '-ss', '10', '-t', '15', '-c', 'copy', 'cut.mp4' ]);这里-ss 10表示从第 10 秒开始,-t 15表示持续 15 秒。当你使用-c copy时,-ss最好放在-i之前,因为这样可以启用在关键帧索引上的快速寻址,不会造成画面卡顿或黑屏。如果-ss放在-i后面,且没有重编码,裁剪起点可能不是精确的。如果你追求精确到帧,那必须重新编码(去掉-c copy),并配合-fps_mode vfr或-vsync处理时间戳。
浏览器端的问题在于,部分构建版本不支持-ss在-i之前的快速模式。所以我会默认写成-ss 10 -i input.mp4,再根据结果微调。另外,时间参数单位是秒,如果从 UI 收到的是毫秒,记得除以 1000。
4.2 提取视频帧:fps 和质量因子怎么搭配
视频抽帧是常见需求,比如生成预览图。命令如下:
await ffmpeg.exec([ '-i', 'video.mp4', '-frames:v', '1', '-q:v', '2', 'frame.jpg' ]);-frames:v 1表示只输出一帧,-q:v 2是输出 JPEG 的质量因子,数字越小质量越高(通常在 2-5 之间)。如果你想每隔一秒抽一帧,可以加-vf fps=1:
await ffmpeg.exec([ '-i', 'video.mp4', '-vf', 'fps=1', 'frame_%03d.jpg' ]);这里%03d是文件名递增占位符,会生成frame_001.jpg、frame_002.jpg……但注意:FFmpeg 在内存文件系统里生成多张图片时,所有文件都会存在 MEMFS 里。如果你要导出,就得用readFile遍历所有文件名。不要试图用通配符去readFile,只能一个个读。所以我一般建议输出到 zip 包再导出,这需要额外引入压缩库。
4.3 给视频加背景音乐:流复制 vs 重编码的选择
这个需求也很典型:给视频配上音轨。一种粗暴做法是把音频直接拼上去,但不会混音。更常见的是“加背景音乐且降低原声”或“替换音轨”:
// 用背景音乐替换原音轨 await ffmpeg.exec([ '-i', 'silent.mp4', '-i', 'music.mp3', '-c:v', 'copy', '-c:a', 'aac', '-shortest', 'output.mp4' ]);这里有两个输入文件:第一个是视频,第二个是音频。-c:v copy表示视频不重新编码,速度极快;-c:a aac是音频重新编码为 AAC,这是为了和新容器兼容。-shortest表示最终时长以短的输入为准,避免音频或视频过长导致文件异常。
如果你要“原声和背景音乐混合”,那就复杂了,需要用-filter_complex:
await ffmpeg.exec([ '-i', 'video.mp4', '-i', 'music.mp3', '-filter_complex', '[0:a][1:a]amix=inputs=2:duration=first[a]', '-map', '0:v', '-map', '[a]', '-c:v', 'copy', '-c:a', 'aac', 'merged.mp4' ]);这里的amix滤镜会把两个音轨混在一起,duration=first表示以第一个输入音轨的时长为准。注意-map参数必须显式指定,否则 FFmpeg 可能选取默认流,导致视频轨道丢失或音频只有一路。这种复杂滤镜在浏览器端跑,非常吃计算资源,如果视频分辨率高,建议先降低分辨率或者在 worker 里跑,避免卡死主线程。
5. 避坑手册:我在浏览器端调 FFmpeg 踩过的 5 个坑
5.1 找不到输入文件:明明 writeFile 成功了,却说 No such file or directory
现象:转码命令报错ffmpeg: cant open input file 'input.mp4': No such file or directory,但代码里确实调用了writeFile。
原因:最常见的原因是写入的文件名和命令里的文件名不一致。比如你writeFile时用了input.mp4,但命令里写了./input.mp4或者大小写不对。此外,有的版本writeFile默认写到根目录,而命令里带了子路径。
解决:统一命名规范,全部小写,检查-i后面的字符串和你传入的字符串完全相等。我写过一个小函数,把用户选择的文件名过滤掉特殊字符再writeFile,命令里直接用这个干净的字符串,基本能杜绝问题。同时,如果你用了ffmpeg.writeFile后立刻exec,建议中间await一瞬间给文件系统 flush,偶尔有异步写入未完成的玄学问题。
5.2 大文件转码时浏览器崩溃:内存直接爆掉
现象:输入一个 400MB 的 MP4,转码开始几秒后,页面变灰,控制台报Out of Memory或直接页面崩溃。
原因:ffmpeg.wasm 处理文件时,会复制一份输入到内存文件系统,转码中间还会产生多帧缓冲,内存峰值可能是输入文件大小的 2-3 倍。如果你输入 400MB,峰值可能到 1GB 以上,浏览器 tab 扛不住。
解决:对文件大小做前端拦截,超过 200MB 就提示用户;或者做分片处理,但这会复杂很多。我通常用ffprobe(wasm 版本里也有)先读元数据,判断时长和码率,再用更低的分辨率参数去转码。另外,可以尝试在ffmpeg.load时传memory配置,但改过之后稳定性更差。我的经验是:把单文件上限控制在 300MB 以内最稳,否则就老实走后端。
5.3 转码像卡死一样不动:没有多线程,单核心硬扛
现象:转码开始后,CPU 占用只有 100%(单核),进度条半天不动,感觉像死循环。
原因:Emscripten 编译的 FFmpeg 默认是单线程构建,没有启用 pthread 和 SIMD。所以它只能用一个 CPU 核心,处理速度远不如本地 FFmpeg。这不是 bug,是构建配置决定的。
解决:换用官方的@ffmpeg/core-mt版本,它支持多线程,需要你额外部署 SharedArrayBuffer 相关响应头。但开启多线程后,还要处理跨线程内存问题,复杂度上升。我的建议是:对于短视频(小于 1 分钟),单线程可以接受;更长的视频,提前告知用户“预计需要几分钟”,不要让人以为卡死了。另外,把转码放在 Worker 里跑,至少不阻塞界面,桌面上能看到进度。
5.4 下载下来的视频无法播放:MIME 类型和扩展名对不上
现象:转码完成后,下载得到一个.webm文件,但双击打开说格式不支持,或者浏览器内预览黑屏。
原因:Blob构造时如果你直接写new Blob([data], { type: 'video/mp4' }),而实际内容是 WebM,播放器按照 MP4 容器解码就会失败。还有一种是文件名后缀和编码格式不匹配,比如用libvpx编码但文件名写.mp4。
解决:先确定你的输出容器和后缀一致,再给 Blob 传正确的 MIME。我一般会写一个映射表:
const MIME_MAP = { 'mp4': 'video/mp4', 'webm': 'video/webm', 'jpg': 'image/jpeg', 'png': 'image/png', };下载时,a.download也用这个映射来取名。这样即使浏览器对某些 MIME 敏感,也能尽可能正常解析。
5.5 自带编解码器太少:转出来的视频没有声音,或干脆不支持
现象:用-c:v copy导出某些格式时,命令报Unknown encoder 'libx264',或者转出来的视频文件很小,打开只有画面没声音。
原因:@ffmpeg/core 不是完整版 FFmpeg,只捆绑了部分编解码器。比如有些构建默认没带libx264(常见于专利原因),或者音频 encoder 只支持aac、mp3,不支持opus。如果你用到未包含的编码器,命令直接失败;如果只包含了解码器,没有编码器,就会产生“解析成功但写不出数据”的翻车。
解决:在选型阶段先确认你的目标格式需要哪些编解码器。常用组合是:视频用libvpx(VP8/VP9)输出 WebM,音频用aac输出 MP4。如果一定要 H.264,需要找支持libx264的构建包,比如某种定制版 core。这时候不要自己从头编译,找一个社区维护好的构建直接用,省事。我最后悔的一次是自己编译 core,折腾三天,最后发现只是少了一个配置项,白白浪费时间。
6. 进阶技巧:把转码任务封装成一个可取消的异步队列
当你把 ffmpeg.wasm 集成到实际项目里,马上会意识到一个问题:转码是耗时的异步任务,用户可能同时排队多个文件,也可能点击后想取消。我先写一个极简的队列封装:
class TranscodeQueue { constructor(ffmpeg) { this.ffmpeg = ffmpeg; this.queue = []; this.running = false; this.abortFlag = false; } add(task) { this.queue.push(task); if (!this.running) this._run(); } cancel() { this.abortFlag = true; // 尝试结束当前 ffmpeg 进程 this.ffmpeg?.terminate?.(); } async _run() { this.running = true; while (this.queue.length > 0) { const task = this.queue.shift(); this.abortFlag = false; try { await task.process(); } catch (e) { console.error('转码失败:', e); } } this.running = false; } }这个封装的思路:所有任务进队列,_run依次执行,active标志保证只有一个任务在跑。cancel()暴力调用ffmpeg.terminate()杀掉当前进程,然后下一个任务启动时再重新load(),因为 terminate 后核心就不可用了。
使用方式:
const queue = new TranscodeQueue(ffmpeg); queue.add({ process: () => convertToWebM(file1) }); queue.add({ process: () => cutVideo(file2) });这个方案看似简单,但解决了几个实际问题:避免多个实例同时操作共享的 WASM 内存;取消时能真正中断执行,而不是像exec那样无法中途停止。当然,terminate()会释放内存,所以取消后需要重新ffmpeg.load(),这属于“后悔药”机制,总比页面崩溃强。
最后说一个我的习惯:以后凡是遇到浏览器端 FFmpeg 任务,我都在写代码前先把可能出现的文件大小、编码器、内存峰值列出来,再决定用哪个 core 和什么策略。这个习惯让我少了很多返工。这套方案不是万能的,但只要你摸清它的边界,在合适场景下,它就是绕过后端服务的最短路径。希望帮到你。
本文还有配套的精品资源,点击获取