ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ffmpeg.js 实战:用 WebAssembly 在浏览器中完成视频转码

ffmpeg.js 实战:用 WebAssembly 在浏览器中完成视频转码

简介:ffmpeg.js 是一套基于 WebAssembly 的 JavaScript 封装,利用浏览器原生能力,让开发者无需任何后端服务即可直接运行 FFmpeg,完成音视频转码、格式互转、截取片段等操作,有效避免传统服务端转码的部署成本。其面向熟悉 HTML5、JavaScript 的前端或全栈开发者,也适合需要本地化处理视频、保护隐私或降低服务器负载的项目团队。资源压缩包内含 122 个文件,共 3.44MB,其中 27 个 JS 文件是核心源码,6 个 HTML 为可直接运行的演示页面,8 个 Markdown 文档用于说明用法,PNG 图片展示操作界面或流程示意,YAML 配置和 TS 类型定义则方便工程化集成。配套内容覆盖摄像头采集转码、图片转视频、多路拼接等典型场景,并给出使用 createWorker 加载、write/transcode/read 管线的 API 示例,几行代码即可实现 AVI 转 MP4;包内还包含 ESLint 规则、Dockerfile、Git 配置等工程化文件,便于直接构建、调试与二次开发。目前已有 6400 人学习下载,是入门浏览器端视频处理时较完整的参考素材,无论是快速试验还是嵌入现有项目,都有实用价值。

1. ffmpeg.js:把 FFmpeg 装进浏览器,后端取消也不影响视频处理

做前端的人大概率都遇到过这种需求:用户上传一段手机拍摄的 4K 视频,想在页面上直接压缩、裁剪、或者抽一帧做封面。传统做法是扔给后端转码,但后端审批周期长、服务器带宽贵,遇到瞬时上传高峰还得排队。ffmpeg.js 的思路很反直觉:把 FFmpeg 编译成 WebAssembly,直接在浏览器里执行转码,全程不需要任何后端服务。你拖进来的视频从编码到导出都在本地完成,隐私性也好,适合做 PWA 离线工具、企业内网小工具、以及一切不想为此搭转码服务的场景。这篇我把这个资源从加载原理、命令移植到内存陷阱完整过一遍,照着操作能把一个能用的转换器写出来。

2. 最短链路:从加载核心到跑通第一个转码命令

2.1 先分清楚两个包:解码核心与封装层

ffmpeg.js 这个词在不同时期指代的东西不完全一样。老版本是一个编译好的单文件,直接引入就能用,但 API 比较粗糙,遇到一次转多个文件或者需要定制编解码器的时候就很难受。现在主流的做法是拆成两层:@ffmpeg/ffmpeg负责提供像load、exec、writeFile、readFile这类高层 API;@ffmpeg/core是真正被编译成 WASM 的 FFmpeg 本体。拆开的好处是你可以在不重装核心的情况下,只换封装层来适配自己的业务。

我一般会在 npm 里这样装:

npm install @ffmpeg/ffmpeg @ffmpeg/util

@ffmpeg/util提供fetchFile,它能把 File 对象直接转成 Uint8Array,省得自己写 FileReader。有人会疑惑为什么还需要单独拉这个工具包,其实老版本里这类方法都写在内核里,后来为了给包瘦身就拆出来了。你愿意的话完全可以自己用arrayBuffer()实现,替换成本也不高。

2.2 构建最小转码流程

下面这段是我跑通最短链路时的完整代码,逻辑很直白:加载核心,把用户选择的文件写入 FFmpeg 的虚拟文件系统,执行命令,再把结果读出来。

import { FFmpeg } from '@ffmpeg/ffmpeg'; import { fetchFile } from '@ffmpeg/util'; const ffmpeg = new FFmpeg(); await ffmpeg.load(); ffmpeg.writeFile('input.mp4', await fetchFile(fileInput.files[0])); await ffmpeg.exec(['-i', 'input.mp4', '-vf', 'scale=1280:-2', 'output.mp4']); const data = ffmpeg.readFile('output.mp4'); const blob = new Blob([data.buffer], { type: 'video/mp4' }); const url = URL.createObjectURL(blob);

