去年接过一个迁移的活:客户有一批历史录播,一万多条,存在几台老 NAS 上,要迁到新存储并统一转成 H.265。问题在于——没人知道这批文件里有多少是坏的。有些是多年前转码中途断电留下的半截文件,有些是能打开但播到一半就卡死,还有些干脆是 0 字节的占位文件。
我一开始的做法很朴素:写个循环,对每条视频跑
ffprobe打印信息,然后人肉看。跑了一晚上,看了两百多条,眼睛花了,而且心里很清楚——这样看出来的"正常",不代表文件真的能播。后来我把这套流程重做了一遍,核心改动只有一个:把"读元数据"换成"真的解码一遍"。就是这一个改动,坏文件的检出率从 30% 变成了接近 100%。这篇文章把最终的流水线完整写出来,包括脚本、并发策略、报告格式,以及那些只有踩过才知道的细节。
TL;DR:视频质检分两层——元数据层(容器、编码、分辨率、时长、码率、音轨,用
ffprobe -show_format -show_streams -of json拿)和解码层(真的把每一帧解出来,ffmpeg -v error -i file -f null -)。只做第一层会漏掉大量"看起来正常但播不了"的文件。批量跑要用并发(xargs -P 或进程池)并强制超时,否则一个坏文件能把整个批次卡死。最终输出 CSV/JSON 报告,按严重程度分级,先修最要命的。
目录
- 一、视频"坏"的方式比你想象的多
- 二、ffprobe 基础:把元数据拿出来
- 三、设计一张体检表:到底要查哪些项
- 四、最关键的一步:真解码验证
- 五、并发与超时:不然一个坏文件卡死整批
- 六、报告怎么出:分级而不是罗列
- 七、完整脚本
- 八、我踩过的坑
- 九、进阶:黑屏、静音、花屏检测
- 十、合规与温馨提示
一、视频"坏"的方式比你想象的多
先说我后来统计出来的坏文件类型分布(那一万多条里的实际情况):
| 坏法 | 占比 | 元数据能发现吗 |
|---|---|---|
| 文件截断(转码中断,尾部缺失) | 最多 | 部分能(时长异常短) |
| 索引损坏(moov atom 丢失) | 多 | 能(ffprobe 直接报错) |
| 能读元数据但中间有坏帧 | 中 | 不能 |
| 音轨丢失 / 音轨编码异常 | 中 | 能 |
| 0 字节或极小文件 | 少 | 能 |
| 分辨率/帧率异常(比如 0×0) | 少 | 能 |
| 时长元数据与实际流不一致 | 中 | 能(但要对) |
| 视频軌在但全是黑帧 | 少 | 不能(需要第九章的方法) |
关键结论在最后一列:"能读元数据但中间有坏帧"和"全黑帧"这两类,只读元数据是完全查不出来的。而恰恰是这两类,在播放时最容易出问题(播到一半卡死、或者用户以为下到了结果是一片黑)。
所以我的质检流水线必须有解码验证这一层。
二、ffprobe 基础:把元数据拿出来
最基本的用法:
ffprobe -v error -show_format -show_streams -of json input.mp4
输出是个大 JSON。常用字段对应关系:
| 你想知道的 | 在哪 |
|---|---|
| 容器格式 | format.format_name |
| 总时长(秒) | format.duration |
| 总码率 | format.bit_rate |
| 文件大小 | format.size |
| 视频编码 | streams[codec_type=video].codec_name |
| 分辨率 | ...width / ...height |
| 帧率 | ...avg_frame_rate(是个分数字符串,如 30000/1001) |
| 像素格式 | ...pix_fmt |
| 音频编码/采样率/声道 | streams[codec_type=audio] 下对应字段 |
想少输出点东西,用 -show_entries 精确指定:
ffprobe -v error \
-show_entries format=duration,size,bit_rate,format_name \
-show_entries stream=codec_type,codec_name,width,height,avg_frame_rate \
-of default=noprint_wrappers=1 input.mp4
要机器可读就用 -of json,要方便人看用 -of default。
-v error 一定要加。不加的话 ffprobe 会把一堆版本、编译信息混进输出,解析的时候全是坑。
三、设计一张体检表:到底要查哪些项
我最终定的检查项,按严重程度分三级:
P0(必须处理,文件基本不可用)
- ffprobe 无法读取 / 退出码非 0
- 文件大小为 0 或小于 1 KB
- 没有视频流
- 解码验证失败(有报错帧)
P1(影响使用,需要修或标记)
- 没有音轨(如果内容应该有声音)
- 时长为 0 或异常短(< 1 秒)
- 分辨率异常(宽或高为 0,或超过 7680)
- 容器时长与实际流时长差异 > 2%
- 视频码率异常低(< 100 kbps,可能是转码失败)
P2(记录即可,用于后续决策)
- 编码格式(决定要不要转 H.265)
- 分辨率、帧率(决定是否降档)
- 像素格式(是否 10bit / HDR)
- 是否有旋转 metadata(displaymatrix,影响播放器显示方向)
把这套规则写清楚之后,脚本的输出就不只是"一堆数据",而是"哪些必须先修"。这个分级在后续跟客户沟通时省了大量时间——一万多条文件不可能全修,先修 P0 就行。
四、最关键的一步:真解码验证
这条命令是整个流水线的核心:
ffmpeg -v error -i input.mp4 -f null - 2>error.log
它的意思是:把输入文件完整解码,输出到空设备(不写文件),只把错误信息打到 stderr。
- 如果
error.log是空的 → 文件能完整解码,基本健康 - 如果非空 → 里面有具体的错误信息,比如:
[mov,mp4,m4a,3gp,3g2,mj2 @ 0x...] moov atom not found
[h264 @ 0x...] error while decoding MB 32 18, bytestream -7
[aac @ 0x...] Number of bands exceeds limit
注意 -v error 的位置。它要放在 -i 前面才对输入阶段生效——我一开始放在后面,结果正常文件也输出一堆信息,判断逻辑全乱了。
解码验证的代价是慢(要把整个文件解一遍)。我的优化策略:
- P0/P1 的元数据检查先跑,已经判定为坏的文件不用浪费时间解码。
- 只对通过元数据检查的文件做解码验证。
- 长视频可以抽样:只解前 30 秒 + 中间 30 秒 + 最后 30 秒,用
-ss/-t分三段。这样速度快很多,代价是可能漏掉中间某一段的坏帧。对归档迁移这种场景,我一般会做全量(反正要转码,不如一起验);对日常巡检用抽样就够了。
# 抽样验证:只解最后 30 秒(截断文件通常坏在尾部)
ffmpeg -v error -sseof -30 -i input.mp4 -f null - 2>&1 | head -20
-sseof -30 是从末尾往前 30 秒,对检测"转码中断导致的截断"特别有效。
五、并发与超时:不然一个坏文件卡死整批
这是我第一批跑的时候犯的错:串行跑一万条,跑到第 300 多条的时候卡住了——某个损坏的 MP4 让 ffprobe 进入了等待状态,不超时也不退出。整个批次就挂在那儿,一整晚白跑。
三个必须加的保护:
1. 强制超时
timeout 60 ffprobe -v error -show_format -of json input.mp4
timeout 是 GNU coreutils 的命令,超时返回 124。macOS 上没自带,可以 brew install coreutils 用 gtimeout,或者在 Python 里用 subprocess.run(..., timeout=...)(我最后选了后者,跨平台)。
2. 限制单条日志大小
损坏严重的视频可能产生几万行错误信息。不限制的话日志能把磁盘写满:
ffmpeg -v error -i bad.mp4 -f null - 2>&1 | head -50
或者用 -max_error_rate?不,我用的是 head 截断,简单有效。
3. 并发控制
# xargs 并发(-P 4 表示 4 路)
find /data -name '*.mp4' -print0 | xargs -0 -P 4 -I{} sh -c 'ffprobe -v error -show_format -of json "{}" > "{}.json"'
或者用 GNU parallel:
find /data -name '*.mp4' | parallel -j 8 --timeout 60 'ffprobe -v error -show_format -of json {} > {}.json'
并发数怎么定?我实测的经验:元数据读取是 IO 密集型,8~16 路都行;解码验证是 CPU 密集型,一般设成 CPU 核数。我在 8 核机器上,元数据检查用 16 路,解码验证用 8 路。
六、报告怎么出:分级而不是罗列
一万条文件的报告,如果是一万行明细,等于没有报告。我的做法是两级输出:
第一级:汇总(给人看)
总计: 10432 条
─────────────────────────────
P0 致命 : 187 条 (1.8%)
P1 需处理 : 643 条 (6.2%)
P2 仅记录 : 1094 条 (10.5%)
健康 : 8508 条 (81.6%)
─────────────────────────────
P0 细分:
无法读取 : 92
解码失败 : 71
无视频流 : 15
空文件 : 9
─────────────────────────────
编码分布:
h264 : 9214
hevc : 731
mpeg4 : 402
其他/未知 : 85
─────────────────────────────
需要转码(非hevc): 9701 条
预计可节省空间 : ~38%(按 H.265 CRF 26 估算)
第二级:明细(给脚本看)
CSV 格式,一行一个文件:
path,size_mb,duration_s,vcodec,acodec,width,height,fps,bitrate_kbps,level,issues
/data/a/001.mp4,1024.3,1832.5,h264,aac,1920,1080,29.97,4680,OK,
/data/a/002.mp4,0.0,,,unknown,,0,0,,P0,empty_file
/data/b/003.mp4,512.1,1830.0,h264,,1280,720,25.00,2240,P1,no_audio;duration_mismatch
/data/c/004.mp4,880.2,1200.0,h264,aac,1920,1080,29.97,6000,P0,decode_error:MB_bytestream
有这个 CSV,后续不管是"把所有 P0 移到隔离目录"还是"把 P1 重新转码",都是一行 awk 的事:
# 把 P0 文件移到隔离区
awk -F, '$10=="P0" {print $1}' report.csv | while read f; do mv "$f" /quarantine/; done
七、完整脚本
下面是我实际在用的简化版。去掉了进度条和断点续跑,但核心逻辑完整,能直接跑:
#!/usr/bin/env python3
"""视频批量质检:元数据检查 + 解码验证,输出 CSV 明细与汇总。"""
import csv, json, subprocess, sys
from concurrent.futures import ThreadPoolExecutor
from fractions import Fraction
from pathlib import Path
PROBE_TIMEOUT = 60 # 元数据读取超时(秒)
DECODE_TIMEOUT = 900 # 解码验证超时(秒)
WORKERS = 16 # 并发数
VIDEO_EXTS = {'.mp4', '.mkv', '.ts', '.mov', '.avi', '.flv', '.webm', '.m4v'}
def run(cmd, timeout):
try:
p = subprocess.run(cmd, capture_output=True, timeout=timeout)
return p.returncode, p.stdout, p.stderr
except subprocess.TimeoutExpired:
return -1, b'', b'TIMEOUT'
def probe(path):
code, out, err = run(
['ffprobe', '-v', 'error', '-show_format', '-show_streams',
'-of', 'json', str(path)], PROBE_TIMEOUT)
if code != 0:
return None
try:
return json.loads(out.decode('utf-8', 'replace'))
except json.JSONDecodeError:
return None
def pick_streams(info):
v = next((s for s in info.get('streams', []) if s.get('codec_type') == 'video'), None)
a = next((s for s in info.get('streams', []) if s.get('codec_type') == 'audio'), None)
return v, a
def fps_of(stream):
"""帧率在 ffprobe 里是分数字符串,要转成浮点数。"""
raw = (stream or {}).get('avg_frame_rate', '0/0')
try:
return round(float(Fraction(raw)), 3)
except (ValueError, ZeroDivisionError):
return 0.0
def check(path):
rec = {'path': str(path), 'size_mb': round(path.stat().st_size / 1048576, 2)}
issues, level = [], 'OK'
if path.stat().st_size < 1024:
return {**rec, 'level': 'P0', 'issues': 'empty_file'}
info = probe(path)
if info is None:
return {**rec, 'level': 'P0', 'issues': 'probe_failed'}
fmt = info.get('format', {})
v, a = pick_streams(info)
if not v:
return {**rec, 'level': 'P0', 'issues': 'no_video_stream'}
# 元数据字段
rec.update({
'duration_s': round(float(fmt.get('duration') or 0), 2),
'vcodec': v.get('codec_name', ''),
'acodec': (a or {}).get('codec_name', ''),
'width': v.get('width', 0),
'height': v.get('height', 0),
'fps': fps_of(v),
'bitrate_kbps': round(int(fmt.get('bit_rate') or 0) / 1000),
})
# P1 规则
if not a:
issues.append('no_audio')
if rec['duration_s'] < 1:
issues.append('duration_zero')
if rec['width'] <= 0 or rec['height'] <= 0:
issues.append('bad_resolution')
if rec['bitrate_kbps'] and rec['bitrate_kbps'] < 100:
issues.append('bitrate_too_low')
if rec['vcodec'] == 'h264' and rec['bitrate_kbps'] > 80000:
issues.append('bitrate_abnormal_high')
# 解码验证(只对通过元数据检查的做,省时间)
if not issues:
code, _, err = run(['ffmpeg', '-v', 'error', '-i', str(path),
'-f', 'null', '-'], DECODE_TIMEOUT)
errtxt = err.decode('utf-8', 'replace').strip()
if code == -1:
issues.append('decode_timeout'); level = 'P0'
elif errtxt:
issues.append('decode_error:' + errtxt.splitlines()[0][:80])
level = 'P0'
if issues and level == 'OK':
level = 'P1'
return {**rec, 'level': level, 'issues': ';'.join(issues)}
def main(root, out_csv):
files = [p for p in Path(root).rglob('*') if p.suffix.lower() in VIDEO_EXTS]
print(f'发现 {len(files)} 个文件,开始检查...', file=sys.stderr)
with ThreadPoolExecutor(max_workers=WORKERS) as ex, \
open(out_csv, 'w', newline='', encoding='utf-8-sig') as f:
fields = ['path', 'size_mb', 'duration_s', 'vcodec', 'acodec',
'width', 'height', 'fps', 'bitrate_kbps', 'level', 'issues']
w = csv.DictWriter(f, fieldnames=fields, extrasaction='ignore')
w.writeheader()
for i, rec in enumerate(ex.map(check, files), 1):
w.writerow(rec)
if i % 200 == 0:
print(f' 已处理 {i}/{len(files)}', file=sys.stderr)
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else 'report.csv')
用法:
python3 qa_check.py /data/videos report.csv
CSV 用了 utf-8-sig 编码——中文路径在 Excel 里直接打开不乱码,这个细节帮我省了和客户解释"为什么你的报告是乱码"的麻烦。
汇总统计我单独写了个脚本读 CSV 出统计(就是第六节那张表),没放进主流程,因为不同项目要的统计维度不一样,硬编码在质检脚本里反而碍事。
八、我踩过的坑
1. 忘了 -v error
ffprobe 默认会往 stderr 输出版本和编译信息。我第一版脚本把 stderr 也 capture 了,结果"有没有错误"的判断全乱。要么加 -v error,要么只解析 stdout 的 JSON。
2. 坏文件把整批卡死
最痛的一次。没加 timeout,某个损坏的 MP4 让 ffprobe 挂了一整夜。现在我的原则是:任何对外部文件的调用都必须有超时,包括看起来很快的元数据读取。
3. 帧率是分数字符串
avg_frame_rate 的值是 "30000/1001" 这种,直接 float() 会抛异常。用 Fraction 转换,别自己 split。
4. 用 streams[0] 当视频流
多轨文件(比如带封面图的 MP4,或者有多个音轨的 MKV)里,streams[0] 可能是个 mjpeg 封面。必须按 codec_type == 'video' 筛选,而且最好再确认 width > 0。
5. 容器时长和流时长不一致
format.duration 是容器记录的,stream.duration 是实际流时长。有些截断文件的容器时长是完整值(比如 1800 秒),但实际流只有 300 秒。所以我在 P1 规则里加了"两者差异 > 2%"的检查——虽然示例脚本里没展开,实际项目建议加上,用 min() 取两者较小值作为真实时长更保险。
6. 旋转 metadata 导致宽高"不对"
手机竖屏拍的视频,width/height 可能是 1920x1080 但带 rotate=90 的 metadata,播放器里显示是竖的。如果你按 height > width 判断横竖屏会全错。要读 side_data 里的 displaymatrix 或 tags.rotate。
7. 并发开太大反而慢
我一开始开了 64 路,结果机械盘疯狂寻道,整体比 8 路还慢。机械盘建议 4~8 路,SSD 可以 16~32 路。解码验证则按 CPU 核数来。
8. 中文/长路径在 Windows 上出问题
subprocess 传路径时如果系统编码不是 UTF-8,中文文件名会变成乱码。Python 3.15 之前建议显式处理,或者干脆在 Linux/macOS 上跑(我最后是把质检脚本丢到 Linux 服务器上跑的,省事)。
九、进阶:黑屏、静音、花屏检测
元数据查不出的问题,靠这几个滤镜。
黑屏检测(视频能播但内容是黑的):
ffmpeg -i input.mp4 -vf blackdetect=d=2:pix_th=0.10 -f null - 2>&1 | grep blackdetect
输出类似:
[blackdetect @ 0x...] black_start:0 black_end:15.2 black_duration:15.2
如果黑屏时长占了视频大部分,基本就是废片。d=2 表示持续 2 秒以上才算,避免误判转场。
静音检测(有音轨但没声音):
ffmpeg -i input.mp4 -af silencedetect=n=-50dB:d=2 -f null - 2>&1 | grep silence
花屏/马赛克:这个没有可靠的自动检测方法。我的做法是抽帧生成缩略图拼图,人眼快速扫:
# 每 10% 抽一帧,拼成一张 5x2 的图
ffmpeg -i input.mp4 -vf "select='not(mod(n\,300))',scale=320:-1,tile=5x2" \
-frames:v 1 contact_sheet.jpg
一万条视频不可能全看,但 P0/P1 的那几百条生成拼图扫一遍,半小时能看完,比打开播放器快太多。
跟源对比(如果你有原始文件):用 VMAF 或 PSNR 量化差异,适合验证转码后的质量衰减:
ffmpeg -i encoded.mp4 -i source.mp4 -lavfi libvmaf -f null -
十、合规与温馨提示
质检本身是只读操作(不修改原文件),风险比转码低得多,但仍有几件事要注意:
-
先隔离,别直接删。脚本判定为 P0 的文件,我的做法一律
mv到隔离目录,保留至少一个月再考虑清理。误判是会发生的——尤其是"解码有报错但能播"的那种,直接删了可能真的删掉了唯一一份。 -
报告里注意脱敏。如果文件名/路径包含客户信息、员工姓名、内部项目代号,输出 CSV 给第三方前要处理。我吃过一次亏,报告里带出了内部目录结构,被客户安全同事问了好一阵。
-
确认你有权访问这些数据。给公司或者客户跑批量质检前,确认这些文件不是你权限范围之外的。尤其是跨部门、跨系统的时候。
-
资源占用要打招呼。解码验证是 CPU 密集型,一万条文件全量解码可能让服务器满载一两天。跑之前跟运维/同事说一声,最好限制并发并安排在业务低峰——我一般是白天做元数据检查(快、负载低),晚上跑解码验证。
这套流水线跑完,那一万多条文件的迁移从"不知道从哪下手"变成了"先修 187 条 P0,剩下的按批次转码"。质检的价值不在于找出所有问题,而在于把"未知"变成"可量化的已知"——有了分级清单,你才知道工作量有多大、该先做什么。这比任何优化都重要。