提示

返回博客列表

交付前的最后一道关:视频自动验收清单与脚本

我们交付视频前有个"检查环节"——人工看一遍:能不能播、分辨率对不对、有没有声音。

这个环节漏过三次:

  1. 有一批文件忘了加 +faststart,客户在网页上播放要等很久才开始;
  2. 有一批的输出音频是 22050 Hz(源的问题被我们继承了),客户那边某些设备无声;
  3. 有一批时长对不上(容器写的 45 分钟,实际只有 43 分钟),客户按时长做的排期全错。

这三次都是"人工看了觉得没问题"的——因为人检查的是"能不能播",而不是"参数对不对"。前者一眼能看出来,后者必须逐项比对。

后来我写了验收脚本:定义一份"规格"(这个交付应该满足什么),脚本逐项检查并出报告。漏项率归零,而且报告和客户的验收标准是一一对应的——客户说要什么,我们就检查什么。

TL;DR:验收分五层:容器层(格式、faststart、时长、大小)、视频流(编码、分辨率、帧率、pix_fmt、profile/level、码率、GOP)、音频流(编码、采样率、声道、码率)、质量层(解码无错误、无黑帧/冻结帧、时长与帧数一致)、业务层(文件名、封面、章节、元数据)。做法:把要求写成一份规格文件(JSON/YAML),脚本读规格 + ffprobe 逐项比对,输出"通过/警告/失败"报告,用退出码接入 CI。其中最容易被忽略也最重要的一项是解码验证(ffmpeg -v error -i file -f null -,检查有没有解码错误)。

目录

一、为什么人工检查一定会漏

人工检查的天然局限:

局限 说明
检查的是"能不能播" 能播就觉得没问题,参数对不对不会去查
没有清单 凭记忆检查,总会漏项
标准会漂移 看第 1 个和第 50 个时的严格程度不一样
数量一大就敷衍 300 个文件,后面基本是划过去

根本问题是:验收标准没有"外化"——它只存在于检查人的脑子里。脚本的作用就是把它外化成一份可执行的规格。

二、验收要检查的五层

容器层

检查项 期望 为什么
格式 MP4(或约定格式) 兼容性
faststart moov 在文件开头 网页/移动端秒开
时长 与规格一致(±1 秒) 客户排期依据
文件大小 在合理区间 太大/太小都是异常
流的数量 1 视频 + 1 音频(+ 约定) 多余流(比如多余音轨)要确认

视频流

检查项 期望 为什么
编码 H.264 / H.265 / AV1(按约定) 兼容性
分辨率 1920×1080(按约定)
帧率 25/30(按约定)
pix_fmt yuv420p(8bit 交付) 10bit/4:2:2 兼容性差
profile/level high / 4.1(按约定) 设备能力
码率 在 [min, max] 区间 太低画质不够,太高浪费
GOP / 关键帧间隔 按约定(ABR 必须) 对齐、随机访问
色彩参数 bt709 / tv 色彩正确性
SAR 1:1 像素宽高比

音频流

检查项 期望
编码 AAC-LC
采样率 48000(或 44100)
声道 2(立体声)
码率 ≥ 128k
语言标签 chi / eng(按需)

质量层

检查项 方法
解码无错误 ffmpeg -v error -i f -f null - 无输出
无黑帧/冻结帧 帧差检测
时长与帧数一致 帧数 ÷ 帧率 ≈ 时长
无花屏/严重块效应 无参考质量评估(可选)

业务层

检查项 说明
文件名规范 约定的命名格式
封面图 存在且尺寸正确
章节 存在且数量合理(按需)
元数据 title / author / copyright
字幕 存在且语言正确(按需)

三、规格文件怎么定义

用 JSON 描述"这次交付要满足什么":

{
  "name": "客户A-课程交付-v2",
  "container": {
    "format": "mp4",
    "require_faststart": true,
    "max_size_mb": 2048
  },
  "video": {
    "codec": "h264",
    "width": 1920,
    "height": 1080,
    "fps": [24, 25, 30],
    "pix_fmt": ["yuv420p"],
    "profile": ["high", "main"],
    "max_level": 41,
    "bitrate_kbps": {"min": 1500, "max": 8000},
    "color_space": "bt709",
    "color_range": "tv"
  },
  "audio": {
    "codec": "aac",
    "sample_rate": [44100, 48000],
    "channels": 2,
    "min_bitrate_kbps": 128
  },
  "quality": {
    "require_clean_decode": true,
    "max_black_seconds": 3.0,
    "max_frozen_seconds": 2.0,
    "duration_tolerance_seconds": 1.0
  },
  "naming": {
    "pattern": "^[A-Z]{2}-\\d{4}-.+\\.mp4$"
  }
}

