返回博客
2026年8月13日Sergei Solod10 分钟阅读

我如何把 Animated WebP、GIF 和 APNG 转成体积小、浏览器兼容性稳的 H.264 MP4

这套 production pipeline 会重建真正显示出来的 canvas state 和 source timing,为每个 source 单独选择 CFR,不做 upscale,把动画独立编码成保守的 H.264 segment,再通过 stream copy 合并,避免第二次 lossy encode,并在发布前严格验证 packet timeline。

H.264FFmpegMP4Animated WebPGIFAPNG视频压缩媒体管线

我做这套 pipeline,并不是因为想折腾 codec。真正的原因很简单:很多 animated image 在实际使用中已经等同于几秒钟的无声视频,但继续以动画图片的形式分发,带宽成本越来越不划算。

我处理的内容主要是短 animated WebP、GIF 和 APNG:通常只有几秒,几十个真正显示出来的 frame,前后画面之间有很高的 temporal redundancy。流量以移动端为主,同一个 asset 会被反复请求,所以一次性的 encoding 时间远没有之后每次分发需要发送多少 byte 重要。

“把动画转成 MP4”听起来很简单。但 production 里,animated image 并不一定是“一组完整图片以固定 FPS 顺序播放”。它可能包含局部矩形、disposal 规则、alpha blending、不规则 delay、含义模糊的 0 duration、不同 orientation,以及被通用 probe 错误概括的 timing metadata。

因此,我最终得到的不是一条 FFmpeg 命令,而是一组必须一直成立的 invariant:

animated WebP / GIF / APNG
        ↓
decode 并重建真正显示出来的 canvas state
        ↓
恢复 source timeline
        ↓
为每个 source 单独选择 CFR
        ↓
不 upscale,先统一 geometry
        ↓
encode 一个保守的 H.264 segment
        ↓
每个 source 独立处理
        ↓
用 stream copy concat 兼容的 segment
        ↓
校正 final packet timeline
        ↓
probe + packet 检查 + 完整 decode
        ↓
atomic publish

codec 只是其中一部分。真正困难的是:底层表示方式完全改变之后,原动画“在什么时间显示了什么内容”仍然不能被破坏。

第一条规则:提取真正显示的 frame,而不是文件里存储的矩形

最危险的误解,是认为 animated image 里的每个 frame 都是一张完整图片,每次都覆盖整个上一帧。

Animated WebP 的 frame 可以有位置、矩形大小、blend method 和 disposal method。APNG 有 offset、width/height、dispose_op、blend_op。GIF 也可以要求保留上一画面、恢复背景,或者恢复之前的 canvas 状态。

因此,文件内部保存的 frame 可能只是依赖已有 canvas 的一个小 patch。如果把这些 patch 当成完整画面直接 encode,得到的不是更小的同一段动画,而是错误的视频。

我的 extraction 边界是正确 viewer 在那个时刻真正会显示的完整 canvas state。也就是说,先应用上一帧 disposal,再应用当前 frame blending,最后得到已经 composited 的 pixels。

这看起来像 decoder 细节,但实际上是整个 pipeline 的第一个 correctness invariant。一旦错误的画面被压进 H.264,后面的 encoder setting 无法把它恢复。

timing 是 source data,不是一个可以猜的 FPS

  • Animated WebP 以 1 ms 为单位保存每个 frame 的 duration。
  • GIF 以 1/100 秒保存 delay。
  • APNG 使用 delay_num / delay_den;如果 denominator 为 0,PNG specification 要求按 100 处理。

这些 per-frame delay 才是真正的 timeline。一个平均 FPS 只是摘要。

我遇到过一个真实 WebP:1264×720,49 个显示 frame,delay 在 62 和 63 ms 之间交替,总 duration 为 3.063 秒。这实际上非常接近 16 fps,也就是每 frame 62.5 ms。

但 probe 给出的数字却是 25 fps。

如果直接相信 25,就会改变原始 timing,或者生成没有意义的重复 frame。真正应该相信的是 source frame duration。

先规范异常 timing,再选择 CFR

WebP specification 明确说明,duration 0 以及很多非常短的值,例如 10 ms 或更小,可能依赖具体实现解释。GIF 也允许 zero delay;APNG 允许 numerator 为 0。

我的 policy 使用毫秒精度,最低 frame delay 为 10 ms;只有确实拿不到有效 timing 时,才使用 100 ms fallback。

这两个数字不是 universal rule。重要的是顺序:先处理异常 timing,再量化成 CFR,而不是用一个方便的 default 覆盖有效的 source timing。

为什么我不把所有动画都做成 30 fps

如果动画实际每秒只变化约 16 次,存成 30 fps 并不会创造新的 motion 信息。H.264 虽然能很好地压缩重复内容,但没有必要先制造多余 temporal sample。

我会为每个 source 从下面的集合里单独选 CFR:

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

selector 从真实 timeline 出发,选择仍能干净表示画面变化的最低 candidate;如果更低的都不合适,再 fallback 到 30 fps。

我不提供一个脱离内容类型的“万能 timing error threshold”。更重要的原则是:先确定真实 display time,再选择能足够准确表示它的最低成本 CFR。

62/63 ms 的例子天然适合 16 fps,因为一帧正好 62.5 ms。

不同 source 可以使用不同 CFR,最后仍然是一个 MP4

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,但最终文件按区间来看是不同 CFR,因此整体上可以理解为 variable-rate。我不会为了得到“一个全局 FPS”再把所有 segment 重编码一次。

为什么使用 90 kHz time base

