我做这套 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:
| FPS | 90 kHz 下精确 frame duration |
|---|---|
| 10 | 9000 ticks |
| 12 | 7500 ticks |
| 15 | 6000 ticks |
| 16 | 5625 ticks |
| 18 | 5000 ticks |
| 20 | 4500 ticks |
| 24 | 3750 ticks |
| 25 | 3600 ticks |
| 30 | 3000 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 类型 | 最大范围 |
|---|---|
| Landscape | 1280×720 |
| Portrait | 720×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 故意选择保守方案
| 设置 | 值 |
|---|---|
| Container | MP4 |
| Codec | H.264 / libx264 |
| Sample entry | avc1 |
| Profile / level | Main @ 3.1 |
| Pixel format | 8-bit yuv420p |
| Rate control | CRF 28 |
| Preset | veryslow |
| Tune | animation |
| Reference frames | 4 |
| Maximum B-frames | 5 |
| Open GOP | off |
| B-pyramid | normal |
| Maximum GOP | 约 5 秒 |
| VBV | 4M maxrate / 8M bufsize |
| Color | BT.709, limited range |
| Track/movie timescale | 90,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
- 读取真实 animation metadata。
- 按 blend/disposal 重建完整 visible canvas state。
- 清理 delay,建立 ms timeline。
- 从 10/12/15/16/18/20/24/25/30 中为每个 source 选择 CFR。
- 不 upscale,计算 geometry,padding,alpha flatten。
- H.264 Main@3.1/yuv420p/avc1,
veryslow,CRF 28。 - 使用 90-kHz time base。
- validate 每个 segment。
-c:v copyconcat。- final timeline 规范到 exact grid。
- packet inspection + full decode。
- 通过后再 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 的含义。