简介:这份资源围绕 ffmpeg.js 展开,面向希望在前端直接完成音视频处理、又不想搭建后端转码服务的开发者,尤其适合具备一定 JavaScript 基础、需要实现浏览器端视频转码与媒体处理的技术人员。压缩包共 122 个文件,约 3.44MB,以 61 个 png 截图、27 个 js 脚本、8 个 md 说明文档和 6 个 html 示例页面为主,另含少量 json、yml 配置及 avi、wav、ogg 等测试媒体,便于边看文档边运行示例。资源提供简洁易用的 API,几行代码即可完成视频转码,并附有网络摄像头、图片转视频、concatDemuxer 等演示页面,可帮助读者理解浏览器内 FFmpeg 的加载、写入、转码与读取流程。目前已有 6402 人学习下载,适合作为前端音视频处理的入门与参考素材。
1. 浏览器里跑 FFmpeg:一个纯前端音视频处理方案的真实用法
第一次看到「浏览器里直接跑 FFmpeg」这个说法,我以为是标题党。直到有个需求摆在面前:用户要在网页上把一段视频转成 GIF、裁掉黑边、再压一下码率,而服务器那边明确不给开转码服务——带宽、成本、合规都是问题。这时候 ffmpeg.js 这类方案就成了唯一出路:它把 FFmpeg 编译成 WebAssembly,配合 Emscripten 生成的胶水代码,让整个音视频处理链路完全跑在用户的浏览器里,后端一行代码都不用写。适合谁?做在线剪辑工具的前端、需要本地预处理上传文件的产品、以及想给 Web 应用加一点「重活」能力但不想养转码集群的团队。下面按「它是什么 → 怎么跑起来 → 怎么改」拆一遍。
2. 技术底座拆解:WebAssembly 版 FFmpeg 是怎么塞进浏览器的
2.1 从 C 到 wasm:编译链路决定了能力边界
FFmpeg 本体是 C 写的,浏览器不认识 C,所以中间必须过一道 Emscripten。整条链路大致是:FFmpeg 源码 → 配置裁剪(--disable-everything再按需开 codec)→emconfigure/emmake交叉编译 → 产出.wasm二进制 +.js胶水文件。胶水文件负责三件事:加载 wasm 模块、在 JS 和 wasm 线性内存之间搬运数据、把 POSIX 风格的open/read/write映射到 Emscripten 的虚拟文件系统(MEMFS)。
这里有个关键认知:浏览器里的 FFmpeg 没有真正的磁盘。所有输入输出文件都活在内存里的 MEMFS 中,你writeFile进去一个input.mp4,FFmpeg 处理完再readFile出来一个output.gif。这意味着文件大小直接受内存限制,几百 MB 的视频在低端机上很容易把标签页干崩。常见做法是前端先做分片或降采样,别指望它处理 4K 长片。
// 典型的 Emscripten 模块初始化:等 wasm 就绪后再操作虚拟文件系统 const ffmpeg = await createFFmpegCore({ // locateFile 告诉加载器去哪里找 .wasm,路径要和打包产物一致 locateFile: (path) => `/wasm/${path}`, // 打印 FFmpeg 的 stderr,调试时非常有用 print: (text) => console.log('[ffmpeg]', text), printErr: (text) => console.warn('[ffmpeg-err]', text), }); // MEMFS 写入:把用户选的 File 读成 Uint8Array 再塞进虚拟盘 const data = new Uint8Array(await file.arrayBuffer()); ffmpeg.FS.writeFile('input.mp4', data);locateFile是最容易翻车的地方——打包工具(Vite/Webpack)会把.wasm改名加 hash,路径对不上就 404,控制台只报一句failed to asynchronously prepare wasm,新手很容易卡在这里。print/printErr两个回调建议一开始就接上,FFmpeg 的报错信息全走 stderr,不接等于黑匣子。
2.2 目录结构与模块职责
一个能跑的最小工程,目录大致长这样:
| 路径 | 职责 |
|---|---|
public/wasm/ffmpeg-core.wasm | 编译产物,体积最大,必须原样静态托管 |
public/wasm/ffmpeg-core.js | Emscripten 胶水代码,负责加载与内存桥接 |
src/ffmpeg/load.ts | 单例加载器,保证 wasm 只初始化一次 |
src/ffmpeg/exec.ts | 封装exec调用,拼参数、读结果、清理 MEMFS |
src/ffmpeg/worker.ts | Web Worker 入口,把重活挪出主线程 |
src/components/Transcoder.tsx | UI 层,选文件、显示进度、下载产物 |
职责划分的核心原则是:加载器只管加载,执行器只管执行,Worker 只管隔离。把这三件事混在一个文件里,后期加个「取消任务」功能就得大改。我一般会把exec的返回值统一成{ code, stdout, stderr, output },调用方只看code === 0判断成败,产物从output拿,这样上层 UI 不用关心 FFmpeg 细节。
2.3 为什么必须放进 Web Worker
FFmpeg 的 wasm 执行是同步阻塞的。哪怕你await了加载,真正跑exec的那几秒到几十秒里,主线程是被占死的——页面卡住、按钮点不动、进度条不刷新。血泪经验:第一版我图省事直接在主线程跑,转一个 30 秒视频,整个页面冻了 8 秒,用户以为崩了直接关掉。
正确姿势是把 wasm 实例整个放进 Worker,主线程只通过postMessage传ArrayBuffer和参数。注意ArrayBuffer是可转移对象(transferable),传的时候用第二个参数转移所有权,避免结构化克隆的拷贝开销:
// 主线程:把文件数据转移给 Worker,零拷贝 worker.postMessage( { type: 'transcode', file: buffer, args: ['-i', 'input.mp4', 'output.gif'] }, [buffer] // 转移列表,转移后主线程这边的 buffer 会变成 detached );转移之后主线程手里的buffer就废了(byteLength变 0),如果 UI 还要用原文件做预览,得提前留一份或者用slice复制。这个坑我在两个项目里各踩过一次。
3. 跑起来:从零搭一个可用的转码页面
3.1 环境准备与依赖安装
先明确一点:这个方案不需要 Node 后端,但开发阶段仍然需要 Node 来跑打包工具。推荐 Node 18+,包管理器用 pnpm 或 npm 都行。核心依赖其实只有编译好的 ffmpeg-core 产物,如果你不想自己编译(编译 FFmpeg 到 wasm 是个体力活,动辄半小时起步),直接用社区预编译的包即可。
# 初始化一个 Vite 项目,选 vanilla 或 react 都行 npm create vite@latest ffmpeg-web-demo -- --template react-ts cd ffmpeg-web-demo # 安装加载器封装(社区常用包,负责 wasm 生命周期管理) npm install @ffmpeg/ffmpeg @ffmpeg/core # 启动开发服务器 npm run dev装完之后第一件事是确认@ffmpeg/core里的ffmpeg-core.wasm和ffmpeg-core.js能被静态访问。Vite 项目里把它们拷到public/wasm/下最省心,因为public目录的内容会原样输出、不加 hash,locateFile写死路径也不会失效。
提示:不同版本的 core 包对 wasm 的加载方式有差异,有的要求
coreURL和wasmURL分开传,有的支持locateFile统一处理。升级依赖后先看控制台有没有 404,再怀疑代码。
3.2 加载 wasm 与执行第一条转码命令
加载逻辑要写成单例,因为 wasm 初始化很贵(几十 MB 的二进制要下载 + 编译),重复加载既慢又可能内存泄漏。下面是一个带状态机的加载器:
// src/ffmpeg/load.ts import { FFmpeg } from '@ffmpeg/ffmpeg'; let instance: FFmpeg | null = null; let loading: Promise<FFmpeg> | null = null; export async function getFFmpeg(): Promise<FFmpeg> { if (instance) return instance; // 并发调用时复用同一个 Promise,避免重复初始化 if (loading) return loading; loading = (async () => { const ffmpeg = new FFmpeg(); // 日志回调:FFmpeg 的所有输出都从这里出来 ffmpeg.on('log', ({ message }) => console.log('[ffmpeg]', message)); // 进度回调:0~1 的浮点数,用于驱动进度条 ffmpeg.on('progress', ({ progress }) => { window.dispatchEvent(new CustomEvent('ffmpeg-progress', { detail: progress })); }); await ffmpeg.load({ coreURL: '/wasm/ffmpeg-core.js', wasmURL: '/wasm/ffmpeg-core.wasm', }); instance = ffmpeg; return ffmpeg; })(); return loading; }coreURL指向胶水 JS,wasmURL指向二进制,两个都必须可访问。progress事件不是所有命令都会触发(比如纯音频抽取可能没有),UI 上要做兜底,别把进度条写死成必须动。
执行一条命令的封装:
// src/ffmpeg/exec.ts import { getFFmpeg } from './load'; export async function transcode(file: File, args: string[], outputName: string) { const ffmpeg = await getFFmpeg(); const inputName = 'input' + file.name.slice(file.name.lastIndexOf('.')); const data = new Uint8Array(await file.arrayBuffer()); // 写入虚拟文件系统 await ffmpeg.writeFile(inputName, data); // 执行:args 里用 inputName / outputName 占位 const code = await ffmpeg.exec([...args, inputName, outputName]); if (code !== 0) { // 非零退出码代表失败,stderr 已经在 log 回调里打出来了 throw new Error(`ffmpeg exited with code ${code}`); } // 读回产物并清理,避免 MEMFS 越积越大 const output = await ffmpeg.readFile(outputName); await ffmpeg.deleteFile(inputName); await ffmpeg.deleteFile(outputName); return new Blob([output], { type: 'application/octet-stream' }); }参数说明:args是 FFmpeg 原生参数数组,比如['-vf', 'scale=640:-1', '-r', '10'];outputName必须带扩展名,FFmpeg 靠扩展名推断封装格式。exec返回的是退出码,0才是成功,别用 try/catch 判断——FFmpeg 失败时不一定抛异常,可能只是返回非零码。
3.3 把重活挪进 Worker 并接上进度
Worker 文件里重复一遍加载逻辑,然后监听消息:
// src/ffmpeg/worker.js import { getFFmpeg } from './load'; self.onmessage = async (e) => { const { file, args, outputName } = e.data; try { const ffmpeg = await getFFmpeg(); await ffmpeg.writeFile('input.mp4', new Uint8Array(file)); const code = await ffmpeg.exec([...args, 'input.mp4', outputName]); if (code !== 0) throw new Error('exec failed: ' + code); const output = await ffmpeg.readFile(outputName); // 产物转移回主线程 self.postMessage({ ok: true, output }, [output.buffer]); } catch (err) { self.postMessage({ ok: false, error: String(err) }); } };主线程侧用new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })创建,Vite 会自动处理打包。进度事件在 Worker 里触发后,通过postMessage转发给主线程,主线程再更新 UI——注意 Worker 里不能直接操作 DOM。
跑通的标准是:选一个 10MB 以内的 mp4,转成 gif,能在页面上看到进度从 0 走到 1,最后下载下来的 gif 能正常播放。如果卡在加载阶段,先看 Network 面板里 wasm 有没有 200;如果卡在执行阶段,看 log 回调里最后一行是什么。
4. 避坑与排查:那些让页面白屏或卡死的瞬间
4.1 现象:wasm 加载 404,控制台只有一句模糊报错
原因几乎总是打包工具改了文件名或路径。Vite 默认会把import的资源加 hash,而locateFile拼的是固定路径。解决办法是把 core 产物放public/下,或者用?url后缀导入拿到真实 URL 再传给coreURL。排查时直接在浏览器地址栏敲/wasm/ffmpeg-core.wasm,能下载就说明路径没问题。
4.2 现象:转码到一半页面卡死,进度条不动
主线程被阻塞了。确认exec是不是跑在主线程——很多人封装时忘了 Worker,直接在组件里await ffmpeg.exec。另一个隐蔽原因是readFile返回的Uint8Array很大,主线程做new Blob时也会卡一下,产物超过 100MB 时建议在 Worker 里就转成 Blob 再传。
4.3 现象:第二次转码报「文件已存在」或内存暴涨
MEMFS 里的文件没清理。每次writeFile前先deleteFile兜底,或者用try/finally保证清理。我见过一个项目连续转 20 个视频不清理,最后标签页 OOM 崩溃,用户数据全丢。后悔药就是养成「写完必删」的习惯。
4.4 现象:某些格式转出来是 0 字节
编译时裁剪掉了对应的 codec。预编译包通常只带常用编码器(h264、gif、mp3),遇到 hevc、av1 这类要么换带全量 codec 的构建,要么在编译配置里显式打开。判断方法看 log 里有没有Unknown encoder或Decoder not found。
4.5 现象:移动端 Safari 直接白屏
部分旧版本对 wasm 的内存上限和 SharedArrayBuffer 支持有限。先确认WebAssembly可用,再检查是否用了多线程版 core(需要 COOP/COEP 响应头)。单线程版兼容性更好,代价是慢一些。上线前在真机上过一遍,别只信桌面 Chrome。
5. 二次开发:把通用转码器改成业务专用工具
真正落地时,没人会给用户一个裸的 FFmpeg 命令行输入框。二次开发的重点是把参数模板化、把错误翻译成人话、把产物直接对接业务。我一般会抽一层「任务描述」对象,UI 只选预设,底层拼参数:
// 预设任务:把业务语义翻译成 FFmpeg 参数 const PRESETS = { gifPreview: { args: ['-vf', 'fps=10,scale=480:-1:flags=lanczos', '-loop', '0'], ext: '.gif', }, compressMp4: { args: ['-vcodec', 'libx264', '-crf', '28', '-preset', 'veryfast', '-an'], ext: '.mp4', }, extractAudio: { args: ['-vn', '-acodec', 'libmp3lame', '-q:a', '4'], ext: '.mp3', }, } as const; // 调用侧只认 key,不认参数 async function runPreset(key: keyof typeof PRESETS, file: File) { const preset = PRESETS[key]; return transcode(file, preset.args, 'output' + preset.ext); }这样加新功能只是往PRESETS里加一条,UI 和 Worker 都不用动。参数里的crf控制质量(越小越清晰、体积越大),preset控制编码速度,scale=480:-1表示宽固定 480、高按比例,这些是调优时最常动的旋钮。
验证改造是否成功,我有一套固定动作:先用一个 5 秒的小样本跑通全链路,再用一个接近真实上限的大文件压测内存,最后在低端安卓机上点一遍。三个都过才算完。有一次我跳过了大文件压测,上线后用户传 200MB 视频直接崩,回滚都来不及——从那以后我每次改转码参数,都强制走一遍「小样本 → 大文件 → 真机」这三步,一步不省。希望这套拆法能帮到你,少走几个我踩过的坑。
本文还有配套的精品资源,点击获取