不同客户/不同项目用不同的规格文件,放在 specs/ 目录里版本管理。这样:

  • 换客户只要换规格文件,不用改脚本;
  • 规格变更有记录(Git);
  • 可以直接把客户的验收文档翻译成规格文件。

四、脚本实现

#!/usr/bin/env python3
"""视频交付验收。用法:python accept.py file.mp4 --spec specs/customer_a.json"""
import json
import re
import subprocess
import sys
from pathlib import Path


def probe(path: str) -> dict:
    """ffprobe 拿全部信息。"""
    cmd = ['ffprobe', '-v', 'error', '-show_format', '-show_streams',
           '-of', 'json', path]
    out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
    return json.loads(out)


class Result:
    def __init__(self):
        self.items = []

    def check(self, name, ok, detail='', level='fail'):
        self.items.append({'name': name, 'ok': ok, 'detail': detail, 'level': level})

    @property
    def failed(self):
        return [i for i in self.items if not i['ok'] and i['level'] == 'fail']

    @property
    def warned(self):
        return [i for i in self.items if not i['ok'] and i['level'] == 'warn']


def accept(path: str, spec: dict) -> Result:
    r = Result()
    info = probe(path)
    fmt = info['format']
    v = next((s for s in info['streams'] if s['codec_type'] == 'video'), None)
    a = next((s for s in info['streams'] if s['codec_type'] == 'audio'), None)

    # ---- 容器层 ----
    fmt_name = fmt.get('format_name', '')
    r.check('容器格式', spec['container']['format'] in fmt_name,
            f'实际: {fmt_name}')

    if spec['container'].get('require_faststart'):
        r.check('faststart', moov_at_front(path), 'moov 不在文件开头')

    size_mb = int(fmt.get('size', 0)) / 1024 / 1024
    r.check('文件大小', size_mb <= spec['container']['max_size_mb'],
            f'{size_mb:.1f} MB')

    # ---- 视频流 ----
    if v is None:
        r.check('视频流存在', False, '没有视频流')
        return r

    r.check('视频编码', v.get('codec_name') == spec['video']['codec'],
            f"实际: {v.get('codec_name')}")
    r.check('分辨率', v.get('width') == spec['video']['width'] and
            v.get('height') == spec['video']['height'],
            f"{v.get('width')}x{v.get('height')}")

    fps = eval_fps(v.get('avg_frame_rate', '0/0'))
    r.check('帧率', any(abs(fps - f) < 0.1 for f in spec['video']['fps']),
            f'{fps:.2f}')

    r.check('像素格式', v.get('pix_fmt') in spec['video']['pix_fmt'],
            f"实际: {v.get('pix_fmt')}")
    r.check('profile', v.get('profile') in spec['video']['profile'],
            f"实际: {v.get('profile')}")

    vb = int(v.get('bit_rate') or fmt.get('bit_rate') or 0) / 1000
    rng = spec['video']['bitrate_kbps']
    r.check('视频码率', rng['min'] <= vb <= rng['max'], f'{vb:.0f} kbps',
            level='warn')      # 码率超标一般只警告

    # ---- 音频流 ----
    if a is None:
        r.check('音频流存在', False, '没有音轨')
    else:
        r.check('音频编码', a.get('codec_name') == spec['audio']['codec'],
                f"实际: {a.get('codec_name')}")
        sr = int(a.get('sample_rate', 0))
        r.check('采样率', sr in spec['audio']['sample_rate'], f'{sr} Hz')
        r.check('声道数', a.get('channels') == spec['audio']['channels'],
                f"{a.get('channels')}")
        ab = int(a.get('bit_rate') or 0) / 1000
        r.check('音频码率', ab >= spec['audio']['min_bitrate_kbps'],
                f'{ab:.0f} kbps', level='warn')

    # ---- 质量层 ----
    if spec['quality'].get('require_clean_decode'):
        errs = decode_check(path)
        r.check('解码无错误', not errs, errs[:500])

    dur = float(fmt.get('duration', 0))
    nb = int(v.get('nb_frames') or 0)
    if nb and fps:
        calc = nb / fps
        r.check('时长与帧数一致', abs(calc - dur) <= spec['quality']['duration_tolerance_seconds'],
                f'容器 {dur:.1f}s vs 帧数推算 {calc:.1f}s')

    # ---- 命名 ----
    pat = spec.get('naming', {}).get('pattern')
    if pat:
        r.check('文件名规范', re.match(pat, Path(path).name) is not None,
                Path(path).name, level='warn')

    return r


