ブラウザでの動画リマックス:fMP4、ISOBMFF、トランスコードしない理由
ストリーミング動画ダウンロードの最終ステップ——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 にまとめるには:
- TS パケットヘッダーを解析し、PID で映像/音声に分離
- PES パケットから codec データを抽出
- 各フレームの PTS/DTS とサイズを記録
stblボックス(サンプルテーブル)を構築- 完全な
moovを構築 - 映像/音声サンプルを再インターリーブして
mdatに書き込む 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 データをそのまま移す」と伝える。これがリマックス。
+faststart は moov ボックスをファイル先頭に移動する——プレイヤーがファイルの最初の数 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 にリマックス:
| 手段 | 実測時間 | ピークメモリ |
|---|---|---|
| ネイティブ 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 ボックス(デコード時間→表示時間)がこのマッピングを記録。リマックス時に正確に記録しないと——プレイヤーがスタッタ、タイムラインがずれ、一部のプレイヤーは拒否する。
抜け 2:編集リスト。 elst ボックスでコンテナレベルのトリムができる——例えば「最初の 2 秒をスキップ」。HLS 清単に #EXT-X-START タグがある場合、編集リストを構築する必要がある。忘れると、プレイヤーは 0 から再生し、広告や黒画面から始まるかもしれない。
抜け 3:H.265/HEVC プロファイル。 ブラウザとデバイスでサポートする HEVC プロファイルが異なる。リマックスは codec を変えないので、ソースがデバイス未サポートのプロファイルなら、出力も未サポート。これはリマックスの問題ではない——でもユーザーは「ダウンロードした動画が再生できない」と文句を言う。
抜け 4:AAC 編集リストの差異。 AAC パケットには付加データ(AudioSpecificConfig)がある。stsd の esds ボックスにこれを正確に含める必要がある。忘れると、プレイヤーは再生するが音声に問題が出る——よくある症状は「映像はあるのに音が出ない」。
参考資料
- ISO/IEC 14496-12 — ISOBMFF 仕様——正式仕様、有料だが権威
- MP4 File Format 仕様——さまざまな ftyp と brand のデータベース
- FFmpeg MPEG-TS ドキュメント——TS パケット構造の理解に
- mp4box.js——GPAC の JS MP4 ツール、読み書き両方できる
- Bento4——オープンソースの MP4 ツール、出力の正確性検証用
まとめ
ブラウザで動画をマージする正しい哲学:リマックス、トランスコードしない。 ダウンロードした 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-AES と METHOD=AES-128 は別世界——後者は処理できる、前者は触れてはいけない——かを説明。
おすすめ記事
- FlowPick はどうやってブラウザだけで数百の動画セグメントを 1 つの MP4 にまとめるのか——この記事のやさしい版
- WebCodecs + Web Workers + OPFS:ブラウザ動画処理実戦——FFmpeg WASM が適さない時のより低レベルな API
- HLS ディープ:暗号化、マルチトラック、覚えておくべき EXT-X タグ——リマックスする TS セグメントがどこから来るか
- DASH ディープ:SegmentTemplate、ContentProtection、視点切り替え MPD——fMP4 セグメントの供給元
- ブラウザ動画処理パフォーマンスベンチマーク——この記事の手法の完全なパフォーマンスデータ
- FlowPick vs. yt-dlp——ブラウザリマックス vs ネイティブリマックスの違い