这段代码的逻辑线是:先load()把 WASM 核心拉到浏览器内存,然后writeFile把用户文件丢进虚拟文件系统,接着exec执行 FFmpeg 参数,最后readFile把生成结果拿出来变成 Blob。注意exec里传的是完整的命令参数数组,不是一整条字符串,这点和命令行习惯完全不同。

参数含义逐个看:-i指定输入文件;-vf scale=1280:-2是把画面宽度压到 1280 像素,高度按原始宽高比自动计算,-2这个写法是为了让编码器满意,因为很多编码器要求宽度或高度是偶数,写成-2比写720更安全。最后output.mp4是输出文件名,也固定写在虚拟文件系统里。

2.3 理解虚拟文件系统,这是与命令行的本质差异

你在浏览器里跑的 FFmpeg 看不到真实磁盘文件,只能操作一个挂在内存里的虚拟文件系统。换句话说,所有输入文件必须先writeFile进去,所有输出文件必须用readFile取出来,不能像命令行那样直接/home/user/a.mp4。我刚开始用的时候就栽在这一点上,以为exec(['-i', './video.mp4', ...])能直接读取页面里的文件,结果输出文件的字节数永远是 0。

这也引出一个实践原则:文件命名尽量用短纯英文名,不要带中文、空格、括号。Windows 用户平时习惯了带空格的路径,但这里不是文件系统,是 FFmpeg 内部的内存表,空格路径在解析时容易出幺蛾子。后面我会专门写一节这块翻车案例。如果你想做更底层的文件管理,其实可以自己接手核心层做定制。

3. 把命令行参数迁移到浏览器:命令映射与多文件处理

3.1 常见 FFmpeg 命令到 ffmpeg.js 的映射

做前端的人接触到 FFmpeg 命令行时,最直接的诉求就是把以前写在 shell 里的命令搬过来。下面这张表是我平时迁移时固定对照的,列了三种最常见的处理类型:

场景命令行写法ffmpeg.js 里对应参数数组
压缩 H.264ffmpeg -i in.mp4 -c:v libx264 -crf 28 out.mp4['-i','in.mp4','-c:v','libx264','-crf','28','out.mp4']
裁剪ffmpeg -i in.mp4 -ss 10 -t 5 out.mp4['-i','in.mp4','-ss','10','-t','5','out.mp4']
抽取封面ffmpeg -i in.mp4 -frames:v 1 cover.jpg['-i','in.mp4','-frames:v','1','cover.jpg']

你会发现参数数组的写法跟命令行几乎一一对应,本质上只是把空格切成了数组元素。唯一要留意的是-vf这种带逗号的参数,例如scale=320:240、fps=15这类 filter 表达式,在数组里要写成一个完整的字符串,不要按逗号拆开,否则 FFmpeg 会解析出奇怪的结果。

3.2 从一行命令到可执行代码的完整迁移案例

我拿一个真实需求举例:把用户上传的视频压缩到 720p,同时去掉音频轨,输出成 mp4。命令行是这样:

ffmpeg -i raw.mp4 -vf scale=1280:720 -an -c:v libx264 -crf 30 -preset veryfast output.mp4

迁移到 ffmpeg.js 就是:

await ffmpeg.exec([ '-i', 'raw.mp4', '-vf', 'scale=1280:720', '-an', '-c:v', 'libx264', '-crf', '30', '-preset', 'veryfast', 'output.mp4' ]);

这里有几个值得展开的点。-an表示丢弃音轨,这能让压缩速度快不少,适合只需要画面预览的场景。-preset veryfast表示用更快的预设,浏览器里性能本身就比本机命令行弱,预设一定选veryfast或ultrafast,不要用slow,否则一个 30 秒的视频可能转 40 秒。-crf 30是质量参数,值越大画质越差文件越小,一般 23 到 30 之间比较合理,但浏览器场景我会默认给 28 到 32,因为用户通常要的是“能看”而非“高保真”。

