stories

程式碼開源了!我是如何用三個月肝出 FlowPick 的

一個不爽自己造輪子的獨立開發者,記錄從發現問題到開源發布的完整歷程:webRequest 嗅探、FFmpeg WASM 踩坑、TSToMP4Muxer 自研、三層寫盤策略的真實開發故事。
FlowPick 團隊
16 分鐘閱讀
# 開源 # 獨立開發 # wasm # 架構 # 開發日誌

今天把程式碼推到了 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 也不支援。我需要一個降級方案。

最終做成了三層寫盤策略:

  1. 優先 FSA API(Chrome/Edge,無大小限制)
  2. 降級 StreamSaver.js(依賴 Service Worker,相容性好一點)
  3. 最後 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 天生支援重封裝。

理論上,我需要做的是:

  1. 載入 FFmpeg WASM
  2. 把分片寫進 FFmpeg 的虛擬檔案系統(它在記憶體裡模擬了一套檔案系統)
  3. 執行這條命令:
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 多頁,我只看了和重封裝相關的那幾十頁。ftypmoovmvhdtrakmdiaminfstbl——這些 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

邏輯是:

  1. 發現 #EXT-X-KEY,解析出 Key URI 和 IV
  2. 請求 Key URI 拿到 16 位元組的金鑰(瀏覽器擴充功能裡發的請求,帶著使用者當前的 Cookie,所以需要登入才能拿到的金鑰這裡也能拿到)
  3. 對每個下載完的分片,用 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。


推薦閱讀