程式碼開源了!我是如何用三個月肝出 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 對比 — 和這個領域最老牌擴充功能的正面對比