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

发布时间:2026/10/18 8:28:02
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这类高层 APIffmpeg/core是真正被编译成 WASM 的 FFmpeg 本体。拆开的好处是你可以在不重装核心的情况下只换封装层来适配自己的业务。我一般会在 npm 里这样装npm install ffmpeg/ffmpeg ffmpeg/utilffmpeg/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, scale1280:-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 scale1280:-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这种带逗号的参数例如scale320:240、fps15这类 filter 表达式在数组里要写成一个完整的字符串不要按逗号拆开否则 FFmpeg 会解析出奇怪的结果。3.2 从一行命令到可执行代码的完整迁移案例我拿一个真实需求举例把用户上传的视频压缩到 720p同时去掉音频轨输出成 mp4。命令行是这样ffmpeg -i raw.mp4 -vf scale1280:720 -an -c:v libx264 -crf 30 -preset veryfast output.mp4迁移到 ffmpeg.js 就是await ffmpeg.exec([ -i, raw.mp4, -vf, scale1280: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, scale480:-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 里会周期性输出类似time00: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, scale720:-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 编码的时候用了baselineprofileiPhone 对 profile 的要求更严格至少要用main或high。解决方法是显式指定 profileawait 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 多线程等但基本骨架保持这样不动就够稳。从那以后我每次做前端视频工具都强制先走一遍这套封装流程确认负载、参数、文件名三者都在掌控内才算开始这习惯帮我避掉了不少无法解释的怪问题希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询