我使用 90,000 Hz video timescale,因为允许的每个 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 的一帧不是“大约 41.667 ms”,而是在 encoded timeline 中精确 3750 ticks。16 fps 则是 5625 ticks。

video track timescale 和 movie timescale 都设为 90,000,validator 也直接检查这条 integer tick grid。

encode 之前先规范 geometry,而且绝不 upscale

source 类型最大范围
Landscape1280×720
Portrait720×1280
final sequence 中 orientation 混合约 960×960 canvas

小 source 不放大。插值出来的 pixels 并没有新的 source detail。

不同 aspect ratio 需要进入同一个 final stream 时,我把画面 fit 到公共 canvas,再用 padding 填空,而不是 stretch。我的 background 是 black。

alpha 也是明确的 conversion rule:这个 H.264/yuv420p pipeline 不保留透明通道,因此 transparency 会 flatten 到选定 background 上。

为什么 H.264 MP4 在这类 workload 中通常更省

GIF、APNG、animated WebP 自己也有 partial update、disposal 等优化,因此不能说它们总是单独存储每一张完整 frame。

但 H.264 本身就是为 video temporal compression 设计的,可以使用 reference pictures、inter prediction、P-frames 和 B-frames。这非常适合大部分背景不变、只有少量区域移动的 illustrated clip。

Apple 当前建议 static web video 使用 H.264 MP4,并明确建议用 MP4 代替 animated GIF。Safari documentation 还指出,相比 modern video codec,GIF 可能消耗最多 12 倍 bandwidth、约 2 倍 energy。

这个 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 compatibility 选择的 conservative envelope,不是现代设备的能力上限。Android 从 Android 6.0 起要求 Main Profile decoder,并把 1280×720@30 fps 列为 H.264 HD 推荐配置。Apple 也建议 static web file 使用 H.264 MP4。

CPU 一次多花,bandwidth 以后一直省

-preset veryslow

encoding 只做一次,delivery 会发生很多次,所以让 x264 花更多 CPU 去寻找更高效 representation 是合理的。decoder complexity 则通过 profile、level、refs 和 B-frame structure 单独限制。

CRF 28 也只适合我的 illustrated、bandwidth-first workload,不是 universal recommendation。

每个动画独立 encode 成一个 segment

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

在这条命令之前,timeline-aware frame sequence 和 resize/pad geometry 已经准备好。GOP 大约是 selectedFps * 5

concat 能成立,是因为 segment contract 足够严格

FFmpeg concat demuxer 要求 compatible streams,并使用每个 file 的 duration 来计算下一个 file 的 timestamp。因此 compatibility 不是 concat 时修补的,而是在接受 segment 之前就必须保证。

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

-c:v copy 避免第二次 H.264 lossy encode。

stream copy 不等于 timestamp 自动完美

90000 / 24 = 3750 ticks

validator 曾在真实 concatenated output 中发现一个 3751-tick packet。视觉上几乎看不出来,但它破坏了我专门设计的 exact grid invariant。

这说明:保持 H.264 payload 不变,并不能保证 segment boundary 调整之后 muxed timestamp 一定落在目标 grid 上。

现在 final concat 会使用 setts bitstream filter,在保留 -c:v copy 的同时把 PTS、DTS、duration 规范到已知的 90-kHz timeline。具体 expression 由 segment timeline 生成,不是通用 copy-paste 配方。

validation 本身就是 encoder 的一部分

FFmpeg exit code 0 不够。我会检查 streams、profile/level、pixel format、dimensions、time base、frame/packet counts、exact durations、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 -

只有全部通过后才 atomic publish。

这套 pipeline 主要防止哪些错误

  • 把 raw subframe 当成 visible canvas encode。
  • 相信猜出来的 FPS。
  • 所有内容强制 30 fps。
  • upscale 小图。
  • stretch aspect ratio。
  • concat 后重新 lossy encode。
  • -c copy 当成 timing correctness 的证明。
  • 只检查 header,不做 full decode。

我明确选择不保留的东西

alpha 会 flatten。任意 VFR 会由 source-specific CFR 近似。过高 resolution 会缩小。lossy WebP 会经过额外 lossy generation。audio 不在这个问题范围内。这些都是明确 trade-off。

什么时候我不会用这套方法

如果 transparency 必须保留、exact irregular timing 本身具有语义、目标是 archival master,或者系统已经有 adaptive multi-codec video stack,我不会直接使用这套 pipeline。对于已经非常小的 optimized animated WebP,也会先 benchmark,而不是假设 MP4 一定更小。

现在的实际 pipeline

  1. 读取真实 animation metadata。
  2. 按 blend/disposal 重建完整 visible canvas state。
  3. 清理 delay,建立 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。
  7. 使用 90-kHz time base。
  8. validate 每个 segment。
  9. -c:v copy concat。
  10. final timeline 规范到 exact grid。
  11. packet inspection + full decode。
  12. 通过后再 publish。

最重要的结论:真正的 asset 是 timeline

Animated WebP、GIF、APNG 不是简单的“很多张图片”,而是一串带时间信息的 canvas state。

把这串 timeline 当成 source of truth 后,很多设计自然清晰了:不虚构 motion 就能降低 cadence,不虚构 pixel 就能降低 resolution,每个 source 可以有自己的 CFR,concat 也不需要再来一次 lossy encode。

H.264 让文件变小,MP4 让 delivery 变简单。但如果在 frame extraction 或 timing 阶段已经破坏动画,这两者都没有意义。

codec 反而是简单的部分。真正的工程工作,是在改变 representation 的同时保留 animation 的含义。

主要一手文档