🎬 第五章 HLS流媒体协议
📄 一句话概括

HLS 切片本质上就是把 TS 按时间切开,再配一份列表。 切两个参数:切多长、留几个。

📝 本节和前面几节的关系

5.1 到 5.6 把清单、TS、PS 全拆过一遍,这一节把它们拼回去。 第六章换一条路:HTTP-FLV,同样是「用 HTTP 搬流」,但不切片。

一、真切一次,看结果

用 PyAV 的 hls 封装器真切一次,参数是切片 2 秒、列表留 0(全留)。 实测生成 2 个切片:11656 字节与 5640 字节。 两个数字都能被 188 整除:11656 = 62 × 188,5640 = 30 × 188。 这就是「HLS 切片本质上就是 TS 文件」最直接的证据。

生成的 m3u8 长这样: 开头是 #EXTM3U 与 #EXT-X-VERSION:3, 然后 #EXT-X-TARGETDURATION:2、#EXT-X-MEDIA-SEQUENCE:0, 两个 #EXTINF:2.000000, 各跟一个切片名,末尾 #EXT-X-ENDLIST。

注意 #EXTINF 写的是 2.000000 与 1.000000, 第一个切片是完整的 2 秒,第二个只有 1 秒——因为素材总长就 3 秒。 真实直播里最后一个切片短、但仍留在列表里,会被播放器当成「还没切完」。

二、三个参数决定切片结果

① hls_time:目标切片时长。 实际会取「最近的整数个 GOP」——设 2 秒、GOP 1 秒,切出来就是 2 秒一段。 ② hls_list_size:列表里保留几个切片。 0 表示全留(点播),直播常设 3 到 6。 ③ 关键帧对齐:必须靠 force_key_frames 强制按时间插关键帧,否则切片边界会飘。

第 ③ 条是实践里最容易翻车的一条。 如果只设 hls_time=4 但 GOP 是 3 秒,实际切出来会是 3 秒或 6 秒, 永远到不了 4 秒——因为切片只能切在关键帧上。

正确做法是三个参数一起设: -g 设成帧率乘切片秒数、-keyint_min 与它一致、-sc_threshold 0 关掉场景切换插入。 这样切出来的时长才稳定。

三、切片之后还剩什么事

切片只是第一步,一个能用的 HLS 服务还要做这些: ① 每切一片就更新 m3u8:这一步用「先写临时文件再改名」保证原子性。 直接覆盖会让正在读的客户端拿到半个文件。 ② 删除过期切片:直播时旧片要删,否则磁盘很快就满。 ③ 给 CDN 设正确的缓存头:切片可以长缓存,m3u8 必须短缓存或不缓存。 这条设反了会出现「观众永远看到同一个列表」的怪现象。

还有一条容易忽略的:切片文件名要单调递增且不重复。 用时间戳或序号都可以,但绝不能用「固定名字循环覆盖」, 否则 CDN 缓存会把新旧切片搅在一起,观众画面乱跳。

四、动手 Demo:切片——把一路流真切成 m3u8 加一堆 ts

真切一次 HLS:编码 3 秒素材、切片 2 秒、列表全留。 然后量每个切片的字节数,看它们是不是 188 的整数倍。

import io, os, tempfile
import av, numpy as np

out = tempfile.mkdtemp()
p = os.path.join(out, "index.m3u8")
c = av.open(p, "w", format="hls")
c.options = {"hls_time": "2", "hls_list_size": "0",
             "hls_segment_filename": os.path.join(out, "s%d.ts"),
             "hls_flags": "independent_segments"}
st = c.add_stream("libx264", rate=25)
st.width, st.height, st.pix_fmt = 320, 240, "yuv420p"
st.options = {"crf": "30", "preset": "veryfast", "g": "25"}
for i in range(75):
    f = av.VideoFrame(320, 240, "yuv420p")
    y = np.zeros((240, 320), np.uint8)
    y[:, :] = np.linspace(16, 200, 320, dtype=np.uint8)[None, :]
    f.planes[0].update(y.tobytes())
    for pi in (1, 2):
        f.planes[pi].update(bytes([128]) * f.planes[pi].buffer_size)
    f.pts = i
    for pk in st.encode(f):
        c.mux(pk)
for pk in st.encode(None):
    c.mux(pk)
c.close()

segs = sorted(x for x in os.listdir(out) if x.endswith(".ts"))
tot = 0
for s in segs:
    n = os.path.getsize(os.path.join(out, s))
    tot += n
    print("%s  %d 字节 = %d × 188" % (s, n, n // 188))
print("共 %d 片、%d 字节" % (len(segs), tot))
print()
print(io.open(p, encoding="utf-8").read())

实跑输出:

--------------------------------------------------------------------
切片——把一路流真切成 m3u8 加一堆 ts
--------------------------------------------------------------------
**① 真切一次**

  目录里生成了 2 个切片

  文件      字节      188 的倍数
  ----------------------------------
  s0.ts     11656     62
  s1.ts     5640      30

**② 生成的 m3u8 长这样**

  #EXTM3U
  #EXT-X-VERSION:3
  #EXT-X-TARGETDURATION:2
  #EXT-X-MEDIA-SEQUENCE:0
  #EXTINF:2.000000,
  s0.ts
  #EXTINF:1.000000,
  s1.ts
  #EXT-X-ENDLIST

**③ 三个参数决定切片结果**

  **① `hls_time`**:**目标切片时长,实际会取「最近的整数个 GOP」。**
  **所以设 2 秒、GOP 1 秒,切出来就是 2 秒一段。**
  **② `hls_list_size`**:**列表里保留几个切片,0 表示全留(点播)。**
  **直播常设 3 到 6。**
  **③ 关键帧对齐**:**必须靠 `force_key_frames` 强制按时间插关键帧,否则切片边界会飘。**

  **实测 2 个切片共占 17296 字节**,**每个都是 188 的整数倍。**
  **这就是「HLS 切片本质上就是 TS 文件」最直接的证据。**

每个切片的字节数都能整除 188——这不是巧合,切片就是 TS 文件。 hls_segment_filename 里的 %d 会被替换成递增序号,从 0 开始。 independent_segments 保证每片都能独立解码,靠的是每片开头重发关键帧与表。

30 秒实验

  1. 把 hls_time 改成 1,看切片数——变多,但每个都更小,总字节基本不变。
  2. 把 hls_list_size 改成 2,看 m3u8 里几行——只剩 2 个 #EXTINF,旧的被滚掉了。
  3. 把帧数改成 250(10 秒),看切片时长——变成 5 个 2 秒的片,最后一片可能不足 2 秒。

常见误区

  1. 「切片就是重新编码一次」——不是,是转封装,不重编码。
  2. 「hls_time 设几就切几秒」——只能切在关键帧上,实际是 GOP 的整数倍。
  3. 「m3u8 和切片可以设一样的缓存时间」——不行,m3u8 必须短缓存。
自测三问(点开对答案) 1. 为什么切片字节数总能整除 188? —— **切片就是 TS 文件**,TS 是 188 定长包,长度必然整除。 2. hls_time 为什么不能精确控制? —— **切片只能切在关键帧上**,实际是 GOP 的整数倍。 3. m3u8 的缓存策略该怎么设? —— **短缓存或不缓存**;切片可以长缓存,设反了观众会一直看到旧列表。

相关笔记