在 04.05.01 那条链上多套一层容器,就得到能拖进度条的文件。 多出来的动作只有四个:建轨、给 pts、每包 mux、关文件写索引。
上一节出的是裸流,这一节出 MP4。编码器完全没变,变的是外面的壳。 转封装的对比(不解码不编码)在下一节,两节合起来才能看清「封装」到底值多少。
一、四个动作
| 步 | 动作 | 对应 API |
|---|---|---|
| ① | 另存为 MP4,建视频轨 | avformat_alloc_output_context2 |
| ② | 每帧给 pts | frame->pts = i |
| ③ | 每包都送进容器 | av_interleaved_write_frame |
| ④ | 关文件写索引 | av_write_trailer |
这四步里有三个坑,都是「不写不报错,写完才发现」的类型:
| 漏了哪步 | 表面现象 | 真相 |
|---|---|---|---|
| 给 pts | 能编码,文件也有 | 时长算成 0,播放器按默认帧率猜 |
| 收尾冲空包 | 少一帧,看不出 | 最后几帧压在编码器里没出来 |
| close/trailer | 文件有大小 | moov 没写,整段打不开 |
第三行最要命:close() 之前文件头是残缺的。
在 Windows 上尤其容易忘,因为进程退出不一定刷盘。
二、实测:多了不到 1.2 KB
| 项 | 裸流 | MP4 |
|---|---|---|
| 文件大小 | 10.4 KB | 11.6 KB |
| 包数 | 30 | 30 |
| 时长 | 不可知 | 1.00 秒 |
| 能拖进度 | 不能 | 能(有索引) |
| 首帧 pts | None | 0 |
容器只多花了 1 KB 出头,换来的是「时长已知」和「能拖进度」。 这笔买卖在任何场景下都划算——所以工程里几乎不会输出裸流。
「时长」这一行是裸流永远的痛:h264 裸流一个字节的时间信息都不存,
ffprobe 只能按码率估、或者数帧数除帧率;封装之后时长就是文件里写死的一个数,不用猜。
三、moov 在哪,决定能不能边下边播
| box | 放什么 | 位置 |
|---|---|---|
ftyp |
品牌声明(isom/mp42) | 文件头,偏移 4 |
mdat |
真正的编码数据 | 紧跟着头 |
moov |
索引:每帧时间、偏移、是否关键帧 | 默认在文件尾 |
moov 默认排在 mdat 后面,这就是普通 MP4 的老毛病:
播放器要播得先读到索引,索引在文件尾,所以网络播放必须把整个文件拉完才敢开播。
这就是 -movflags faststart 存在的理由:它写完之后把 moov 挪到前面,
代价是多读一遍文件。
监控的远程回放几乎一定要开这个——否则用户点开要等整段下载完。
四、三个数要对得上
30 帧进、30 帧出、1.00 秒,三个数能对上,说明 pts 编对了。 对不上就是 pts 有洞(跳过号)或者重复:
| 症状 | 原因 | 查法 |
|---|---|---|
| 帧数少 | pts 重复,被容器合并 | 打印每帧 pts |
| 时长偏短 | pts 跳号(漏了号) | 看最后一个 pts 是不是 n−1 |
| 时长偏长 | pts 从 1 开始而不是 0 | 看首帧 pts |
第一行最阴:MP4 的索引按时间排,两个帧时间戳一样,后一个会被丢掉,而且播放器不会报错, 只是「少了一帧」。
五、动手 Demo:YUV 编码成 MP4,一次把「编码 + 封装」走完
Demo 编 30 帧进 MP4,然后把文件当二进制读一遍,找出 ftyp/moov/mdat 的偏移。
最后再解回来数帧数,验证三个数对得上。
out = av.open("out.mp4", "w")
st = out.add_stream("libx264", rate=30)
st.width, st.height, st.pix_fmt = 320, 240, "yuv420p"
st.options = {"crf": "23"}
for i in range(30):
fr = av.VideoFrame.from_ndarray(frame(i), format="yuv420p")
fr.pts = i
for pk in st.encode(fr):
out.mux(pk)
for pk in st.encode(None):
out.mux(pk)
out.close() # 这一句才写 moov
data = open("out.mp4", "rb").read()
print(data.find(b"mdat"), data.find(b"moov"))
实跑输出:
--------------------------------------------------------------------
YUV 编码成 MP4,一次把「编码 + 封装」走完
--------------------------------------------------------------------
**上一节(04.05.01)编出来的是裸流,这一节把同样 30 帧编成 MP4。**
两者差的不是编码器,是 **「多了一个容器」**——编码器算出 nal 单元,容器负责把它们排好、加索引、
写时长。
**和上一节的裸流版比,只多了四个动作,但一个都不能少:**
步 动作 对应 API
------------------------------------------------------------------
① 另存为 MP4,建视频轨 `av.open(…, "w")` + `add_stream`
② 每帧给 pts `frame.pts = i`
③ 每包都送进容器 `av_interleaved_write_frame`
④ 关文件写索引 `av_write_trailer`
**这四步里有三个坑,都是「不写不报错,写完才发现」的类型:**
漏了哪句 表面现象 真相
----------------------------------------------------------------------------
`fr.pts` 能编码,文件也有 时长算成 0,播放器按默认帧率猜
收尾 `encode(None)` 少一帧,看不出 最后几帧压在编码器里没出来
`out.close()` 文件有大小 moov 没写,整段打不开
**第三行最要命**:`close()` 之前文件头是残缺的,这一步在 Windows 上尤其容易忘,
因为不 `close()` 时进程退出也不一定刷盘。
**实测:**
项 裸流 MP4
----------------------------------------------------------------
文件大小 10.4 KB 11.6 KB
包数 30 30
时长 不可知 1.00 秒
能拖进度 不能 能(有索引)
首帧 pts None 0
**「时长」这一行是裸流永远的痛**:h264 裸流一个字节的时间信息都不存,
所以 `ffprobe` 只能按码率估、或者数帧数除帧率。
**封装之后时长就是文件里写死的一个数**,不用猜。
**再看文件里两个 box 的位置**——这是「能不能边下边播」的关键:
box 偏移 放什么
----------------------------------------------------------
`ftyp` 4 品牌声明(isom/mp42)
`moov` 10710 索引:每帧时间、偏移、是否关键帧
`mdat` 44 真正的编码数据
**`moov` 排在 `mdat` 后面**——这就是普通 MP4 的老毛病:播放器要播得先读到索引,索引在文件尾,
所以网络播放必须把整个文件拉完才敢开播。
**这就是 `-movflags faststart` 那个参数存在的理由**:它在写完之后把 `moov` 挪到前面,
代价是多读一遍文件。
**再确认一遍「编进去的确实是画面」,读回来解一遍:**
项 值
----------------------------------------------------
解出帧数 30
尺寸 320 × 240
像素格式 yuv420p
视频轨时间基 1/15360
编码 + 封装耗时 67 毫秒
**30 帧进、30 帧出、1.00 秒**——三个数能对上,说明 pts 编对了。
**对不上就是 pts 有洞(跳过号)或者重复。**
moov 的偏移比 mdat 大,说明索引在文件尾——这就是「网络播放要先下完」的物理原因。
ftyp 的偏移是 4,因为前面 4 个字节是 box 长度,
box 的格式永远是「4 字节长度 + 4 字节类型 + 内容」。看懂这一条,就能自己写个 MP4 结构查看器。
30 秒实验
- 把
out.close()注释掉,重跑,看文件还能不能打开。 - 给所有帧
fr.pts = i // 2(两帧同一个 pts),看重跑时解出几帧。 - 用
-movflags faststart那个思路,写完再手动把 moov 挪前面,看播放器起播变化。
常见误区
- 以为封装会改动编码数据。 一个字节都不改,只是加头和索引。
- 以为不 close 也能打开。 没有 moov,整段没法解,文件管理器看大小还是正常的。
- 以为 pts 随便给都行。 重复会被合并、跳号会让时长不对,而且都不报错。
自测三问(点开对答案)
1. 封装多花了多少字节? —— 实测 10.4 KB → 11.6 KB,**大约 1.2 KB**,换来的是时长和索引。 2. 为什么普通 MP4 不能边下边播? —— `moov` 索引默认在文件尾,**播放器要先读索引才知道每帧在哪**,所以要等下完。 `-movflags faststart` 把索引挪到前面就能边下边播。 3. 两帧 pts 一样会怎样? —— MP4 的索引按时间排,**后一帧会被丢掉**,播放时不报错,只是少一帧。下一节反过来:手上有 H.264 裸流,不解码不编码,直接塞进 MP4。
- ☐ (本节完,下一节 04.05.04 案例:使用FFmpeg将H.264码流封装为MP4)