ブログに戻る
2026年8月13日Sergei Solod15 分で読めます

Animated WebP・GIF・APNGを小さくてブラウザで扱いやすいH.264 MP4に変換するまで

実際に表示されるcanvas stateとsource timingを復元し、素材ごとにCFRを選び、upscaleを避け、H.264 segmentを独立してencodeし、二回目のlossy encodeなしでconcatしたうえで、packet timelineまで厳密に検証してから公開するproduction pipelineについてまとめます。

H.264FFmpegMP4Animated WebPGIFAPNG動画圧縮メディアパイプライン

このパイプラインを作り始めた理由は、コーデックを試してみたかったからではありません。実際には数秒の無音動画として扱えるコンテンツを、animated image のまま配信するコストが無視できなくなってきたからです。

対象は主に短い animated WebP、GIF、APNG です。数秒程度、表示されるフレームは数十枚、前後のフレームで変化しない領域が大きい素材が多く、トラフィックはモバイル中心です。エンコードは一度だけですが、生成したファイルは何度も配信されます。そのため、私にとって重要なのはエンコード時間よりも、その後ずっと送信し続けるバイト数でした。

「アニメーションを MP4 に変換する」とだけ書けば簡単に見えます。しかし production pipeline として考えると、問題はかなり複雑です。animated image は、必ずしも「同じ大きさの完成画像が一定 FPS で並んでいるファイル」ではありません。部分矩形、dispose、alpha blending、不規則な delay、0 に近い duration、異なる縦横比、そして一般的な probe がうまく要約できない timing metadata が含まれます。

そのため、私の処理は「WebP を FFmpeg に渡す」ではなく、次の invariant を順番に守る形になりました。

animated WebP / GIF / APNG
        ↓
実際に表示される canvas state を decode・再構成
        ↓
source timeline を復元
        ↓
source ごとに CFR を選択
        ↓
upscale せず geometry を正規化
        ↓
保守的な H.264 segment を encode
        ↓
各 source を独立して処理
        ↓
互換性のある segment を stream copy で concat
        ↓
final packet timeline を正規化
        ↓
probe + packet 検証 + 全体 decode
        ↓
atomic publish

H.264 の設定そのものより、変換前のアニメーションが何を、いつ表示していたのかを壊さないことのほうが難しい部分でした。

保存されている矩形ではなく、実際に表示されるフレームを取り出す

最初に避けるべき間違いは、「内部の各 frame は毎回 canvas 全体を置き換える完成画像だ」と考えることです。

Animated WebP では frame の位置、矩形サイズ、blend method、disposal method を持てます。APNG には offset、width/height、dispose_op、blend_op があります。GIF も Graphic Control Extension により、前の表示を残す、background に戻す、以前の canvas 状態へ戻す、といった処理を指定できます。

つまりファイル内部の frame は、すでに描かれている canvas の上に置く小さな patch にすぎないことがあります。その patch を独立した完成画像だと思って H.264 に渡せば、サイズの小さい同じアニメーションではなく、内容そのものが間違った動画になります。

そこで私が frame extraction の基準にしているのは、正しい viewer がその瞬間に表示する完全な canvas stateです。前 frame の disposal を反映し、現在 frame の blending を適用した後の、合成済み pixels を使います。

これは decoder の細かな実装事項に見えますが、実際には pipeline 全体で最初の correctness invariant です。ここで間違った画像を作ってしまえば、その後 x264 の設定をどれだけ調整しても直せません。

FPS を推測するのではなく、source の frame timing を読む

各形式は timing を別の形で保持しています。

  • Animated WebP は frame duration を 1 ms 単位で持ちます。
  • GIF は delay を 1/100 秒単位で持ちます。
  • APNG は delay_num / delay_den の分数で指定し、denominator が 0 の場合は PNG specification により 100 として扱われます。

