有次我们把 ffmpeg 从 5.1 升级到 6.0(为了用新的 AV1 编码器)。升级过程很顺利,测试也过了——我们测的是"能不能转码成功"和"耗时有没有变慢"。
半个月后,客户反馈"最近几批视频好像没以前清楚"。我对比了新旧版本转出来的文件,同样的参数,VMAF 平均低了 1.6 分。原因大概是新版本里 x265 的某个默认参数变了(或者 libx265 的子版本变了),而我们的参数里没显式指定那一项。
这件事让我意识到:我们没有"画质基线"。每次改动之后,画质是变好还是变坏,全靠感觉——而感觉是不可靠的,尤其是这种 1~2 分的细微变化。
于是我建了一套回归流水线:一批固定的测试素材、一份基线数据、每次变更自动跑一遍对比。这篇写完整的做法。
TL;DR:三件套——黄金素材集(10~20 段有代表性的短片,覆盖各种内容类型)、基线数据(每段的 VMAF/SSIM/码率/耗时,存成 JSON 进 Git)、CI 门禁(每次变更自动跑,VMAF 下降超过阈值就报警/阻断)。判断阈值:单素材 VMAF 降 > 1.0 分告警、> 2.0 分阻断,码率变化 > 10% 关注。两个实用细节:CI 上耗时指标会抖动(负载影响),所以耗时只做参考;VMAF 不受负载影响,可以严格卡。素材集要自己拍或用公开测试序列,别用有版权的内容。
目录
- 一、为什么需要质量回归
- 二、建立黄金素材集
- 三、跑出第一份基线
- 四、回归流程与判定规则
- 五、控制运行时间
- 六、在 CI 上跑的注意事项
- 七、报告长什么样
- 八、除了画质还要回归什么
- 九、这套东西抓到过的三个问题
- 十、坑清单
一、为什么需要质量回归
转码输出会被这些东西悄悄改变:
| 变更 | 影响 |
|---|---|
| ffmpeg 版本升级 | 默认参数、滤镜实现、封装行为都可能变 |
| libx264/libx265 子版本升级 | 编码输出会变(这是最常见的) |
| 改了编码参数 | 显而易见,但影响范围难评估 |
| 改了滤镜链 | 缩放算法、色彩处理 |
| 换了编译选项 | 极少但可能 |
这些变化的共同点:
- 不报错——转码照样成功,文件照样能播;
- 幅度小——1~2 分的 VMAF 差异,肉眼在快速对比时看不出来;
- 发现得晚——往往要等客户反馈。
质量回归的作用就是:让这些变化在合并之前就被发现。
二、建立黄金素材集
选择标准:
| 标准 | 说明 |
|---|---|
| 覆盖内容类型 | 动画、访谈、赛事/高运动、屏录/文字、夜景/噪点、老片/颗粒 |
| 能复现问题 | 每类至少一段"曾经出过问题"的素材 |
| 短 | 每段 20~30 秒(跑得快) |
| 原始质量高 | 素材本身要是高质量的(否则测的是素材的损伤) |
| 来源干净 | 自己拍、或者公开的测试序列,不要用有版权的内容 |
我现在的素材集(14 段,每段 30 秒,总计约 600 MB):
| 编号 | 类型 | 分辨率/帧率 | 特点 |
|---|---|---|---|
| g01 | 动画 | 1080p/24 | 纯色 + 线条 |
| g02 | 访谈 | 1080p/25 | 人物中景、虚化背景 |
| g03 | 赛事 | 1080p/50 | 高速运动、草地细节 |
| g04 | 屏录 | 1080p/30 | PPT 小字 + 鼠标 |
| g05 | 夜景 | 1080p/30 | 高噪点、暗部多 |
| g06 | 老片 | 720p/25 | 颗粒、划痕 |
| g07 | 风景 | 4K/30 | 渐变天空(测色带) |
| g08 | 演唱会 | 1080p/30 | 灯光变化剧烈 |
| g09 | 街头 | 1080p/30 | 中等运动、细节多 |
| g10 | 人脸特写 | 1080p/25 | 测肤色还原 |
| g11 | 静态 PPT | 1080p/5 | 极低帧率 |
| g12 | 高对比 | 1080p/30 | 黑白对比强 |
| g13 | 水下/雾 | 1080p/30 | 低对比、低饱和 |
| g14 | 屏幕录制(动态) | 1080p/60 | 代码滚动、高帧率 |
素材来源:大部分是我们自己拍的(公司同事拍的街景、会议录像),一部分是公开测试序列(Xiph.org 的测试视频是 CC 授权的,可以放心用)。我把来源和授权写在一个 SOURCES.md 里,避免以后说不清。
存放:素材集单独一个 Git 仓库(或者用 Git LFS),CI 里 clone 下来。不要放在代码仓库里(几百 MB 会让 clone 变慢)。
三、跑出第一份基线
基线数据结构:
{
"meta": {
"created_at": "2026-03-11T10:00:00+08:00",
"ffmpeg_version": "6.0",
"x265_version": "3.5",
"preset_name": "photo_default",
"command": "ffmpeg -i {src} -c:v libx265 -crf 26 -preset slow -x265-params \"...\" {out}"
},
"results": {
"g01": {"vmaf": 94.21, "ssim": 0.9812, "psnr": 41.3,
"bitrate_kbps": 1180, "encode_seconds": 42.1, "size_bytes": 4425000},
"g02": {"vmaf": 93.55, "ssim": 0.9778, "psnr": 40.1,
"bitrate_kbps": 2260, "encode_seconds": 51.3, "size_bytes": 8475000}
}
}
生成脚本:
import json
import subprocess
import time
from pathlib import Path
def run_one(src: Path, preset: dict, workdir: Path) -> dict:
out = workdir / f'{src.stem}_out.mp4'
cmd = preset['command'].format(src=str(src), out=str(out))
t0 = time.time()
subprocess.run(cmd, shell=True, check=True)
seconds = time.time() - t0
vmaf = measure_vmaf(out, src)
ssim = measure_ssim(out, src)
size = out.stat().st_size
duration = probe_duration(out)
return {
'vmaf': round(vmaf, 2),
'ssim': round(ssim, 4),
'bitrate_kbps': int(size * 8 / duration / 1000),
'encode_seconds': round(seconds, 1),
'size_bytes': size,
}
def measure_vmaf(distorted: Path, reference: Path) -> float:
cmd = ['ffmpeg', '-v', 'error', '-i', str(distorted), '-i', str(reference),
'-lavfi', '[0:v][1:v]libvmaf=log_fmt=xml:log_path=/dev/null',
'-f', 'null', '-']
proc = subprocess.run(cmd, capture_output=True, text=True)
for line in proc.stderr.splitlines():
if 'VMAF score' in line:
return float(line.split(':')[-1].strip())
raise RuntimeError('VMAF 解析失败(ffmpeg 编译时带 libvmaf 了吗?)')
基线要提交进 Git(它是文本文件,几 KB)。这样每次回归都能拿到"上次的状态"。
四、回归流程与判定规则
1. CI 触发(PR / 定时任务)
↓
2. 拉素材集 + 基线
↓
3. 对每条素材跑当前配置
↓
4. 和基线对比
↓
5. 生成报告 + 按规则判定
↓
6. 通过 → 合并;不通过 → 阻断/告警
判定规则(我定的,可以根据需要调整):
| 指标 | 变化 | 动作 |
|---|---|---|
| VMAF | 降 0.5~1.0 | 记录(正常波动) |
| VMAF | 降 1.0~2.0 | 告警(发群消息,人工确认) |
| VMAF | 降 > 2.0 | 阻断(不能合并) |
| VMAF | 升 > 1.0 | 提示"画质提升"(可能是有意的改动) |
| 码率 | 变化 > 10% | 关注(可能是参数变了) |
| 耗时 | 变慢 > 25% | 关注(CI 上耗时抖动大,只做参考) |
为什么要分级而不是一刀切:
- 0.5 分以内的波动是正常的(编码器有随机性、浮点累积);
- 1~2 分值得看一眼(可能只是某类内容受影响);
- 超过 2 分几乎一定是出问题了。
分内容类型看:整体平均会掩盖问题。比如"屏录类的 VMAF 掉了 3 分,其他没变"——平均下来只掉 0.3 分,看不出来。所以报告必须逐条列出。
五、控制运行时间
全量跑 14 条 × 50 秒 = 12 分钟(单进程)。并行(8 进程)后约 2 分钟。
但对慢 preset 或者 4K 素材,可能要几十分钟。控制办法:
| 办法 | 说明 |
|---|---|
| 缩短素材 | 30 秒 → 15 秒(VMAF 差异很小) |
| 并行 | 进程池,8~16 路 |
| 分层跑 | PR 时跑快速子集(6 条),合并到主分支后跑全量 |
| 夜跑全量 | 定时任务,每天凌晨跑全量 + 多组 preset |
我们的配置:
- PR 触发:跑 6 条核心素材(g01~g06),约 3 分钟;
- 每日夜跑:跑全部 14 条 × 3 套 preset,约 40 分钟,出趋势报告。
六、在 CI 上跑的注意事项
1. 耗时指标会抖动
CI runner 的负载是共享的,同一段代码跑两次可能差 30%。所以:
- 耗时不作为阻断条件(只记录、看趋势);
- 如果要精确测耗时,用专用的固定机器,并且跑多次取中位数。
2. VMAF 不受负载影响
VMAF 是确定性的计算(同样的输入 → 同样的输出),所以 CI 上跑出来的 VMAF 是可信的。这是它适合做门禁的原因。
3. 环境一致性
CI 上的 ffmpeg 版本必须和生产一致(不然测的不是要发布的东西)。我们的做法是把 ffmpeg 打进 Docker 镜像,CI 和生产用同一个镜像:
FROM python:3.11-slim
COPY ffmpeg-static/ffmpeg /usr/local/bin/
COPY ffmpeg-static/ffprobe /usr/local/bin/
RUN chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
基线的 meta 里记录版本信息,如果版本变了,就提示"基线已过期,需要重新生成"——否则你会拿新版本的结果去比旧版本的基线,得出错误的结论。
4. 素材集的缓存
CI 里每次 clone 600MB 的素材很慢。用 CI 的缓存机制(或者预先打进镜像)。
七、报告长什么样
生成的 markdown 报告(发到群里或者直接贴到 PR 评论):
## 转码质量回归报告
环境:ffmpeg 6.0 / x265 3.5 / preset=photo_default
对比基线:2026-03-11 (commit a1b2c3d)
| 素材 | VMAF(基线) | VMAF(当前) | Δ | 码率 Δ | 状态 |
|---|---|---|---|---|---|
| g01 动画 | 94.21 | 94.18 | -0.03 | +0.2% | ✅ |
| g02 访谈 | 93.55 | 93.51 | -0.04 | +0.5% | ✅ |
| g03 赛事 | 91.20 | 91.15 | -0.05 | +0.8% | ✅ |
| g04 屏录 | 92.80 | **89.42** | **-3.38** | +1.2% | ❌ 阻断 |
| g05 夜景 | 90.15 | 90.11 | -0.04 | +0.3% | ✅ |
| g06 老片 | 89.90 | 89.87 | -0.03 | -0.4% | ✅ |
结论:❌ 未通过 —— g04(屏录)VMAF 下降 3.38 分,超过阻断阈值 2.0
看这个报告,一眼就知道问题出在屏录类——而不是"平均下降 0.6 分"(后者会被忽略)。
趋势图(我用 matplotlib 生成一个简单的折线图,存成 artifact):显示每个素材的 VMAF 随时间的曲线,能看出"渐进式劣化"。
八、除了画质还要回归什么
画质是核心,但这几项也值得自动化:
| 检查项 | 怎么测 | 说明 |
|---|---|---|
| 输出体积 | 文件大小 | 突然变大说明参数错了 |
| 编码耗时 | 计时 | 看趋势 |
| 元数据正确性 | ffprobe 检查分辨率/帧率/时长 | 时长不对是常见问题 |
| 封装兼容性 | 能不能被常见播放器打开(ffprobe 能解析即可近似) | |
| 色彩参数 | color_primaries 等 |
色彩处理回归 |
| 音频 | 音轨存在、时长、采样率 | 容易被忽略 |
| faststart | 检查 moov 位置 | |
| 关键帧间隔 | 统计 IDR 数量 | ABR 场景很重要 |
我把这些做成了一个"契约测试"(contract test):跑完转码后,对输出文件跑一组断言:
def assert_output_contract(out: Path, expect: dict):
info = probe(out)
assert abs(info['duration'] - expect['duration']) < 0.5, '时长不符'
assert info['width'] == expect['width'], '分辨率不符'
assert has_audio_stream(out), '音轨丢失'
assert moov_at_front(out), '未开启 faststart'
assert info['pix_fmt'] == expect['pix_fmt'], '像素格式不符'
九、这套东西抓到过的三个问题
问题 1:ffmpeg 升级后屏录画质下降 3.4 分
就是开头说的那次。回归报告明确指出"只有屏录类受影响",我们定位到是新版本里某个与 psy 相关的默认值变化,于是在参数里显式指定了 psy-rd=0(屏录预设本来就有,但通用预设没有)。
问题 2:缩放滤镜顺序被改,4K 素材变糊
有人为了修另一个 bug 调整了滤镜链顺序,把 scale 放到了 yadif 前面。对 1080p 素材没影响(所以人工测试没发现),但对隔行的 4K 素材,先缩放会让去隔行失效,VMAF 掉 4.2 分。回归在 PR 阶段就拦住了。
问题 3:某个依赖库升级导致色彩处理变化
Pillow 升级后,封面生成流程输出的图片色彩略有不同(不是转码问题,但同一套思路)。这个是通过"封面图的哈希对比"发现的——同一套思想也可以用在非编码的场景。
十、坑清单
- 没有基线就做对比 → 不知道"正常值"是多少。第一件事是生成基线。
- 只用平均值判断 → 掩盖单类型劣化。必须逐条看。
- 素材集不具代表性 → 测不出问题。覆盖各种类型,尤其是出过问题的。
- 用有版权的素材 → 合规风险。自己拍或用公开测试序列。
- 阈值定得太严 → 频繁误报,最后没人看。0.5 分以内视为正常波动。
- 阈值定得太松 → 抓不到问题。> 2 分必须阻断。
- 用 CI 上的耗时做门禁 → 抖动大,误报多。耗时只做参考。
- 版本变了还用旧基线 → 结论错误。meta 里记录版本,变了就重新生成基线。
- 跑得太慢 → 没人等 PR 检查。用子集 + 并行 + 分层跑。
- 只测画质不测其他 → 时长/音轨/封装出问题照样漏。做契约测试。
- 基线不更新 → 有意的改进(比如换了更好的参数)会被一直报"异常"。改进之后要主动更新基线(并且 commit message 里写清楚)。
- 报告没人看 → 发到群里/贴到 PR,并且@相关人。报告要能一眼看出结论(❌ 通过 / ✅ 未通过)。
- 本地跑和 CI 跑结果不一致 → 环境不同(ffmpeg 版本)。用容器统一环境。
- 忘了测 preset 组合 → 改了通用参数,某个 preset 受损。夜跑覆盖多套 preset。
- 把回归当负担 → 它其实是"让你敢于升级"的底气。有回归之后,我们升级 ffmpeg 从"犹豫半年"变成"随时升"。
最后说说这套东西带来的最大改变。
它把"升级依赖"从一件令人焦虑的事,变成了一件平常的事。
在建立回归之前,我们升级 ffmpeg 的流程是:先在测试环境跑几天、人工抽查几个文件、然后挑一个业务低峰上线、上线后祈祷。这个过程通常要拖一两个月,而且最后还是可能出问题(就是那次)。
建立回归之后:升级 → 提 PR → CI 跑回归 → 报告显示"全部通过"或者"XX 类受影响" → 决定合并还是修。整个过程一天。
这里的关键洞察是:我们害怕的不是"出问题",而是"不知道有没有出问题"。回归流水线解决的正是后者——它不保证不出问题,但保证出问题一定会被发现,而且是在上线之前。
还有一个附带的收益:基线数据成了团队的知识资产。现在有人问"我们的转码画质大概什么水平",我可以打开基线文件说"赛事类 91.2、动画类 94.2、屏录类 92.8"——这些数字比任何主观描述都有说服力,跟客户沟通时尤其有用。
最后提醒一句:基线要定期更新。我们每半年会主动"重设基线"(在确认当前配置是最优的前提下),避免基线因为累积的小改动而漂移,也避免它变成"只能变好不能变坏"的僵化约束。