第3章:ffprobe 探针:读懂视频的体检报告

拿到一个视频文件,第一件事不是转码,而是先做体检——用 ffprobe 把容器、流、帧三个层次逐一读出,让每个数字都对得上号,再决定下一步怎么走

🏛️

本章导师:狄仁杰

核心方法论:系统分析

「体检报告不会说谎,但只有会读的人才能从中看到真相。播放器能播,不代表你了解这个文件;把容器、流、帧三个层次逐一拆开,让每个数字都能对上号,你才算真正掌握了它。记住:真相藏在细节里。」

3.1 为什么需要体检:从"能播"到"懂它"

双击一个视频,播放器就放出来了——但"能播"不等于"懂它"。转码要选编码器、截图要定时间点、硬解要挑解码器、切片要算时长,每一步决策都建立在同一个前提上:这个文件里到底是什么?是 MP4 还是 MKV?里面有几条流?视频是什么编码、多大分辨率、多少帧率?这些问题的答案,就是本章的主角 ffprobe(说人话:FFmpeg 全家桶里的"体检医生",只读不改,专门把多媒体文件的内部结构一条条读出来给你看)给出的。

ffprobe 和 ffmpeg、ffplay 是"三兄弟":ffmpeg(手术刀,负责转码)用 libavformat(解析容器的库)打开文件、用 libavcodec(编解码库)压缩和解压数据;ffplay(放映机)负责播放;而 ffprobe 不转码、不播放,只调用同一套库把文件"读一遍"然后汇报。所以 ffprobe 得出的结论,和 ffmpeg 转码时看到的是同一份数据——体检报告可信。本章全程用一个真实样本:先造一个 6 秒的测试视频(h264 视频流 + aac 音频流 + 章节 + 元数据),再对它做层层体检。先确认工具版本:

# 版本确认:本章全部输出来自本机 ffmpeg 8.1.1(Homebrew ffmpeg-full)
ffprobe -version
# 输出(真实,节选):
ffprobe version 8.1.1 Copyright (c) 2007-2026 the FFmpeg developers
  built with Apple clang version 21.0.0 (clang-2100.0.123.102)
  libavutil      60. 26.101 / 60. 26.101
  libavcodec     62. 28.101 / 62. 28.101
  libavformat    62. 12.101 / 62. 12.101

最朴素的用法是什么选项都不加,直接 ffprobe 文件名——它会把整个文件的"概览一页纸"打出来。这一页纸信息密度极高,后面几节我们逐行拆解,先整体看一眼(真实输出节选,完整版见 3.3 节):

ffprobe sample.mp4
# 输出(真实,节选):
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'sample.mp4':
  Duration: 00:00:06.00, start: 0.000000, bitrate: 376 kb/s
  Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 320x240 [SAR 1:1 DAR 4:3], 270 kb/s, 25 fps, 25 tbr, 12800 tbn (default)
  Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, mono, fltp, 96 kb/s (default)
狄仁杰提示

把"体检"想成破案:先看现场(容器层)→ 再找线索(流层)→ 最后核对每个时间点(帧层)。本章的三层拆解就是这个顺序。第 4 章会系统讲容器、编码与时间基的概念,这一章先把"怎么读出数据"练熟。

3.2 造一个体检样本:h264 + aac + 章节 + 元数据

体检得有样本。为了让报告"有血有肉",先用 ffmpeg 亲手造一个测试视频:6 秒、320×240、25 帧/秒(fps,说人话:每秒播放多少幅画面)的 h264 视频流,配一条 44100 Hz 单声道的 aac 音频流,再加两条章节和标题等元数据。视频画面用 testsrc2(说人话:FFmpeg 内置的测试图案发生器,自带滚动时间码和色条,专门用来做测试样本),音频用 sine(正弦波发生器,这里发 440Hz 的"标准音 A")。

生成分两步。第一步写一个 ffmetadata 文件(说人话:FFmpeg 约定俗成的元数据描述文件,[CHAPTER] 段定义章节的起止时间与标题,TIMEBASE=1/1000 表示下面 START/END 的单位是毫秒):

