代码开源了!我是如何用三个月肝出 FlowPick 的
今天把代码推到了 GitHub,仓库是公开的了。
写这篇文章不是为了庆祝,主要是想把三个月里踩过的那些坑记下来,方便以后忘了自己查,也给有同样需求的人参考。文章会涉及一些技术细节,但不会写得太学术——是人话,不是技术文档。
起因:一次让我忍了几个月的体验
去年底,我在用一个在线课程平台追一系列技术课程。平台不允许下载,但我有个习惯是想离线看——通勤地铁没信号,咖啡店 WiFi 不稳,回家路上想重复看某段不想拖进度条。
我试了 Video DownloadHelper,对 HLS 流需要装 CoApp。装 CoApp,行,装完发现处理有个加密课程直接报错。又搜了几个在线工具,能用的那几个要么限速、要么文件大了就炸、要么套着服务器中转我觉得不太放心。
youtube-dl 能处理一部分,但那个平台 yt-dlp 根本不认,要么没有该平台的 extractor,要么 cookie 传不进去。
就这么拖了几个月,某天周末闲着,脑子里忽然想:这不就是一个"解析 M3U8、下载切片、拼文件"的流程吗,浏览器里能做这件事吗?
打开 MDN 翻了翻,发现 Web Crypto API、File System Access API、WebAssembly 这三个东西最近几年都稳了,兼容性在 Chrome 上已经不是问题了。那就写吧。
第一周:搞定"能检测到吗"
任何工具最先要解决的问题是:怎么知道页面上有什么视频。
浏览器扩展有个 API 叫 webRequest,可以监听标签页发出的所有网络请求——注意是所有请求,在网络层,不是 DOM 层。只要播放器一发出 M3U8 或 MPD 请求,扩展就能截获。
Extension → webRequest.onBeforeRequest
→ 过滤 Content-Type
→ 命中 application/x-mpegurl → 记录为 HLS
→ 命中 application/dash+xml → 记录为 DASH
→ 命中 video/* → 记录为直链视频
这部分写起来比我预期快。两天后,我有一个能在扩展弹窗里列出当前页面所有视频流的原型。打开 B 站测试,哗——列出了视频流、音频流、广告流,全部条目,还带 URL 和文件大小。
有个细节折腾了我一会儿:M3U8 分两种,Master Playlist 和 Media Playlist。Master 是目录,列出所有可用画质,每个指向一个 Media Playlist;Media Playlist 才是真正的切片清单。我最初把两种都显示出来了,用户看到的是一堆条目,不知道选哪个。后来改成:识别 Master Playlist 时,自动解析里面的画质选项,只展示人能看懂的分辨率列表,不暴露原始 URL 层级。
第三周:第一个真正难的问题——文件写到哪
下载几百个切片,这是个异步并发问题,不难。难的是这些数据写到哪。
浏览器 JavaScript 没有直接访问文件系统的权限。最简单的方法是把所有数据凑成一个 Blob,然后触发下载——URL.createObjectURL(blob) 配合一个隐藏的 <a> 标签。
我先用这个方法把原型跑通了。500MB 的视频,问题不大。800MB,还行。1GB,Chrome 开始有点抖——原因很明显,Blob 模式要把整个文件加载进内存,1GB 的视频意味着浏览器得同时握着 1GB 的 ArrayBuffer,加上页面本身和扩展的开销,内存压力非常大。1.5GB 以上,直接卡死了。
我给 Blob 模式设了硬限制:1.5GB 拒绝处理,800MB 开始警告。这不是解决问题,是把问题的边界标清楚。
真正的解决方案是 File System Access API(FSA)。Chrome 86 开始支持,允许 JavaScript 拿到一个文件句柄,然后通过 WritableStream 持续往里写,就像原生程序写文件一样——内存里同时只有当前写入的那一块数据,和文件总大小无关。
const writable = await handle.createWritable()
// 配了 16MB 的 buffer
const stream = new WritableStream({
async write(chunk) { await writable.write(chunk) },
async close() { await writable.close() }
}, { highWaterMark: 16 * 1024 * 1024 })
这样一来,下载 5GB 的视频和下载 500MB 的视频,内存占用基本没区别。
但问题是 Firefox 不支持 FSA API,Safari 也不支持。我需要一个降级方案。
最终做成了三层写盘策略:
- 优先 FSA API(Chrome/Edge,无大小限制)
- 降级 StreamSaver.js(依赖 Service Worker,兼容性好一点)
- 最后 Blob 模式(全浏览器,但 1.5GB 限制)
进到哪层由运行时检测决定,用户不需要知道背后发生了什么。
第五周:真正的Boss战——切片合并成 MP4
下载切片我能做,写文件我能做。但 HLS 的切片是 TS 格式(MPEG Transport Stream),大多数人想要的是 MP4。
把几百个 .ts 文件直接二进制拼起来会得到一个 .ts 大文件,VLC 能播,但 Windows 原生播放器不认、剪映不能导入、上传到 B 站会报格式错误。实际上能用,但用户体验很差。
TS 变 MP4 本质上是重封装(remux):把 TS 里的视频流和音频流提取出来,装进 MP4 的容器结构里。不涉及重新编码,所以速度理论上很快,也不损失质量。
问题是:在浏览器里,谁来做这件事?
答案在这之前一年已经有了——FFmpeg 被编译成了 WebAssembly。有几个开源项目做了这件事,最出名的是 @ffmpeg/ffmpeg。WASM 可以在浏览器 JavaScript 环境里跑接近原生速度的二进制代码,FFmpeg 天生支持重封装。
理论上,我需要做的是:
- 加载 FFmpeg WASM
- 把切片写进 FFmpeg 的虚拟文件系统(它在内存里模拟了一套文件系统)
- 执行这条命令:
ffmpeg -f concat -safe 0 -i filelist.txt \
-c copy \
-movflags +faststart \
-bsf:a aac_adtstoasc \
output.mp4
-c copy 是流复制,不重编码,只改容器。-bsf:a aac_adtstoasc 是把 AAC 音频从 TS 用的 ADTS 封装转成 MP4 用的 ASC 封装,如果不加这个,有些播放器音频会有问题。-movflags +faststart 把 MP4 的索引(moov atom)移到文件头,方便边下边播。
听起来很美好。实际上踩了好几个坑:
坑一:FFmpeg WASM 首次加载要 10-30 秒。
WASM 核心文件大约 30MB,要从网络下载再编译。用户点了下载按钮,然后等了 20 秒什么都没发生——体验极差。
解法:下载开始就立刻触发 FFmpeg 加载,让它和切片下载并行进行。等切片都下完了,FFmpeg 通常也初始化好了,基本感知不到等待。加上浏览器缓存机制,首次之后加载只需要 2-5 秒。
坑二:WASM 虚拟文件系统是在内存里的。
FFmpeg 处理文件,需要先把切片写进它的虚拟文件系统。一个 1080P 的一小时视频,600-800MB 的切片,全部写进 WASM 内存……加上 FFmpeg 自己的运行时内存(200-400MB),容易超出 WASM 的 4GB 地址空间限制,直接崩溃。
这个问题在大文件场景不是偶发的,是必然的。我需要一个不依赖 FFmpeg WASM 的大文件方案。
第七周:写了 TSToMP4Muxer
我花了将近两周写了一个纯 JavaScript 的 TS → MP4 流式重封装器,叫 TSToMP4Muxer。
它的原理是:不等所有切片下完再处理,而是边下边解析边写盘。TS 格式的每个数据包固定 188 字节,我逐包读取,提取 PES(Packetized Elementary Stream)数据,实时转换为 MP4 的 Box 格式,通过 Streams API 持续写到磁盘。
切片 0 下载完
→ 解析 188 字节 TS 包
→ 提取视频/音频 PES
→ 写入 MP4 mdat Box
切片 1 下载完
→ 继续追加
...
切片 N 写入
→ 写入 moov Box(索引)
→ 关闭文件
这样内存里同时只有当前正在处理的那个切片,和文件大小完全解耦。同时因为是纯 JS,不需要加载 FFmpeg WASM 文件,省掉了那 30MB 的加载开销和初始化时间。
代价是覆盖场景窄——它只能做单 TS → MP4,处理不了 DASH 的音视频合并(那需要两路输入同步处理),也处理不了各种边缘格式。但对于 HLS 下载这个最主流的场景,TSToMP4Muxer 是更好的方案。
现在的逻辑是:普通 HLS 优先走 TSToMP4Muxer,流式处理,快而省内存;遇到 DASH、或者需要合并两路流的情况,走 FFmpeg WASM;文件超过 2GB 的 HLS,也走 TSToMP4Muxer,避免 WASM 内存溢出。
写这东西花了很长时间。MP4 格式的 Box 结构是有规范的,但规范有 600 多页,我只看了和重封装相关的那几十页。ftyp、moov、mvhd、trak、mdia、minf、stbl——这些 Box 嵌套的层级关系不对,播放器就读不了。调试过程是:写出来,VLC 打开,看报什么错,查规范,改,重复。大概循环了二十多次。
最让我崩溃的一次:视频能播了,但时间轴是乱的——拖进度条会跳到随机位置,不是你期望的那个时间。原因是 stts(Sample to Time)和 ctts(Composition Time Offset)这两个 Box 里的时间戳计算没有处理 B 帧的 DTS/PTS 偏移。H.264 有 B 帧,解码顺序(DTS)和显示顺序(PTS)不一样,必须在 ctts 里单独记录每帧的显示时间偏移。找到问题,修好,花了两天。
第九周:AES-128 加密——没想象的难
我一直觉得加密视频的处理会是最难的部分,结果发现比 TSToMP4Muxer 容易多了。
HLS 的加密格式在 M3U8 里很清晰:
#EXT-X-KEY:METHOD=AES-128,URI="https://api.example.com/key?token=abc",IV=0x00000000000000000000000000000001
逻辑是:
- 发现
#EXT-X-KEY,解析出 Key URI 和 IV - 请求 Key URI 拿到 16 字节的密钥(浏览器扩展里发的请求,带着用户当前的 Cookie,所以需要登录才能拿到的密钥这里也能拿到)
- 对每个下载完的切片,用
Web Crypto API做 AES-128-CBC 解密
const keyBytes = await crypto.subtle.importKey('raw', key, { name: 'AES-CBC' }, false, ['decrypt'])
const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: ivBuffer }, keyBytes, encryptedData)
crypto.subtle 是浏览器内置的 Web Crypto API,底层有硬件加速,解密速度不是瓶颈,几毫秒处理完一个切片。
真正花时间的是 IV 的处理。HLS 规范里说:如果 M3U8 没有显式声明 IV,每个切片的 IV 应该用切片的序号(MSB 格式,16 字节)。我最开始没处理这个规则,所有切片都用了第一个 IV,加密课程解密出来音频对但视频乱码。翻了 RFC 8216 找到这条规则,加上去,好了。
处理不了的: Widevine、PlayReady、FairPlay。这些是 DRM,密钥在操作系统或硬件里,JavaScript 根本访问不到。遇到这类内容,FlowPick 只能如实告诉用户"不支持",没有任何方法绕过。Netflix、Disney+ 就是这类平台,不在设计范围内。
第十一周:DASH 的音视频合并
DASH 是另一种主流流媒体格式,YouTube、B 站普通投稿都在用。和 HLS 的核心差别:音频和视频是两条完全独立的流。
这不是设计缺陷,是有意为之——这样平台可以只存一套视频轨,配几套不同语言的音频轨,存储成本直接减几倍。但对下载工具来说,意味着必须同时下载两路数据然后合并。
合并两路数据用的是 FFmpeg WASM:
ffmpeg
-f concat -safe 0 -i video_list.txt
-f concat -safe 0 -i audio_list.txt
-c:v copy -c:a copy
-movflags +faststart
output.mp4
这里有个细节:DASH 的切片格式是 FMP4(Fragmented MP4),不是 HLS 的 TS。FMP4 的特殊之处是每个切片文件单独能播,但要正确解析,必须先有一个 init segment——包含编解码器参数的初始化片段(ftyp + moov Box)。缺少 init segment,FFmpeg 不知道视频的分辨率、帧率、编解码器,没法处理后续的数据切片。
所以 DASH 的处理流程比 HLS 多一步:先下 init segment,再下所有数据切片,合并时 init segment 必须放在最前面。我最初忘了处理 init segment,FFmpeg 合并出来的 MP4 打开就报"文件损坏",查了两天才意识到问题在这里。
DASH 的并行下载策略也要改一下——视频切片和音频切片要同时下,而不是先下完视频再下音频,这样总下载时间减少了将近一半。Worker Pool 的设计支持这个:两路任务并行,共用一个线程池,哪个空闲就去拉哪路的下一个切片。
并发下载:Worker Pool 而不是预分割
并发下载这块我想单独说一下,因为有个实现细节让我改了两次。
最直觉的做法是:把 200 个切片分成 4 等份,4 个 Worker 各下一份。这个方法的问题是负载不均衡——HLS 切片不是等大小的,视频开头和结尾的切片往往小,中间的大,预分割可能导致某个 Worker 卡在一堆大切片上,另外三个 Worker 早就闲着了。
FlowPick 用的是共享计数器方案:
let nextIndex = 0
const worker = async () => {
while (nextIndex < segments.length) {
const index = nextIndex++ // 原子性地抢下一个任务
await downloadAndProcess(segments[index])
}
}
// 启动 N 个 worker,全部共享 nextIndex
await Promise.all(Array.from({ length: concurrency }, () => worker()))
每个 Worker 完成当前切片后立刻去抢下一个,不等其他 Worker,不管它是大切片还是小切片。这样天然做到了动态负载均衡,总完成时间接近理论最优。
并发数默认设的 2,可以调到最多 8。为什么不默认 8?因为对某些 CDN,高并发会触发限速或者 429 错误,反而更慢。4-6 个并发通常是性价比最高的区间,8 个开始出现收益递减。
重试机制是指数退避:失败后等 400ms,再失败等 800ms,再失败等 1600ms,3 次全失败才放弃。大部分网络波动都能在第一次或第二次重试时恢复。4xx 错误(403、404)不重试——服务器明确拒绝了,重试没有意义。
内存管理:防止浏览器崩溃
下到最后阶段,内存管理是让我最花心思的部分。
问题是:你不知道下载的文件会有多大。用户选择 1080P 的直播回放,可能 2GB,可能 20GB,在下完所有切片之前根本不确定。
我做了一个在下载前的采样估算:
// 先下 max(1, total × 10%) 个切片,取平均大小,乘以总数估算
const sampleCount = Math.min(5, Math.max(1, Math.floor(totalCount * 0.1)))
估算出来之后,根据大小选写盘策略:800MB 以下随便,800MB-1.5GB 必须用流式写,超过 1.5GB 拒绝 Blob 模式。
但估算不够,运行时还得监控。下载过程中持续统计已分配的 ArrayBuffer 总量,超过 600MB 触发警告,超过 1200MB 暂停新切片下载,优先把已下载的切片写盘、释放内存,再继续。这样即使估算偏差大,也有后手。

一些做了又删掉的功能
三个月里有几个功能做了一半,后来删了:
转码(transcoding):我一度想支持"下载时转成 720P",减小文件体积。后来发现在浏览器里转码太慢了——FFmpeg WASM 做重封装尚可,做重编码涉及解码每帧视频再重新编码,CPU 占用飙满,1GB 视频可能要跑 30 分钟。用户体验不可接受。最终 FlowPick 只做重封装(改容器不改编解码器),想转码的告诉用户去用命令行 FFmpeg。
实时进度条的 ETA(剩余时间估算):我第一版用的是瞬时速度,结果进度条抖动非常厉害——CDN 切片大小不均匀,瞬时速度可能在 0.5MB/s 和 50MB/s 之间蹦。改成 5 秒滑动窗口平均,稳了很多。
多文件批量下载:原来准备不做,后来发现有人问能不能把所有集数一起下,加了一个简单的队列,每个下载任务排队执行,不支持并行(两个视频同时下载会把带宽和 FFmpeg 内存都撑爆)。
三个月的结果
代码量大概是 1.2 万行 TypeScript,扩展 + 在线工具 + 落地页。
能做的:
- HLS/M3U8 下载,AES-128 自动解密
- DASH/MPD 下载,音视频自动合并
- 直链视频批量嗅探
- 图片批量检测和下载
- 音频资源检测
- 输出MP4,无大小限制(Chrome/Edge)
做不到的(不是偷懒,是真做不了):
- Widevine/PlayReady/FairPlay DRM——硬件级保护,JS 访问不到密钥
- 视频转码——浏览器算力不够,体验太差
- 格式转换到 MKV/AVI——FFmpeg WASM 集成目前只做了 MP4/TS 路径
代码开源了,仓库在 GitHub 上。扩展已上架三个浏览器:Chrome 网上应用店、Edge 加载项、Firefox 附加组件。如果你发现 bug 或者有想法,欢迎开 Issue 或者直接 PR。
推荐阅读
- FlowPick 是怎么在浏览器里把切片合成 MP4 的 — 这篇讲故事,那篇讲原理,搭配看
- 如何下载 M3U8/HLS 流:完整入门指南 — 这些坑踩完之后,用户看到的操作只有五步
- 什么是 DASH 流媒体?MPD 文件解析 — 文章里多次提到的 DASH 格式详解
- 如何下载 Bilibili 视频到本地 — DASH 音视频合并的典型真实案例
- FlowPick 与 Video DownloadHelper 对比 — 和这个领域最老牌扩展的正面对比