还有个细节:ffmpeg.js 里输出编码器能力是受 WASM 编译时配置限制的。核心包里默认带的是 baseline 级 H.264 编码器,libx264这个别名存在,但高级参数不一定完整生效。如果遇到输出文件无法在某些播放器里播放,通常就是 H.264 的 profile/level 限制造成,后面避坑章会细讲。

3.3 多文件流水线:中间文件与异步注意点

单次exec只能执行一次命令,如果你需要先转码再裁剪,那就得执行两遍命令,而这两遍之间的临时文件要自己维护。我举个具体场景:把一个大视频先缩到小尺寸,再截取前五秒,最后输出 gif。实际执行过程是:

// 第一步:压缩尺寸 await ffmpeg.exec(['-i', 'input.mp4', '-vf', 'scale=480:-2', '-an', 'temp.mp4']); // 第二步:截取前五秒并转成 gif await ffmpeg.exec(['-i', 'temp.mp4', '-t', '5', 'out.gif']); const gifData = ffmpeg.readFile('out.gif');

这里需要特别提醒,temp.mp4会一直留在虚拟文件系统里,如果不清理,后续执行可能被旧文件干扰。遇到这个项目多文件交互时,我通常建议用deleteFile把临时文件清理掉。

另外一点容易让人困惑的是多个 FFmpeg 实例。有时候你会想同时执行两个转码任务,比如一个压缩、一个抽帧。每创建一个new FFmpeg()就重新加载一遍 WASM,内存占用是叠加的,我实测过同时开三个实例,在比较老的内存受限笔记本上就直接卡死。这个场景的正确做法是串行执行,或者只开一个实例,用exec依次执行,千万不要图省事并发开多个实例。如果一定要并发,可以试试 Worker 分发,每个 Worker 持有一个实例,那套玩法我会在下一章展开。

4. 大文件与 Worker:别让主线程卡死在转码过程中

4.1 进度消息与 log 回调

ffmpeg.js 默认会把转码日志打到控制台,但控制台日志对用户没用。你要做的是把进度读出来显示在页面上。官方封装的方式是注册一个log回调:

const ffmpeg = new FFmpeg(); ffmpeg.on('log', ({ message }) => { console.log(message); }); ffmpeg.on('progress', ({ progress, time }) => { if (progress >= 0) { progressBar.style.width = `${Math.round(progress * 100)}%`; } }); await ffmpeg.load();

这段代码里最实用的是progress事件,它的progress字段范围是 0 到 1,表示转码完成百分比。注意这个字段在转码刚启动时可能为 0,某些特殊任务下也可能一直为 0,比如纯音频转码,因为 FFmpeg 不一定能计算总时长。遇到这种情况不要慌,看一下time字段,或者干脆退回到 log 文本解析,log 里会周期性输出类似time=00:00:12.34的进度。

4.2 用 Worker 隔离线程,让页面不假死

视频转码是很重的计算,直接放在主线程会让 UI 卡成冰。正确处理方式是放进 Web Worker,主线程只负责收发消息。我常用的封装结构是这样:

// worker.js import { FFmpeg } from '@ffmpeg/ffmpeg'; let ffmpeg = null; self.onmessage = async (e) => { const { type, payload } = e.data; if (type === 'load') { ffmpeg = new FFmpeg(); await ffmpeg.load(); self.postMessage({ type: 'loaded' }); return; } if (type === 'transcode') { ffmpeg.writeFile('input.mp4', payload.fileData); await ffmpeg.exec(['-i', 'input.mp4', '-vf', 'scale=720:-2', 'output.mp4']); const data = ffmpeg.readFile('output.mp4'); self.postMessage({ type: 'done', data: data.buffer }, [data.buffer]); } };

