tips

ブラウザでの動画リマックス:fMP4、ISOBMFF、トランスコードしない理由

TS セグメントを MP4 にまとめるのはトランスコードではなくリマックス。fMP4 構造、なぜ init セグメントが命なのか、純 JS での実装、FFmpeg WASM との性能比較まで。
FlowPick チーム
14 分で読了
# remux # mp4 # isobmff # wasm # ディープ

ストリーミング動画ダウンロードの最終ステップ——TS セグメントや fMP4 セグメントを再生できる MP4 にまとめる——には 2 つのやり方がある。正しい方と間違った方。

間違いはトランスコード:デコードして、再エンコードする。遅くて CPU 負荷が高くて品質低下があって、ブラウザではほぼ実行不可能。

正しいのはリマックス:コンテナをデマルチプレクスして、生の codec データを取り出し、新しいコンテナにマルチプレクスし直す。速くて CPU 負荷が低くて品質劣化がない。この記事はブラウザでリマックスをどうやるか、なぜこれができるか、多くの人がこれが正しい選択だと気づいていない理由を説明する。

コンテナ vs codec

概念を分ける。90% の混乱はここをごちゃ混ぜにすることから来る。

Codec は圧縮アルゴリズム。H.264、H.265、VP9、AV1——これらは codec。画像の列をどうビットストリームに圧縮するかを定義する。

コンテナ はファイルフォーマット。MP4、MKV、WebM、MPEG-TS——これらはコンテナ。codec ビットストリーム、音声トラック、字幕、タイムスタンプ等を 1 つのファイルにどうパッケージするかを定義する。

.mp4 ファイルには H.264 映像 + AAC 音声が入っているかもしれないし、H.265 映像 + Opus 音声が入っているかもしれない。コンテナは codec が何かを気にしない——パッケージするだけ。

トランスコード は codec を変える(H.264 → H.265)。リマックス はコンテナを変える(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)のインスタンス。構造はボックス(または atom)の再帰的ネスト。

ftyp    ファイルタイプボックス
moov    ムービーボックス(メタデータ)
├── mvhd    ムービーヘッダー(長さ、timescale)
├── trak    トラック 1(映像)
│   ├── tkhd    トラックヘッダー
│   ├── mdia    メディア情報
│   │   ├── mdhd    メディアヘッダー(codec の timescale)
│   │   ├── hdlr    ハンドラー('vide' = 映像)
│   │   └── minf    メディア情報
│   │       ├── stbl   サンプルテーブル(重要!)
│   │       │   ├── stsd   サンプル記述(codec 詳細)
│   │       │   ├── stts   デコード時間→表示時間
│   │       │   ├── stsc   サンプル→チャンク
│   │       │   ├── stsz   サンプルサイズ
│   │       │   └── stco   チャンクオフセット
│   │       └── ...
│   └── ...
├── trak    トラック 2(音声)
│   └── ...
└── ...
mdat    メディアデータ(実際の codec ビットストリーム)

重要なのは stbl(サンプルテーブル)——プレイヤーに「mdat のどのバイトがどのフレームか、各フレームのサイズ、いつ表示するか」を教える。このテーブルがないと、mdat は意味不明なバイトの塊。

リマックスがやるべきこと:正しい moov を構築し、mdat に codec データをそのまま入れる。

なぜ fMP4 は結合できるのか

Fragmented MP4(fMP4)は ISOBMFF のバリアントで、各トラックが moof(ムービーフラグメント)ボックスを持つ複数のフラグメントに分割される。

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 セグメントを 1 つの MP4 にまとめるには:

  1. TS パケットヘッダーを解析し、PID で映像/音声に分離
  2. PES パケットから codec データを抽出
  3. 各フレームの PTS/DTS とサイズを記録
  4. stbl ボックス(サンプルテーブル)を構築
  5. 完全な moov を構築
  6. 映像/音声サンプルを再インターリーブして mdat に書き込む
  7. stco(チャンクオフセット)をバックパッチ——moov を書く前に最終オフセットが分からないので

これは重い作業だが、デコードは不要。FFmpeg の mpegts デマルチプレクサ + mp4 マルチプレクサがこれを行う。ブラウザでは FFmpeg WASM が最も一般的な選択肢——FFmpeg がこのロジックを実装していて、エッジケースをすべて処理しているから。

純 JS のパスもある。 jayer のようなライブラリは TS デマルチプレクスを行い、手動で MP4 ボックスを構築できる。利点は 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 ボックスをファイル先頭に移動する——プレイヤーがファイルの最初の数 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 インスタンスをチャンク間でリセットし、メモリピークを「1 チャンクのデータ + FFmpeg 内部バッファ」——約 100MB に抑える。

問題:結果の MP4 は複数の独立したファイルを繋いだもので、シーク可能な単一 MP4 ではない。「再生/ダウンロード」には十分。映像編集ソフトへのエクスポートには不十分——タイムラインが壊れる。

修正はリマックス後に mp4box.js で「fixup」——すべてのチャンクの moov を読み、1 つの連続した moov を合成し、stco オフセットを書き直す。複雑だが可能。FlowPick は 2GB を超えるファイルでこのパスを使う。

いつ FFmpeg WASM ではなく WebCodecs を使うか

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 ボックス(デコード時間→表示時間)がこのマッピングを記録。リマックス時に正確に記録しないと——プレイヤーがスタッタ、タイムラインがずれ、一部のプレイヤーは拒否する。

抜け 2:編集リスト。 elst ボックスでコンテナレベルのトリムができる——例えば「最初の 2 秒をスキップ」。HLS 清単に #EXT-X-START タグがある場合、編集リストを構築する必要がある。忘れると、プレイヤーは 0 から再生し、広告や黒画面から始まるかもしれない。

抜け 3:H.265/HEVC プロファイル。 ブラウザとデバイスでサポートする HEVC プロファイルが異なる。リマックスは codec を変えないので、ソースがデバイス未サポートのプロファイルなら、出力も未サポート。これはリマックスの問題ではない——でもユーザーは「ダウンロードした動画が再生できない」と文句を言う。

抜け 4:AAC 編集リストの差異。 AAC パケットには付加データ(AudioSpecificConfig)がある。stsdesds ボックスにこれを正確に含める必要がある。忘れると、プレイヤーは再生するが音声に問題が出る——よくある症状は「映像はあるのに音が出ない」。

参考資料

まとめ

ブラウザで動画をマージする正しい哲学:リマックス、トランスコードしない。 ダウンロードした TS/fMP4 セグメントにはすでにエンコード済みの codec データが含まれている。やるべきはコンテナを変えることだけ。この操作は O(n) のバイト操作——デコードもエンコードもなく、品質劣化もなく、CPU 重作業もない。

fMP4 の場合は文字通りバイト連結。TS の場合はデマルチプレクスが必要——FFmpeg WASM が最も堅牢な実装、純 JS も可能だがエッジケースに注意。

パフォーマンスの差は「10 秒 vs 5 分」——もし「マージ」ステップが明らかに遅ければ、ツールがトランスコードしている印。-c copy / copy_audio / copy_video フラグが設定されているか確認。

次は HLS 清単の #EXT-X-KEY タグの深掘り と、なぜ METHOD=SAMPLE-AESMETHOD=AES-128 は別世界——後者は処理できる、前者は触れてはいけない——かを説明。


おすすめ記事