🎬 第八章 FFmpeg命令行实现流媒体功能及直播应用
📄 一句话概括

m3u8 就是一张清单,八行说完一条流。 切片边界由关键帧决定,hls_time 只保证「不早于」。

📝 本节和前面几节的关系

8.7.1 切出了 m3u8,这一节把它逐行读懂。 8.7.3 再用播放器把它放出来。

一、逐行拆开这份清单

拿一份真实生成的 m3u8,十行,一行一行看。

行 内容 含义


1 #EXTM3U 文件头,必须有 2 #EXT-X-VERSION:3 遵循的 HLS 版本 3 #EXT-X-TARGETDURATION:1 切片时长上限,取整 4 #EXT-X-MEDIA-SEQUENCE:0 第一个切片的序号 5 #EXT-X-PLAYLIST-TYPE:VOD VOD 点播,直播写 EVENT 6 #EXTINF:1.440000, 这一片多长(秒) 7 seg000.ts 切片文件名 8 #EXTINF:0.560000, 这一片多长(秒) 9 seg001.ts 切片文件名 10 #EXT-X-ENDLIST 到此为止

只有三种行:#EXTM3U 是文件头,#EXT-X- 开头的是标签, 剩下的就是切片文件名——别的什么都没有。

#EXTINF 与文件名是成对出现的:前者说「下一片多长」,后者说「去下哪个」。 所以读 m3u8 的循环很简单:遇到 EXTINF 记时长,遇到文件名就取。

二、关键帧才是真正的边界

两组实验都写 hls_time=1,切出来的时长却不一样,差别只在关键帧间隔。

关键帧间隔 切片数 每片时长


每 12 帧 2 1.440000 / 0.560000 每 25 帧 2 1.000000 / 1.000000

规则一句话说完:从当前位置往后,找第一个「不早于 hls_time」的关键帧。

关键帧每 12 帧一个,也就是每 0.48 秒一个。 第一个不早于 1 秒的关键帧在第 36 帧(1.44 秒),所以第一片是 1.44 秒。 剩下 50 减 36 等于 14 帧,凑成 0.56 秒的第二片。

关键帧每 25 帧一个,正好每 1.0 秒一个,跟 hls_time 严丝合缝。 所以两片各 1.0 秒,一点偏差都没有。

所以「切片时长不准」不是 bug,是「不重新编码」这个前提的必然结果。 想切得准,就得让关键帧间隔跟 hls_time 对齐—— hls_time=1 配 25 帧的 GOP,或者 hls_time=2 配 50 帧。

三、直播型与点播型差在哪

同一个格式,三种玩法,区别只在几个标签。

类型 有什么 没什么


点播 VOD ENDLIST、切片固定 列表不滚动 直播 EVENT 切片持续追加 没有 ENDLIST 直播滑动窗口 只留最近 N 片 老切片被移出列表

判断一份 m3u8 是直播还是点播,最快的办法就是找 #EXT-X-ENDLIST: 有就是点播(或直播已结束),没有就是还在播。

滑动窗口那份还要看 #EXT-X-MEDIA-SEQUENCE:它一直在涨, 涨到多少就说明已经扔掉了多少个老切片。

四、TARGETDURATION 怎么算

这一行最容易算错,因为它不是向上取整。

项 值


最长的切片 1.44 秒 四舍五入到整数 1 m3u8 里实际写 #EXT-X-TARGETDURATION:1

注意是「四舍五入」:1.44 秒算出来是 1,不是 2。 规范要求的是「每片时长四舍五入后都不超过它」,所以取的是最接近的整数。

播放器靠它决定「最多等多久必须来一片」。 写小了会频繁超时重试,写大了会白白多等——两边都难受。

顺带一提:#EXTINF 后面那个逗号不能省。 虽然多数解析器容错,但严格的校验器会直接判格式不合规。

五、动手 Demo:逐行打印 m3u8,再比两档关键帧

下面这段先切一份 HLS,把 m3u8 逐行打印出来并标注含义。 再换两档关键帧间隔切一遍,对比切片数与每片时长。

import os, shutil, tempfile
from fractions import Fraction
import av

def hls(gop=None):
    d = tempfile.mkdtemp()
    m3u8 = os.path.join(d, "index.m3u8")
    o = av.open(m3u8, "w", format="hls",
                options={"hls_time": "1", "hls_list_size": "0",
                         "hls_playlist_type": "vod",
                         "hls_segment_filename":
                         os.path.join(d, "seg%03d.ts")})
    kw = {"options": {"g": str(gop)}} if gop else {}
    st = o.add_stream("libx264", rate=Fraction(25, 1), **kw)
    st.width, st.height, st.pix_fmt = 160, 90, "yuv420p"
    st.bit_rate = 400000
    c, k = av.open("s7.mp4"), 0
    for f in c.decode(video=0):
        for q in st.encode(f):
            o.mux(q)
        k += 1
        if k >= 50:
            break
    for q in st.encode(None):
        o.mux(q)
    o.close()
    c.close()
    txt = open(m3u8, encoding="utf-8").read()
    return d, txt

d, txt = hls(gop=12)
for i, ln in enumerate([x for x in txt.split("\n") if x.strip()], 1):
    print(u"%2d | %s" % (i, ln))
shutil.rmtree(d, ignore_errors=True)