主线程这边调用的时候,注意消息里要处理二进制数据,尤其是postMessage传回文件内容时要把data.buffer作为第二个可转移参数传出去,避免拷贝一份大内存。现代浏览器在postMessage里支持 transferable objects,加了之后大文件传输的内存开销能降不少。

Worker 方案有个额外好处:即使转码中途抛出异常导致 Worker 崩了,页面主体还是活着的,你可以捕获错误然后重建 Worker。我做过一个给用户上传视频压缩的工具,当时就靠 Worker 隔离避免了一次大视频转码过程中的界面白屏问题,那种血泪经验比什么优化技巧都值钱。

4.3 内存限制与超大视频的取舍

浏览器能分配给 WASM 的内存是有限的,通常在 2GB 左右,而且这 2GB 里要放输入文件、输出文件、中间缓冲、FFmpeg 运行时本身。我做过一次 1.2GB 视频直接读取再转码的实验,直接触发内存溢出。解决思路有两个:一是切片处理,用-ss和-t分段转码再拼接;二是不用readFile一次性读完,而是分块读取虚拟文件系统里的内容。切片方案在代码里非常好实现:

// 先看视频总时长,再循环分段处理 const totalDuration = 60; // 单位秒,可以用 -i 配合空输出获取 const chunkSize = 10; for (let start = 0; start < totalDuration; start += chunkSize) { await ffmpeg.exec([ '-i', 'input.mp4', '-ss', String(start), '-t', String(chunkSize), '-c:v', 'libx264', `chunk_${start}.mp4` ]); }

这段代码是把视频按 10 秒一段切成多个文件,最后再用 concat 协议合并。这里很容易翻车,因为部分关键帧和 B 帧的切割会导致各段之间画面重复或跳帧,简单用 concat 合并会出问题。更稳的做法是每段都重新编码关键帧间隔,-g 25这种参数可以固定 GOP 大小,这样各段间就没那么难衔接。但记得这只是权宜之计,真要生产级合并最好还是走服务端,或者用MediaRecorder配合 canvas 来做后续拼接。

5. 避坑指南:五次典型翻车,每个都值得记住

5.1 load 之后 exec 直接报错:核心包没加载成功

现象举例:load()返回成功后跑exec,立刻抛错,控制台出现RuntimeError: memory access out of bounds。

这个问题的原因通常是load()里没有指定核心文件路径,浏览器去默认位置加载@ffmpeg/core的.wasm,但打包工具(尤其是 Vite 的预构建阶段)会把.wasm文件处理异常,导致加载到的二进制不完整。解决方法是显式传入 core URL,或者把核心文件作为静态资源单独放置:

const ffmpeg = new FFmpeg(); await ffmpeg.load({ coreURL: '/vendor/ffmpeg-core.js', wasmURL: '/vendor/ffmpeg-core.wasm' });

这种做法我在很多项目里都用过,比让 bundler 自动处理省心非常多。注意coreURL和wasmURL必须都指向同一个核心版本,混用旧版和新版会直接黑匣子崩溃。

5.2 转换出的 mp4 在 iPhone 上播放不了

现象:桌面 Chrome 能播,传到微信里或 iPhone 自带播放器就黑屏没声音。原因大概率是 H.264 编码的时候用了baselineprofile,iPhone 对 profile 的要求更严格,至少要用main或high。解决方法是显式指定 profile:

await ffmpeg.exec([ '-i', 'input.mp4', '-c:v', 'libx264', '-profile:v', 'main', '-pix_fmt', 'yuv420p', 'output.mp4' ]);

-pix_fmt yuv420p也很关键,因为 FFmpeg 可能输出yuv444p或yuvj420p这类格式,部分播放器不支持。这两项加官方打包版本里都支持,加上基本能解决兼容性问题。

5.3 exec 执行成功但 readFile 拿到的数据只有几个字节

