从 AVPacket 到 AVFrame,H.264/H.265 在 FFmpeg 中是如何承上启下的
前言:为什么只懂 H.264/H.265 还不够
很多人学视频编码时,容易停在“标准知识”这一层。
比如知道:
- H.264 和 H.265 是主流视频编码标准
- I / P / B 帧分别是什么
- SPS / PPS / VPS 是参数集
- Annex B 和 AVCC 是两种常见码流组织方式
这些知识当然重要,但如果你真正做工程,只懂这些还远远不够。
因为在真实项目里,你面对的通常不是“抽象的 H.264 标准”,而是:
- 一个 MP4 文件或者 RTSP 流怎么被送进系统
- 一段压缩码流怎么进入解码器
- 解码后为什么变成了
AVFrame - 滤镜为什么处理的是 frame,而不是 packet
- 编码器为什么会“吃进去一帧,却不立刻吐出一个包”
- 时间戳为什么总是乱
- 为什么有时候明明是 H.264,却还是解不出来
所以说,标准知识是基础,FFmpeg 中的数据流转才是工程落地的关键。
如果把 H.264/H.265 比作“视频压缩语言”,那么 FFmpeg 就像一个完整的视频处理流水线,而 codec 层正处在这个流水线的中枢位置。
这篇文章的目标,就是把这个中枢位置讲清楚。
1. 先看全局:FFmpeg 整体链路到底在做什么
在工程里,FFmpeg 通常不只是“解码器”或者“编码器”,而是一整套多媒体处理框架。
一条典型的视频处理链路,经常可以抽象成下面这几个环节:
flowchart LR
A[输入源\n文件 / 摄像头 / RTSP / RTP] --> B[Demux\n解封装]
B --> C[Packet\n压缩数据包]
C --> D[Decode\n解码]
D --> E[Frame\n原始像素帧]
E --> F[Filter\n滤镜/缩放/格式转换]
F --> G[Encode\n编码]
G --> H[Packet\n压缩数据包]
H --> I[Mux\n封装]
I --> J[输出\nMP4 / FLV / TS / 推流]
这条链路可以出现在很多场景里:
1.1 播放
如果是播放器,主线一般是:
demux -> decode -> render
也就是说:
- 先把容器里的流拆出来
- 再把压缩视频解成原始帧
- 最后送给渲染模块显示
1.2 转码
如果是转码,主线一般是:
demux -> decode -> filter -> encode -> mux
也就是:
- 先解封装
- 再解码
- 如果需要缩放、裁剪、叠字、转像素格式,就进入 filter
- 然后重新编码
- 最后再封装成新的输出文件
1.3 推流
如果是直播或推流,常见情况会更复杂:
- 有时是采集原始帧,然后编码、封装、推送
- 有时是收到压缩码流后,做转封装直接推送
- 有时要先解码、处理、再重新编码推送
所以从工程角度看,H.264/H.265 并不是 FFmpeg 的全部,它只是整个媒体流水线中的 codec 层。
2. H.264/H.265 在 FFmpeg 中处于什么位置
如果把整个链路拆开,H.264/H.265 的位置其实很清楚。
2.1 输入侧:它可能是 demux 之后拿到的压缩码流
比如你打开一个 MP4 文件,FFmpeg 先做的不是“立刻解码”,而是先解封装。
MP4 只是容器,里面真正的视频内容,可能是:
- H.264
- H.265
- MPEG-4
- VP9
- AV1
demux 的作用,是把容器层拆开,拿到压缩后的 packet。
这些 packet 里装着的,才是 H.264/H.265 码流数据。
2.2 解码侧:它会把 packet 变成 frame
如果这段 packet 是 H.264/H.265,那么接下来就交给对应解码器处理。
解码器做的事,本质上就是:
- 解析码流结构
- 熵解码
- 反量化
- 反变换
- 做帧间/帧内重建
- 输出原始图像帧
也就是说:
输入是压缩数据包 AVPacket,输出是原始图像帧 AVFrame。
2.3 编码侧:frame 会被重新压成 packet
如果你后面还要输出成 H.264/H.265,那么编码器又会做反过来的事:
- 输入原始图像帧
AVFrame - 做预测、变换、量化、熵编码
- 输出压缩后的
AVPacket
2.4 输出侧:packet 再被 mux 到容器里
编码出来的 packet 还不能直接说“这是个 MP4 文件”。
因为 packet 只是压缩码流数据,真正要落成 MP4 / FLV / TS,还要经过 mux,把这些 packet 写进对应容器结构中。
所以从前到后串起来就是:
1 | |
这就是 H.264/H.265 在 FFmpeg 中“承上启下”的真正位置。
3. 解码主线:从 AVPacket 到 AVFrame
这部分是理解 FFmpeg codec 层最核心的一步。
3.1 AVPacket 是什么
AVPacket 可以把它理解成:
一段带有时间信息的压缩数据包。
它通常包含这些关键信息:
- 指向压缩数据的缓冲区
- 数据大小
ptsdts- stream index
- flags(比如关键帧标记)
对视频来说,packet 里装的往往不是“完整一张图”,而是某段压缩码流。
注意,这里非常容易产生误解。
AVPacket 不等于一帧画面
很多初学者会下意识觉得:
一个 packet = 一帧视频
这不总成立。
原因有几个:
- 有些封装格式里,一个 packet 可能对应一个 access unit
- 有些场景下,一个 packet 可能不完整,需要 parser 进一步拆
- 有时一帧图像的数据可能跨多个 packet
- 音频里这种“一包不等于一帧”的情况更常见
所以更稳妥的理解是:
packet 是压缩码流在系统中的传输单位,而不是天然等于显示帧。
3.2 AVFrame 是什么
AVFrame 可以理解成:
一帧已经解码后的原始媒体数据。
对视频来说,里面通常是:
- YUV 或 RGB 像素数据
- 宽高
- 像素格式
- 每个平面的 data 指针
- linesize
- pts
- 颜色空间、色度范围等附加信息
比如最常见的视频 frame,可能是:
AV_PIX_FMT_YUV420P- 宽
1920 - 高
1080
这时 frame 里的数据,已经不再是压缩码流,而是真正可以做:
- 缩放
- 裁剪
- 颜色转换
- 叠字
- AI 推理
- 渲染显示
的原始像素内容了。
3.3 AVCodec 和 AVCodecContext 在干什么
AVCodec
AVCodec 更像“编解码器的静态描述”。
它告诉你:
- 这是什么 codec
- 是编码器还是解码器
- 支持哪些能力
- 对应哪些回调实现
比如:
h264hevclibx264h264_nvenc
AVCodecContext
AVCodecContext 则更像“某次编解码实例的运行上下文”。
里面会保存:
- 编解码参数
- 分辨率
- 像素格式
- 比特率
- GOP 设置
- B 帧数量
- 线程数
- extradata
- 内部缓冲状态
如果类比面向对象,可以粗略理解为:
AVCodec像类定义AVCodecContext像类实例
真正 send/receive 时,主要操作的就是 context。
3.4 avcodec_send_packet / avcodec_receive_frame
现代 FFmpeg 推荐的解码接口是:
avcodec_send_packet()avcodec_receive_frame()
它的核心思想是:
输入和输出分离,允许内部缓存。
一个最小解码主线大致像这样:
1 | |
为什么要设计成 send/receive 两步?
因为解码器内部不一定是“送一个包,立刻出一帧”。
原因包括:
- B 帧重排序
- 参考帧等待
- 输入 packet 不完整
- 解码器内部缓存机制
所以 send/receive 的模型,本质上比过去的“单次调用即完成全部工作”的接口更贴近真实编解码过程。
3.5 为什么 packet 不等于 frame
这件事值得单独强调一次。
原因 1:压缩层和显示层不是一个粒度
packet 是压缩层的数据组织单位。
frame 是解码后的图像单位。
它们不是同一个层次的概念。
原因 2:编码器/解码器可能缓存数据
尤其是有 B 帧时,显示顺序和编码顺序本来就可能不同。
所以:
- 你送进去的 packet,未必马上对应输出 frame
- 你拿到的 frame,也未必对应刚送进去的那个 packet
原因 3:封装层还会进一步影响 packet 组织
不同容器、不同流协议、不同 parser 行为,都会让 packet 和“自然的一帧”之间的关系变得没那么直接。
所以做工程时,别把 packet 想成“压缩版 frame”,那样很容易在时间戳、缓存和 parser 上踩坑。
4. 编码主线:从 AVFrame 到 AVPacket
如果说解码是“压缩码流还原成原始图像”,那编码就是反过来的过程。
4.1 avcodec_send_frame / avcodec_receive_packet
现代 FFmpeg 推荐的编码接口是:
avcodec_send_frame()avcodec_receive_packet()
一个最小编码主线可以抽象成这样:
1 | |
和解码一样,它也不是简单的一进一出。
编码器内部可能需要:
- 做 lookahead
- 等待更多参考帧
- 执行 B 帧重排序
- 控制码率波动
- 积累足够数据后再输出
所以编码天然就带缓存特性。
4.2 编码延迟是怎么来的
很多人第一次接触编码接口时会困惑:
为什么我送进去一帧,编码器没立刻吐一个 packet?
因为编码器不只是“压缩当前帧”,它还要做全局上的决策。
例如:
- 这一帧是不是该做 I 帧
- 后面几帧的运动情况如何
- B 帧要插在哪里
- 当前码率预算够不够
这些都可能让编码器先缓存若干帧,再决定怎么输出。
所以编码延迟的来源,本质上包括:
- B 帧带来的重排序
- lookahead 机制
- 码率控制策略
- 编码器内部缓冲
这也是为什么“低延迟编码”通常要主动关掉一些高压缩效率能力。
4.3 为什么 B 帧会影响输出时序
B 帧会同时参考前后帧。
这意味着编码器想正确生成某个 B 帧,往往需要先看到后面的参考帧。
结果就是:
- 输入顺序
- 编码顺序
- 解码顺序
- 显示顺序
这几个顺序可能并不相同。
这也是很多时间戳问题的根源之一。
在 FFmpeg 里,如果你不理解 B 帧重排序,很容易在这些地方出问题:
pts/dts弄混- mux 后播放器时序异常
- 低延迟场景莫名其妙多出缓存
- 以为编码器“卡住了”
4.4 为什么编码器可能“吃进去一帧,不立刻吐出一包”
这件事其实就是编码器缓存特性的直观表现。
工程上你应该默认:
编码器不是纯函数,而是带状态机和缓冲区的模块。
因此在编码结束时,还必须记得 flush。
典型做法是:
1 | |
如果不 flush,最后几帧可能根本出不来。
5. parser、extradata 和 codec id 是怎么把码流接起来的
这一节是工程里特别容易被忽略,但又非常关键的一层。
5.1 demux 出来的东西为什么不一定能直接送解码器
很多人觉得:
av_read_frame()读出来 packet,直接送avcodec_send_packet()不就好了?
很多时候可以,但不是永远都行。
因为 demux 出来的 packet,取决于:
- 容器格式
- 封装方式
- 码流组织形式
- 是否带完整参数集
- 是否已经对齐成 access unit
举个典型例子:
- MP4 里的 H.264,常常是 AVCC 格式
- 裸流或 TS 里更常见的是 Annex B
如果解码器期待的数据组织方式和你当前拿到的不一致,就需要 parser 或 bitstream filter 来帮你转换。
5.2 parser 的作用
parser 的作用可以粗略理解为:
在压缩码流和解码器之间,做进一步整理和切分。
它常见的职责包括:
- 边界对齐
- 提取帧级信息
- 补充必要的解析结构
- 帮助解码器正确识别 access unit
所以 parser 不是“解码器的一部分”,但它常常是码流真正稳定进入解码器前的重要中间层。
5.3 extradata 的作用
extradata 是 FFmpeg 里一个非常重要的概念。
它通常保存 codec 初始化所需的额外参数信息,比如:
- H.264 的 SPS / PPS
- H.265 的 VPS / SPS / PPS
- 某些音频 codec 的 header 信息
为什么要有它?
因为解码器启动时,必须先知道:
- 分辨率
- profile / level
- 编码参数
- 参考结构的一些基础信息
这些信息并不一定每个 packet 都重复带一份,所以经常会被提取到 AVCodecContext 的 extradata 里。
5.4 SPS/PPS/VPS 在 FFmpeg 里常常如何保存
这几个参数集在工程里常见有两种存在方式:
方式 1:跟在码流里
比如 Annex B 裸流中,SPS/PPS/VPS 可能直接以内联 NALU 的方式出现在数据流里。
方式 2:放在 extradata 里
比如 MP4 这类容器里,常常把参数集提到容器头部或 codec extradata 中,而不是每次都直接写进 packet 数据主体。
这就是为什么你经常会遇到这种问题:
- 同样是 H.264,MP4 里的 packet 和裸流里的 packet 长得不一样
- 同样是 hevc,写入 FLV / RTMP 前经常还得做额外处理
说到底,问题不在于“是不是 H.264/H.265”,而在于:
码流组织方式是否和上下游预期一致。
6. filter 为什么一般处理的是 frame,而不是 packet
这件事其实很好理解。
6.1 packet 是压缩域数据
packet 里的内容,还是编码后的码流。
在这个层面,你拿到的不是清晰的像素矩阵,而是:
- NALU
- 熵编码结果
- 预测残差组织
- 参数集和切片信息
这层数据更适合:
- 存储
- 传输
- 封装
- 解封装
但不适合直接做大多数图像处理。
6.2 frame 是像素域数据
frame 里才是真正的图像像素。
所以像这些操作:
- 缩放
- 裁剪
- 旋转
- 叠字
- 水印
- 色彩空间转换
- AI 检测
- 预处理
都天然更适合在 frame 层做。
因此 FFmpeg 里的 filter graph,绝大多数时候处理的都是 AVFrame。
6.3 为什么也存在“压缩域处理”但不常见
理论上,某些处理也可以在压缩域做,比如:
- 某些码流级分析
- 参数集修改
- bitstream 层面重写
但这些通常不是通用滤镜干的事,而更接近:
- bitstream filter
- 特殊码流处理模块
- 封装前后适配逻辑
所以你在工程里默认理解成:
packet 更偏传输与封装,frame 更偏内容处理。
这个模型通常是对的。
7. 常见 H.264/H.265 编码器在 FFmpeg 中的对应关系
理解 FFmpeg 的工程语境,还得知道常见编码器实现都是什么角色。
7.1 libx264
这是最经典的 H.264 软件编码器接口。
特点通常是:
- 压缩效率高
- 参数丰富
- 社区成熟
- CPU 开销相对大
很多“高质量 H.264 编码”讨论,绕不开它。
7.2 libx265
这是 H.265 的主流软件编码器接口。
特点通常是:
- 压缩效率更强
- 编码复杂度更高
- 参数体系更复杂
- CPU 压力更明显
7.3 h264_nvenc / hevc_nvenc
这是 NVIDIA 硬件编码路线。
优点通常是:
- 编码速度快
- CPU 压力小
- 适合实时场景
但也常见这些 trade-off:
- 参数能力和软件编码器不同
- 某些场景下主观质量未必最优
- 对显卡和驱动环境有依赖
7.4 h264_qsv / hevc_qsv
这是 Intel Quick Sync Video 路线。
它的核心价值也是:
- 利用硬件加速
- 降低 CPU 占用
- 提升实时编码能力
7.5 VAAPI 路线
这是 Linux / Intel / AMD 常见的一类硬件加速接口路线。
在部署时,它经常和这些问题绑在一起:
- 驱动兼容性
- 设备节点权限
- 像素格式限制
- 零拷贝路径是否打通
所以硬编硬解从来不只是“更快”,它还涉及:
- 数据在 CPU / GPU 间怎么流
- 像素格式是否匹配
- 是否需要额外 upload / download
- 设备上下文怎么管理
8. 工程里最常见的几个坑
这一节很值钱,因为很多问题不是“不会 API”,而是工程理解不到位。
8.1 时间戳问题
这是最常见也最烦的一类坑。
典型表现包括:
- 播放卡顿
- 音画不同步
- mux 后文件异常
- 推流端时序漂移
核心往往在于:
pts/dts理解不清- time_base 换算错误
- B 帧重排序没处理好
- 输入和输出时钟体系不一致
8.2 像素格式不匹配
比如:
- 解码出来是
yuvj420p - 编码器只接受
yuv420p - 硬件编码器要求
nv12 - filter 输出和 encoder 输入不一致
这时如果不主动做格式转换,轻则报错,重则结果异常。
8.3 分辨率变化
有些输入流中途会变分辨率,比如:
- 摄像头动态切换
- 网络流源端重协商
- 某些文件源中途参数变化
如果工程链路里默认“分辨率恒定”,那 context、buffer、filter graph 都可能出问题。
8.4 B 帧重排序
B 帧会影响:
- 编码输出时机
- 解码显示顺序
- pts / dts 排布
- flush 行为
如果你只按“送一帧出一包”的直觉写逻辑,基本迟早踩坑。
8.5 参数集缺失
比如 H.264/H.265 流里如果缺:
- SPS
- PPS
- VPS
那解码器很可能根本起不来。
这在这些场景尤其常见:
- 裸流截断
- 推流首包不完整
- 转封装时丢失参数集
- Annex B / AVCC 转换没做好
8.6 Annex B 和 AVCC 混用
这是 H.264/H.265 工程中极常见的坑。
看起来都是同一个 codec,实际上:
- 数据边界表达方式不一样
- 参数集组织方式不一样
- 某些 muxer / decoder 预期不一样
所以“明明是 H.264,为什么这个能播那个不能播”,很多时候问题就在这里。
8.7 解码器 / 编码器缓存刷新问题
如果没有正确 flush:
- 最后几帧解码结果可能取不干净
- 最后几个编码 packet 可能直接丢失
这是很多新手 demo 经常藏着的 bug。
9. 一个最小转码流程怎么理解
如果你想把这篇文章前面的内容全部串起来,一个最典型的例子就是最小转码流程。
flowchart LR
A[输入文件] --> B[avformat_open_input]
B --> C[av_read_frame]
C --> D[AVPacket]
D --> E[avcodec_send_packet]
E --> F[avcodec_receive_frame]
F --> G[AVFrame]
G --> H[sws_scale / filter graph]
H --> I[avcodec_send_frame]
I --> J[avcodec_receive_packet]
J --> K[AVPacket]
K --> L[av_interleaved_write_frame]
L --> M[输出文件]
如果把它翻译成人话,就是:
- 打开输入文件
- 解封装拿到压缩 packet
- 把 packet 送给解码器
- 从解码器拿出原始 frame
- 如果需要,对 frame 做处理
- 把处理后的 frame 送给编码器
- 从编码器拿出新的压缩 packet
- 把 packet 写进输出容器
这条链路看起来不复杂,但工程中的绝大多数问题,几乎都藏在这些节点之间:
- packet 是否完整
- frame 的像素格式是否匹配
- 时间戳是否正确传递
- 编码器是否缓存了数据
- mux 侧是否接受当前码流组织方式
所以理解“链路之间的接口语义”,往往比死记 API 名字更重要。
10. 面试里怎么讲 FFmpeg 中的编解码链路
如果你面试音视频岗,被问到 FFmpeg 编解码主线,不建议只背 API。
你更应该把它讲成一条数据流转链路。
可以用下面这个思路。
10.1 什么是 packet
packet 是压缩域的数据组织单位,通常由 demux 模块从容器里读出来,里面携带码流数据和时间戳信息。
10.2 什么是 frame
frame 是解码后的原始媒体数据。对视频来说,通常是 YUV/RGB 像素数据,也是滤镜、渲染、AI 处理更常操作的对象。
10.3 编解码器上下文是干什么的
AVCodecContext 保存了编解码实例的配置和内部状态,比如分辨率、像素格式、码率、GOP、extradata 和内部缓存。
10.4 为什么 filter 处理 frame
因为 packet 还是压缩码流,内容没有展开。多数图像处理逻辑都必须建立在像素域数据上,所以 filter graph 通常工作在 frame 层。
10.5 为什么硬编硬解不只是“更快”
因为它还会涉及:
- 硬件设备上下文管理
- CPU/GPU 间数据搬运
- 支持的像素格式限制
- 零拷贝路径是否成立
- 驱动与平台兼容性
所以硬件编解码是“性能 + 架构 + 兼容性”的综合问题,不只是速度问题。
如果你能把这些点讲顺,说明你已经不只是会调 API,而是真的理解这条链路在工程里怎么跑。
11. 总结:H.264/H.265 在 FFmpeg 里真正承上启下的位置
回到这篇文章最核心的问题:
H.264/H.265 在 FFmpeg 中,到底处于哪一层?
答案是:
它处在 codec 层,前面接 demux / 原始采集,后面接 filter / mux / 渲染 / 推流,是整条媒体处理链路里最关键的中间桥梁。
更具体一点说:
- 在输入侧,它承接 demux 拿到的压缩 packet
- 在解码侧,它把 packet 变成 frame
- 在编码侧,它把 frame 压回 packet
- 在输出侧,它把 packet 交给 mux 进入容器或网络协议
所以真正理解 FFmpeg,不只是会背几个模块名字,而是要理解:
压缩域和像素域如何切换,packet 和 frame 如何流动,codec 层如何把上下游串起来。
这也是为什么,一旦你真正理解 codec 层的位置,整个音视频工程感会明显提升。
因为你不再只是“会调用 FFmpeg API”,而是开始知道:
- 数据从哪来
- 在哪一层被改变形态
- 为什么某些处理必须发生在 frame 层
- 为什么某些坑总出现在 packet、时间戳和参数集上
这类理解,才是真正能支撑你继续往下走到:
- RTSP / RTP
- 时间戳同步
- 硬编硬解
- 零拷贝
- 直播链路
- 车载视频链路
这些更复杂工程主题的基础。
后记
如果说前两篇主要是在建立“编码世界观”和“编解码流程理解”,那么这一篇的作用,就是把这些知识拉回工程现场。
因为真正做项目时,最难的从来不是“知道 H.264 有哪些 NALU 类型”,而是:
- 知道一段码流现在处于哪一层
- 知道它该进哪个模块
- 知道它为什么在这里出问题
- 知道怎么把上下游重新接顺
这也是从“学知识”走向“做工程”的关键一步。