# meta.txt —— 章节与元数据描述文件
;FFMETADATA1
title=体检样本:侦探的考验
artist=狄仁杰工作室
comment=ffprobe 第 3 章测试样本
[CHAPTER]
TIMEBASE=1/1000
START=0
END=3000
title=序幕
[CHAPTER]
TIMEBASE=1/1000
START=3000
END=6000
title=真相浮现

第二步跑 ffmpeg:-f lavfi -i 引入两个虚拟输入(画面 + 声音),-i meta.txt 引入元数据文件,-map(说人话:指定把哪些输入流写进输出)选择视频流和音频流,-map_metadata 2 把第 2 个输入(meta.txt)的元数据和章节带进输出,-c:v libx264 / -c:a aac 指定编码器,-movflags +faststart 把 moov 元数据挪到文件头、便于边下载边播放:

# 生成 sample.mp4(本机真实执行,输出尾部节选)
ffmpeg -y -f lavfi -i "testsrc2=size=320x240:rate=25:duration=6" \
       -f lavfi -i "sine=frequency=440:sample_rate=44100:duration=6" \
       -i meta.txt -map 0:v -map 1:a -map_metadata 2 \
       -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p \
       -c:a aac -b:a 96k -movflags +faststart sample.mp4
# 输出尾部(真实):
[libx264 @ 0x...] kb/s:269.16
[aac @ 0x...] Qavg: 123.618

