有个源文件让我很头疼:
ffprobe看元数据:1080p、30fps、H.264、时长 1 小时 22 分——全正常;- 播放器能播,画面没问题;
- 但转码之后时长变成了 1 小时 18 分;
- 而且画面颜色偏暗,跟源文件在别的播放器里看的不一样。
我一开始以为是 ffmpeg 的问题,换了版本、换了参数,都没用。最后把裸码流(
.h264)导出来,逐层解析 SPS,才发现:
- SPS 里根本没有写帧率信息(
timing_info缺失),ffmpeg 只能按默认的 25fps 猜——而容器里写的是 30fps,两者不一致导致时长算错;- VUI 里的色彩信息缺失,播放器按 BT.601 处理,而实际内容是 BT.709——所以偏暗。
这类问题只看容器元数据是查不出来的,必须下到码流层。这篇写我后来整理的码流分析方法。
TL;DR:容器元数据(moov)和码流参数(SPS/PPS)是两套独立的信息,它们可以不一致,而且不一致时以码流为准。诊断工具链:
ffmpeg -c copy -bsf:v h264_mp4toannexb -f h264导出裸流 → 解析 NALU 类型 → 读 SPS 里的分辨率/profile/level/帧率(timing_info)/色彩信息(VUI) → 用ffprobe -show_frames看pict_type序列分析 GOP。四类典型问题:帧率谎报(SPS 无 timing_info)、颜色不对(VUI 缺失或错误)、时长不一致(容器 vs 实际帧数)、拼接/截取异常(开放 GOP)。
目录
- 一、为什么元数据会"说谎"
- 二、H.264 码流的基本结构
- 三、NALU 类型速查
- 四、SPS 里到底藏着什么
- 五、怎么把码流导出来看
- 六、GOP 分析:I/P/B 与开放/闭合 GOP
- 七、四类典型问题的码流层诊断
- 八、H.265/HEVC 的差异
- 九、工具清单
- 十、坑清单
一、为什么元数据会"说谎"
一个视频文件有两套描述自己的信息:
| 层次 | 在哪 | 谁写的 | 包含 |
|---|---|---|---|
| 容器层 | MP4 的 moov、MKV 的 Segment Info |
封装器 | 时长、帧率、分辨率、codec 声明 |
| 码流层 | H.264 的 SPS/PPS、HEVC 的 VPS/SPS/PPS | 编码器 | 分辨率、profile/level、帧率、色彩、GOP 约束 |
两者独立写入,某些工具只改一层不改另一层——比如:
- 用
ffmpeg -c copy改容器,容器时间戳变了,但码流没变; - 某些录制设备写容器时很随意(时长随便填),但码流是正确的;
- 反过来也有:码流缺信息(SPS 没写帧率),容器里补了一个"看起来合理"的值。
播放器/解码器的行为:解码依赖码流层(要解 SPS 才知道怎么解码),时长/帧率通常优先用容器层(快)。所以两者不一致时,会出现各种诡异现象。
核心原则:怀疑元数据的时候,去看码流。
二、H.264 码流的基本结构
H.264 码流由一个个 NALU(Network Abstraction Layer Unit) 组成。有两种封装方式:
| 封装 | 用在 | 分隔方式 |
|---|---|---|
| Annex-B(字节流) | 裸 .h264 文件、TS、直播 |
起始码 00 00 00 01(4 字节)或 00 00 01(3 字节) |
| AVCC(长度前缀) | MP4、MKV | 每个 NALU 前面是 4 字节大端长度 |
两者互转用 bitstream filter:
# MP4(AVCC) → Annex-B(分析用)
ffmpeg -i in.mp4 -c:v copy -bsf:v h264_mp4toannexb -f h264 out.h264
# Annex-B → MP4
ffmpeg -i in.h264 -c:v copy out.mp4
从 MP4 里提取码流做分析时,-bsf:v h264_mp4toannexb 必须加,否则出来的字节流格式不对(没有起始码),分析工具解析不了。
每个 NALU 的第一个字节是头部:
bit: 7 6 5 4 3 2 1 0
F NRI Type
│ │ └── nal_unit_type(5 bit,决定这个 NALU 是什么)
│ └── nal_ref_idc(2 bit,重要性指示)
└── forbidden_zero_bit(必须是 0,是 1 说明传输错误)
三、NALU 类型速查
H.264 / AVC:
| Type | 含义 | 说明 |
|---|---|---|
| 1 | 非 IDR 片的编码数据 | P/B 帧数据 |
| 2/3/4 | 数据分区 | 少用 |
| 5 | IDR 片 | 关键帧 |
| 6 | SEI | 补充增强信息(时间码、HDR 元数据等) |
| 7 | SPS | 序列参数集(最重要) |
| 8 | PPS | 图像参数集 |
| 9 | AUD | 访问单元分隔符 |
| 10/11 | 序列结束 / 流结束 | |
| 12 | Filler | 填充 |
H.265 / HEVC(类型值不同):
| Type | 含义 |
|---|---|
| 1~9 | 编码片(TSA/STSA/RADL/RASL 等) |
| 19 | IDR_N_LP(关键帧) |
| 20 | IDR_W_DLP |
| 21 | CRA(Clean Random Access,开放 GOP 的关键帧) |
| 32 | VPS |
| 33 | SPS |
| 34 | PPS |
| 35 | AUD |
| 39 / 40 | Prefix SEI / Suffix SEI |
快速统计一个裸流里各类 NALU 的数量(判断关键帧密度、有没有 SPS):
import sys
from collections import Counter
H264_NAMES = {1: 'non-IDR', 5: 'IDR', 6: 'SEI', 7: 'SPS', 8: 'PPS', 9: 'AUD', 12: 'filler'}
def scan_nalus(path: str):
data = open(path, 'rb').read()
# 找起始码
positions = []
i = 0
n = len(data)
while i < n - 4:
if data[i] == 0 and data[i + 1] == 0 and data[i + 2] == 1:
start = i + 3
# 跳过前导 0
positions.append(start)
i = start
elif data[i] == 0 and data[i + 1] == 0 and data[i + 2] == 0 and data[i + 3] == 1:
positions.append(i + 4)
i += 4
else:
i += 1
continue
i += 1
counter = Counter()
for p in positions:
if p >= n:
continue
nal_type = data[p] & 0x1F
counter[H264_NAMES.get(nal_type, f'type{nal_type}')] += 1
return counter
if __name__ == '__main__':
c = scan_nalus(sys.argv[1])
total = sum(c.values())
for k, v in c.most_common():
print(f'{k:<10} {v:>8} ({v / total * 100:.2f}%)')
print(f'{"TOTAL":<10} {total:>8}')
输出示例:
non-IDR 84123 (97.31%)
IDR 342 (0.40%)
AUD 2341 (2.71%)
SPS 342 (0.40%)
PPS 342 (0.40%)
IDR 数量 = 关键帧数。342 个 IDR、86465 个片,平均 GOP 长度 ≈ 253 帧。
如果 SPS 数量是 0,说明这个流有问题(或者你的解析不对)。
四、SPS 里到底藏着什么
SPS(Sequence Parameter Set)是整个视频序列的"说明书",解码器必须先拿到它。里面最关键的字段:
| 字段 | 含义 | 出问题的表现 |
|---|---|---|
profile_idc |
profile(66=baseline, 77=main, 100=high) | 设备不支持 |
level_idc |
level(30=3.0, 41=4.1…) | 设备拒绝播放 |
pic_width_in_mbs 等 |
分辨率 | |
chroma_format_idc |
色度采样(1=4:2:0, 2=4:2:2, 3=4:4:4) | 4:2:2 兼容性差 |
bit_depth_luma |
位深(8 / 10) | 10bit 老设备黑屏 |
log2_max_frame_num |
帧序号上限 | |
max_num_ref_frames |
参考帧数量 | 影响解码器内存需求 |
timing_info |
帧率(num_units_in_tick / time_scale) |
帧率谎报的头号原因 |
| VUI | 色彩信息、宽高比、视频范围 | 颜色发灰/发暗 |
timing_info:帧率的真相
fps = time_scale / (2 × num_units_in_tick)
常见值:
| 目标帧率 | time_scale | num_units_in_tick |
|---|---|---|
| 30 | 60 | 1 |
| 29.97 (30000/1001) | 60000 | 1001 |
| 25 | 50 | 1 |
| 24 | 48 | 1 |
如果 SPS 里根本没有 timing_info_present_flag(很多编码器默认不写),解码器/ffmpeg 就只能猜——通常默认 25fps。
这就是我开头那个案例的原因:容器写 30fps,码流没写,ffmpeg 在需要重新计算时间戳时用了 25fps 的假设,时长和帧率全乱。
检查:
ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate,avg_frame_rate,codec_time_base -of default=noprint_wrappers=1 in.mp4
# r_frame_rate=30/1 ← 容器说 30
# avg_frame_rate=30/1
但 r_frame_rate 也是从容器/码流推断的。真正的验证是数帧:
# 数实际帧数
ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 in.mp4
# 147600
# 容器时长
ffprobe -v error -show_entries format=duration -of csv=p=0 in.mp4
# 4920.000000
# 147600 / 4920 = 30.0 → 实际确实是 30fps,容器是对的
如果实际帧率(帧数 ÷ 时长)跟 SPS/容器声明的不一致,就是码流层的问题。
VUI:颜色不对的根源
VUI(Video Usability Information)里这几个字段决定颜色:
| 字段 | 含义 | 常见值 |
|---|---|---|
colour_primaries |
色域 | 1=BT.709(HD 标准), 5=BT.601(SD) |
transfer_characteristics |
传输特性 | 1=BT.709, 18=HLG, 16=PQ |
matrix_coefficients |
转换矩阵 | 1=BT.709, 6=BT.601 |
video_full_range_flag |
范围 | 0=Limited(16-235), 1=Full(0-255) |
缺失 VUI 时,播放器通常按 BT.601 + Limited 处理,而 HD 内容实际是 BT.709 + Limited——结果就是画面偏暗/偏灰(这正是项目里那篇色彩空间文章讲的现象,只是这层原因在码流里)。
修复(在转码时显式写入):
ffmpeg -i in.mp4 \
-c:v libx264 -crf 20 \
-color_primaries bt709 -color_trc bt709 -colorspace bt709 \
-color_range tv \
-c:a copy out.mp4
或者不改码流,只改容器元数据(-c copy,代价极小):
ffmpeg -i in.mp4 -c copy \
-bsf:v h264_metadata=colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1:video_full_range_flag=0 \
out.mp4
h264_metadata 这个 bitstream filter 很有用——它能在不重新编码的情况下修改 SPS 里的部分字段(色彩、level、帧率等)。
查看当前值:
ffprobe -v error -select_streams v:0 \
-show_entries stream=color_primaries,color_transfer,color_space,color_range \
-of default=noprint_wrappers=1 in.mp4
# color_primaries=bt709
# color_transfer=bt709
# color_space=bt709
# color_range=tv
五、怎么把码流导出来看
完整的诊断流程:
# 1. 导出裸流(Annex-B)
ffmpeg -i in.mp4 -c:v copy -bsf:v h264_mp4toannexb -f h264 /tmp/out.h264
# 2. 统计 NALU
python3 scan_nalus.py /tmp/out.h264
# 3. 用 ffprobe 看帧类型序列(GOP 结构)
ffprobe -v error -select_streams v:0 -show_frames \
-show_entries frame=pict_type,key_frame,pts_time -of csv=p=0 in.mp4 | head -40
# 4. 看码流层参数
ffprobe -v error -select_streams v:0 -show_entries stream=profile,level,pix_fmt,codec_time_base,r_frame_rate -of default=noprint_wrappers=1 in.mp4
第 3 步的输出:
frame,I,1,0.000000
frame,P,0,0.033367
frame,B,0,0.066733
frame,B,0,0.100100
frame,P,0,0.133467
...
第一列是帧类型(I/P/B),第二列 key_frame(1=关键帧)。看这个序列能直接读出 GOP 结构。
快速统计 GOP:
ffprobe -v error -select_streams v:0 -show_frames \
-show_entries frame=key_frame -of csv=p=0 in.mp4 | \
awk 'BEGIN{g=0;n=0} {n++; if($1==1){if(g>0) print "GOP: " g; g=0} else {g++}} END{print "last GOP: " g}'
六、GOP 分析:I/P/B 与开放/闭合 GOP
GOP(Group of Pictures) 是两个关键帧之间的帧序列。典型结构:
I B B P B B P B B P ... (下一个 I)
- I 帧:帧内编码,不依赖别的帧(可以随机访问);
- P 帧:参考前面的帧;
- B 帧:参考前后两面的帧(压缩率最高,但有延迟)。
IDR vs 普通 I 帧——这个区别很重要:
| IDR 帧 | 普通 I 帧 | |
|---|---|---|
| 解码器状态 | 清空参考帧缓冲(完全重置) | 不清空 |
| 随机访问 | 可以(从这帧开始独立解码) | 不一定(可能依赖前面的帧) |
| NALU 类型(H.264) | 5 | 1 |
开放 GOP vs 闭合 GOP:
闭合 GOP(Closed GOP):
[I][B][B][P][B][B][P] [I][B][B][P]...
↑ 新 GOP 的 I 帧是 IDR,前面的帧不能参考它后面的
开放 GOP(Open GOP):
[I][B][B][P][B][B][P] [I][B][B][P]...
↑ 这个 I 帧之后的 B 帧可能参考前面的 GOP(CRA)
开放 GOP 的压缩率更高(B 帧可以跨 GOP 参考),但带来两个问题:
- 截取/拼接:从一个非 IDR 的 I 帧开始截取,前面的 B 帧参考不到,会报错或者画面花;
- ABR 切换:切换点如果不是 IDR,新档位的帧可能依赖旧档位的参考帧。
x264/x265 默认是开放 GOP 吗? x264 默认 --open-gop 关闭(闭合 GOP),但某些预设和 B 帧配置下会有类似行为。x265 默认也是关闭的。要显式确认:
# x264 强制闭合 GOP
-x264-params "open-gop=0:intra-refresh=0"
# x265
-x265-params "open-gop=0"
拼接/截取场景一定要用闭合 GOP——我遇到过一次"两个视频拼起来中间花 2 秒"的问题,根因就是开放 GOP。
七、四类典型问题的码流层诊断
问题 1:帧率不对 / 时长变短
现象:转码后时长跟源不一致,或者帧率变成 25。
诊断:
# 实际帧数
ffprobe -count_frames -v error -select_streams v:0 -show_entries stream=nb_read_frames -of csv=p=0 in.mp4
# 容器时长
ffprobe -v error -show_entries format=duration -of csv=p=0 in.mp4
如果 SPS 缺 timing_info → 补上(重新编码时)或者显式指定:
ffmpeg -i in.mp4 -r 30 -c:v libx264 ... out.mp4 # -r 在 -i 之前,重解释输入帧率
更干净的办法是用 -video_track_timescale 或者干脆重编码时给准确帧率。
问题 2:颜色发暗/发灰
诊断:查 VUI(上面的 color_primaries 等字段)。
修复:-bsf:v h264_metadata=... 改码流,或者转码时加 -color_primaries 等参数。项目里另有专门讲色彩空间的文章,这里只补充"根因可能在 SPS 里"这一点。
问题 3:时长不一致(容器 vs 实际)
现象:ffprobe 报告 format.duration=4920,但视频流 duration=4800。
诊断:
ffprobe -v error -show_entries format=duration -of csv=p=0 in.mp4 # 容器
ffprobe -v error -select_streams v:0 -show_entries stream=duration -of csv=p=0 in.mp4
ffprobe -v error -select_streams a:0 -show_entries stream=duration -of csv=p=0 in.mp4
原因:录制中断(容器时长是"声明值")、音视轨长度不同、时间戳跳变。
诊断是不是时间戳跳变(前面音画同步那篇讲过,这里补码流视角):
ffprobe -v error -select_streams v:0 -show_frames -show_entries frame=pts_time -of csv=p=0 in.mp4 | \
awk -F, '{if(NR>1 && $1-prev > 1) print "JUMP at frame " NR ": delta " $1-prev; prev=$1}'
问题 4:拼接/截取后画面花
诊断:看截取点是不是 IDR:
# 找指定时间点前后最近的关键帧
ffprobe -v error -select_streams v:0 -skip_frame nokey -show_frames \
-show_entries frame=pts_time -of csv=p=0 in.mp4 | head -20
如果截取点不是关键帧且用了 -c copy,ffmpeg 会从上一个关键帧开始(所以时长会变长),或者生成一段开头花屏的视频(取决于版本和参数)。
开放 GOP 的情况更糟:即使从 I 帧开始,如果那个 I 帧不是 IDR,它后面的 B 帧可能参考了前面的帧 → 花屏。
解决:拼接/截取场景用 -x264-params open-gop=0 生成闭合 GOP 的源文件。
八、H.265/HEVC 的差异
HEVC 的码流结构类似,但:
| 差异 | 说明 |
|---|---|
| 多了 VPS(type 32) | 视频参数集,在 SPS 之上再加一层 |
| NALU 类型值不同 | IDR 是 19/20,CRA 是 21 |
| 多了 CRA(21) | Clean Random Access,开放 GOP 的随机访问点 |
| bsf 名字不同 | hevc_mp4toannexb |
| SPS 字段更复杂 | 多了 tier、更强的并行工具(tile/WPP) |
# HEVC 导出裸流
ffmpeg -i in.mp4 -c:v copy -bsf:v hevc_mp4toannexb -f hevc out.h265
HEVC 的一个实用点:CRA 帧存在说明这个流可能是开放 GOP,用于拼接时要小心。
九、工具清单
| 工具 | 用途 | 备注 |
|---|---|---|
ffprobe -show_frames |
帧类型、PTS 序列 | 最常用 |
ffmpeg -bsf:v h264_mp4toannexb |
导出裸流 | 分析必备 |
h264bitstream(h264_analyze) |
解析 SPS/PPS 全部字段 | 开源 C 工具,最详细 |
| Elecard StreamEye | 可视化码流分析 | 商业,功能强大 |
mp4box -info |
看 MP4 box 结构 | GPAC 工具集 |
| Python + 自写 NALU 扫描 | 批量统计 | 灵活 |
h264_analyze 的输出示例(能看到 SPS 的所有字段):
Profile: High (100)
Level: 4.1
Resolution: 1920x1080
Chroma: 4:2:0, bit depth: 8
timing_info: num_units_in_tick=1, time_scale=60 → 30 fps
VUI: colour_primaries=1, transfer=1, matrix=1, range=limited
这个工具在排查"帧率/色彩"问题时比 ffprobe 直接得多,建议装一个(编译很简单)。
十、坑清单
- 只看容器元数据就下结论 → 元数据可能是错的。关键问题看码流。
- 导出裸流忘了
-bsf:v h264_mp4toannexb→ 出来的是 AVCC 格式,分析工具解析不了。 - HEVC 用了 h264 的 bsf → 要
hevc_mp4toannexb。 - 以为 SPS 一定有帧率 → 很多编码器不写
timing_info。 - 颜色不对只调播放器 → 根因在 VUI,要在码流层修。
color_range搞错(tv vs pc)→ 画面发灰或者过曝。- 开放 GOP 的源直接拼接 → 接缝处花屏。用闭合 GOP。
- 以为 I 帧就是 IDR → 不一定是。看 NALU type 5(H.264)。
- 截取点不是关键帧还用
-c copy→ 出来的片段开头花屏或者时长不对。 - 数帧数没加
-count_frames→nb_frames可能是 0(元数据没写)或者不准。 - 用 NALU 数量直接当帧数 → 一个帧可能有多个 slice,还有 SEI/AUD 等非数据 NALU。
- 解析 NALU 时没处理 3 字节起始码 → 漏掉一部分。
- profile/level 只看容器 → 容器和 SPS 可能不一致,解码器看 SPS。
- 改了色彩参数但用
-c copy→ 需要-bsf:v h264_metadata才能真正写进 SPS。 - 10bit 问题只查容器 →
pix_fmt或 SPS 的bit_depth_luma才是真相。
最后说说什么时候需要下到码流层。
绝大多数时候不需要——ffprobe 的元数据输出够用了。但当你遇到"看起来一切正常,但结果就是不对"的问题时,码流层就是最后的裁判:
- 时长/帧率算不对 → 数帧 + 看 SPS timing_info;
- 颜色不对 → 看 VUI;
- 拼接/截取花屏 → 看 GOP 结构和 IDR 位置;
- 设备播不了 → 看 profile/level/bit depth(SPS 里的,比容器可靠);
- 转码报错定位 → 用 NALU 扫描找到损坏位置。
我的排查顺序是:容器元数据 → 数帧验证 → 导出裸流看 NALU → 解析 SPS。前两步两分钟,后两步十分钟。大部分"玄学问题"在第三步就现形了。
顺带说一句:这些知识不常用,但用一次就能省掉几小时的盲目试错。我把 NALU 扫描脚本和常用命令存在项目的 docs/ 里,每次遇到奇怪的源文件就跑一遍——它多次把"这文件是不是坏了"这种争论,变成了"第 3 秒处有一个损坏的 NALU"这样明确的结论。