この per-frame delay が本来の timeline です。probe が表示する平均的な FPS は要約値にすぎず、source of truth とは限りません。

実際に扱った WebP の一つは 1264×720、表示フレーム数 49、各 delay が 62 ms と 63 ms で交互になっており、合計 duration は 3.063 秒でした。これはほぼ 16 fps、つまり 1 frame あたり 62.5 ms の cadence です。

ところが一般的な probe はこの source を 25 fps と表示しました。

その 25 という数字だけを信じて 25 fps や 30 fps に変換していたら、source の timing を変えるか、不要な重複 frame を増やしていたはずです。私が信頼すべきだったのは frame ごとの delay でした。

異常な timing は CFR を選ぶ前に正規化する

0 や極端に短い delay は、その値をそのまま再生時間として扱うのが適切とは限りません。WebP specification も duration 0 や、しばしば 10 ms 以下の非常に短い値について、実装側の解釈が入ることを明記しています。GIF も zero delay を許し、APNG も numerator 0 を許します。

私の pipeline では timing を ms 精度で扱い、最低 frame delay を 10 ms にし、有効な timing が得られない場合だけ 100 ms を fallback として使います。

10 ms と 100 ms がすべての用途で正しいという意味ではありません。重要なのは、CFR に量子化する前に異常値だけを明示的な policy で処理し、正常な source timing は勝手な default に置き換えないことです。

すべてを 30 fps にしない

表示状態が実際には 1 秒あたり約 16 回しか変化しないアニメーションを 30 fps で保存しても、存在しない動きが増えるわけではありません。H.264 は重複を効率よく圧縮できますが、不要な temporal sample を最初から作らないほうが合理的です。

私は source ごとに次の候補から CFR を選びます。

10, 12, 15, 16, 18, 20, 24, 25, 30 fps

元の timeline を基準に、表示変化を十分きれいに表現できる最も低い candidate を使います。低い候補が合わなければ 30 fps が fallback です。

ここで一つの「万能な timing error threshold」は公開しません。そこは content class に対して検証すべき部分だからです。より重要なのは、実際の display time を先に確定し、それを十分に表現できる最も安い CFR を後から選ぶという順序です。

62/63 ms の例なら、16 fps の 62.5 ms/frame が自然に合います。

source ごとに CFR が違っても、最後に一つの MP4 にできる

最終 MP4 は複数の独立したアニメーションから作られることがあります。一つは 12 fps、別のものは 16 fps、さらに 24 fps や 30 fps が最適かもしれません。

concat のためだけに全 source を一つの global FPS へ再エンコードすることはしません。

source A → 12 fps CFR H.264 segment
source B → 16 fps CFR H.264 segment
source C → 24 fps CFR H.264 segment
source D → 30 fps CFR H.264 segment

segments → stream-copy concat → one MP4

各 segment は CFR です。一方、全体としては区間ごとに cadence が変わるため、piecewise-CFR、つまり全体では variable-rate に近い構造になります。

これを成立させる条件は、各 segment の stream contract を合わせ、すべての候補 FPS を正確に表現できる共通 time base を使うことです。

90 kHz の time base を使う理由

video track timescale は 90,000 Hz にしています。理由は、選択可能な FPS がすべて 90,000 を割り切れるからです。

FPS90 kHz での正確な frame duration
109000 ticks
127500 ticks
156000 ticks
165625 ticks
185000 ticks
204500 ticks
243750 ticks
253600 ticks
303000 ticks

24 fps の 1 frame は「約 41.667 ms」ではなく、encoded timeline 上では正確に 3750 ticks です。16 fps は 5625 ticks です。

video track timescale と movie timescale の両方を 90,000 にし、validator でも丸めた秒数ではなく、この integer tick grid を invariant として確認します。

geometry は encode 前に正規化し、upscale はしない

source の形上限
Landscape1280×720
Portrait720×1280
向きが混在する final sequenceおよそ 960×960 canvas

