1、前言
如果你经常接触音视频,那么对于 M3U8 应该不会陌生, M3U8 简单来说就是 HLS(HTTP Live Streaming) ,指的是苹果开发的基于 HTTP 协议的流媒体解决方案,它可以在普通的 HTTP 的应用上直接提供点播和直播的能力。
在 HLS 里会将视流文件切分成小片(ts)并建立索引文件(M3U8),一般如下图所示,首先会有一个 M3U8 文件,然后对应在 #EXTINF
的 tag 下会有很多 TS 格式切片,这应该是我们认知中的 M3U8 文件的标准。
❝
详细讲解 M3U8 的这里就不详细展开,感兴趣的可以看之前分享过的探索移动端音视频与GSYVideoPlayer之旅 。
❞
「那么,如果在 M3U8 里的不是 TS 链接,而是 png 链接或者 bmp 链接会是什么情况」?今天的主题就是探讨如何适配带有图片的非标准 M3U8 视频。
❝
「我们不鼓励「非标」,而是通过「非标」的适配来做科普」。
❞
2、为什么 M3U8 里会有图片?
首先,标准的 HLS 协议里 M3U8 文件内肯定是 TS 的切片链接,「那么为什么会有 png/bmp 之类的图片链接存在?或者说,为什么会是图片链接」?
这就不得不说「劳动人民的智慧」,众所周知,如果想让一个视频加载更快,那么最简单的办法就是给视频上 CDN ,但是碍于某些团队或者个人「囊中羞涩」,所以开始有人瞄上了公共图床的 CDN ,然后再结合 M3U8 的特性,一套民间的「免费」 视频 CDN 潜规则就这样悄然流行起来。
❝
如果你想将一个完整视频伪装成图片上传公共图床明显不现实,因为体积太大,很多图床也会对图片的大小做限制,但是如果是 M3U8 ,那么就可以把「视频分解成无数 TS ,再把 TS 伪装成图片分批上传,这样就可以给视频「依附」上 CDN 的能力」。
❞
3、TS & 图片
如下图所示,这就是一个「非标」的 M3U8 视频链接,可以看到 #EXTINF
tag 下会的链接都是 png
格式的后缀,「那么这种 png 后缀,会不会影响视频播放呢」?
「答案是不会,因为 FFmpeg 里播放并不是认定后缀」,而是通过读取每个 #EXTINF
tag 链接的二进制 Header,最终匹配它们的封装和编解码格式。
❝
所以其实在 M3U8 里
#EXTINF
tag 下的链接后缀并不重要,可以是 png 或者 bmp ,甚至你写 txt 也是可以的,重点其实是包本身的编码。❞
那么如果这个视频链接,真的是一个图片呢?如下图所示,可以看到这个 png 本身就是一个完整的图片,不过这个图片的大小和它本身的质量并不匹配,毕竟这样一个图片不可能高达 1.9 MB 。
如下图所示,我们查看这张图片的二进制,可以看到文件的 Header 确实是 PNG ,但是后面还有类似 FFmpeg Service 这样的描述,可以确实这就是一个伪装成真实 PNG 格式的视频文件。
从二进制字节看可以发现这就是一个 TS 封装的视频文件,因为在它的二进制代码里,有「以 0x47
开头,长度为 188 字节,并且通过 0xFF
进行填充的规律 packet 存在」。
❝
后面我们会详细解释。
❞
「那么这个 PNG 可以正常播放吗?答案是可以的」。那为什么明明是 PNG 的 Header ,却可以被解析成视频?
首先 FFmpeg 在播放前,会根据前面提到的 0x47
/ 188 这个特征去识别这是一个 TS 封装的视频,之后在 mpegts.c
的对应封装处理逻辑里,会针对识别 0x47
作为包的起始位置去解析,所以 PNG 包部分会被忽略。
❝
「
0x47
是一个 TS 包的固定 header ,一般一个 TS 包是 188 字节,不够长度一般会用0xFF
填充」,而 FFmpeg 会针对每个格式去做识别,计算它们的score
,根据每种格式的score
决定它可能是什么格式,比如mpegts.c
里是mpegts_probe
函数,它通过analyze
函数就会找到0x47
起始做一系列的判断。❞
另外还一个叫 mpegts_read_header
的函数会读取数据头信息,比如解析出 TS 流当中的数据包大小,节目信息,PMT表,Video PID,Audio PID 等等,这些也是 TS 流播放的重要依据。
而在 mpegts.c
里最重要的 read_packet
函数也是,读取的时候会读取 TS_PACKET_SIZE
(188)的大小,然后判断包的首字节是不是 0x47
,如果不是就通过 mpegts_resync
重新同步一下去尝试寻找 0x47
。
「可能这时候细心的你已经发现了「盲点」,前面 PNG 的 Header 二进制里不就是 89 50 4E 47
吗?这里不也是有 0x47
?,这种情况下 mpegts.c
在解包的时候不就会「错乱」了吗」?
如下图所示,因为如果从图片的 0x47
开始算, 以 188 的包长度计算,下一个包不就找不到 0x47
了吗?
「答案是会,但是有方法保证它不会」。这就不得不提 mpegts_resync
函数,在前面截图的代码里有 if ((*data)[0] != 0x47)
时会调用 mpegts_resync
,如下代码所示,它的关键作用是:
-
首先通过
avio_seek
往回移动-FFMIN(seekback, pos)
的大小,对应到上面的图片,就是把指针移回读取上图黄色标注的数据开始的 FF 位置,也就是还没读取这一块数据的时候。 -
在
for
循环里通过avio_r8
让指针往前逐步读字节,当遇到0x47
就停下来,让指针回到0x47
,然后调用reanalyze
重新分析数据。 -
可以看到,在经过
mpegts_resync
函数同步之后,指针回重新被同步到上图黄色标准里的0x47
位置,再重新执行ffio_read_indirect
打开一组 188 的数据,从而让 TS 包解析回归正常
/* XXX: try to find a better synchro over several packets (use
* get_packet_size() ?) */
static int mpegts_resync(AVFormatContext *s, int seekback, const uint8_t *current_packet)
{
MpegTSContext *ts = s->priv_data;
AVIOContext *pb = s->pb;
int c, i;
uint64_t pos = avio_tell(pb);
avio_seek(pb, -FFMIN(seekback, pos), SEEK_CUR);
//Special case for files like 01c56b0dc1.ts
if (current_packet[0] == 0x80 && current_packet[12] == 0x47) {
avio_seek(pb, 12, SEEK_CUR);
return 0;
}
for (i = 0; i < ts->resync_size; i++) {
c = avio_r8(pb);
if (avio_feof(pb))
return AVERROR_EOF;
if (c == 0x47) {
avio_seek(pb, -1, SEEK_CUR);
reanalyze(s->priv_data);
return 0;
}
}
av_log(s, AV_LOG_ERROR,
"max resync size reached, could not find sync byte\n");
/* no sync found */
return AVERROR_INVALIDDATA;
}
所以上述这个 PNG 图片尽管会有一点「冗余」的错误数据,但是最终还是可以被 mpegts.c
正常解析,从而播放。
「所以 M3U8 里有图片链接,是因为「劳动人民」需要「免费 CDN」,而链接后缀和前置格式不大会影响视 TS 封装的播放,现有的 IJKPlayer 封装的 FFmpeg 就支持播放伪装成图片的 TS 视频链接。」
4、正文
对,这里开始才是正文,前面的 png 操作还算是比较「常规」,但是接下来的一些特殊案例,就是如果你不适配,大概就播放不了的场景。
因为把 TS 伪装成图片是一种「非标准」的做法,自然就存在各式各样的「骚操作」,例如下面这个 M3U8,就包含有 bmp、png、ts 三种格式的链接。
❝
最有趣的事,尽管链接上写的时候 png ,但是实际这个链接的 header 描述里也是一个 bmp ,然后这个 bmp 的数据还是还被 AES-128 加密。
❞
我们下载这个 M3U8 里其中一个 bmp,如下图所示,通过大小可以很明显看到它也是一个伪装成 bmp 的视频链接,但是它有点特殊,因为:「它经过了 M3U8 的 AES-128 加密,同时它的二进制组成也有些特殊」。
如下图所示,查看这个加密的 bmp 文件的二进制,可以看到从 Header 看它确实是 bmp 格式,同时因为 TS 视频的数据被 AES-128 加密了,所以此时我们看不到原始的 TS 封装信息,但是因为它所在的 M3U8 里有可用的加密 key,所以我们可以直接通过一些工具来下载和解密。
比如我们可以通过开源的 M3U8-Downloader 来下载得到一个解密后 bmp,如下图所示是上面的 bmp 文件经过下载解密之后的二进制格式,可以看到此时已经可以看到一些我们熟悉的信息,比如 H264 的描述,比如 0x47
和大量 0xFF
填充。
另外可以看到,此时的 BMP 因为 「AES-128」的解密作用下,此时的 bmp 已经不是一个正常的图片格式,无法以图片的形式打开查看。
❝
因为 Header 没了。
❞
同时,此时的伪装 TS 封装在解密后依然不是 0x47
开头,所以如下图所示,视频在播放时,会找到我们蓝色选中第二行里的 0x47
的位置,然后开始往后读取一个 188 长度的 TS 包进行解析播放。
「但是问题来了,此时播放出来的视频,会出现没有画面的情况」。为什么会有这种情况?这就要说到前面提到的 mpegts_resync
。
因为从第一个 0x47
开始读取,那么第二个包就会是上图画出来的红色部分,因为不是 0x47
开头,所以会通过 mpegts_resync
函数找到绿色的 0x47
,然后继续往后读取。
「这样乍一看没有什么问题,但是其实忽略了黄色部分的 0x47
」 ,如果仔细去数,你就会发现黄色部分的 0x47
到绿色的 0x47
,恰好就是 188 的长度,所以其实这部分应该是一个完整的 TS 包,并且是很重要的一个包,也是因为它没被正确读取,所以导致了播放出现没有画面的情况。
那么这个包是什么,为什么它会这么重要?
【学习地址】:FFmpeg/WebRTC/RTMP/NDK/Android音视频流媒体高级开发
【文章福利】:免费领取更多音视频学习资料包、大厂面试题、技术视频和学习路线图,资料包括(C/C++,Linux,FFmpeg webRTC rtmp hls rtsp ffplay srs 等等)有需要的可以点击1079654574加群领取哦~
5、TS & PAT & PMT
我们前面会出现画面无法被解析,其实就是因为我们说被「丢失」的包导致的,它恰好就是 TS 里的 PAT 包 :
-
PAT (Program Association Table)主要的作用就是指明了 PMT 表的 PID 值
-
PMT(Program Map Table)主要的作用就是指明了音视频流的 PID 值
-
PID 确定 TS 包中的数据属于什么类型
「所以由于 PAT 没有被正确的解析,所以没有得到正确 PMT,从而没有找到正确的视频编码包的 PID,所以出现了没有画面的情况」。
这也是为什么 PAT 包那么重要,简单来说,正常情况下解析一个 TS 封装的流程为:
❝
TS 流里每个 packet 一般都是 188 个字节,解析 TS 需要先解析每个 packet ,然后需要从一个 packet 中解析出 PAT 的 PID,PAT 的 PID 一般为 0,然后从 PAT 包中解析出 PMT 的 PID,再根据 PMT 的 PID 找到 PMT 包,在从 PMT 包中解析出 Video 和 Audio 的PID,然后根据PID找出相应的音视频包。
❞
如下图所示,一般 TS 包的 header 主要由 4 个字节组成,其中 sync_byte
是一个字节(8b),固定为 0x47
,而 PID
是一个 13b 的二进制,一般 PID 为 0 的 packet 就会被认定为是 PAT。
比如前面被我们忽略的 47 40 00 10
它对应二进制是 0100 0111 0100 0000 0000 0000 0001 0000
,按照上面拆分:
sync_byte(1B) | 0x47 / 0100 0111 |
---|---|
transport_error_indicator (1b) | 传输错误指示符,通常都为 0,这里也是 0 |
payload_unit_start_indicator(1b) | 负载单元起始标示符,一个完整的数据包开始时标记为1,这里恰好是 1 |
transport_priority(1b) | 传输优先级,0为低优先级,1为高优先级,通常取 0,这里恰好是 0 |
PID(13b) | 这里恰好就是 0 0000 0000 0000,也就是 0,「PID 为 0 就说明这个 TS 包是 M3U8 里的 PAT 包」 |
Transport_scrambling_control(2b) | 传输加扰控制,00表示未加密,这里是 00 |
Adaptation_field_control(2b) | 00保留;01 为无自适应域,这里为 01 |
Continuity_counter(4b) | 表示该计数器为 0,PID 相同的包的计数因该是连续,递增计数器,从0-f,起始值不一定取 0,但 PID相同的包计数器必须是连续,这里是 0000 |
所以可以看到,被我们忽略的 47 40 00 10
开头的包,恰好就是最重要的 PAT 包,这也是为什么这个视频播放是会没有画面的原因,因为最终对应视频留的 PID 没有被解析出来。
然后我们再去看这个 PAT 表里的数据,如下图所示是 PAT 的内容部分的结构示意图,我们主要需要的是 Program Number(PMT) 的 PID ,在 N loop
部分前有 64b ,也就是 8 个字节,后面 N loop
部分才是开始循环的实际节目表,其中一个节目是 32b ,也就是 4 个字节,最后 CRC 结束标志为 32b ,也就是 4 个字节。
所以回到二进制里,黄色部分就是需要固定字节,然后红色下划线的 00 01 EF FF
就是节目表,「该 PAT 里只有一个节目单,其中 00 01
是 number ,也就是节目 number 为 01 , PID 是 FFF
, 也就是该节目的 PID 是 4095」。
❝
黄色前还有一个 00 属于 adapter 区的,因为前面 Adaptation_field_control 是 01。
❞
然后我们在看下一个 TS 包,如下图红色部分,它的 Header 是 47 4F FF 01
,对应的二进制就是 0100 0111 0100 1111 1111 1111 000 0001
,那么它的 PID 就是 0 1111 1111 1111
这 13 位,也就是 FFF
(4095)。
所以,到这里一切都清晰了,「因为忽略的是 PAT 包,所以会导致后面这个 PMT ID 4095 不被解析为特殊的 TS 包,从而获取不到对应的节目数据」。
那 PMT 如何读取出流信息?如下图所示是一个 PMT 的 TS 包结构,我们直接看 N loop
部分,一个 loop 大概要 40b ,也就是 5 个字节,其中我们主要是 stream type 和 elementary PID。
其中 stream type 对应的字节代表了流的具体类型,比如 0x0f
就是 aac 音频, 0x1b
就是 h264 的视频,所以 TS 里可以通过 PMT 得到需要当然封装具体的音视频解码格式。
那么回到二进制里,如下图所示,结合 PMT 的结构,可以看到有两个 stream ,其中 stream_type h.264 编码对应 0x1b
,aac 编码对应 0x0f
,而 E100
: 111[0 0001 0000 0000] ,后 13 位也就是 256,所以视频的 PID 是 256 ,也就是 h264 的视频 pid 是 256 ,而 acc 的音频的 PID 是 257。
我们再看下一个包,可以看到这个包里有 264 的描述,它的 header 是 47 41 00 31
,也就是 0100 0111 0100 0001 0000 0000 0011 0001
,对应的 PID 就是 0 0001 0000 0000
,也就是 256,这就和前面的 PMT 继续对应上了。
image-20230331160122249
更直观一点,我们简单写一个 python 脚本,输出下所有的 PID ,可以看到除了 0 和 4095 ,剩下的就都是 256 和 257 这样的流数据包,所以到这里就可以完全对应上: 「PID 为 0 的包是 PAT ,通过 PAT 得到 PMT 的 PID ,找到 PID 就可以得到 stream type 和 stream pid ,然后就可以找到对应的 stream pid 的 TS 包去读取音视频流数据」。
# 导入需要的模块
import sys
# 定义常量
TS_PACKET_SIZE = 188
# 打开 TS 文件
with open(sys.argv[1], 'rb') as ts_file:
pids = []
# 循环读取 TS 数据包
while True:
ts_packet = ts_file.read(TS_PACKET_SIZE)
if not ts_packet:
break
# 提取 PID 并输出
pid = (ts_packet[1] & 0x1F) << 8 | ts_packet[2]
pids.append(pid)
print(pids)
❝
所以前面的的「奇奇怪怪」的编码,恰好会让 FFmpeg 忽略掉 PAT 数据,从而导致加载到节目表而导致没有画面。
❞
6、开始适配
基于这个逻辑,我觉得应该是首先解决 PAT 包被忽略的问题,所以如下代码所示,在 mpegts.c
的 read_packet
里我添加了 if((*data)[0] == 0x47 && (*data)[188] != 0x47)
的判断,如果包是以 0x47
开头,但是下一个包不是 0x47
,那么就在包内重新去寻找一个能「首尾相接」的 0x47
TS 包,然后重新 ffio_read_indirect
。
static int read_packet(AVFormatContext *s, uint8_t *buf, int raw_packet_size,
const uint8_t **data)
{
AVIOContext *pb = s->pb;
int len;
len = ffio_read_indirect(pb, buf, TS_PACKET_SIZE, data);
if (len != TS_PACKET_SIZE)
return len < 0 ? len : AVERROR_EOF
for (;;) {
len = ffio_read_indirect(pb, buf, TS_PACKET_SIZE, data);
if (len != TS_PACKET_SIZE)
return len < 0 ? len : AVERROR_EOF;
/* check packet sync byte */
if ((*data)[0] != 0x47) {
/* find a new packet start */
if (mpegts_resync(s, raw_packet_size, *data) < 0)
return AVERROR(EAGAIN);
else
continue;
} else {
/ / / 添加的部分 / / /
if((*data)[0] == 0x47 && (*data)[188] != 0x47) {
for(int i = 0; i < TS_PACKET_SIZE; i++) {
if((*data)[i] == 0x47 && (*data)[i+188] == 0x47) {
avio_seek(pb, i, SEEK_CUR);
avio_seek(pb, -TS_PACKET_SIZE, SEEK_CUR);
reanalyze(s->priv_data);
len = ffio_read_indirect(pb, buf, TS_PACKET_SIZE, data);
if (len != TS_PACKET_SIZE)
return len < 0 ? len : AVERROR_EOF;
return 0;
}
}
} else {
break;
}
}
}
return 0;
}
重新打包之后,它确实可以解析出画面了,而画面却出现花屏,花屏肯定是播放过程中出现了丢包,导致 IBP 帧解析出现异常,所以我们上面的写法存在问题。
而恰巧这时候我们发现,前面 bmp 填充部分的长度,恰好也是 188 字节,这种巧合让我不禁怀疑,是不是其实我们不需要忽略这个前置字节?
所以我们直接无视 0x47
的开头,直接读取解析(因为后续一些解析逻辑也会判断 0x47
,所以这里我们强行无视),然后重新打包之后,我们惊喜的发现可以播放了,也不会花屏了,但是又有新的问题出现:一个 TS 播放完了它不会切换到下一个 TS 。
然后我们再去看这个 TS 文件的末尾,原来文件末尾填充了大量的 0x00
字节,从而导致读取时无法正常触发结束标识。
所以我们再次简单修改下,当遇到 0x00
开头的包时,我们用 mpegts_resync
函数处理一下,如果找不到正常的包,我们就可以直接返回 AVERROR(EAGAIN)
结束这个 TS 的播放。
到这里我们可以看到这个 M3U8 可以正常播放了,对应的 stream 也可以被解析出现,虽然这里的修改很简单粗暴,但是这样的修改,就可以在兼容正规协议的情况下,也可以适配到这种「民间非标」支持,重点是通过这个例子,可以形象的普及 TS 封装里的基础概念。
❝
以上修改需要调整 FFmpeg 的源码,然后重新构建动态库。
❞
另外这个格式的文件的 ExoPlayer 下也是无法被播放,主要是因为前面说的读错了 0x47
的包头位置,「因为后面 0xFF
太多,会导致超过两个 TS_PACKET_SIZE
的判断」,从而抛出异常,如果要想 ExoPlayer 也支持播放,可以从这点切入去修改源码。
❝
另外你会发现浏览器是可以播放这类链接,因为如
hls.js
在这方面的检测没有那么严谨。❞
7、最后
上述播放调整是基于 IJKPlayer 上的 FFmpeg 版本进行,虽然如今 IJKPlayer 已经没有维护,但是基于 IJKPlayer 做一些调整优化还是很方便。
当然,因为 IJKPlayer 整体构建环境比较老,所以如果你重新构建编译,可以参考 GSYVideoPlayer 下的 编译 IJKPlayer so 相关支持 ,目前文档已经支持到 Mac M1/M2 下的环境 。
还有调试这类 TS 文件,个人建议使用本地播放 M3U8 来进行测试,这样我们可以更方便在播放时动态修改本地的 TS 二进制字节,例如可以修改 M3U8 为下面的文件格式。
当然,如果播放本地 M3U8 遇到了下方类似的错误提示,可以参考下方代码添加 "allowed_extensions", "ALL"
到 IJKPlayer 里来临时允许播放。
(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "protocol_whitelist", "crypto,file,http,https,tcp,tls,udp,rtmp,rtsp"); (IjkMediaPlayer.OPT_CATEGORY_FORMAT, "allowed_extensions", "ALL"); (IjkMediaPlayer.OPT_CATEGORY_PLAYER, "enable-accurate-seek", 1);
好了,本篇到这里就结束了,通过讲解适配对 TS 封装一系列的骚操作,相信大家对 TS 的一些基础概念都有了一定的认识,最后总结一下;
-
「对于 TS 解码,视频的后缀格式和封装 header 并不会实际影响播放效果」
-
「TS 封装是以
0x47
开头,188 字节长度,会用0xFF
做冗余填充的包格式」 -
「TS 封装里 PID 是唯一标识,而 PID 为 0 的包是 PAT 包」
-
「PAT 包很重要,因为通过 PAT 包才能找到 PMT 包,找到 PMT 包才能正确获取音视频的 PID」
-
「FFmpeg 的
mpegts.c
里,mpegts_resync
函数可以帮助你重新同步到 TS packet 的包头」。