我构建这套流程,并不是为了试验各种编解码器,而是因为动画图片已经成了一种昂贵的传输方式——它承载的内容实际上就是几秒钟的无声视频。
我处理的素材主要是很短的动画 WebP、GIF 和 APNG:通常只有几秒,真正显示的帧往往只有几十张,而且相邻画面之间存在大量时间冗余。观看主要发生在移动设备上,同一文件可能被反复请求,因此对我来说,一次性的编码成本远没有之后每次传输的字节数重要。
难点并不是调用 FFmpeg。动画图片不保证一定是一组以固定帧率排列的完整大图。它可能包含局部矩形、混合与处置规则、透明通道、不规则延迟、持续时间为零的帧、不同方向,以及会被普通探测工具错误概括的时间元数据。
所以我把转换看成一组必须保持的约束,而不是一条命令:
动画 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 个实际显示帧
输出
1 个 H.264 MP4
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。与所有源动画的总字节数相比,大约缩小到 1/19,也就是减少约 94.7% 的数据。
这是整套流程端到端的结果,并不是“旧 H.264 对新 H.264”的干净 A/B 测试。表示方式本身从数百个动画图片文件变成了一个利用时间压缩的视频,所以我不会把全部 19 倍的缩减归功于 CRF 28、veryslow 或任何单一编码选项。
第一步,重建观看者真正看到的完整画面
最危险的捷径,是假设动画文件中存储的每一帧都是一张可以完整替换前一帧的图片。
动画 WebP 的帧可以描述某个位置上的矩形以及混合和处置行为。APNG 帧拥有偏移、尺寸、延迟、处置和混合操作。GIF 也可以保留之前的画布、清除某一区域,或者恢复更早的状态。
因此,存储的帧可能只是依赖前面画布状态的一小块补丁。如果把这些补丁当成完整图片直接编码,得到的不是正确动画的更小版本,而是错误的动画。
我的提取边界是完整的显示画布状态:应用前一帧的处置规则和当前帧的混合规则后,正确播放器实际会显示的全部合成像素。
这是整套流程的第一层正确性保证。一旦错误的局部帧被压平进 H.264,后续的 CRF、预设或封装选项都无法修复它。
帧时间是源数据,不是需要猜测的 FPS
不同动画格式保存时间的方式不同。动画 WebP 以 1 ms 为单位保存每帧持续时间。GIF 以百分之一秒保存延迟。APNG 为每帧延迟保存分子和分母;分母为 0 时,PNG 规范按 100 处理。
这些逐帧延迟才是真正的时间线。普通探测工具显示的醒目 FPS 只是摘要,而且可能具有误导性。
我实际处理过一个 1264×720 的 WebP,共有 49 个显示帧。帧延迟在 62 和 63 ms 之间交替,总时长 3.063 秒。这实际上接近 16 fps,因为 16 fps 下每帧恰好是 62.5 ms。
但一个普通探测工具却把这个源报告成 25 fps。如果相信那个数字,就会改变源时间,或者制造没有必要的重复画面。
损坏或含糊的延迟也需要明确策略。WebP 明确把持续时间为 0、以及通常很小的持续时间的解释交给具体实现。GIF 可以出现 0 延迟。APNG 允许分子为 0,意思是尽可能快地渲染下一帧,不过播放器仍可能设置实际的下限。
我的规范化策略以毫秒精度保留时间,对 0 或明显过小的帧持续时间使用 10 ms 的小下限,只有在确实没有可用时间信息时才回退到 100 ms。这些是我的工程策略,不是通用标准。
为整个最终 MP4 选择一个 CFR,而不是默认强制 30 fps
重建显示状态及其持续时间后,我把源时间线映射到视频时间线,而不是盲目把一切都编码成 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×900,而不必是 960×960。所有片段的编码尺寸仍完全一致,因此仍可通过码流复制进行拼接,同时避免编码毫无作用的黑色区域。
我保持宽高比,用填充处理剩余空间,而不是拉伸图片。这套流程中填充为黑色。普通 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 控制在约五秒: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 时出现 5580 个时间刻度,而契约要求 5625;之后的 24 fps 输出又出现 3751,而精确网格要求 3750。关于单个时间刻度偏差的深入调查是另一个主题;这里的结论只是,确定性的时间线错误不会因为重试同一编码而自动消失。
对于媒体对象本身,我检查预期码流数量、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 设备以及主流桌面浏览器上检查开始播放、跳转、循环、后台切换/恢复和范围播放。
只有文件本身和交付路径都通过契约后,我才会原子替换生产文件。
这套流程有意放弃的信息
这是用于交付的转换,不是归档母版。透明通道会被合成到背景。源中不规则的时间会量化到最终 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 规范第三版 — APNG 帧时间、偏移、混合与处置操作。
- GIF89a 规范 — GIF 延迟与处置行为。
- FFmpeg 格式文档 — 拼接用解复用器要求与 MP4 封装行为。
- FFmpeg 比特流过滤器文档 —
setts。 - ffprobe 文档 — 码流与数据包检查。
- Apple:为 Safari 交付视频内容 — 静态视频的 H.264 MP4 与动画 GIF 替代建议。
- Android 支持的媒体格式 — H.264 支持与 HTTP 流式传输要求。