小さい source を大きくすることはありません。補間で増やした pixel に新しい source detail はないからです。

異なる aspect ratio の segment を一つの final stream に入れる必要がある場合は、共通 canvas に fit して余白を padding します。stretch はしません。私の pipeline では background は black です。

alpha についても同様に明示的です。この H.264/yuv420p pipeline は透明度を保持しないため、source の alpha は選んだ background に flatten します。

なぜこの workload では H.264 MP4 が配信コストを下げやすいのか

GIF、APNG、animated WebP も単純に毎回 canvas 全体を保存するだけの形式ではありません。それぞれ部分更新や disposal などの仕組みがあります。したがって「animated image は全部の complete frame を個別に保存する」という説明は正しくありません。

それでも H.264 は video codec として inter prediction、reference pictures、P-frames、B-frames を使い、時間方向の重複を圧縮するよう設計されています。

背景の大部分が静止し、顔、髪、手、カメラの crop など一部だけが変わる illustrated clip では、この性質が非常によく合います。

Apple は現在、static web video に H.264 MP4 を推奨し、animated GIF の代わりに MP4 を使うよう案内しています。Safari documentation では GIF が modern video codec と比べて bandwidth で最大 12 倍、energy で約 2 倍高くなる可能性があると説明されています。

ただし、この 12× は私の benchmark ではありません。Apple のドキュメント上の例です。

Animated WebP はさらに慎重に考える必要があります。lossy WebP はすでに圧縮済みなので、H.264 化は 二回目の lossy generation です。temporal redundancy が高ければ大きく小さくできることがありますが、quality validation は必要です。

H.264 profile は意図的に保守的にする

設定
ContainerMP4
CodecH.264 / libx264
Sample entryavc1
Profile / levelMain @ 3.1
Pixel format8-bit yuv420p
Rate controlCRF 28
Presetveryslow
Tuneanimation
Reference frames4
Maximum B-frames5
Open GOPoff
B-pyramidnormal
Maximum GOP約 5 秒
VBV4M maxrate / 8M bufsize
ColorBT.709, limited range
Track/movie timescale90,000
Audio/subtitles/dataなし

Main@3.1 は現代の端末の性能上限ではありません。one-file delivery のために私が選んだ conservative envelope です。Android は Android 6.0 以降で Main Profile decoder を必須としており、H.264 HD の推奨例として 1280×720@30 fps を掲載しています。Apple も static web file に H.264 MP4 を推奨しています。

CPU は一度だけ使い、bandwidth は何度も払わない

-preset veryslow

source は一度 encode され、その結果は何度も配信されます。そのため x264 の search に CPU を使うことは合理的です。decoder complexity は profile、level、refs、B-frame structure で別に制限します。

CRF 28 も私の illustrated・bandwidth-first workload に合わせた値であり、あらゆる映像に対する universal recommendation ではありません。

各アニメーションを独立した segment として encode する

ffmpeg -framerate "$SELECTED_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

これは codec 部分だけを簡略化して示したものです。実際には、この前に timeline-aware な frame sequence と resize/pad geometry を生成しています。

GOP はおよそ 5 秒で、selectedFps * 5 です。16 fps なら 80、24 fps なら 120、30 fps なら 150 になります。

concat が成立するのは segment contract を厳しくしているから

FFmpeg の concat demuxer は同じ codec や time base など、互換性のある stream を必要とします。また各 file の duration を次 file の開始 timestamp の計算に使います。

そのため compatibility は concat 時に「直す」のではなく、segment を受け入れる前に確立します。codec family、profile/level、pixel format、stream layout、final sequence の canvas、color signaling、90-kHz time base を合わせます。不要な audio/subtitle stream は入れません。

ffmpeg -f concat -safe 0 -i segments.ffconcat \
  -c:v copy \
  -an -sn -dn \
  -movflags +faststart \
  final.mp4

