简介:这份资源围绕 ffmpeg.js 展开,面向希望在前端直接完成音视频处理的前端开发者与多媒体应用实践者,解决传统转码必须依赖后端服务、部署链路冗长的问题。压缩包共 122 个文件,约 3.44MB,以 61 个 png 截图、27 个 js 脚本、8 个 md 说明文档、6 个 html 示例页为主,辅以少量 json、yml 配置及 avi、wav、ogg 等测试媒体,覆盖源码、演示页面与运行配置。资源提供简洁易用的 API,几行代码即可完成视频转码,并附网络摄像头、图片转视频、concatDemuxer 等演示场景,便于理解浏览器端 FFmpeg 的加载、写入、转码与读取流程。已有 6402 人学习下载,适合前端工程师、在线剪辑工具开发者参考,可据此搭建无后端依赖的音视频处理原型,并借鉴其目录组织与示例排错思路。
1. 浏览器里跑 FFmpeg:一个把转码后端整个省掉的方案
上周有个做在线课件工具的朋友找我,说他们上传视频后要转码、抽帧、裁时长,后端排了一台机器专门跑 FFmpeg,队列一堵用户就骂。我让他把 ffmpeg.js 这套东西拉下来试了试,结果一半的活直接在浏览器里干完了,服务器只负责存文件。这就是 ffmpeg.js 的价值:把 FFmpeg 编译成 WebAssembly,配合 JavaScript 胶水层,让音视频转码、格式转换、截取、抽帧这些操作在用户本机完成,不需要任何后端转码服务。它适合做在线剪辑、课件处理、格式转换工具的前端开发者,也适合想砍掉转码服务器成本的小团队。下面按「它怎么跑起来 → 参数怎么配 → 哪里会翻车」的顺序拆。
2. 先搞懂 wasm 加载链路:为什么它能不靠后端转码
2.1 FFmpeg 被拆成了哪几块
原生 FFmpeg 是一个命令行程序,靠操作系统提供文件读写、线程、内存。搬到浏览器里,这三样东西全变了:文件系统没了,线程受限制,内存要手动管。ffmpeg.js 的做法是把 FFmpeg 的 C 代码用 Emscripten 编译成.wasm二进制,再用一层 JS 模拟出虚拟文件系统(MEMFS),把用户选的File对象写进这个虚拟盘,然后调用编译出来的ffmpeg_main入口,参数和命令行几乎一模一样。
所以你在浏览器里写的其实是这么一行等价命令:
ffmpeg -i input.mp4 -ss 00:00:05 -t 10 -c copy output.mp4只不过input.mp4和output.mp4都在内存里的虚拟文件系统中,跑完再从虚拟盘读出来变成Blob给用户下载。理解这一点很关键:所有路径都是虚拟路径,不是真实磁盘路径,写错路径是最常见的第一个坑。
2.2 加载顺序与初始化代码
这套资源的核心文件通常包含ffmpeg-core.js、ffmpeg-core.wasm和一个封装脚本。加载顺序不能乱:先加载 core 的 JS,再等 wasm 实例化完成,最后才能调exec。下面是我一般会写的初始化骨架:
// 加载 ffmpeg-core,注意 wasm 路径要和实际部署目录一致 const coreURL = '/static/ffmpeg/ffmpeg-core.js'; const wasmURL = '/static/ffmpeg/ffmpeg-core.wasm'; async function initFFmpeg() { // 动态注入 core 脚本,等 onload 再继续 await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = coreURL; s.onload = resolve; s.onerror = () => reject(new Error('core 脚本加载失败')); document.head.appendChild(s); }); // createFFmpeg 是封装层提供的工厂函数 const ffmpeg = createFFmpeg({ log: true, // 打开日志,排错时必开 corePath: coreURL, // 告诉封装层去哪找 core wasmPath: wasmURL, // 显式指定 wasm,避免相对路径猜错 mainName: 'main' // 入口函数名,和编译产物保持一致 }); await ffmpeg.load(); // 这一步才会真正实例化 wasm return ffmpeg; }逻辑说明:createFFmpeg只是配置对象,load()才是真正把 wasm 拉下来实例化的动作,没await就调exec必然报「not loaded」。log: true在开发期一定要开,FFmpeg 的报错信息全在日志里,关掉等于蒙眼开车。wasmPath显式写死比依赖默认相对路径稳,打包工具改目录结构时不会莫名其妙 404。
参数上,corePath和wasmPath要指向同一次构建产物,版本错配会出现「wasm 实例化成功但 exec 崩溃」这种玄学现象。mainName一般不用改,除非你拿到的编译产物入口名不同,改错会直接报函数不存在。
2.3 一次完整的转码调用
初始化完成后,一次「把 mp4 转成 webm 并压缩」的调用长这样:
async function transcode(file) { const ffmpeg = await initFFmpeg(); // 1. 把浏览器 File 读成 Uint8Array 写进虚拟文件系统 const data = new Uint8Array(await file.arrayBuffer()); ffmpeg.FS('writeFile', 'input.mp4', data); // 2. 执行转码,参数和命令行一致 await ffmpeg.run( '-i', 'input.mp4', '-c:v', 'libvpx-vp9', '-b:v', '1M', '-c:a', 'libopus', 'output.webm' ); // 3. 从虚拟盘读回结果 const out = ffmpeg.FS('readFile', 'output.webm'); const blob = new Blob([out.buffer], { type: 'video/webm' }); // 4. 清理虚拟盘,否则内存只增不减 ffmpeg.FS('unlink', 'input.mp4'); ffmpeg.FS('unlink', 'output.webm'); return blob; }逻辑说明:FS('writeFile', ...)是 Emscripten 虚拟文件系统的接口,第一个参数是操作名,后面跟虚拟路径和数据。run接收的是变长参数列表,每个参数单独一个字符串,不要拼成一整条命令字符串传进去,那样会被当成单个参数解析失败。读回结果时out.buffer才是底层ArrayBuffer,直接传out给Blob在某些浏览器上会得到空文件。
参数上,-b:v 1M是目标码率,浏览器端转码 CPU 吃紧,码率别设太高;libvpx-vp9编码慢但压缩好,追求速度可以换libvpx。最后那两步unlink是血泪经验,不清理的话连续转十个文件,标签页内存能飙到一两个 G,用户直接白屏。
3. 参数与虚拟文件系统:把命令行经验平移过来
3.1 常用参数在浏览器里的等价写法
原生 FFmpeg 的参数绝大多数能直接用,但有几类要特别注意。下面这张表是我实际跑过、确认在 wasm 版本里可用的组合:
| 需求 | 参数写法 | 浏览器端注意点 |
|---|---|---|
| 截取片段 | -ss 00:00:05 -t 10 | -ss放-i前是快速定位,放后面是精确但慢 |
| 只换容器不重编码 | -c copy | 速度极快,但源流编码不被目标容器支持时会失败 |
| 抽帧成图片 | -vf fps=1 out_%03d.jpg | 输出多文件,要循环readFile逐个取 |
| 提取音频 | -vn -c:a libmp3lame | 需要编译时带 mp3 编码器,不是所有构建都有 |
| 缩放分辨率 | -vf scale=1280:-1 | -1表示按比例算,别写-1:-1 |
-c copy这一行值得单独说:很多人以为换容器一定秒完成,结果遇到源视频是 H.265、目标容器是 webm 时直接报错,因为 webm 不支持 H.265。这种时候要么换目标格式,要么老老实实重编码。判断方法很简单,看日志里有没有Could not find tag for codec,有就是编码不被容器接受。
3.2 多文件输出与抽帧的读取方式
抽帧是这套方案里很实用的场景,比如给视频生成封面候选。输出是多个文件,读取要按命名规则循环:
async function extractFrames(file, fps = 1) { const ffmpeg = await initFFmpeg(); ffmpeg.FS('writeFile', 'src.mp4', new Uint8Array(await file.arrayBuffer())); // 每秒抽一帧,输出 frame_001.jpg、frame_002.jpg ... await ffmpeg.run('-i', 'src.mp4', '-vf', `fps=${fps}`, 'frame_%03d.jpg'); // 按序号读取,直到读不到为止 const frames = []; for (let i = 1; ; i++) { const name = `frame_${String(i).padStart(3, '0')}.jpg`; try { const buf = ffmpeg.FS('readFile', name); frames.push(new Blob([buf.buffer], { type: 'image/jpeg' })); ffmpeg.FS('unlink', name); // 读一个删一个,控制内存 } catch (e) { break; // 读不到说明序号到头了 } } ffmpeg.FS('unlink', 'src.mp4'); return frames; }逻辑说明:%03d是 FFmpeg 的序号占位符,会从 001 开始递增,所以读取时用padStart(3, '0')对齐。readFile读不存在的文件会抛异常,正好拿来当循环终止条件,比事先算帧数省事。每读一帧就unlink,是因为抽帧动辄几十上百张,全堆在虚拟盘里内存扛不住。
参数上,fps=1是每秒一帧,想要更密可以设fps=5,但输出文件数会成倍涨,浏览器内存和下载体验都要权衡。如果只要第一帧当封面,用-frames:v 1比抽帧再取第一张更省。
3.3 大文件的分块处理思路
浏览器内存有上限,几百兆的视频一次性读进Uint8Array再写虚拟盘,很容易触发标签页崩溃。常见做法是先判断大小,超过阈值就走分块:用-ss和-t把长视频切成若干段分别转码,最后在前端按顺序拼接播放列表,或者干脆提示用户分段上传。我一般把阈值设在 200MB 左右,超过就切段,每段 30 秒,转完一段释放一段的虚拟盘空间。这不是 FFmpeg 本身的限制,是浏览器单标签页内存的现实约束,绕不过去,只能设计上规避。
4. 避坑与排查:五个真实翻车现场
4.1 报「FS is not a function」或虚拟盘操作全失败
现象:load()成功了,但一调ffmpeg.FS('writeFile', ...)就报方法不存在。 原因:封装层版本和 core 版本不匹配,老封装层把文件系统挂在别的命名空间下,或者 core 编译时没导出 FS 接口。 解决:确认ffmpeg-core.js、ffmpeg-core.wasm、封装脚本来自同一份构建产物,别东拼西凑。换版本时三个文件一起换,别只更新 wasm。
4.2 转码中途标签页崩溃或白屏
现象:小文件正常,稍大一点跑到一半页面就挂了,控制台没有明确报错。 原因:虚拟盘里的输入、输出、临时文件全占内存,加上 wasm 堆本身有上限,累计超了就崩。 解决:每次run结束后unlink所有中间文件;大文件走 3.3 的分块;createFFmpeg时如果封装层支持TOTAL_MEMORY之类的内存参数,适当调大,但别指望无限加,浏览器有硬顶。
4.3 输出文件是 0 字节或无法播放
现象:run没报错,readFile也读到了,但下载下来打不开。 原因:多半是-c copy用在了编码不兼容的场景,或者输出容器扩展名和实际编码对不上;也可能是读回时传了out而不是out.buffer。 解决:先开log: true看完整日志,搜Could not find tag或Invalid argument;确认Blob构造用的是buf.buffer;不确定编码兼容性时先去掉-c copy走重编码验证链路是否通。
4.4 首次加载慢到用户以为卡死
现象:页面打开后十几秒没反应,wasm 文件几百 KB 到几 MB 不等。 原因:ffmpeg-core.wasm体积不小,首次加载要走网络下载加实例化,弱网下更明显。 解决:把 wasm 放 CDN 并开压缩传输;初始化放在用户真正触发转码时再做,别页面一进来就load();加载期间给明确的进度提示,别让用户对着白屏猜。
4.5 移动端浏览器直接失败
现象:桌面正常,手机上一调就报内存不足或直接闪退。 原因:移动端浏览器对 wasm 内存和单标签页资源限制更严,大视频基本没戏。 解决:移动端只做小文件、短时长、低分辨率的轻量任务,比如抽帧、裁剪几秒;重活引导到桌面端或退回服务端处理。别指望一套逻辑通吃所有端。
5. 进阶:把转码做成可验证的流水线
真正把这套东西用稳,关键不在会调run,而在每次改动参数后能快速验证结果对不对。我现在的习惯是给每个转码任务配一个「自检」步骤:转完之后不急着给用户下载,先用同一套 wasm 跑一次-i output.xxx探测输出文件的时长和流信息,和预期比对。
async function verifyOutput(ffmpeg, name, expectDurationSec) { // 用 -i 探测,FFmpeg 会把信息打到日志里 const logs = []; ffmpeg.setLogger(({ message }) => logs.push(message)); try { await ffmpeg.run('-i', name); } catch (e) { // 只给 -i 不给输出,FFmpeg 会以非零码退出,属正常 } const text = logs.join('\n'); const m = text.match(/Duration:\s(\d+):(\d+):(\d+)/); if (!m) return { ok: false, reason: '无法解析时长' }; const sec = (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]); return { ok: Math.abs(sec - expectDurationSec) < 2, actual: sec }; }这段的价值在于把「看起来转成功了」变成「时长对得上」。-i单独使用时 FFmpeg 会打印流信息然后以错误码退出,这是它的固有行为,用try/catch吞掉即可,别当成失败。setLogger挂上回调后,日志不再只进控制台,可以结构化解析。参数上,时长容差我一般给 2 秒,容器封装和关键帧对齐会有小幅偏差,卡太死会误报。
再往前一步,可以把常用任务做成配置表,而不是散落在各处的run调用:
| 任务类型 | 关键参数 | 预期产物 | 自检项 |
|---|---|---|---|
| 压缩 | -b:v 1M -c:a aac | 单文件 | 时长一致、码率下降 |
| 裁剪 | -ss -t -c copy | 单文件 | 时长等于-t |
| 抽帧 | -vf fps=N | 多图 | 张数约等于时长乘 N |
| 提音频 | -vn -c:a libmp3lame | 单文件 | 无视频流 |
有了这张表,新人接手也知道每个任务该验什么,不用靠口口相传。从那以后我每次改转码参数,都强制先跑一遍自检再交付,宁可多花两秒探测,也不想用户下载到一个打不开的文件再回来找。希望帮到你。
本文还有配套的精品资源,点击获取