📹 第四章 H.264/H.265视频编码并存储
📄 一句话概括

这一节把前面几节拼成一个能跑的编码器:读 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 秒实验

  1. 把 fr.pts = k 删掉,再封装成 MP4,看时长变成多少。
  2. 把源文件往后截断 100 字节,看重跑时「帧数」是不是变成 29,最后一帧的残数据会被丢掉。
  3. 把 CRF 改成 40 重跑,看压缩比涨到多少。

常见误区

  1. 以为裸 YUV 能自己报帧数。 它只存像素,帧数只能拿文件大小除出来。
  2. 以为不给 pts 也没关系。 编码器会按帧率猜,可变帧率时全错,而且不报错。
  3. 以为「帧/秒」能代表编码器产能。 30 帧的样本里初始化开销比编码开销还大。
自测三问(点开对答案) 1. 怎么算出裸 YUV 文件有多少帧? —— 帧大小 = 宽 × 高 × 1.5(yuv420p),帧数 = 文件大小 ÷ 帧大小。**除不尽说明参数错了。** 2. 不给 pts 会怎样? —— 编码器按帧率猜每帧的间隔,**帧率稳定时猜对,可变帧率时全错**,而且不报错。 3. 为什么说监控必须按 pts 计时间? —— 监控的帧率会随画面动静变化,**按帧数乘帧长算时间一定错**。

下一节把 AVFrame 的字段一个个拆开,尤其是 linesize 这个「画面歪了」的源头。

相关笔记