def eval_fps(rate: str) -> float:
    """'30000/1001' -> 29.97"""
    try:
        n, d = rate.split('/')
        return float(n) / float(d) if float(d) else 0.0
    except Exception:
        return 0.0


def decode_check(path: str, timeout: int = 600) -> str:
    """完整解码一遍,返回错误信息(空字符串表示没问题)。"""
    cmd = ['ffmpeg', '-v', 'error', '-i', path, '-f', 'null', '-']
    p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    return (p.stderr or '').strip()

用 level='warn' 区分"必须修"和"提醒"——比如码率超标通常是提醒(客户可能接受),但编码格式错了必须修。

五、最重要的一项:解码验证

ffmpeg -v error -i file.mp4 -f null - 2>err.log
# err.log 为空 = 能完整解码

这一项能抓到什么:

  • 文件截断(最后一段缺失);
  • 索引损坏;
  • 中间有坏帧(这个光看元数据完全看不出来);
  • 编码参数异常。

前面那篇 ffprobe 质检的文章讲过这个("真的解一遍"),它是所有检查里性价比最高的一项——一条命令,能抓出大部分"看起来正常但有问题"的文件。

注意:

  1. 要加超时(损坏的文件可能让 ffmpeg 卡住):
    python subprocess.run(cmd, timeout=600)
  2. 错误信息要截断(损坏严重的视频可能输出几万行):
    python errs[:500]
  3. 这个项目耗时较长(要完整解码)。批量时可以只对抽样文件做,或者放到夜间任务。

六、时长与帧数一致性

这是抓到过真问题的一项。

dur_container = float(fmt['duration'])       # 容器声明的时长
nb_frames = int(v['nb_frames'])              # 实际帧数
fps = 帧率
dur_calc = nb_frames / fps                   # 推算的时长

两者差超过 1 秒 → 有问题(时间戳异常、容器信息错误、VFR)。

注意:nb_frames 可能是 0(元数据没写)。这时候要真的数:

ffprobe -v error -count_frames -select_streams v:0 \
  -show_entries stream=nb_read_frames -of csv=p=0 file.mp4

数帧很慢(要解码整个文件),所以只在"怀疑有问题"时才做,或者放夜间任务。

七、faststart 怎么检查

MP4 的 moov box 在哪:

def moov_at_front(path: str, scan_bytes: int = 2 * 1024 * 1024) -> bool:
    """粗略判断 moov 是否在文件开头(读前 2MB 找 'moov')。"""
    with open(path, 'rb') as f:
        head = f.read(scan_bytes)
    # ftyp 之后应该很快出现 moov
    idx = head.find(b'moov')
    if idx == -1:
        return False          # 前 2MB 里没有 → 大概率在末尾
    return idx < 1024 * 1024  # 在前 1MB 内认为是在开头

更严谨的做法是解析 box 结构(读 ftyp → 看下一个 box 是不是 moov),但对"验收"这个目的,上面的近似足够。

修复:

ffmpeg -i in.mp4 -c copy -movflags +faststart out.mp4

(不重新编码,秒完成。)

八、批量验收与报告

from concurrent.futures import ProcessPoolExecutor


def main(paths, spec_path):
    spec = json.load(open(spec_path))
    with ProcessPoolExecutor(max_workers=8) as ex:
        results = list(ex.map(lambda p: (p, accept(p, spec)), paths))

    rows = []
    for path, r in results:
        rows.append({
            'file': Path(path).name,
            'status': 'FAIL' if r.failed else ('WARN' if r.warned else 'PASS'),
            'failed': '; '.join(i['name'] for i in r.failed),
            'warned': '; '.join(i['name'] for i in r.warned),
        })

    # 输出 CSV + 控制台摘要
    import csv
    with open('acceptance_report.csv', 'w', newline='', encoding='utf-8') as f:
        w = csv.DictWriter(f, fieldnames=['file', 'status', 'failed', 'warned'])
        w.writeheader()
        w.writerows(rows)

    n_fail = sum(1 for x in rows if x['status'] == 'FAIL')
    print(f'共 {len(rows)} 个文件:通过 {len(rows) - n_fail},失败 {n_fail}')
    for x in rows:
        if x['status'] == 'FAIL':
            print(f"  FAIL {x['file']}: {x['failed']}")

    sys.exit(1 if n_fail else 0)        # 退出码给 CI 用

