我们交付视频前有个"检查环节"——人工看一遍:能不能播、分辨率对不对、有没有声音。
这个环节漏过三次:
- 有一批文件忘了加
+faststart,客户在网页上播放要等很久才开始;- 有一批的输出音频是 22050 Hz(源的问题被我们继承了),客户那边某些设备无声;
- 有一批时长对不上(容器写的 45 分钟,实际只有 43 分钟),客户按时长做的排期全错。
这三次都是"人工看了觉得没问题"的——因为人检查的是"能不能播",而不是"参数对不对"。前者一眼能看出来,后者必须逐项比对。
后来我写了验收脚本:定义一份"规格"(这个交付应该满足什么),脚本逐项检查并出报告。漏项率归零,而且报告和客户的验收标准是一一对应的——客户说要什么,我们就检查什么。
TL;DR:验收分五层:容器层(格式、faststart、时长、大小)、视频流(编码、分辨率、帧率、pix_fmt、profile/level、码率、GOP)、音频流(编码、采样率、声道、码率)、质量层(解码无错误、无黑帧/冻结帧、时长与帧数一致)、业务层(文件名、封面、章节、元数据)。做法:把要求写成一份规格文件(JSON/YAML),脚本读规格 + ffprobe 逐项比对,输出"通过/警告/失败"报告,用退出码接入 CI。其中最容易被忽略也最重要的一项是解码验证(
ffmpeg -v error -i file -f null -,检查有没有解码错误)。
目录
- 一、为什么人工检查一定会漏
- 二、验收要检查的五层
- 三、规格文件怎么定义
- 四、脚本实现
- 五、最重要的一项:解码验证
- 六、时长与帧数一致性
- 七、faststart 怎么检查
- 八、批量验收与报告
- 九、跟客户的验收标准对齐
- 十、坑清单
一、为什么人工检查一定会漏
人工检查的天然局限:
| 局限 | 说明 |
|---|---|
| 检查的是"能不能播" | 能播就觉得没问题,参数对不对不会去查 |
| 没有清单 | 凭记忆检查,总会漏项 |
| 标准会漂移 | 看第 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 质检的文章讲过这个("真的解一遍"),它是所有检查里性价比最高的一项——一条命令,能抓出大部分"看起来正常但有问题"的文件。
注意:
- 要加超时(损坏的文件可能让 ffmpeg 卡住):
python subprocess.run(cmd, timeout=600) - 错误信息要截断(损坏严重的视频可能输出几万行):
python errs[:500] - 这个项目耗时较长(要完整解码)。批量时可以只对抽样文件做,或者放到夜间任务。
六、时长与帧数一致性
这是抓到过真问题的一项。
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 里可以写"源分辨率要求"这类条目。
十、坑清单
- 靠人眼验收 → 只检查"能不能播",参数错误全漏。必须脚本化。
- 没有清单 → 凭记忆检查会漏项。外化成规格文件。
- 不检查 faststart → 网页播放要下完才能开始。
- 不检查采样率 → 22050/32000 的音频在部分设备无声。
- 不检查时长与帧数 → 时间戳问题发现不了。
- 不做解码验证 → 坏帧/截断完全看不出来。这是最重要的一项。
- 解码验证不加超时 → 损坏文件让 ffmpeg 卡住,整个验收卡死。
- 错误信息不截断 → 几万行错误刷屏。
- 不做批量 → 300 个文件人工跑脚本也是负担。
- 报告不给退出码 → CI 无法自动阻断。
- 规格文件和脚本混在一起 → 换客户要改代码。规格外置。
- 不跟客户确认规格 → 理解偏差导致返工。
- 只对抽样做解码验证 → 可以,但要说明"抽样比例",并且重要交付做全量。
- 忽略元数据 → title/author/copyright 也要检查(客户可能在意)。
- 验收通过就以为万事大吉 → 验收是"参数符合",不代表"内容正确"(比如剪错了片段)。内容层面还是要人看。
最后说说验收这件事的本质。
验收是把"口头的要求"变成"可执行的断言"。 这一步转化的价值,远大于脚本本身的技术含量。
因为口头要求天然是模糊的、可争议的:
- "要高清的" → 是 1080p 还是 720p?码率多少?
- "要有声音" → 采样率多少?声道几个?
- "能在手机上播" → 什么系统什么版本?
而 spec 文件里每写一个字段,就是消除一次歧义。客户确认 spec 的过程,实际上是在帮双方把标准想清楚——很多争议在这一步就消失了,而不是等到交付后被挑出来。
还有一点:验收报告是交付物的组成部分,不是内部工具。我们现在的交付包里会有一份 验收报告.html——客户打开就能看到"哪些检查通过了、参数都是多少"。这个动作的成本是零,但它传递的信息是"我们是认真检查过的",对信任的建立很有帮助。
最后提醒一句:脚本能检查"参数对不对",不能检查"内容对不对"。有一次验收全绿,但客户发现我们交付的片段剪错了位置——脚本永远不会发现这种问题。所以重要的交付,除了跑脚本,还是要人抽看几个片段的内容。脚本负责参数,人负责内容,两者的分工要明确。