第4章:音视频基础:容器、编码与时间基

视频文件不是一块铁板:容器负责装、编码负责压、帧负责组织画面,时间基则给每一帧编上精确的号。把这三层结构从第一性原理推明白,ffmpeg 的一万个参数就都成了表象

🔬

本章导师:费曼

核心方法论:第一性原理

「别人丢给你一个视频文件,你看到的是几百万个 0 和 1;费曼会问:这些比特被组织成了什么?——容器负责装,编码负责压,时间基负责给每一帧编号。把这三件事从第一性原理推出来,ffmpeg 的一万个选项都只是表象。搞懂本质,一切工具都是表象。」

4.1 三层结构:容器、编码流与帧

双击一个视频能播,但"能播"的背后是三件完全不同的事在协作。用第一性原理拆开:视频文件 = 容器(说人话:装视频、音频、字幕的"档案袋",决定文件怎么组织)+ 编码流(说人话:压缩算法,决定画面和声音怎么被压成二进制)+ (说人话:一幅完整的画面,压缩的最小单位)。播放器的工作流程正好反过来:拆档案袋(解封装 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 章的主场)。把"容器管怎么装、编码管怎么压"钉在脑子里,本章后面的时间基实验才看得懂。

4.2 编码的本质:帧内与帧间压缩

第一性原理问:一段 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 帧在排队。

4.3 实测:GOP 与关键帧间隔

第 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 无场景突变,本例恰好均匀)。

4.4 时间基:用有理数给时间编号

现在进入本章最"第一性"的概念。播放器必须回答一个问题:这一帧该在什么时候显示?计算机里时间怎么存?用浮点秒(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。

4.5 容器决定时间基:同一码流三个容器

关键问题: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。

4.6 PTS 与 DTS:解码顺序 vs 显示顺序

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),时间基决定怎么算时间(刻度 + 整数换算)。任何视频问题的排查,都从"这三层分别是什么、刻度是什么"开始——这就是第一性原理的用法。

章末练习

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

把下列对象归入三层结构之一,并为每个对象写一句话说明它在那一层的职责:① MP4;② H.264;③ I 帧;④ PTS。再回答:为什么同一条 h264 码流既能装进 .mp4 也能装进 .mkv?

提示

回顾 4.1 节的三层:容器管"怎么装"、编码管"怎么压"、帧与时间戳管"怎么组织画面、怎么计时"。

参考答案

① MP4 → 容器层(怎么装,档案袋);② H.264 → 编码层(怎么压,压缩算法);③ I 帧 → 帧层(一幅完整画面,压缩的最小单位);④ PTS → 帧层的时间属性(这一帧什么时候显示)。容器与编码是正交的两个维度(解耦),所以同一码流可以装进任何支持它的容器——这就是「容器 ≠ 编码」。

练习 2:实测 GOP 进阶

-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 越精准,文件也越大——权衡题没有标准答案,只有场景。

练习 3:时间基换算与反证 进阶

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 码流本身没有时间基,刻度是容器层写时间戳时的选择。

练习 4:B 帧重排 挑战

已知 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 章见。