退出码很重要:CI 里 exit 1 就会阻断发布流程。

报告也做成 HTML(给客户看更友好):

# 简单的 HTML 表格 + 颜色标记
import html
rows_html = ''.join(
    f"<tr class='{r['status'].lower()}'><td>{html.escape(r['file'])}</td>"
    f"<td>{r['status']}</td><td>{html.escape(r['failed'])}</td></tr>"
    for r in rows)

九、跟客户的验收标准对齐

最重要的实践:把客户的验收文档翻译成规格文件,并且让客户确认这份规格。

流程:

1. 客户提供验收标准(文档/邮件)
      ↓
2. 我们翻译成 spec JSON
      ↓
3. 发给客户确认:"我们理解的标准是这样,请确认"
      ↓
4. 客户确认后,作为交付依据
      ↓
5. 每次交付跑脚本,报告作为交付附件

好处:

  • 避免扯皮:客户说"音轨要 128k",我们有 spec 和确认记录;
  • 减少返工:分歧在交付前就暴露了;
  • 报告即证据:交付时附上验收报告,客户一看就放心。

我遇到过的情况:客户口头说"1080p 就行",我们按 1080p 交付,结果他们说"我们要的是 4K 源转的 1080p"(意思是源要 4K)。这种歧义如果有 spec 确认就能避免——spec 里可以写"源分辨率要求"这类条目。

十、坑清单

  1. 靠人眼验收 → 只检查"能不能播",参数错误全漏。必须脚本化。
  2. 没有清单 → 凭记忆检查会漏项。外化成规格文件。
  3. 不检查 faststart → 网页播放要下完才能开始。
  4. 不检查采样率 → 22050/32000 的音频在部分设备无声。
  5. 不检查时长与帧数 → 时间戳问题发现不了。
  6. 不做解码验证 → 坏帧/截断完全看不出来。这是最重要的一项。
  7. 解码验证不加超时 → 损坏文件让 ffmpeg 卡住,整个验收卡死。
  8. 错误信息不截断 → 几万行错误刷屏。
  9. 不做批量 → 300 个文件人工跑脚本也是负担。
  10. 报告不给退出码 → CI 无法自动阻断。
  11. 规格文件和脚本混在一起 → 换客户要改代码。规格外置。
  12. 不跟客户确认规格 → 理解偏差导致返工。
  13. 只对抽样做解码验证 → 可以,但要说明"抽样比例",并且重要交付做全量。
  14. 忽略元数据 → title/author/copyright 也要检查(客户可能在意)。
  15. 验收通过就以为万事大吉 → 验收是"参数符合",不代表"内容正确"(比如剪错了片段)。内容层面还是要人看。

最后说说验收这件事的本质。

验收是把"口头的要求"变成"可执行的断言"。 这一步转化的价值,远大于脚本本身的技术含量。

因为口头要求天然是模糊的、可争议的:

  • "要高清的" → 是 1080p 还是 720p?码率多少?
  • "要有声音" → 采样率多少?声道几个?
  • "能在手机上播" → 什么系统什么版本?

而 spec 文件里每写一个字段,就是消除一次歧义。客户确认 spec 的过程,实际上是在帮双方把标准想清楚——很多争议在这一步就消失了,而不是等到交付后被挑出来。

还有一点:验收报告是交付物的组成部分,不是内部工具。我们现在的交付包里会有一份 验收报告.html——客户打开就能看到"哪些检查通过了、参数都是多少"。这个动作的成本是零,但它传递的信息是"我们是认真检查过的",对信任的建立很有帮助。

最后提醒一句:脚本能检查"参数对不对",不能检查"内容对不对"。有一次验收全绿,但客户发现我们交付的片段剪错了位置——脚本永远不会发现这种问题。所以重要的交付,除了跑脚本,还是要人抽看几个片段的内容。脚本负责参数,人负责内容,两者的分工要明确。

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

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

顶部