现象:readFile返回的 Uint8Array 长度完全不匹配预期,甚至长度为 0。原因通常是输出文件名字和输入文件冲突,或者你在执行完命令后又往虚拟文件系统里写入了同名文件把原文件覆盖了。我曾经在一个循环里犯过:第一次执行写output.mp4,第二次之前调用writeFile('output.mp4', ...)准备写新输入,结果把上一步的产物直接抹掉。

排查方法很简单,readFile之前列一下当前虚拟文件系统内容:

ffmpeg.list() # 或者在你使用的封装里调用对应方法

如果你看到只有输入文件而没有输出文件,基本就是命令参数写岔了。另外建议每次输出文件用时间戳命名,能避免大量这类低级问题。

5.4 转码过程宿主仓库或实测过“阻断误报” —— SharedArrayBuffer 不可用

现象:加载核心时报SharedArrayBuffer is not defined或提示需要 cross-origin isolation。原因是新版核心包可能启用了多线程,多线程依赖SharedArrayBuffer,而浏览器出于安全考虑只在响应头带Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp时才暴露这个 API。

解决方法是修改开发服务器或线上服务器响应头,或者回退到单线程核心版本。如果项目不想动服务器配置,我一般直接换单线程版核心,牺牲速度换稳定。这点在做内网工具时特别容易忽略,本地开发环境没问题是因为 Vite 开发服务器默认带了部分隔离头,一上测试服务器就炸。

5.5 WASM 加载时闪了个 404

现象:浏览器 Network 面板里看ffmpeg-core.wasm请求 404。这个大概率是路径问题,尤其是部署到子路径的站点,比如访问地址是https://example.com/tools/video,而你是用绝对路径/vendor/ffmpeg-core.wasm去加载,服务器当然找不到。解决方式就是别写绝对根路径,用相对路径或者 base 路径拼接:

const basePath = document.querySelector('base').getAttribute('href') || ''; await ffmpeg.load({ coreURL: `${basePath}vendor/ffmpeg-core.js`, wasmURL: `${basePath}vendor/ffmpeg-core.wasm` });

6. 封装一个可复用处理函数,把好习惯固化进代码

我在做某个视频处理 Demo 时,把上面这些教训全部揉进了一个统一接口:传入文件、处理参数、吞掉异常、返回结果。后续再写任何前端视频工具,我都直接复用这个函数,基本不用再去趟一遍坑。它的核心结构只有这么一点:

async function processVideo(inputFile, { width = 720, crf = 28 } = {}) { const ffmpeg = new FFmpeg(); await ffmpeg.load({ coreURL: '/vendor/ffmpeg-core.js', wasmURL: '/vendor/ffmpeg-core.wasm' }); const inputName = 'raw' + (inputFile.name.endsWith('.mp4') ? '.mp4' : '.dat'); ffmpeg.writeFile(inputName, await fetchFile(inputFile)); try { await ffmpeg.exec([ '-i', inputName, '-vf', `scale=${width}:-2`, '-c:v', 'libx264', '-profile:v', 'main', '-pix_fmt', 'yuv420p', '-crf', String(crf), '-preset', 'veryfast', 'output.mp4' ]); } catch (err) { console.error('transcode failed:', err); ffmpeg.deleteFile(inputName); throw err; } const data = ffmpeg.readFile('output.mp4'); ffmpeg.deleteFile(inputName); ffmpeg.deleteFile('output.mp4'); return new Blob([data.buffer], { type: 'video/mp4' }); }

这个封装里有三个值得借鉴的动作。一是try/catch里删除输入文件,避免异常后虚拟文件系统里残留垃圾;二是所有临时文件用短名,减少解析风险;三是把profile、pix_fmt写死,确保输出兼容性。每次我换个新环境做类似功能,只要把这个函数拷贝过去,改改参数,就能直接跑。当然你还可以扩展出进度回调、取消转码信号、Worker 多线程等,但基本骨架保持这样不动就够稳。从那以后我每次做前端视频工具都强制先走一遍这套封装流程,确认负载、参数、文件名三者都在掌控内才算开始,这习惯帮我避掉了不少无法解释的怪问题,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进