瀏覽器端影片重封裝:fMP4、ISOBMFF 和不轉碼的理由
下載串流影片的最後一步——把 TS 分片或 fMP4 分片合成一個能播的 MP4——有兩種做法。一種對、一種一種錯。
錯的是轉碼:解碼、重新編碼。慢、耗 CPU、有質量損失、在瀏覽器裡基本不可行。
對的是重封裝:解封裝容器、拿原始 codec 資料、封裝進新容器。快、不耗 CPU、無質量損失。這篇文章就是講重封裝在瀏覽器裡怎麼做,為什麼能這樣做,以及為什麼很多人沒意識到這是對的選擇。
容器 vs codec
先把概念分清楚,因為 90% 的困惑來自這裡混淆。
Codec 是壓縮演算法。H.264、H.265、VP9、AV1——這些是 codec。它定義了「如何把畫面序列壓成位元流」。
Container 是檔案格式。MP4、MKV、WebM、MPEG-TS——這些是 container。它定義了「如何把 codec 位元流、音訊軌道、字幕、時間戳等打包成一個檔案」。
一個 .mp4 檔案裡可能裝 H.264 影片 + AAC 音訊,也可能裝 H.265 影片 + Opus 音訊。container 不關心 codec 是什麼——它只負責打包。
轉碼 是改 codec(H.264 → H.265)。重封裝 是改 container(TS → MP4)但保留 codec。轉碼要解碼再編碼;重封裝不解碼——它直接搬 codec 資料。
HLS 下載的場景:來源是 TS 分片,內含 H.264/AAC。要做的是 TS → MP4 重封裝。H.264 和 AAC 原封不動搬過去。這就是為什麼 30 分鐘影片能在 10 秒內完成——不解碼就沒有計算量。
ISOBMFF:MP4 的結構
MP4 是 ISO Base Media File Format(ISOBMFF,ISO 14496-12)的實例。結構是 box(或 atom)的遞迴巢狀。
ftyp 檔案類型 box
moov 電影 box(元資料)
├── mvhd 電影標頭(時長、timescale)
├── trak 軌道 1(影片)
│ ├── tkhd 軌道標頭
│ ├── mdia 媒體資訊
│ │ ├── mdhd 媒體標頭(codec 的 timescale)
│ │ ├── hdlr 處理器('vide' = 影片)
│ │ └── minf 媒體資訊
│ │ ├── stbl 樣本表(關鍵!)
│ │ │ ├── stsd 樣本描述(codec 詳情)
│ │ │ ├── stts 解碼時間到顯示時間
│ │ │ ├── stsc 樣本到 chunk
│ │ │ ├── stsz 樣本大小
│ │ │ └── stco chunk 偏移
│ │ └── ...
│ └── ...
├── trak 軌道 2(音訊)
│ └── ...
└── ...
mdat 媒體資料(實際的 codec 位元流)
關鍵是 stbl(樣本表)——它告訴播放器「mdat 裡的位元組哪段是哪一幀、每幀多大、什麼時候顯示」。沒有這個表,mdat 就是亂七八糟的位元組。
重封裝要做的事:建一個正確的 moov,把 mdat 裡的 codec 資料原樣放進去。
為什麼 fMP4 能拼接
Fragmented MP4(fMP4)是 ISOBMFF 的變體,每個軌道被切成多個帶 moof(影片片段)box 的片段。
ftyp
moov (軌道佈局,但沒有樣本表)
moof (片段 1 的樣本表)
mdat (片段 1 的媒體資料)
moof (片段 2 的樣本表)
mdat (片段 2 的媒體資料)
...
每對 moof+mdat 自描述——它自己帶樣本表和媒體資料。所以拼接 fMP4 片段就是位元組級串聯:
async function mergeFmp4(initUrl, segmentUrls) {
const init = new Uint8Array(await (await fetch(initUrl)).arrayBuffer())
const chunks = [init]
for (const url of segmentUrls) {
chunks.push(new Uint8Array(await (await fetch(url)).arrayBuffer()))
}
const total = chunks.reduce((s, c) => s + c.byteLength, 0)
const out = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
out.set(chunk, offset)
offset += chunk.byteLength
}
return new Blob([out], { type: 'video/mp4' })
}
這就是 FlowPick DASH 下載路徑的核心邏輯——大概 20 行 JS,跑得跟原生工具一樣快,因為沒有什麼計算可做,就是位元組拼接。
為什麼 TS 不能這樣
MPEG-TS 沒有「每個分片獨立可拼接」的屬性。TS 分片是一串 188 位元組的封包,每個帶 PID、PTS、DTS。要把多個 TS 分片合成一個 MP4:
- 解析 TS 封包頭,按 PID 分流到影片/音訊
- 從 PES 封包抽 codec 資料
- 記錄每個畫面的 PTS/DTS 和大小
- 建
stblbox(樣本表) - 建完整的
moov - 重新交錯影片/音訊樣本,寫入
mdat - 回填
stco(chunk 偏移),因為寫moov之前不知道最終偏移
這工作量重,但不需要解碼。FFmpeg 的 mpegts 解封裝器 + mp4 封裝器幹的就是這個。在瀏覽器裡用 FFmpeg WASM 是最常見方案,因為 FFmpeg 實作過這個邏輯、各種邊界情況都處理過。
但有個純 JS 路徑。 jayer 之類的庫做了 TS 解封裝,然後你手動建 MP4 box。好處是不需要 25MB 的 WASM 下載。壞處是邊界情況可能翻車——FFmpeg 在各種 codec、各種 TS 變體下都測過幾十年了。
FFmpeg WASM 重封裝
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile } from '@ffmpeg/util'
const ffmpeg = new FFmpeg()
await ffmpeg.load({
coreURL: '/ffmpeg-core.js',
wasmURL: '/ffmpeg-core.wasm'
})
// 把所有 TS 分片寫進記憶體 FS
await ffmpeg.writeFile('list.txt', segmentUrls.map((_, i) => `file 'seg_${i}.ts'`).join('\n'))
for (let i = 0; i < segmentUrls.length; i++) {
await ffmpeg.writeFile(`seg_${i}.ts`, await fetchFile(segmentUrls[i]))
}
// 重封裝
await ffmpeg.exec([
'-f', 'concat',
'-i', 'list.txt',
'-c', 'copy', // 不轉碼!只重封裝
'-f', 'mp4',
'-movflags', '+faststart',
'out.mp4'
])
const data = await ffmpeg.readFile('out.mp4')
const blob = new Blob([data.buffer], { type: 'video/mp4' })
-c copy 是這裡的魔法。它告訴 FFmpeg「不解碼、不重新編碼,codec 資料原樣搬」。這就是重封裝。
+faststart 把 moov box 移到檔案開頭——這樣播放器拿到檔案前幾 KB 就能開始播,不用下載整個檔案。串流和漸進式下載必備。
記憶體管理:大檔案的分塊處理
上面那段在 30 分鐘 1080p 影片上沒問題——500MB 的分片 + 輸出還裝得進瀏覽器標籤頁 2GB 的軟上限。但 4K 30 分鐘大約 2.5GB,4K 2 小時電影就 10GB+。
分塊策略:
async function chunkedRemux(segmentUrls, chunkSize = 50) {
const parts = []
for (let i = 0; i < segmentUrls.length; i += chunkSize) {
const chunk = segmentUrls.slice(i, i + chunkSize)
const partBlob = await remuxChunk(chunk, i === 0)
parts.push(partBlob)
// 讓記憶體回收
await new Promise(r => setTimeout(r, 0))
}
return new Blob(parts, { type: 'video/mp4' })
}
每塊獨立重封裝,FFmpeg WASM 實例在塊之間重設,記憶體峰值壓在「一個塊的資料 + FFmpeg 內部緩衝」——約 100MB。
問題:合出來的 MP4 是多個獨立檔案串起來的,不是一個 seek 連續的 MP4。對「播放/下載」場景夠用;對「匯出到剪輯軟體」不夠,因為時間軸會斷。
解法是重封裝後做一次 mp4box.js 的「fixup」——讀所有塊的 moov,合成一個連續的 moov,重寫 stco 偏移。複雜但能做。FlowPick 對超過 2GB 的檔案走這條路。
什麼時候用 WebCodecs 而不是 FFmpeg WASM
有個替代路徑,不需要 FFmpeg:WebCodecs + 手寫封裝。
- 用 WebCodecs 的
VideoDecoder/AudioDecoder解碼 - 用
mp4box.js或手寫封裝器建 MP4 - 用 WebCodecs 的
VideoEncoder/AudioEncoder重新編碼(如果要轉碼的話)
對重封裝不需要解碼器——沒有轉碼發生。但 WebCodecs 在這裡另有用途:解析 TS 分片抽 codec chunk。這部分 FFmpeg WASM 也能做,但 WebCodecs 在某些 codec 上硬體加速得更快。
WebCodecs 那篇 講完整的故事。我的看法:對純重封裝(TS → MP4),FFmpeg WASM 仍是更穩健的選擇;WebCodecs 適合需要逐幀操作(縮圖、濾鏡、分析)的場景。
效能實測
30 分鐘 1080p HLS(300 個 TS 分片、500MB)重封裝成 MP4:
| 方案 | 實際耗時 | 記憶體峰值 |
|---|---|---|
| 原生 FFmpeg | 8s | 80MB |
| FFmpeg WASM 單執行緒 | 75s | 220MB |
| FFmpeg WASM 多執行緒 | 22s | 280MB |
| 純 JS TS 解封裝 + mp4box.js | 45s | 180MB |
原生是基線,瀏覽器選項都在它的 3-10 倍內。多執行緒 FFmpeg WASM 對即時使用夠用——30 分鐘影片 22 秒處理完。
純 JS 路徑比 FFmpeg WASM 單執行緒快——沒有 WASM 呼叫開銷——但不那麼穩健,某些 codec 變體會出問題。完整基準資料在 效能基準那篇。
踩過的坑
坑 1:PTS/DTS 亂序。 帶 B 幀的 H.264,解碼順序(DTS)和顯示順序(PTS)不同。stts box(解碼時間到顯示時間)記錄這個映射。重封裝時要正確記錄——錯了,播放器會頓、時間軸會錯、有些播放器直接拒絕。
坑 2:編輯清單。 elst box 讓 container 層修剪——例如「跳過前 2 秒」。HLS 清單有 #EXT-X-START 標籤時要建編輯清單。忘了,播放器從 0 開始播,可能從廣告或黑幀開始。
坑 3:H.265/HEVC profile。 不同瀏覽器和裝置支援的 HEVC profile 不同。重封裝不改變 codec,所以如果來源是裝置不支援的 profile,輸出也不支援。這不是重封裝的問題——但使用者會抱怨「下的影片播不了」。
坑 4:AAC 編輯清單差異。 AAC 封包有附加資料(AudioSpecificConfig)。stsd 裡的 esds box 要正確含這個。忘了,播放器可能播放但音訊有問題——常見症狀是「有影片沒聲音」。
參考資料
- ISO/IEC 14496-12 — ISOBMFF 規範 — 正式規範,付費但權威
- MP4 File Format 規範 — 各種 ftyp 和 brand 的資料庫
- FFmpeg MPEG-TS 文件 — 理解 TS 封包結構
- mp4box.js — GPAC 的 JS MP4 工具,能寫也能讀
- Bento4 — 開源 MP4 工具,用來驗證輸出正確性
小結
瀏覽器端影片合併的對的哲學:重封裝,不轉碼。 你下載的 TS/fMP4 分片已經含編碼好的 codec 資料。你的工作只是改變 container。這個操作是 O(n) 位元組操作——不涉及解碼、不涉及編碼、沒有質量損失、沒有 CPU 重活。
fMP4 場景下這就是字面意義的位元組拼接。TS 場景下需要解封裝——FFmpeg WASM 是最穩健的實作,純 JS 也行但邊界情況要小心。
效能差異是「10 秒還是 5 分鐘」——如果「合併」步驟明顯慢,說明你的工具在轉碼而不是重封裝。查 -c copy / copy_audio / copy_video 標誌有沒有設。
下一篇講 HLS 清單裡 #EXT-X-KEY 標籤的深度,以及為什麼 METHOD=SAMPLE-AES 跟 METHOD=AES-128 是兩個世界——後者你能處理,前者碰不得。
推薦閱讀
- FlowPick 是怎麼在瀏覽器裡把幾百個影片分片合成 MP4 的 — 這篇的淺版本
- WebCodecs + Web Workers + OPFS:瀏覽器影片處理實戰 — FFmpeg WASM 不適合時的更底層 API
- HLS 深度:加密、多音軌、值得記住的 EXT-X 標籤 — 你重封裝的那些 TS 分片是哪來的
- DASH 深度:SegmentTemplate、ContentProtection 與多視角 MPD — fMP4 分片的來源
- 瀏覽器影片處理效能基準 — 本文方法的完整效能資料
- FlowPick vs. yt-dlp — 瀏覽器重封裝 vs 原生重封裝的差異