提示

返回博客列表

从码流层面排查视频问题:NALU、SPS/PPS、GOP 与那些"元数据说谎"的文件

有个源文件让我很头疼:

  • ffprobe 看元数据:1080p、30fps、H.264、时长 1 小时 22 分——全正常;
  • 播放器能播,画面没问题;
  • 但转码之后时长变成了 1 小时 18 分;
  • 而且画面颜色偏暗,跟源文件在别的播放器里看的不一样。

我一开始以为是 ffmpeg 的问题,换了版本、换了参数,都没用。最后把裸码流(.h264)导出来,逐层解析 SPS,才发现:

  1. SPS 里根本没有写帧率信息(timing_info 缺失),ffmpeg 只能按默认的 25fps 猜——而容器里写的是 30fps,两者不一致导致时长算错;
  2. 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)。

目录

一、为什么元数据会"说谎"

一个视频文件有两套描述自己的信息:

层次 在哪 谁写的 包含
容器层 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 参考),但带来两个问题:

  1. 截取/拼接:从一个非 IDR 的 I 帧开始截取,前面的 B 帧参考不到,会报错或者画面花;
  2. 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 直接得多,建议装一个(编译很简单)。

十、坑清单

  1. 只看容器元数据就下结论 → 元数据可能是错的。关键问题看码流。
  2. 导出裸流忘了 -bsf:v h264_mp4toannexb → 出来的是 AVCC 格式,分析工具解析不了。
  3. HEVC 用了 h264 的 bsf → 要 hevc_mp4toannexb。
  4. 以为 SPS 一定有帧率 → 很多编码器不写 timing_info。
  5. 颜色不对只调播放器 → 根因在 VUI,要在码流层修。
  6. color_range 搞错(tv vs pc)→ 画面发灰或者过曝。
  7. 开放 GOP 的源直接拼接 → 接缝处花屏。用闭合 GOP。
  8. 以为 I 帧就是 IDR → 不一定是。看 NALU type 5(H.264)。
  9. 截取点不是关键帧还用 -c copy → 出来的片段开头花屏或者时长不对。
  10. 数帧数没加 -count_frames → nb_frames 可能是 0(元数据没写)或者不准。
  11. 用 NALU 数量直接当帧数 → 一个帧可能有多个 slice,还有 SEI/AUD 等非数据 NALU。
  12. 解析 NALU 时没处理 3 字节起始码 → 漏掉一部分。
  13. profile/level 只看容器 → 容器和 SPS 可能不一致,解码器看 SPS。
  14. 改了色彩参数但用 -c copy → 需要 -bsf:v h264_metadata 才能真正写进 SPS。
  15. 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"这样明确的结论。

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

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

顶部