-c:v copy により、すでに lossy な H.264 segment をもう一度 decode・re-encode しません。

stream copy でも timestamp が自動的に完全になるわけではなかった

90 kHz で 24 fps なら frame duration は必ず次の値になるはずです。

90000 / 24 = 3750 ticks

ところが validator が、実際の concatenated output で 3751 ticks の packet を一つ検出しました。

見た目では気づきにくい差です。しかし「この FPS 群なら exact integer grid になる」こと自体が設計上の invariant だったため、validator を緩めるのではなく原因を追いました。

そこで分かったのは、H.264 payload を stream copy しても、segment boundary の timestamp adjustment 後に muxed timestamp が必ず自分の設計した grid に乗るとは限らないということです。

現在の final concat では FFmpeg の setts bitstream filter を使い、-c:v copy のまま PTS、DTS、packet duration を既知の 90-kHz timeline に正規化しています。具体的な expression は各 segment の既知 timing から生成するため、context のない copy-paste 用コマンドとしては公開していません。

validation は最後の確認ではなく encoder の一部

FFmpeg が exit code 0 で終わっただけでは publish しません。stream 数、codec/profile/level、pixel format、dimensions、time base、frame/packet count、exact duration、PTS/DTS、final duration を検証し、最後に全体を実際に decode します。

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 -

すべての contract を満たしたファイルだけを atomic に publish します。

この pipeline が防ぎたい失敗

  • visible canvas ではなく raw subframe を encode する。
  • 推定 FPS を source timing と誤解する。
  • 何でも 30 fps にする。
  • 小さい source を upscale する。
  • aspect ratio を stretch する。
  • concat 後にもう一度 lossy encode する。
  • -c copy を timing correctness の証明だと思う。
  • header だけ確認して full decode をしない。

意図的に保持しないもの

alpha は flatten します。任意の VFR は source ごとに選んだ CFR で近似します。高すぎる解像度は縮小します。lossy WebP には追加の lossy generation が入ります。audio は対象外です。

これらは隠れた制約ではなく、delivery 用 pipeline の明示的な design contract です。

この方法を使わないケース

透明度を任意の background 上で維持する必要がある場合、極端に不規則な frame timing 自体が意味を持つ場合、archive master を作る場合、すでに adaptive multi-codec delivery stack がある場合には、このままの pipeline は使いません。

また、すでに非常に小さく最適化された animated WebP なら、MP4 が必ず小さくなると仮定せず実測します。

現在の実用 pipeline

  1. animation metadata を読み取る。
  2. blend/disposal を尊重して full visible canvas state を再構成する。
  3. delay を sanitize し、ms 精度の timeline を作る。
  4. 10/12/15/16/18/20/24/25/30 から source ごとに最小の適切な CFR を選ぶ。
  5. upscale せず geometry を決め、padding と alpha flatten を行う。
  6. H.264 Main@3.1 / yuv420p / avc1 を veryslow、CRF 28 で encode する。
  7. 90-kHz time base を使う。
  8. 各 segment を contract に対して validate する。
  9. -c:v copy で concat する。
  10. final packet timeline を exact grid に正規化する。
  11. packets を検査し、全体を decode する。
  12. validation を通過した file だけ publish する。

一番重要な考え方:本当の asset は timeline

Animated WebP、GIF、APNG は単なる「たくさんの画像」ではありません。時間情報を持った canvas state の列です。

この列を source of truth と考えるようになってから、存在しない motion を作らず cadence を減らし、存在しない detail を作らず resolution を下げ、source ごとに別 CFR を使い、さらに concat のための二回目の lossy encode も避けられるようになりました。

H.264 はファイルを小さくし、MP4 は配信を簡単にしました。しかし frame extraction や timing の段階ですでにアニメーションを壊していたら、どちらも意味がありません。

codec は簡単な部分でした。本当の仕事は、representation を変えても animation の意味を保つことでした。

参照した一次資料