视频文件不是一块铁板:容器负责装、编码负责压、帧负责组织画面,时间基则给每一帧编上精确的号。把这三层结构从第一性原理推明白,ffmpeg 的一万个参数就都成了表象
核心方法论:第一性原理
「别人丢给你一个视频文件,你看到的是几百万个 0 和 1;费曼会问:这些比特被组织成了什么?——容器负责装,编码负责压,时间基负责给每一帧编号。把这三件事从第一性原理推出来,ffmpeg 的一万个选项都只是表象。搞懂本质,一切工具都是表象。」
双击一个视频能播,但"能播"的背后是三件完全不同的事在协作。用第一性原理拆开:视频文件 = 容器(说人话:装视频、音频、字幕的"档案袋",决定文件怎么组织)+ 编码流(说人话:压缩算法,决定画面和声音怎么被压成二进制)+ 帧(说人话:一幅完整的画面,压缩的最小单位)。播放器的工作流程正好反过来:拆档案袋(解封装 demux)→ 解压缩(解码)→ 按时间顺序显示。
三层为什么必须分开?因为"怎么装"和"怎么压"是两个独立问题:同一条 h264 码流,既能装进 MP4,也能装进 MKV、MPEG-TS;同一个 MP4 文件,视频流可以是 h264,也可以是 hevc。这就是「容器 ≠ 编码」——本章第一块基石,后面所有实验都建立在它之上。先看容器层和流层长什么样(本章样本 sample4.mp4:6 秒、320×240、25 帧/秒的 h264 视频流 + 一条 44.1kHz 单声道 aac 音频流,生成命令见 4.2 节):
ffprobe sample4.mp4
# 输出(真实,节选):
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'sample4.mp4':
Duration: 00:00:06.00, start: 0.000000, bitrate: 348 kb/s
Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 320x240 [SAR 1:1 DAR 4:3], 243 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)
第一行 Input #0, mov,mp4,m4a,3gp,3g2,mj2 是容器身份(MP4 家族在 FFmpeg 内部就叫这一串名字);Stream #0:0 是视频流(h264 编码、320×240、25 fps),Stream #0:1 是音频流(aac、44100 Hz 单声道)。容器层还报总时长 Duration 6.00 秒 和总码率 348 kb/s。再看 FFmpeg 自己维护的容器清单——图例里 D 表示能解封装(demux)、E 表示能封装(mux):
ffmpeg -hide_banner -formats | grep -E "mp4|matroska|mpegts"
# 输出(真实,节选):
E mp4 MP4 (MPEG-4 Part 14)
D mov,mp4,m4a,3gp,3g2,mj2 QuickTime / MOV
DE mpegts MPEG-TS (MPEG-2 Transport Stream)
D matroska,webm Matroska / WebM
E matroska Matroska
注意一个细节:mp4 行只有 E(只能封装)——FFmpeg 把 MP4 的解封装归到了 mov,mp4,... 家族名下,"名字"和"身份"不是一回事。常见三种容器,各管一摊:
| 容器 | 典型场景 | 能装什么 | 实测时间基(4.5 节) | 特点 |
|---|---|---|---|---|
| MP4(.mp4) | 在线播放、通用存档 | h264/hevc + aac 最主流 | 1/12800(h264 @ 25 fps) | 兼容性最好,几乎全设备通吃 |
| MKV(.mkv) | 高清收藏、多音轨多字幕 | 几乎一切编码 + 任意字幕 | 1/1000(毫秒刻度) | 自由开放,兼容性看播放器 |
| MPEG-TS(.ts) | 直播、电视广播 | 任意编码(多为 h264/aac) | 1/90000(90 kHz) | 可流式拼接、断点续传 |
判断"容器"只看后缀会翻车——.mp4 里完全可能装着 hevc 视频。容器与编码是正交的两个维度,唯一可靠的判断方式是 ffprobe(第 3 章的主场)。把"容器管怎么装、编码管怎么压"钉在脑子里,本章后面的时间基实验才看得懂。
第一性原理问:一段 6 秒、320×240 的原始视频有多大?按 yuv420p 每像素 1.5 字节算:320 × 240 × 1.5 × 25 帧 × 6 秒 ≈ 17.3 MB——而 sample4.mp4 只有 261 KB,压缩了约 66 倍。凭什么?因为视频里有两种巨大的冗余:空间冗余(一帧之内,天空是一整片相近的蓝色——帧内压缩)和时间冗余(相邻两帧几乎一模一样,只是画面动了一点点——帧间压缩)。
帧内压缩把每一帧当独立照片压(类似 JPEG:DCT 变换 + 量化,丢掉人眼不敏感的细节);帧间压缩只记录"这帧和参考帧的差异 + 运动矢量"(说人话:画面往哪儿挪了多少)。于是产生三种帧:I 帧(关键帧,帧内编码的完整画面,可独立解码,体积最大);P 帧(前向预测,参考前面已解码的帧,只存差异,中等体积);B 帧(双向预测,同时参考前后两帧,压缩率最高,但解码时必须等"后面"的帧先到——这一点在 4.6 节引爆)。一组从 I 帧开始、到下一个 I 帧之前的所有帧,叫一个 GOP(说人话:一组画面,组内共享同一个关键帧作锚点)。
libx264 默认就开 B 帧(x264 默认 bframes=3 且自适应)。生成带 B 帧的样本并观察帧类型序列——样本还是第 3 章的老配方 testsrc2,6 秒 25 fps 共 150 帧:
# 先造母版,再按默认参数压一遍(本机真实执行)
ffmpeg -y -f lavfi -i "testsrc2=size=320x240:rate=25:duration=6" \
-f lavfi -i "sine=frequency=440:sample_rate=44100:duration=6" \
-c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p \
-c:a aac -b:a 96k master.mp4
ffmpeg -y -i master.mp4 -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -c:a copy sample4.mp4
# 输出尾部(真实):[libx264 @ 0x...] kb/s:269.16 / [aac @ 0x...] Qavg: 123.618
ffprobe -v error -select_streams v:0 -show_entries frame=pict_type -of csv=p=0 sample4.mp4
# 输出(真实,前 14 帧):
I, B B B P B P B P B B B P B ...
# 全片 150 帧统计(真实):
97 B
1 I,
52 P
序列以 I 开头,后面跟着一串 B/P——每两个 I/P 之间夹着最多 3 个 B 帧,这正是"bframes=3 且自适应"的实证。三种帧的差异:
| 帧类型 | 说人话 | 参考 | 体积 | 能独立解码? |
|---|---|---|---|---|
| I 帧(关键帧) | 完整的照片 | 无,帧内编码 | 最大 | 能——seek 只能落在 I 帧 |
| P 帧 | 只记变化 | 前面已解码的帧 | 中 | 不能,依赖参考帧 |
| B 帧 | 前后都参考 | 前面 + 后面的帧 | 最小 | 不能,还要等后面的帧先解码 |
注意:B 帧是"事后诸葛"——它要参考还没显示出来的未来帧,所以解码器必须先把后面的帧解出来。这直接导致文件里的包顺序 ≠ 显示顺序(4.6 节的 PTS/DTS 重排)。你在第 3 章 -show_packets 里见过的负 dts(-0.080000)就是这件事的证据——不是文件坏了,是 B 帧在排队。
第 2 章转码裁剪时,为什么 -c copy 切出来的视频会丢画面?根因就是关键帧间隔(说人话:两个 I 帧之间隔多少帧)。libx264 的默认值是 250 帧——直接问本机的 x264 要官方默认值:
x264 --fullhelp | grep keyint
# 输出(真实):
-I, --keyint <integer or "infinite"> Maximum GOP size [250]
-i, --min-keyint <integer> Minimum GOP size [auto]
--scenecut <integer> How aggressively to insert extra I-frames [40]
25 fps 下 250 帧 = 10 秒一个关键帧。sample4.mp4 全长 150 帧 < 250,所以整片只有 1 个 I 帧(正是 4.2 节统计里的 "1 I")。现在用 -g 50 强制每 50 帧一个关键帧,重新编码并对比统计:
ffmpeg -y -i master.mp4 -c:v libx264 -preset veryfast -crf 23 -g 50 -pix_fmt yuv420p -c:a copy gop50.mp4
# 输出尾部(真实):[libx264 @ 0x...] kb/s:252.75
# 统计帧类型(真实输出;I 行带尾逗号是 csv 格式原样)
ffprobe -v error -select_streams v:0 -show_entries frame=pict_type -of csv=p=0 sample4.mp4 | sort | uniq -c
# 97 B
# 1 I,
# 52 P
ffprobe -v error -select_streams v:0 -show_entries frame=pict_type -of csv=p=0 gop50.mp4 | sort | uniq -c
# 97 B
# 2 I
# 1 I,
# 50 P
# 关键帧位置(grep -n "I"):默认只有第 1 帧;-g 50 在第 1 / 51 / 101 帧(即 0 / 50 / 100)
对比:默认 1 个 I 帧 vs -g 50 的 3 个 I 帧。I 帧越多文件越大(gop50.mp4 269 KB vs sample4.mp4 261 KB),但随机访问点变多——seek(说人话:拖动进度条)只能落在 I 帧,I 帧越密,拖动越跟手、裁剪越精准。这就是第 2 章裁剪丢视频流的完整答案:从非关键帧开始裁剪,前面没有参考帧可解,画面自然就丢了。
注意:GOP 长短是权衡——直播要秒级短 GOP(观众随时切入),存档要长 GOP(省空间)。但 -g 只在编码时生效:用 -c copy 转封装不会改变关键帧位置,GOP 是编码器压码流时定死的。另外 scenecut=40 表示场景突变也会额外插入 I 帧,所以真实视频的关键帧位置不会严格均匀(testsrc2 无场景突变,本例恰好均匀)。
现在进入本章最"第一性"的概念。播放器必须回答一个问题:这一帧该在什么时候显示?计算机里时间怎么存?用浮点秒(0.04 秒)有累计误差——0.04 的二进制是无限循环小数,150 帧累加就会漂移。FFmpeg 的做法是分数:时间基 time_base(说人话:时间的最小刻度,1/25 表示一格 = 1/25 秒),任何时刻 = 整数刻度 × time_base,全程整数运算、零误差。分数在 C 里的样子(本机头文件原文):
# /opt/homebrew/include/libavutil/rational.h 第 58 行起(真实)
typedef struct AVRational{
int num; ///< Numerator
int den; ///< Denominator
} AVRational;
那 25 fps 的视频,time_base 是不是就该是 1/25?实测打脸——用 ffprobe 直接问(本章核心命令,-select_streams v:0 选视频流,-show_entries stream=... 选字段,-of json 输出 JSON):
ffprobe -v error -select_streams v:0 \
-show_entries stream=time_base,r_frame_rate,avg_frame_rate -of json sample4.mp4
# 输出(真实,完整):
{
"programs": [
],
"stream_groups": [
],
"streams": [
{
"r_frame_rate": "25/1",
"avg_frame_rate": "25/1",
"time_base": "1/12800"
}
]
}
time_base = 1/12800,不是 1/25!r_frame_rate 和 avg_frame_rate 都是 25/1(分数形式;前者是码流宣称的帧率,后者按实际帧数 ÷ 时长算出,两者一致说明帧率恒定——变帧率视频这两个值会分家)。而 12800 ÷ 25 = 512:每帧恰好 512 个整数刻度,帧与帧之间没有小数误差——这是封装器选出来的"够细又不浪费"的刻度。时间基换算对账(复用第 3 章体检数据):视频 76800 × (1/12800) = 6.0 秒;音频 264600 × (1/44100) = 6.0 秒——两条流、两种刻度,算出的时长严丝合缝。这就是分数时间基的威力。ffprobe 概览里的三个"基"各有分工:
| 缩写 | 全称含义 | sample4.mp4 实测 | 说明 |
|---|---|---|---|
| tbn | 容器时间基(time base numerator) | 12800(刻度 1/12800 秒) | PTS/DTS 的实际刻度,由封装器决定(4.5 节实测) |
| tbr | 帧率基准(time base rate) | 25 | 每秒约多少帧,FFmpeg 从码流推算 |
| tbc | 编码层时间基(codec) | 概览不打印 | 解码器内部帧刻度,与容器刻度解耦 |
默认输出行 "25 fps, 25 tbr, 12800 tbn" 里三个数各管一层:fps/tbr 是帧率视角,tbn 是时间戳刻度。凡是"第几帧在什么时刻"的问题,先问刻度——刻度是 1/12800 还是 1/1000,算出来的秒数完全不同。别拿浮点秒当时间戳:API 层全是整数刻度 + AVRational。
关键问题:time_base 到底是谁定的?是编码器吗?做个对照实验——同一份 sample4.mp4,用 -c copy 转封装(说人话:不重新编码,码流的字节原样搬进新容器)成 MKV 和 TS,然后探测时间基:
ffmpeg -y -i sample4.mp4 -c copy sample4.mkv
ffmpeg -y -i sample4.mp4 -c copy sample4.ts
# 输出尾部(真实):frame= 150 ... Lsize= 253KiB / 296KiB(转封装极快,无重编码)
for f in sample4.mp4 sample4.mkv sample4.ts; do
echo "-- $f"
ffprobe -v error -select_streams v:0 -show_entries stream=time_base -of default=noprint_wrappers=1 "$f"
done
# 输出(真实):
-- sample4.mp4
time_base=1/12800
-- sample4.mkv
time_base=1/1000
-- sample4.ts
time_base=1/90000
# 默认输出行里的 tbn(真实):
# mp4: 25 fps, 25 tbr, 12800 tbn
# mkv: 25 fps, 25 tbr, 1k tbn
# ts : 25 fps, 25 tbr, 90k tbn
同一个 h264 码流,字节一个没变,时间基从 1/12800 → 1/1000 → 1/90000。结论:时间基由容器(封装器)决定——MKV 规范用毫秒刻度(1/1000),MPEG-TS 用广播电视行业的 90 kHz 刻度(1/90000),MP4 家族按需选细刻度。所以第 3 章 sample.mp4 的 12800 tbn 不是偶然,是 MP4 容器的选择。再做一次反证:把 sample4.ts 转封装回 mp4——
ffmpeg -y -i sample4.ts -c copy back.mp4
ffprobe -v error -select_streams v:0 -show_entries stream=time_base -of default=noprint_wrappers=1 back.mp4
# 输出(真实):time_base=1/90000
反证结果更有意思:ts 转回 mp4,time_base 仍是 1/90000——MP4 封装器在流拷贝时沿用输入流刻度以保住精度,而不是强制换成 12800。所以准确的说法是:时间基是封装器写时间戳时选的刻度——有的容器强制规范刻度(MKV/TS),有的容器沿用输入(MP4 拷贝);直接编码时,MP4 封装器会为帧率挑一个细刻度(25 fps → 12800)。重封装时 FFmpeg 内部干的活,就是把每个包的时间戳从旧刻度换算到新刻度——这个换算函数 av_packet_rescale_ts 是第 11 章丢帧排错的钥匙,4.6 节先亮个相。
注意:混流时各流刻度天生不同(视频 1/12800、音频 1/44100),播放器做音画同步必须换算;任何"时间戳对不上"的错乱,先查刻度。另外,MKV 的 duration_ts 是 N/A(ffprobe 查不到)、时长显示 6.023 秒 vs MP4 的 6.00 秒——容器对"时长"的存储方式也不一样。这同样是容器差异,不是 bug。
B 帧埋的雷现在引爆。播放器要干两件事:解码(按依赖关系)和显示(按时间顺序)。I 帧先解码才能给 P 帧当参考,P 帧先解码才能给 B 帧当参考——但显示顺序里 B 帧在 P 帧前面。两件事顺序不同,就需要两个时间戳:DTS(decode timestamp,解码时刻,说人话:解码器按这个顺序开工)和 PTS(presentation timestamp,显示时刻,说人话:画面按这个顺序上屏)。实测看包(真实输出,csv 四列 = pts,dts,时长,flags,K 打头表示关键帧):
ffprobe -v error -select_streams v:0 -show_entries packet=pts_time,dts_time,duration_time,flags -of csv=p=0 sample4.mp4
# 输出(真实,前 8 个视频包):
0.000000,-0.080000,0.040000,K__
0.160000,-0.040000,0.040000,___
0.080000,0.000000,0.040000,___
0.040000,0.040000,0.040000,___
0.120000,0.080000,0.040000,___
0.240000,0.120000,0.040000,___
0.200000,0.160000,0.040000,___
0.320000,0.200000,0.040000,___
第一列 pts 是显示顺序:0.00 → 0.04 → 0.08 → 0.12 → 0.16……(正是 4.2 节的 I B B B P 序列);第二列 dts 是解码顺序:-0.08 → -0.04 → 0.00 → 0.04 → 0.08……包在文件里的顺序就是 dts 顺序。第一个包(I 帧,pts 0.00)的 dts 是 -0.08:因为显示序第 2~4 位的 B 帧(pts 0.04 / 0.08 / 0.12)都要参考后面的帧,解码器得先把 I 帧和后续帧解出来等它们,解码时钟被迫从 0 秒之前就开始干活——这就是第 3 章"第一个包 dts=-0.080000"的完整解释:负 dts 是 B 帧重排的正常现象,不是文件坏了。两条时间轴对照:
# 文件里的包顺序(= dts 升序 = 解码顺序):
# pts: 0.00 → 0.16 → 0.08 → 0.04 → 0.12 → 0.24 → 0.20 → 0.32 ...
# dts: -0.08 → -0.04 → 0.00 → 0.04 → 0.08 → 0.12 → 0.16 → 0.20 ...
# 播放顺序(= pts 升序 = 显示顺序):
# pts: 0.00 → 0.04 → 0.08 → 0.12 → 0.16 → 0.20 → 0.24 → 0.32 ...
# 同一批包,两条时间轴;解码器按 dts 解,显示器按 pts 排。
现在把 4.4 / 4.5 的刻度问题接上:换容器、进播放器、音画同步,到处都在做同一件事——把时间戳从一套刻度换算到另一套刻度。FFmpeg 的 C API 里,这个函数长这样(本机头文件原文):
# /opt/homebrew/include/libavcodec/packet.h 第 930 行(真实)
void av_packet_rescale_ts(AVPacket *pkt, AVRational tb_src, AVRational tb_dst);
tb_src 是当前刻度,tb_dst 是目标刻度,一调就把包的 pts/dts 全部换算过去。第 11 章排丢帧问题时,第一嫌疑永远是"时间基换算错":容器换了刻度、demux 出来的包没 rescale、播放器时钟还按旧刻度读数——帧就丢了、音画就不同步了。本章这三块基石(容器、编码、时间基)就是到时候的排查地图。
把三块基石串成一句话:容器决定怎么装(含时间戳刻度),编码决定怎么压(I/P/B 帧与 GOP),时间基决定怎么算时间(刻度 + 整数换算)。任何视频问题的排查,都从"这三层分别是什么、刻度是什么"开始——这就是第一性原理的用法。
把下列对象归入三层结构之一,并为每个对象写一句话说明它在那一层的职责:① MP4;② H.264;③ I 帧;④ PTS。再回答:为什么同一条 h264 码流既能装进 .mp4 也能装进 .mkv?
回顾 4.1 节的三层:容器管"怎么装"、编码管"怎么压"、帧与时间戳管"怎么组织画面、怎么计时"。
① MP4 → 容器层(怎么装,档案袋);② H.264 → 编码层(怎么压,压缩算法);③ I 帧 → 帧层(一幅完整画面,压缩的最小单位);④ PTS → 帧层的时间属性(这一帧什么时候显示)。容器与编码是正交的两个维度(解耦),所以同一码流可以装进任何支持它的容器——这就是「容器 ≠ 编码」。
用 -g 30 把 master.mp4(6 秒、25 fps、共 150 帧)重新编码成 gop30.mp4:先预测 I 帧的数量与位置,再用 4.3 节的统计命令实测验证,并和 -g 50(3 个 I 帧)对比。
150 帧、每 30 帧一个关键帧:150 ÷ 30 = 5。验证用 ffprobe ... -show_entries frame=pict_type -of csv=p=0 gop30.mp4 | grep -n "I"。
预测 5 个 I 帧,位置在第 0 / 30 / 60 / 90 / 120 帧。实测(本机真实输出):
ffmpeg -y -i master.mp4 -c:v libx264 -preset veryfast -crf 23 -g 30 -pix_fmt yuv420p -c:a copy gop30.mp4
ffprobe -v error -select_streams v:0 -show_entries frame=pict_type -of csv=p=0 gop30.mp4 | grep -n "I"
# 输出(真实):1:I, 31:I 61:I 91:I 121:I
grep 的行号是 1 基编号,即第 0 / 30 / 60 / 90 / 120 帧;全片统计 5 I + 51 P + 94 B = 150 帧。GOP 越短(-g 30 vs -g 50),I 帧越多,seek 越精准,文件也越大——权衡题没有标准答案,只有场景。
sample4.ts 的视频流 time_base=1/90000、duration_ts=540000:(a) 算出它的时长(秒);(b) 把 sample4.ts 用 -c copy 转封装回 mp4,实测它的 time_base 变成多少?(c) 结合 4.5 节解释:时间基到底由谁决定?
时长 = duration_ts × time_base(duration_ts 的单位是 1/90000 秒一格)。(b) 用 4.5 节的命令实测,别靠猜。
(a) 540000 × (1/90000) = 6.0 秒。(b) 实测仍为 time_base=1/90000,不是 1/12800。(c) 时间基由封装器决定:MKV 强制 1/1000(毫秒)、TS 强制 1/90000(90 kHz 广播电视刻度);MP4 在流拷贝时沿用输入流刻度以保精度(ts → mp4 仍是 1/90000),直接编码时才为帧率挑细刻度(25 fps → 12800)。所以"时间基是编码器定的"是错的——h264 码流本身没有时间基,刻度是容器层写时间戳时的选择。
已知 sample4.mp4 前 5 个视频包的 (pts, dts) 为:(0.00, -0.08)、(0.16, -0.04)、(0.08, 0.00)、(0.04, 0.04)、(0.12, 0.08)。(a) 写出解码顺序对应的 pts 序列;(b) 写出显示顺序对应的 pts 序列;(c) 解释为什么第一个包的 dts 是负的;(d) 如果播放器忽略 pts、直接按 dts 顺序显示画面,会发生什么?
文件里的包顺序就是 dts 升序;显示顺序是 pts 升序。第一个包是 I 帧(flags=K__),它要等后面的 P 帧一起先解码。
(a) 解码顺序(dts 升序)对应 pts:0.00 → 0.16 → 0.08 → 0.04 → 0.12,即 I → P → B → B → B;(b) 显示顺序(pts 升序):0.00 → 0.04 → 0.08 → 0.12 → 0.16,即 I → B → B → B → P;(c) 前 3 个 B 帧要参考后面的 P 帧,P 帧又要参考 I 帧,解码器必须提前开工把 I、P 解出来,解码时钟从 0 秒之前开始,所以 I 帧的 dts = -0.08(负 dts 是 B 帧重排的正常现象);(d) 画面会按"解码完成顺序"上屏,B 帧全部错位、画面乱序抖动,负 dts 时刻的画面也无法安排——所以播放器必须维护两条时间轴:按 dts 解码,按 pts 显示。日后排查丢帧/音画不同步,第一嫌疑就是时间戳在刻度换算(av_packet_rescale_ts)时被搞乱——第 11 章见。