この処理系を作った理由は、コーデックを試して遊びたかったからではありません。実質的には数秒の無音動画であるものを、アニメーション画像として配信するコストが大きくなっていたからです。
扱う素材の大半は短いアニメーションWebP、GIF、APNGです。長さは通常数秒、表示されるフレームは数十枚程度で、時間方向の重複が非常に多い素材です。閲覧の多くはモバイルで、同じファイルが繰り返し要求されます。そのため、一度だけ払うエンコード時間より、その後の配信で毎回送るバイト数のほうを重視します。
難しいのはFFmpegを呼ぶことではありません。アニメーション画像は、一定のフレームレートで並んだ完全な画像の列とは限りません。部分矩形、合成と破棄の規則、アルファ、ばらつく遅延、表示時間0のフレーム、異なる向き、そして一般的な解析ツールでは誤解を招く形に要約されるタイミング情報を含むことがあります。
そこで変換を一つのコマンドではなく、壊してはいけない不変条件の列として扱っています。
アニメーションWebP / GIF / APNG
↓
実際に表示される完全なキャンバス状態を再構成
↓
元のタイミングを復元して正規化
↓
最終シーケンスに入る全ソースを解析
↓
その最終MP4用にCFRを一つ選択
↓
拡大せず最小の共通キャンバスを計算
↓
互換なH.264セグメントをエンコード
↓
ストリーム契約を検証
↓
ストリームコピーで連結
↓
パケット時間軸を正規化・検証
↓
HTTP配信を検証
↓
検証済みファイルへ不可分に切り替え
コーデックも重要ですが、元のアニメーションが実際に何を表示していたかを守ることのほうが重要です。
実測した本番結果:217個のアニメーションWebPが78.49 MBのMP4一つになった
入力は1.49 GBの動画一本ではありません。217個の独立したアニメーションWebPで、合計10,633枚の表示フレームを含み、元アニメーションの総容量が約1.49 GBでした。
入力
217個のアニメーションWebP
合計1.49 GB
10,633表示フレーム
出力
H.264 MP4 1本
78.49 MB
0.98 Mbps
1264×720
30 fps CFR
Main@3.1 / CRF 28 / veryslow
結果のH.264ファイルは78.49 MB、約0.98 Mbpsでした。元アニメーションの合計バイト数と比べると、約19分の1、つまり約94.7%少ないデータ量です。
これは処理全体の端から端までを測った結果であり、「旧H.264対新H.264」の厳密なA/Bテストではありません。表現そのものが数百個のアニメーション画像から、時間方向の圧縮を行う一本の動画へ変わっています。そのため19倍という差をCRF 28、veryslow、あるいは単一のエンコーダー設定だけの効果とはしていません。
最初に、閲覧者が実際に見る完全な画像を再構成する
最も危険な省略は、保存されている各アニメーションフレームが、前の画像を丸ごと置き換える完全な画像だと仮定することです。
アニメーションWebPのフレームは、配置された矩形と合成・破棄の挙動を指定できます。APNGにはオフセット、寸法、遅延、破棄、合成操作があります。GIFも前のキャンバスを残したり、領域を消去したり、以前の状態へ戻したりできます。
つまり保存フレームは、過去のフレームから作られたキャンバスに依存する小さな差分だけかもしれません。これを完全な画像としてエンコードすると、正しいアニメーションの小型版ではなく、間違ったアニメーションになります。
私が抽出単位にしているのは完全な表示キャンバス状態です。前フレームの破棄規則と現フレームの合成規則を適用したあと、正しい再生処理が表示する完全合成済みのピクセルを取得します。
これが処理系で最初の正しさの保証です。誤った部分フレームをH.264へ焼き込んでしまえば、その後のCRF、プリセット、多重化設定では直せません。
フレームの時間はソースデータであり、推測するFPSではない
アニメーション形式ごとに時間の保存方法は違います。アニメーションWebPは各フレームの継続時間を1 ms単位で持ちます。GIFは100分の1秒単位の遅延を持ちます。APNGはフレームごとに遅延の分子と分母を持ち、分母が0ならPNG仕様では100として扱います。
この各フレームの遅延こそが本当の時間軸です。一般的な解析ツールが表示する代表FPSは要約にすぎず、誤解を招く場合があります。
実際に処理したWebPの一つは1264×720、表示フレーム49枚でした。遅延は62 msと63 msが交互で、合計3.063秒でした。16 fpsなら1フレームが62.5 msなので、実質的に16 fpsのリズムです。
ところが一般的な解析ツールは同じソースを25 fpsと報告しました。その数字を信じると、元のタイミングを変えるか、不要な重複画像を作ることになります。
壊れた、または曖昧な遅延に対する方針も必要です。WebPは0のフレーム時間や、しばしば非常に短い時間の解釈を実装側に委ねています。GIFには0遅延があり得ます。APNGも分子0を許し、次のフレームを可能な限り速く描画する意味になりますが、ビューアーは実用上の下限を設けることがあります。
私の正規化方針では時間をミリ秒精度で保持し、0または不自然に短いフレーム時間には10 msの小さな下限を使い、有用な時間情報が本当に無い場合だけ100 msへフォールバックします。これは私の運用方針であって、普遍的な標準値ではありません。
30 fps固定ではなく、最終MP4全体に一つのCFRを選ぶ
表示状態と継続時間を再構成したら、元の時間軸を動画の時間軸へ写します。すべてを無条件に30 fpsでエンコードすることはしません。
同じ最終MP4に入るすべてのソースについて、次の小さな候補集合を評価します。
10, 12, 15, 16, 18, 20, 24, 25, 30 fps
選択器は、最終シーケンス全体を許容できる形で表現できる最も低いCFRを選びます。別の最終MP4なら別の値を選べますが、一つの最終MP4に含まれる独立エンコード済みセグメントはすべて同じCFRを使います。
3.063秒の例では差がわかりやすくなります。16 fpsなら約49出力フレーム、30 fpsなら約92フレームです。ただし同じ最終MP4に本当に30 fpsを必要とする別ソースがあれば、コレクション全体を30 fpsにします。一つの最終ストリーム内でセグメントごとにフレームレートを混ぜません。
動画トラックのタイムスケールには90,000 Hzを使っています。候補となるすべてのCFRが整数のフレーム時間に変換できるからです。
10 fps → 9000 ticks
12 fps → 7500 ticks
15 fps → 6000 ticks
16 fps → 5625 ticks
18 fps → 5000 ticks
20 fps → 4500 ticks
24 fps → 3750 ticks
25 fps → 3600 ticks
30 fps → 3000 ticks
この正確なフレーム格子を決めるのは動画トラックのタイムスケールです。整合性のためMP4全体のタイムスケールも90,000にしていますが、こちらはコンテナ側の別の時計です。検証では丸めた小数時間ではなく、動画パケットがこの整数格子に乗っているかを見ます。
解像度の値は上限であり、必ずそのキャンバスにするわけではない
配信用の上限は、横長でおよそ1280×720、縦長で720×1280、向きが混在する素材では幅・高さとも960以下です。
絶対条件は拡大しないことです。900×600のソースを1280×720にしても細部は増えず、エンコーダーが記述すべき補間ピクセルが増えるだけです。
もう一つ、より重要な点があります。960×960は外枠の上限であって、必須の正方形キャンバスではありません。
まず各ソースについて、縮小だけを行った有効領域の寸法を求めます。次に、縮小済みのすべての有効矩形を収められる最小の共通偶数寸法キャンバスを最終シーケンスに与えます。
たとえば960×540の横長画像と500×900の縦長画像が必要なら、共通キャンバスは960×960ではなく960×900にできます。全セグメントの符号化寸法は同じままなのでストリームコピー連結は可能で、しかも意味のない黒領域を余計にエンコードしません。
アスペクト比は維持し、画像を引き伸ばさずに余白を埋めます。この処理系では余白を黒にしています。通常のH.264/yuv420pは元のアルファチャンネルを保持しないので、透明部分は偶然失わせず、意図的にその背景へ合成します。
この配信問題にH.264 MP4が合う理由
GIF、APNG、アニメーションWebPは単純な形式ではありません。変化していない領域を毎回描き直さずに済む仕組みがあるため、「動画なら必ず小さい」という主張は誤りです。
一方H.264は画像間の時間予測を前提に設計されています。静止した背景と小さな変化領域を持つ短いイラスト系ループは、参照画像、フレーム間予測、Pフレーム、Bフレームと相性が良い素材です。
Appleの現在のSafari向け案内では、静的動画にH.264エンコードのMP4を勧め、アニメーションGIFは現代的な動画コーデックに比べて帯域を最大12倍、エネルギーを約2倍使う場合があると説明しています。この12倍という数値はAppleの例であり、私のベンチマークではありません。
したがって私の実測19倍という結果は本番観測として有用ですが、すでに小さく最適化されたすべてのアニメーションWebPよりMP4が必ず小さいとは仮定せず、実測します。
各アニメーションを一つのストリーム契約のもとでセグメント化する
エンコーダーが動く時点で、表示フレーム、コレクション全体のCFR、最終キャンバス、期待される長さはすでに確定しています。
セグメント用コマンドの中心部分はおおむね次の通りです。
ffmpeg -framerate "$COLLECTION_FPS" -i frame-%06d.png \
-c:v libx264 \
-preset veryslow \
-tune animation \
-crf 28 \
-profile:v main \
-level:v 3.1 \
-pix_fmt yuv420p \
-tag:v avc1 \
-refs 4 \
-bf 5 \
-g "$GOP_FRAMES" \
-maxrate:v 4M \
-bufsize:v 8M \
-x264-params "stitchable=1:open-gop=0:b-pyramid=normal:nal-hrd=none" \
-color_range tv \
-color_primaries bt709 \
-color_trc bt709 \
-colorspace bt709 \
-fps_mode passthrough \
-map_metadata -1 \
-map_chapters -1 \
-an -sn -dn \
-video_track_timescale 90000 \
-movie_timescale 90000 \
-t "$EXPECTED_DURATION" \
segment.mp4
GOP上限は選んだCFRから約5秒になるよう求めます。16 fpsでは80フレーム、24 fpsでは120、30 fpsでは150です。
-t "$EXPECTED_DURATION"は飾りではありません。私のフレームリスト方式では、直前の実フレームの継続時間を反映させるため、最後の画像を終端用の印としてもう一度置きます。明示的な時間上限がなければ、その印が終端の余分なサンプルになることがあります。
49フレーム、3.063秒の実例で再現しました。-tなしでは16 fpsで50フレーム、30 fpsで94フレームになりました。-t 3.063を付けると、意図した16 fpsで49フレーム、30 fpsで92フレームになりました。
ストリームコピー連結は厳密な互換性検証の後だけ安全
FFmpegの連結用デマルチプレクサは、コーデックや時間基準を含め同じストリームを持つファイルを前提とし、各ファイルの継続時間を使って次のファイルを配置します。そのため誤った継続時間のメタデータは時間軸の乱れを作り得ます。
連結時に互換でないファイルを互換にすることはしません。セグメントは受け入れる前に契約を満たす必要があります。
コレクション全体のCFR = 同一
ストリームの時間基準 = 同一
MP4動画トラックのタイムスケール = 同一
キャンバス寸法 / SAR = 同一
プロファイル / レベル / ピクセル形式 = 同一
色信号 = 同一
avcC / AVC追加データ = バイト単位で同一
セグメントを独立にエンコードするためx264のstitchable=1を使いますが、この設定だけでAVC設定が一致した証明とは考えません。連結前に実際の設定バイトを比較します。
契約を満たしていれば、最終連結は動画ビットストリームのレベルで再劣化なしにできます。
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy \
-an -sn -dn \
-movflags +faststart \
final.mp4
-c:v copyにより、すでにエンコード済みのH.264セグメントを一度デコードして再圧縮する処理を避けられます。
検証はMP4ファイルだけでなく配信経路まで含める
FFmpegが終了コード0で終わったという理由だけでは公開しません。
検証器は実際の決定的なタイミング不一致を捕まえました。16 fpsでは契約上5625ティックであるべきところに5580、後には24 fpsで正確には3750であるべきところに3751がありました。1ティック差の詳しい調査は別の話ですが、ここで重要なのは決定的な時間軸エラーは同じ処理を再実行しても直らないことです。
メディア本体について、想定ストリーム数、H.264プロファイル/レベル、ピクセル形式、計画した正確な寸法、SAR、色信号、90 kHzのビデオトラック・タイムスケール、パケット継続時間、フレーム/パケット数、全体継続時間、PTS/DTS関係、セグメント間で同一のAVC設定、moovがmdatより前にあること、そして厳格なエラー処理で完全復号できることを確認します。
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json \
final.mp4
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
ただしローカルで正しいMP4でも、ウェブで間違って配信されることはあります。そこで期待するContent-Type、正しいContent-Length、バイト範囲対応、正しい206 Partial Content応答、正しいContent-Rangeも検証します。
エンコーダー契約を変更したときは、ffprobeだけでハードウェア互換性を証明したとは考えず、小規模な実機確認も行います。現行iPhone/Safari、控えめなAndroid端末、PC向け主要ブラウザで、再生開始、シーク、ループ、バックグラウンド移行と復帰、範囲再生を確認します。
ファイル本体と配信経路の両方が契約を通過して初めて、本番アセットをアトミックに置き換えます。
この処理系が意図的に失う情報
これは配信用変換であり、アーカイブ用マスターではありません。アルファは背景へ合成します。元の不規則なタイミングは最終MP4の一つのCFRへ量子化されます。大きなソースは縮小される場合があります。すでに非可逆なアニメーションWebPにはさらに一世代の非可逆圧縮が入ります。音声は意図的に持ちません。
任意の背景へ合成できる透明度を残す必要がある場合、正確で不規則なフレーム間タイミング自体に意味がある場合、アーカイブ用ソースを作る場合、またはアプリケーションがすでに適応型・複数コーデックの動画配信基盤を持つ場合には、この処理系をそのまま使いません。
すでに非常に小さく最適化されたアニメーションWebPについても、MP4が必ず勝つとは決めつけず測定します。
現在の本番処理順序
- アニメーション形式を検出し、本来のフレーム制御情報を読む。
- 形式の合成・破棄規則に従って完全な表示キャンバス状態を再構成する。
- 各フレームの継続時間を復元し、不正な値を正規化する。
- ミリ秒精度で信頼できる元の時間軸を作る。
- 同じ最終MP4へ入るすべてのソースを解析する。
- 10/12/15/16/18/20/24/25/30からコレクション全体のCFRを一つ選ぶ。
- 表示状態をそのCFR時間軸へ割り当てる。
- 縮小だけで有効寸法を計算し、拡大しない。
- 最終シーケンスに必要な最小の共通偶数寸法キャンバスを作る。
- 引き伸ばさず余白を入れ、アルファを意図的に背景へ合成する。
- 各ソースを同じストリーム 契約のH.264 Main@3.1 / yuv420p / avc1としてエンコードする。
- 各セグメントを想定継続時間で制限する。
- 実際の AVC設定またはタイミングが契約を破るセグメントを拒否する。
- 受理済みセグメントを
-c:v copyで連結する。 - 最終パケット時間軸を正規化し検証する。
- 結果を最後まで復号する。
- HTTP ヘッダー、バイト範囲、部分コンテンツの動作を検証する。
- エンコーダープロファイル変更後に端末/ブラウザーのカナリアテストを実行する。
- すべての確認が成功してからアトミックに公開する。
真実の基準は拡張子ではなく時間軸
アニメーションWebP、GIF、APNGは、画像拡張子を持つファイルの寄せ集めではなく、時間付きの完全表示キャンバス状態の列です。
H.264は時間方向の重複を非常に効率よく利用できますが、誤った合成、作り物の時間、互換でないセグメント情報は直せません。この処理系を信頼できるものにした工夫の大半はx264の前後にあります。
最後に残った原則はこれです。何を不変に保つべきか正確に説明できるようになってから、表現方法を変える。
主要資料
- GoogleのWebPコンテナ仕様 — フレーム矩形、継続時間、合成、破棄。
- W3C PNG仕様 第3版 — APNGのフレーム時間、オフセット、合成・破棄操作。
- GIF89a仕様 — GIFの遅延と破棄動作。
- FFmpeg形式ドキュメント — 連結用デマルチプレクサの要件とMP4多重化の動作。
- FFmpegビットストリームフィルタ資料 —
setts。 - ffprobe資料 — ストリームとパケットの検査。
- Apple:Safari向け動画コンテンツ配信 — 静的動画のH.264 MP4とアニメーションGIFの置換指針。
- Android対応メディア形式 — H.264対応とHTTPストリーミング要件。