注意:样本里藏着一条"意外"的流——MP4 会把章节写成一条文本数据流(后面你会看到 Stream #0:2,编码是 bin_data、tag 是 text),所以 ffprobe 报告"3 条流"而不是 2 条,默认输出末尾还会提示 Unsupported codec with id 98314 for input stream 2——这不是错误,只是数据流无需解码。体检报告里出现"多余的流"往往是这类封装细节,先记下这个伏笔。

3.3 默认输出:一页纸的概览

什么都不加直接 ffprobe sample.mp4,得到的就是"一页纸概览"。逐行读:第一行 Input #0, mov,mp4,m4a,3gp,3g2,mj2 说明容器格式(说人话:装视频、音频、字幕的"档案袋",MP4 家族在 FFmpeg 内部就是这一串名字);Duration 6.00 秒是总时长,bitrate 376 kb/s 是平均总码率;接着是 Chapters 章节列表(两章:序幕、真相浮现)和 Stream 流清单。下面这行视频流的完整版,信息密度极高(真实输出):

ffprobe sample.mp4
# 输出(真实):
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'sample.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    title           : 体检样本:侦探的考验
    artist          : 狄仁杰工作室
    comment         : ffprobe 第 3 章测试样本
  Duration: 00:00:06.00, start: 0.000000, bitrate: 376 kb/s
  Chapters:
    Chapter #0:0: start 0.000000, end 3.000000
      Metadata:
        title           : 序幕
    Chapter #0:1: start 3.000000, end 6.000000
      Metadata:
        title           : 真相浮现
  Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 320x240 [SAR 1:1 DAR 4:3], 270 kb/s, 25 fps, 25 tbr, 12800 tbn (default)
    Metadata:
      handler_name    : VideoHandler
      encoder         : Lavc62.28.101 libx264
  Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, mono, fltp, 96 kb/s (default)
  Stream #0:2[0x3](eng): Data: bin_data (text / 0x74786574), 0 kb/s
Unsupported codec with id 98314 for input stream 2

逐段读这行视频流:Stream #0:0[0x1] 是"第 0 个输入的第 0 条流"(方括号里是流的 ID);h264 (High) 是编码格式与档次(说人话:H.264 编码,High 是常用高档位);avc1 / 0x31637661 是 h264 在 MP4 容器里的 FourCC 标记(四个字符的编码代号);yuv420p 是像素格式(说人话:画面颜色怎么采样存储,4:2:0 是最通用的);320x240 是分辨率;270 kb/s 是这条流的码率;最后的 25 fps, 25 tbr, 12800 tbn 三个缩写是理解时间的关键——fps(frames per second)是帧率,tbr 是实际平均帧率,tbn(time base numerator)是时间基的分子,表示"1 个时间单位 = 1/12800 秒"。音频流同理:aac 编码、44100 Hz 采样率(说人话:每秒采集 44100 个声音样本)、mono 单声道、fltp 是音频采样格式(32 位浮点,planar 布局)。

这页纸太长?脚本里想要"成功即无声"的安静模式,用 -v error(说人话:把日志级别提到 error,只报错不闲聊)——注意它连默认概览一起屏蔽了,这是真实行为,别以为命令没跑:

# -v error:日志级别提到 error,默认概览也不打印(输出为空是正常的)
ffprobe -v error sample.mp4
# 输出(真实):(空 —— 成功即无声)

# 只想去掉版本横幅、保留概览,用 -hide_banner:
ffprobe -hide_banner sample.mp4
狄仁杰提示

记住三个缩写:fps 是"名义帧率",tbr 是"实际平均帧率",tbn 是"时间基"。可变帧率(VFR)视频里 fps 和 tbr 会不一样,而 tbn 决定所有时间戳的换算——第 4 章「音视频基础:容器、编码与时间基」会专门展开。现在只需要知道:12800 tbn 意味着 1 秒被切成 12800 个时间单位。

3.4 容器层:-show_format 读"档案袋"

默认输出是人看的;要机器可读、字段齐全,就得用 -show_format——它把容器层(档案袋封皮)的信息全部打出来:format_name(容器格式)、duration(总时长,秒)、size(文件字节数)、bit_rate(平均总码率)、probe_score(探测置信度,100 表示完全确定)、tags(元数据字典)。配合 -print_format json(说人话:把结果按 JSON 结构输出,脚本解析的标配)就是一份标准体检档案:

ffprobe -show_format -print_format json sample.mp4
# 输出(真实,完整):
{
    "format": {
        "filename": "sample.mp4",
        "nb_streams": 3,
        "nb_programs": 0,
        "nb_stream_groups": 0,
        "format_name": "mov,mp4,m4a,3gp,3g2,mj2",
        "format_long_name": "QuickTime / MOV",
        "start_time": "0.000000",
        "duration": "6.000000",
        "size": "282453",
        "bit_rate": "376604",
        "probe_score": 100,
        "tags": {
            "major_brand": "isom",
            "title": "体检样本:侦探的考验",
            "artist": "狄仁杰工作室",
            "encoder": "Lavf62.12.101",
            "comment": "ffprobe 第 3 章测试样本"
        }
    }
}

体检报告不用全抄,-show_entries(说人话:只挑你要的字段输出)配合 -of default=noprint_wrappers=1(说人话:输出成"字段=值"的纯文本,不套 [FORMAT] 外壳)就能精准提取。下面这条命令只拿 4 个字段,输出干净到可以直接喂给脚本:

ffprobe -v error -show_entries format=duration,bit_rate,format_name,size \
       -of default=noprint_wrappers=1 sample.mp4
# 输出(真实):
format_name=mov,mp4,m4a,3gp,3g2,mj2
duration=6.000000
size=282453
bit_rate=376604
format 字段真实值含义(说人话)
format_namemov,mp4,m4a,3gp,3g2,mj2容器格式,MP4 家族在 FFmpeg 里的统一代号
duration6.000000总时长(秒),所有流共同的时间跨度
size282453文件大小(字节)
bit_rate376604平均总码率(bps,说人话:每秒平均消耗多少比特)
probe_score100探测置信度,100 = 完全确定
tagstitle / artist / …元数据字典(标题、作者等"档案袋贴纸")

注意:容器层的 bit_rate=376604整包平均总码率,不是视频流 270 + 音频流 96 的简单相加(≈366 kb/s)——差额来自容器封装开销(moov 索引、章节文本流等)。体检报告里数字对不上时,先想"还有一层容器开销",这是系统分析的第一步。

3.5 流层:-show_streams 逐流听诊

档案袋里到底装了几条流?-show_streams 逐条打印,每条 [STREAM] 块就是一条流的完整档案:index(流序号)、codec_name(编码器名)、codec_type(video/audio/data)、width/height(分辨率)、pix_fmt(像素格式)、r_frame_rate(名义帧率)、avg_frame_rate(平均帧率)、time_base(时间基)、bit_rate(该流码率)、nb_frames(帧数)。看视频流的真实档案(节选关键字段):

ffprobe -show_streams sample.mp4
# 输出(真实,第 0 条流节选):
[STREAM]
index=0
codec_name=h264
codec_long_name=H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10
profile=High
codec_type=video
codec_tag_string=avc1
codec_tag=0x31637661
width=320
height=240
pix_fmt=yuv420p
level=13
r_frame_rate=25/1
avg_frame_rate=25/1
time_base=1/12800
start_pts=0
start_time=0.000000
duration_ts=76800
duration=6.000000
bit_rate=270080
nb_frames=150
TAG:handler_name=VideoHandler
TAG:encoder=Lavc62.28.101 libx264
[/STREAM]

时间基(time_base)是本章的重点,也是给第 4 章打的地基time_base=1/12800 表示"1 个时间单位 = 1/12800 秒",视频流 duration_ts=76800 是"共 76800 个时间单位",那么时长 = 76800 × 1/12800 = 6.0 秒。音频流 time_base 是 1/44100、duration_ts=264600,264600 ÷ 44100 = 同样 6.0 秒;容器层 duration 也是 6.000000——三个层次严丝合缝,这就是"真相藏在细节里":体检报告里每一层的数字都能互相印证。再听诊一下音频流(用 -select_streams a:0 只挑第 0 条音频流,说人话:按类型和序号筛流):

ffprobe -v error -select_streams a:0 -show_entries \
       stream=codec_name,sample_rate,channels,channel_layout,time_base,duration_ts \
       -of default=noprint_wrappers=1 sample.mp4
# 输出(真实):
codec_name=aac
sample_rate=44100
channels=1
channel_layout=mono
time_base=1/44100
duration_ts=264600
层级探测选项回答的问题代表字段
容器层(format)-show_format整个文件是什么、多大、多长format_name / duration / size / bit_rate
流层(stream)-show_streams里面有几条流、各是什么编码index / codec_name / codec_type / width / height / time_base / bit_rate
帧层(frame)-show_frames每一帧怎么压缩、何时显示pts_time / pict_type / key_frame
狄仁杰提示

r_frame_rate 是"名义帧率"(编码时声明的),avg_frame_rate 是"实际平均帧率"(按真实帧数和时长算的)。恒定帧率(CFR)视频两者相同(都是 25/1);可变帧率(VFR)视频两者会不一样,判断真实帧率要看 avg。这是体检报告里最常见的"细节陷阱"之一。

3.6 帧层:-show_frames 与 -show_packets 看细胞

再往下钻就到了"细胞"级:流由(packet,说人话:编码后的一段数据,一个视频包通常对应一帧)组成,包解码后得到(frame,一幅完整画面)。-show_packets 只读容器层、不解码(快);-show_frames 会真正解码每一帧(慢),但能看到帧类型。看前几帧的真实档案——第一帧是 key_frame=1(关键帧,说人话:不依赖其他帧就能独立解码的帧)、pict_type=I(I 帧,帧内编码),随后是三个 pict_type=B(B 帧,双向预测帧,说人话:要参考前后两边的帧才能解码):

ffprobe -v error -select_streams v:0 -show_frames \
       -show_entries frame=pts_time,pict_type,key_frame sample.mp4
# 输出(真实,前 4 帧):
[FRAME]
key_frame=1
pts_time=0.000000
pict_type=I
[SIDE_DATA]
side_data_type=H.26[45] User Data Unregistered SEI message
[/SIDE_DATA]
[/FRAME]
[FRAME]
key_frame=0
pts_time=0.040000
pict_type=B
[/FRAME]
[FRAME]
key_frame=0
pts_time=0.080000
pict_type=B
[/FRAME]
[FRAME]
key_frame=0
pts_time=0.120000
pict_type=B
[/FRAME]

帧在时间轴上按 0.04 秒(1/25 秒)一帧均匀排开,符合 25 fps。但看的视角就精彩了——第一个包的 dts_time=-0.080000负数pts(presentation timestamp,显示时间戳,说人话:这帧"什么时候该被看到")和 dts(decode timestamp,解码时间戳,"什么时候该被解码")是两条时间轴:因为 B 帧要参考后面的帧,解码顺序(dts)必须把 B 帧的参考帧先送进去,于是前两个 B 帧在"解码时间轴"上被排到了 0 秒之前(真实输出):

ffprobe -v error -select_streams v:0 -show_packets \
       -show_entries packet=pts_time,dts_time,duration_time,size,flags sample.mp4
# 输出(真实,前 5 个包;flags K 开头 = 关键帧):
[PACKET]
pts_time=0.000000
dts_time=-0.080000
duration_time=0.040000
size=4750
flags=K__
[/PACKET]
[PACKET]
pts_time=0.160000
dts_time=-0.040000
duration_time=0.040000
size=2182
flags=___
[/PACKET]
[PACKET]
pts_time=0.080000
dts_time=0.000000
duration_time=0.040000
size=1307
flags=___
[/PACKET]
[PACKET]
pts_time=0.040000
dts_time=0.040000
duration_time=0.040000
size=860
flags=___
[/PACKET]

对照看就明白了:显示顺序(pts)是 I(0.00) → B(0.04) → B(0.08) → B(0.12);解码顺序(dts)是 B(-0.08) → B(-0.04) → I(0.00) → B(0.04)——I 帧虽然最先显示,却要等两个 B 帧"先解码"。把 150 帧的帧类型统计出来:1 个 I 帧 + 42 个 P 帧 + 107 个 B 帧,这就是 h264 的典型 GOP 结构(说人话:一组帧的编排模式,I 帧带头、P/B 帧填充)。-count_frames 还能数出视频流共 150 帧(25 fps × 6 秒,真实):

ffprobe -v error -select_streams v:0 -show_entries frame=pict_type \
       -of csv=p=0 sample.mp4 | sort | uniq -c
# 输出(真实):
  107 B
   1 I,
   42 P

ffprobe -v error -count_frames -select_streams v:0 \
       -show_entries stream=nb_read_frames sample.mp4
# 输出(真实):
[STREAM]
nb_read_frames=150
[/STREAM]

注意-show_frames 会触发完整解码,对长视频、大文件既慢又费内存;只想知道包的大小、时间戳有没有错乱,用 -show_packets(不解码)就够了。另外见到负的 dts 别慌——这是 B 帧重排的正常现象,恰恰证明 pts 与 dts 是两套时间轴,而很多时间戳错乱的 bug 就出在这两套轴上没对齐。

3.7 机器可读:-print_format json 与一行命令提取

人看默认输出,机器看结构化输出。-print_format json 可以把 -show_format / -show_streams / -show_packets / -show_frames 全部转成 JSON;但日常脚本更常用的是"三件套":-select_streams(选哪条流)+ -show_entries(要哪些字段)+ -of csv=p=0(说人话:输出成纯值 CSV,p=0 表示不要表头)。比如提取视频流分辨率,输出干净到可以直接赋值给 shell 变量(真实):

ffprobe -v error -select_streams v:0 \
       -show_entries stream=width,height -of csv=p=0 sample.mp4
# 输出(真实):
320,240

# 提取视频流编码格式:
ffprobe -v error -select_streams v:0 \
       -show_entries stream=codec_name -of csv=p=0 sample.mp4
# 输出(真实):
h264

这一行 codec_name 就是本章的"破案关键"——转码前必须先用 ffprobe 识别输入视频的编码格式。为什么?因为 ffmpeg 默认只会自动选择 CPU 解码器;要用 GPU 硬解,必须知道编码格式、然后在命令行里显式指定对应的 GPU 解码器(比如 h264 配 h264_cuvid,hevc 配 hevc_cuvid)。这是真实项目里的标准流程(摘自作者当年的 GPU 转码实战记录):

# 真实场景:CPU 转码可以依赖 ffmpeg 自动选解码器;
# GPU 转码必须先 ffprobe 探明编码,再显式指定 GPU 解码器。
# 先体检:确认源视频是 h264 ——> 选 h264_cuvid
ffprobe -v error -select_streams v:0 \
       -show_entries stream=codec_name -of csv=p=0 input.mkv
# h264

# 再开药:h264 硬解 + nvenc 硬编码,缩放到 1280 宽
ffmpeg -hwaccel cuvid -c:v h264_cuvid -i input.mkv \
       -c:v h264_nvenc -b:v 2048k -vf scale_npp=1280:-1 -y out.mp4
狄仁杰提示

脚本三件套口诀:-select_streams 选流、-show_entries 选字段、-of 选格式,再加 -v error 静音,输出就能直接喂给 shell 变量或 JSON 解析器。这是 ffprobe 在自动化管线里最值钱的本事。GPU 硬编解码的完整流程(configure 开启、驱动与 nv-codec-headers 版本匹配、容器部署)是第 8 章「GPU 转码实战与容器部署」的主场。

章末练习

练习 1:三层结构配对 入门

把探测选项与它回答的问题配对:① -show_format;② -show_streams;③ -show_frames。候选问题:A. 文件里有几条流、各是什么编码?B. 整个文件是什么容器、多大、多长?C. 每一帧是 I/P/B 哪种类型、什么时候显示?

提示

回顾 3.5 节的"三层结构"表格:容器层管整包、流层管轨道、帧层管细胞。

参考答案

① → B(容器层,format);② → A(流层,stream);③ → C(帧层,frame)。记住:format 管档案袋、stream 管轨道、frame 管画面

练习 2:一行命令提取分辨率 进阶

用 ffprobe 对 sample.mp4 写一条命令,要求输出只有一行320,240(视频流分辨率,无表头、无 [STREAM] 外壳、无日志噪音)。

提示

三件套:-select_streams v:0 选视频流 + -show_entries stream=width,height 选字段 + -of csv=p=0 纯值输出,别忘 -v error

参考答案

ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 sample.mp4,输出 320,240(3.7 节第一条命令,本机实测)。

练习 3:时间基换算 进阶

视频流 time_base=1/12800、duration_ts=76800;音频流 time_base=1/44100、duration_ts=264600。请分别算出两条流的时长(秒),并说明它们和容器层 duration=6.000000 的关系——这说明了体检报告的什么特性?

提示

时长 = duration_ts × time_base,即"时间单位数 × 每个单位多少秒"。两条流算完都该是 6.0 秒。

参考答案

视频:76800 × (1/12800) = 6.0 秒;音频:264600 × (1/44100) = 6.0 秒。与容器层 duration 完全一致——说明体检报告各层数字互相印证、可以交叉验证;如果对不上,多半是流时长不一致或封装异常(3.5 节实测数据)。这也是系统分析的基本功:多路交叉取证。

练习 4:先探病再开药 挑战

写一段 shell 脚本(或一条命令组合):先用 ffprobe 提取 sample.mp4 视频流的 codec_name,若为 h264 则输出 h264_cuvid,为 hevc 则输出 hevc_cuvid,其他编码输出 cpu;然后在真实 sample.mp4 上运行,验证输出为 h264_cuvid。再想想:如果源视频是 hevc,这条 GPU 解码器选择逻辑还成立吗?

提示

先跑 ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 sample.mp4 得到 h264;再用 shell 的 caseif 分支判断。注意把命令替换结果存进变量再比较。

参考答案

参考实现:

codec=$(ffprobe -v error -select_streams v:0 \
  -show_entries stream=codec_name -of csv=p=0 sample.mp4)
case "$codec" in
  h264) echo "h264_cuvid" ;;
  hevc) echo "hevc_cuvid" ;;
  *)    echo "cpu" ;;
esac

在 sample.mp4 上实测输出 h264_cuvid(codec_name=h264)。逻辑对 hevc 同样成立——这正是 3.7 节 GPU 转码场景的"先探病再开药":ffmpeg 只会自动选 CPU 解码器,硬解必须按探明结果显式指定 GPU 解码器。更多 GPU 细节在第 8 章。