这一节把前面几节拼成一个能跑的编码器:读 YUV、逐帧编码、写裸流。 唯一的硬要求是每帧都要给 pts——不给,时间戳全是 NOPTS。
04.04 讲的是「有哪些 API」,这一节讲「怎么串起来」。 这条链是后面所有案例的骨架,04.05.03 只是在这上面加一层封装。
一、先解决「有多少帧」这个问题
YUV 裸文件里没有帧数,也没有宽高。唯一能算出帧数的办法是拿文件大小除。
帧大小 = 宽 × 高 × 1.5 # yuv420p 是 1.5 字节/像素
帧数 = 文件大小 ÷ 帧大小
320×240 一帧是 115200 字节,3456000 字节正好 30 帧。 这一步看着废话,其实是标准做法:读裸流时不知道长度,就只能这么算, 算出来不是整数就说明参数给错了。 这是个很好的自检:除不尽就是宽高或像素格式错了,不用等到看花屏。
二、骨架:三步循环
| 步 | 做什么 | 代码对应 |
|---|---|---|
| ① | 开容器、建编码器、设参数 | av.open + add_stream |
| ② | 逐帧:读一块 → 造帧 → 给 pts → 编码 | for … encode |
| ③ | 收尾冲空包、关文件 | encode(None) + close |
② 里面「给 pts」这一句不能省,下面单独说。 ③ 里两个动作都不能省:不冲空包少帧,不 close 文件残缺。
三、为什么 pts 必须自己给
裸流本身不存时间戳,但编码器要拿 pts 算「这一帧距上一帧多久」,没 pts 就只能按帧率猜。 所以「帧率稳定」时能猜对,「可变帧率」时就全错。
监控为什么特别在意这个:监控的帧率经常是变的(没动静时降帧省带宽,有动静时升回来), 按帧数乘帧长算时间一定错。 所以监控一律按 pts 计时间,帧率只是参考。
这也是为什么第 04.06 整章都在讲时间基:pts 是「刻度数」,离开时间基就没有时间含义。
四、实测结果与三个数
| 项 | 值 |
|---|---|
| 输入帧数 | 30 |
| 输出包数 | 30 |
| 输入大小 | 3.30 MB |
| 输出大小 | 10.2 KB |
| 压缩比 | 332 倍 |
| 耗时 | 几十毫秒(几百到一千多帧/秒) |
压缩比 332 倍和 04.01.01 的 324 倍是同一批数据, 差的几个点来自 CRF 和 GOP 设置的细微不同——这种量级的差别不用追。
「帧/秒」这个数只能当参考:片子太短(30 帧),首次初始化的开销会被摊进去, 跑得越快说明样本越小,不代表真实吞吐。
五、这个案例还差什么
| 缺什么 | 后果 | 在哪补 |
|---|---|---|
| 容器封装 | 裸流没法拖进度 | 04.05.03 |
| 音频 | 只有画面没声音 | 后续章节 |
| 错误处理 | 设备断了就崩 | 第五章 |
第一行是下一节的正题:同样的 30 帧, 封进 MP4 之后就有了时长和索引,播放器才敢让你拖进度条。
六、动手 Demo:完整案例,从 YUV 文件到 H.264 裸流
Demo 从磁盘上真实的 .yuv 文件读,逐帧编码,全程计时。
帧数是「文件大小 ÷ 每帧大小」算出来的,不是硬编码。
fsz = os.path.getsize("src.yuv")
fsz_per = 320 * 240 * 3 // 2
n = fsz // fsz_per
c = av.open("out.h264", "w", format="h264")
s = c.add_stream("libx264", rate=30)
s.width, s.height, s.pix_fmt = 320, 240, "yuv420p"
s.options = {"crf": "23"}
for k in range(n):
a = read_frame("src.yuv", k)
fr = av.VideoFrame.from_ndarray(a, format="yuv420p")
fr.pts = k # 关键:给了才有时间戳
for pk in s.encode(fr):
c.mux(pk)
for pk in s.encode(None):
c.mux(pk)
c.close()
实跑输出:
--------------------------------------------------------------------
完整案例,从 YUV 文件到 H.264 裸流
--------------------------------------------------------------------
**这一节把前面几节拼起来,写一个完整的编码器。**
**不用容器、不用封装,只出裸流。**
源文件 3456000 字节,按每帧 115200 字节算,一共 **30 帧**
**这一步看似废话,其实是「不知道帧数时怎么读数」的标准做法**:文件大小 ÷ 每帧大小。
项 值
--------------------------------------------
输入帧数 30
输出包数 30
输入大小 3.30 MB
输出大小 10.2 KB
压缩比 332 倍
耗时 45 毫秒(672 帧/秒)
**`fr.pts = k` 这一句是裸流编码的关键。**不设 pts,输出里每帧时间戳都是 `NOPTS`。
**裸流本身不存时间戳**,但编码器要拿它算「这一帧距上一帧多久」,**没 pts 就只能按帧率猜**。
**所以「帧率稳定」时能猜对,「可变帧率」时就全错**。
**这也是为什么监控要按 pts 计时间,而不是按帧数乘帧长。**
**输出包数 30,和输入帧数一模一样**——
但这不是「一帧一包当场出完」:**前几帧压在编码器里,靠收尾那一包才补齐**。
**再一次:不冲空包就少帧**,而且不报错。
**这个案例还差什么才能叫「能用」:**
缺什么 后果 在哪补
--------------------------------------------------------------
容器封装 裸流没法拖进度 04.05.03
音频 只有画面没声音 第四章后半
错误处理 设备断了就崩 第五章
30 进、30 出,但过程不是「一帧一包当场出完」:前几帧压在编码器里,靠收尾那一包补齐。 耗时那一栏(帧/秒)在本机跑出来会上下跳,因为 30 帧太短, 首次初始化的开销盖过了真实吞吐。别拿这个数去推产能。
30 秒实验
- 把
fr.pts = k删掉,再封装成 MP4,看时长变成多少。 - 把源文件往后截断 100 字节,看重跑时「帧数」是不是变成 29,最后一帧的残数据会被丢掉。
- 把 CRF 改成 40 重跑,看压缩比涨到多少。
常见误区
- 以为裸 YUV 能自己报帧数。 它只存像素,帧数只能拿文件大小除出来。
- 以为不给 pts 也没关系。 编码器会按帧率猜,可变帧率时全错,而且不报错。
- 以为「帧/秒」能代表编码器产能。 30 帧的样本里初始化开销比编码开销还大。
自测三问(点开对答案)
1. 怎么算出裸 YUV 文件有多少帧? —— 帧大小 = 宽 × 高 × 1.5(yuv420p),帧数 = 文件大小 ÷ 帧大小。**除不尽说明参数错了。** 2. 不给 pts 会怎样? —— 编码器按帧率猜每帧的间隔,**帧率稳定时猜对,可变帧率时全错**,而且不报错。 3. 为什么说监控必须按 pts 计时间? —— 监控的帧率会随画面动静变化,**按帧数乘帧长算时间一定错**。下一节把 AVFrame 的字段一个个拆开,尤其是 linesize 这个「画面歪了」的源头。
- ☐ (本节完,下一节 04.05.02 AVFrame及相关API)