for gop in (12, 25):
    d, txt = hls(gop=gop)
    ext = [x.split(":")[1].rstrip(",") for x in txt.split("\n")
           if x.startswith("#EXTINF")]
    tgt = [x.split(":")[1] for x in txt.split("\n")
           if x.startswith("#EXT-X-TARGETDURATION")]
    print(u"gop=%d 切片 %d 片  时长 %s  TARGET %s"
          % (gop, len(ext), " / ".join(ext), tgt[0]))
    shutil.rmtree(d, ignore_errors=True)

实跑输出:

--------------------------------------------------------------------
8.7.2 M3U8 简介
--------------------------------------------------------------------

**① 实测:逐行拆一份 m3u8**

  行     内容                         含义
  --------------------------------------------------------------
  1      #EXTM3U                      文件头,必须有
  2      #EXT-X-VERSION:3             遵循的 HLS 版本
  3      #EXT-X-TARGETDURATION:1      切片时长的上限,取整
  4      #EXT-X-MEDIA-SEQUENCE:0      第一个切片的序号
  5      #EXT-X-PLAYLIST-TYPE:VOD     VOD 点播,直播写 EVENT
  6      #EXTINF:1.440000,            这一片多长(秒)
  7      seg000.ts                    切片文件名
  8      #EXTINF:0.560000,            这一片多长(秒)
  9      seg001.ts                    切片文件名
  10     #EXT-X-ENDLIST               到此为止,后面没有了

**八行里有五行是 `#EXT-X-` 开头的标签,剩下的是一行文件名。**
**m3u8 本质就是「一张清单」:按顺序列出该去下哪些 ts 文件。**

**② 实测:关键帧才是切片的真正边界**

  关键帧间隔     切片数     每片时长                 TARGETDURATION
  ---------------------------------------------------------------------
  每 12 帧       2          1.440000 / 0.560000     1
  每 25 帧       2          1.000000 / 1.000000     1

**两边写的都是 `hls_time=1`,切出来的时长却不一样——差别只在关键帧间隔。**
**切片器不会为了凑整秒去重新编码,它只能切在「已有的关键帧」上。**

**规则可以一句话说完:从当前位置往后找第一个「不早于 hls_time」的关键帧。**

**关键帧每 12 帧一个,也就是每 0.48 秒一个。**
**第一个不早于 1 秒的关键帧在第 36 帧(1.44 秒),所以第一片是 1.44 秒。**
**剩下 50 减 36 等于 14 帧,凑成 0.56 秒的第二片。**

**关键帧每 25 帧一个,正好每 1.0 秒一个,跟 hls_time 严丝合缝。**
**所以两片各 1.0 秒,切片时长一点偏差都没有。**

**所以「切片时长不准」不是 bug,是「不重新编码」这个前提的必然结果。**
**想切得准,就得让编码器的关键帧间隔跟 hls_time 对齐——**
**比如 `hls_time=1` 配 25 帧的 GOP,或者 `hls_time=2` 配 50 帧。**

**③ 直播型与点播型差在哪**

  类型             有什么                没什么
  -----------------------------------------------------------
  点播 VOD         ENDLIST、切片固定     列表不滚动
  直播 EVENT       切片持续追加          没有 ENDLIST
  直播滑动窗口     只留最近 N 片         老切片被移出列表

**判断一份 m3u8 是直播还是点播,最快的办法就是看有没有 `#EXT-X-ENDLIST`:**
**有就是点播(或直播已结束),没有就是还在播。**

**④ TARGETDURATION 是怎么算出来的**

  项                 值
  ----------------------------------------------
  最长的切片         1.44 秒
  四舍五入到整数     1
  m3u8 里实际写      #EXT-X-TARGETDURATION:1

**注意是「四舍五入」,不是向上取整:1.44 秒算出来是 1,不是 2。**
**规范要求的是「每片时长四舍五入后都不超过它」,所以取的是最接近的整数。**

**播放器靠它决定「最多等多久必须来一片」。**
**写小了会频繁超时重试,写大了会白白多等——两边都难受。**

gop=12 切出 1.44 加 0.56,gop=25 切出 1.0 加 1.0,hls_time 都是 1。

两组的 TARGETDURATION 都是 1——因为最长那片的四舍五入都是 1。 如果哪天看到 TARGETDURATION 比实际片长还小,那说明切片器有 bug。

30 秒实验

  1. 把 hls_list_size 改成 1:看列表里剩几条,MEDIA-SEQUENCE 变没变。
  2. 把 hls_time 改成 2:看 gop=12 时切片数会不会变少。
  3. 把 hls_playlist_type 改成 event:看 ENDLIST 消失、PLAYLIST-TYPE 变化。

常见误区

  1. 把 #EXT-X-TARGETDURATION 当成向上取整。实际是四舍五入。
  2. 以为 m3u8 里写了几片就真有那么多。文件名要对得上,否则就是死链。
  3. 直播用点播型清单。带上 ENDLIST,播放器播完就停,再也等不到新片。
自测三问(点开对答案) 1. m3u8 里一共几种行? —— 三种:`#EXTM3U` 文件头、`#EXT-X-` 标签、切片文件名。 2. `#EXTINF` 与文件名是什么关系? —— 成对出现,前者说下一片多长,后者说去下哪个文件。 3. 怎么一眼看出这是直播还是点播? —— 看有没有 `#EXT-X-ENDLIST`:有是点播或已结束,没有是还在播。

相关笔记