tips

瀏覽器端影片重封裝:fMP4、ISOBMFF 和不轉碼的理由

把 TS 分片拼成 MP4 不是轉碼——是重封裝。講清楚 fMP4 結構、為什麼 init 分片是命脈、純 JS 怎麼實作,以及和 FFmpeg WASM 的效能對比。
FlowPick 團隊
14 分鐘閱讀
# remux # mp4 # isobmff # wasm # 深度

下載串流影片的最後一步——把 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:

  1. 解析 TS 封包頭,按 PID 分流到影片/音訊
  2. 從 PES 封包抽 codec 資料
  3. 記錄每個畫面的 PTS/DTS 和大小
  4. stbl box(樣本表)
  5. 建完整的 moov
  6. 重新交錯影片/音訊樣本,寫入 mdat
  7. 回填 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 資料原樣搬」。這就是重封裝。

+faststartmoov 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:

方案實際耗時記憶體峰值
原生 FFmpeg8s80MB
FFmpeg WASM 單執行緒75s220MB
FFmpeg WASM 多執行緒22s280MB
純 JS TS 解封裝 + mp4box.js45s180MB

原生是基線,瀏覽器選項都在它的 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 要正確含這個。忘了,播放器可能播放但音訊有問題——常見症狀是「有影片沒聲音」。

參考資料

小結

瀏覽器端影片合併的對的哲學:重封裝,不轉碼。 你下載的 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-AESMETHOD=AES-128 是兩個世界——後者你能處理,前者碰不得。


推薦閱讀