WebCodecs + MP4Box.js を用いたブラウザ単体での MP4 生成

ffmpeg やサーバ側の処理を使わず、ブラウザだけで MP4 を作るときのメモ。 まず WebCodecs で映像をエンコードし、その後 MP4Box.js で MP4 コンテナに詰める流れを実装している。

1. Codecs と Containerの役割

観点CodecContainer
役割画や音を圧縮・復元する圧縮済みデータを時刻情報つきで格納する
具体例H.264 / AVC, AAC, AV1MP4, WebM, Matroska
このデモでの担当VideoEncoder が H.264 の EncodedVideoChunk を出力MP4Box.js が MP4 のトラックとサンプル表を構築
入れ替え可能性同じ MP4 に別 codec を入れることがある同じ H.264 を MP4 以外の入れ物に入れることもある

「MP4 を生成する」=「圧縮済みの映像データを作る」+「再生できる MP4 に組み立てる」の二段階になる。

2. MP4 コンテナの骨格

MP4 は ISO Base Media File Format 系のファイルで、実体は box の並びでできている。 先頭から size と type を読んでいく構造で、よく出てくる ftyp, moov, mdat もその box の名前である。

2-1. MP4コンテナの構造

ツリー構造にすると以下のようになる(今回は映像トラック 1 本だけ)。

mp4
├─ ftyp
├─ moov
│  └─ trak
│     └─ mdia
│        └─ minf
│           └─ stbl
│              ├─ stsd
│              ├─ stts
│              ├─ stss
│              ├─ stsc
│              ├─ stsz
│              └─ stco / co64
└─ mdat

2-2. boxに含まれる要素

box役割今回の見方
ftypファイル種別と互換ブランドを書くMP4 系ファイルであることを示す
moov全体のメタデータを持つトラック情報や sample table の親になる
trak各トラックの入れ物今回は映像トラック 1 本だけ
mdiaトラック内のメディア情報vide ハンドラの映像トラックになる
minfメディア種別ごとの情報映像なら vmhd などが入る
stblサンプル表の集合時間、サイズ、同期点を持つ
mdat圧縮済みメディアデータ本体EncodedVideoChunk のバイト列がここへ入る

2-3. 映像トラックで重要になる sample table

再生側に必要なのは映像データ本体だけではない。 どのサンプルが何バイトで、どれだけの時間続き、どこがキーフレームかという表も要る。 そのため stbl の下にいくつかの table がぶら下がる。

box役割実装上の見方
stsdサンプル記述。どの codec でどう解釈するかを書くavc1 がここにぶら下がる
sttsdecoding time to sample各サンプルの継続時間を表す
cttscomposition time to sampleB-frame を使うと表示順との差分が入る
stsssync sample tableキーフレーム一覧になる
stscsample to chunkサンプルと chunk の対応を持つ
stszsample size table各サンプルのバイト数を記録する
stco / co64chunk offsetmdat 内の位置を指す

2-4. H.264 を MP4 に入れるときの avc1

MP4RA の codec 登録でいう avc1 は H.264/AVC の sample entry を表す。 つまり「このトラックのサンプルは H.264 として解釈する」という宣言にあたる。 このページでは details よりも、H.264 のサンプル列を MP4 の sample table と結びつける流れに集中する。

3. WebCodecs が返すものと MP4 に必要なもの

WebCodecs の output callback は EncodedVideoChunk と、 必要に応じて EncodedVideoChunkMetadata を返す。 デモで重要なのは、各 chunk のサイズ・時刻・キーフレームかどうかを MP4 の sample table に写すことだ。

WebCodecs 側の値意味MP4 側での使い道
chunk.byteLength圧縮済みサンプルのサイズstsz 相当の情報になる
chunk.timestamp提示時刻dts / cts の計算材料になる
chunk.type"key" / "delta"stss に反映される

3-1. AVC の annexbavc

AVC では出力形式に annexbavc がある。 MP4 に詰めるなら、最初から avc を選んでおくほうが扱いやすい。

形式パラメータセットの置き場向いている用途このデモでの扱い
annexbビットストリーム内に SPS / PPS を周期的に含めるライブ配信やストリーム途中参加使わない
avcサンプル列とは別に初期化情報を扱いやすいMP4 のように初期化情報を別に保持する形式採用する
delta フレームだけでは途中から再生を始めにくい。 だからキーフレームの位置を MP4 側にも残す必要がある。

3-2. キーフレームが重要な理由

キーフレームは「そのフレーム単体を起点に復号を再開できる場所」なので、シークやサムネイル生成の起点になる。 逆に delta フレームは前のフレーム群を参照するため、そこだけ切り出しても正しく復号できないことがある。 このデモでは 1 秒ごとにキーフレームを打ち、chunk.type === "key" のサンプルを MP4 の同期サンプルとして登録している。

4. 実装方針

  1. 各フレームを VideoFrame として作る。
  2. VideoEncoder を H.264 で構成し、avc: { format: "avc" } を指定する。
  3. 最初の output callback でトラックを初期化する。
  4. EncodedVideoChunkaddSample() で MP4 に積む。キーフレームなら is_sync を立てる。
  5. 最後に save()Blob 化し、そのままブラウザで再生・保存する。
// エンコーダーの構成
const encoder = new VideoEncoder({
  output: (chunk, metadata) => {
    // 最初の chunk が来たタイミングで MP4 の映像トラックを作る
    if (trackId === null) {
      trackId = mp4File.addTrack({
        timescale: 1000,
        width,
        height,
        hdlr: "vide",
        type: "avc1",
        avcDecoderConfigRecord: getAvcDecoderConfigRecord(metadata),
      });
    }

    // chunk.type が key なら MP4 の sync sample として扱う
    mp4File.addSample(trackId, chunkBytes, {
      duration,
      dts,
      cts: dts,
      is_sync: chunk.type === "key",
    });
  },
});

encoder.configure({
  codec: "avc1.42001f",
  width,
  height,
  bitrate,
  framerate: fps,
  avc: { format: "avc" },
});

5. 動作デモ

下のデモでは、Canvas に描いたフレームをそのまま H.264 にして MP4 に詰める。 生成後は <video> で再生でき、サイズ、サンプル数、キーフレーム数を確認できる。

確認中 このブラウザの対応状況を確認しています。
待機中
Codec avc1.42001f
解像度 640 x 360
サンプル数 -
キーフレーム数 -
出力サイズ -
生成後プレビュー

6. 補足

6-1. timescale と duration

MP4 の時刻は整数で持つので、実装側で timescale を決めて dts, cts, duration をその単位で積むことになる。 ここでは計算を見やすくするため timescale = 1000 としている。 1 フレームぶんの長さは整数 timescale に丸めながら積まれるので、 30 fps のような値では端数を含む継続時間が混ざることがある。

6-2. 実装時の注意

  • VideoEncoder が見えていても、その codec string や profile で encode できるとは限らない。先に VideoEncoder.isConfigSupported() を通しておく。