提示

返回博客列表

一万条视频怎么体检:我用 ffprobe 搭的批量质检流水线,以及真正能查出问题的那一步

去年接过一个迁移的活:客户有一批历史录播,一万多条,存在几台老 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 报告,按严重程度分级,先修最要命的。

目录

一、视频"坏"的方式比你想象的多

先说我后来统计出来的坏文件类型分布(那一万多条里的实际情况):

坏法 占比 元数据能发现吗
文件截断(转码中断,尾部缺失) 最多 部分能(时长异常短)
索引损坏(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 前面才对输入阶段生效——我一开始放在后面,结果正常文件也输出一堆信息,判断逻辑全乱了。

解码验证的代价是慢(要把整个文件解一遍)。我的优化策略:

  1. P0/P1 的元数据检查先跑,已经判定为坏的文件不用浪费时间解码。
  2. 只对通过元数据检查的文件做解码验证
  3. 长视频可以抽样:只解前 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 coreutilsgtimeout,或者在 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 里的 displaymatrixtags.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 -

十、合规与温馨提示

质检本身是只读操作(不修改原文件),风险比转码低得多,但仍有几件事要注意:

  1. 先隔离,别直接删。脚本判定为 P0 的文件,我的做法一律 mv 到隔离目录,保留至少一个月再考虑清理。误判是会发生的——尤其是"解码有报错但能播"的那种,直接删了可能真的删掉了唯一一份。

  2. 报告里注意脱敏。如果文件名/路径包含客户信息、员工姓名、内部项目代号,输出 CSV 给第三方前要处理。我吃过一次亏,报告里带出了内部目录结构,被客户安全同事问了好一阵。

  3. 确认你有权访问这些数据。给公司或者客户跑批量质检前,确认这些文件不是你权限范围之外的。尤其是跨部门、跨系统的时候。

  4. 资源占用要打招呼。解码验证是 CPU 密集型,一万条文件全量解码可能让服务器满载一两天。跑之前跟运维/同事说一声,最好限制并发并安排在业务低峰——我一般是白天做元数据检查(快、负载低),晚上跑解码验证。


这套流水线跑完,那一万多条文件的迁移从"不知道从哪下手"变成了"先修 187 条 P0,剩下的按批次转码"。质检的价值不在于找出所有问题,而在于把"未知"变成"可量化的已知"——有了分级清单,你才知道工作量有多大、该先做什么。这比任何优化都重要。

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

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

顶部