提示

返回博客列表

怎么知道这次升级没搞坏画质:转码质量回归流水线

有次我们把 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 不受负载影响,可以严格卡。素材集要自己拍或用公开测试序列,别用有版权的内容。

目录

一、为什么需要质量回归

转码输出会被这些东西悄悄改变:

变更 影响
ffmpeg 版本升级 默认参数、滤镜实现、封装行为都可能变
libx264/libx265 子版本升级 编码输出会变(这是最常见的)
改了编码参数 显而易见,但影响范围难评估
改了滤镜链 缩放算法、色彩处理
换了编译选项 极少但可能

这些变化的共同点:

  1. 不报错——转码照样成功,文件照样能播;
  2. 幅度小——1~2 分的 VMAF 差异,肉眼在快速对比时看不出来;
  3. 发现得晚——往往要等客户反馈。

质量回归的作用就是:让这些变化在合并之前就被发现。

二、建立黄金素材集

选择标准:

标准 说明
覆盖内容类型 动画、访谈、赛事/高运动、屏录/文字、夜景/噪点、老片/颗粒
能复现问题 每类至少一段"曾经出过问题"的素材
短 每段 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 升级后,封面生成流程输出的图片色彩略有不同(不是转码问题,但同一套思路)。这个是通过"封面图的哈希对比"发现的——同一套思想也可以用在非编码的场景。

十、坑清单

  1. 没有基线就做对比 → 不知道"正常值"是多少。第一件事是生成基线。
  2. 只用平均值判断 → 掩盖单类型劣化。必须逐条看。
  3. 素材集不具代表性 → 测不出问题。覆盖各种类型,尤其是出过问题的。
  4. 用有版权的素材 → 合规风险。自己拍或用公开测试序列。
  5. 阈值定得太严 → 频繁误报,最后没人看。0.5 分以内视为正常波动。
  6. 阈值定得太松 → 抓不到问题。> 2 分必须阻断。
  7. 用 CI 上的耗时做门禁 → 抖动大,误报多。耗时只做参考。
  8. 版本变了还用旧基线 → 结论错误。meta 里记录版本,变了就重新生成基线。
  9. 跑得太慢 → 没人等 PR 检查。用子集 + 并行 + 分层跑。
  10. 只测画质不测其他 → 时长/音轨/封装出问题照样漏。做契约测试。
  11. 基线不更新 → 有意的改进(比如换了更好的参数)会被一直报"异常"。改进之后要主动更新基线(并且 commit message 里写清楚)。
  12. 报告没人看 → 发到群里/贴到 PR,并且@相关人。报告要能一眼看出结论(❌ 通过 / ✅ 未通过)。
  13. 本地跑和 CI 跑结果不一致 → 环境不同(ffmpeg 版本)。用容器统一环境。
  14. 忘了测 preset 组合 → 改了通用参数,某个 preset 受损。夜跑覆盖多套 preset。
  15. 把回归当负担 → 它其实是"让你敢于升级"的底气。有回归之后,我们升级 ffmpeg 从"犹豫半年"变成"随时升"。

最后说说这套东西带来的最大改变。

它把"升级依赖"从一件令人焦虑的事,变成了一件平常的事。

在建立回归之前,我们升级 ffmpeg 的流程是:先在测试环境跑几天、人工抽查几个文件、然后挑一个业务低峰上线、上线后祈祷。这个过程通常要拖一两个月,而且最后还是可能出问题(就是那次)。

建立回归之后:升级 → 提 PR → CI 跑回归 → 报告显示"全部通过"或者"XX 类受影响" → 决定合并还是修。整个过程一天。

这里的关键洞察是:我们害怕的不是"出问题",而是"不知道有没有出问题"。回归流水线解决的正是后者——它不保证不出问题,但保证出问题一定会被发现,而且是在上线之前。

还有一个附带的收益:基线数据成了团队的知识资产。现在有人问"我们的转码画质大概什么水平",我可以打开基线文件说"赛事类 91.2、动画类 94.2、屏录类 92.8"——这些数字比任何主观描述都有说服力,跟客户沟通时尤其有用。

最后提醒一句:基线要定期更新。我们每半年会主动"重设基线"(在确认当前配置是最优的前提下),避免基线因为累积的小改动而漂移,也避免它变成"只能变好不能变坏"的僵化约束。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部