如果看到一个需要播放的视频链接显示是一张图片,你会不会感觉有点懵?如果这张图片写着 png,然后实际格式是 bmp ,你会不会更懵了?如果这个 bmp 还做了加密篡改呢?今天我们要聊的就是这样一个充满骚操作的音视频故事。
❝
「本篇主要是想通过这个「故事」,更直观地给大家普及 M3U8 里的一些基础常识」。
❞前言
如果你经常接触音视频,那么对于 M3U8 应该不会陌生, M3U8 简单来说就是 HLS(HTTP Live Streaming) ,指的是苹果开发的基于 HTTP 协议的流媒体解决方案,它可以在普通的 HTTP 的应用上直接提供点播和直播的能力。
在 HLS 里会将视流文件切分成小片(ts)并建立索引文件(M3U8),一般如下图所示,首先会有一个 M3U8 文件,然后对应在#EXTINF的 tag 下会有很多 TS 格式切片,这应该是我们认知中的 M3U8 文件的标准。
❝
详细讲解 M3U8 的这里就不详细展开,感兴趣的可以看之前分享过的探索移动端音视频与GSYVideoPlayer之旅。
❞
「那么,如果在 M3U8 里的不是 TS 链接,而是 png 链接或者 bmp 链接会是什么情况」?今天的主题就是探讨如何适配带有图片的非标准 M3U8 视频。
❝
「我们不鼓励「非标」,而是通过「非标」的适配来做科普」。
❞为什么 M3U8 里会有图片?
首先,标准的 HLS 协议里 M3U8 文件内肯定是 TS 的切片链接,「那么为什么会有 png/bmp 之类的图片链接存在?或者说,为什么会是图片链接」?
这就不得不说「劳动人民的智慧」,众所周知,如果想让一个视频加载更快,那么最简单的办法就是给视频上 CDN ,但是碍于某些团队或者个人「囊中羞涩」,所以开始有人瞄上了公共图床的 CDN ,然后再结合 M3U8 的特性,一套民间的「免费」 视频 CDN 潜规则就这样悄然流行起来。
❝
如果你想将一个完整视频伪装成图片上传公共图床明显不现实,因为体积太大,很多图床也会对图片的大小做限制,但是如果是 M3U8 ,那么就可以把「视频分解成无数 TS ,再把 TS 伪装成图片分批上传,这样就可以给视频「依附」上 CDN 的能力」。
❞TS & 图片
如下图所示,这就是一个「非标」的 M3U8 视频链接,可以看到#EXTINFtag 下会的链接都是png格式的后缀,「那么这种 png 后缀,会不会影响视频播放呢」?
「答案是不会,因为 FFmpeg 里播放并不是认定后缀」,而是通过读取每个#EXTINFtag 链接的二进制 Header,最终匹配它们的封装和编解码格式。
❝
所以其实在 M3U8 里#EXTINFtag 下的链接后缀并不重要,可以是 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,如下代码所示,它的关键作用是:
/* 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 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 byten");
/* no sync found */
return AVERROR_INVALIDDATA;
}
所以上述这个 PNG 图片尽管会有一点「冗余」的错误数据,但是最终还是可以被mpegts.c正常解析,从而播放。
「所以 M3U8 里有图片链接,是因为「劳动人民」需要「免费 CDN」,而链接后缀和前置格式不大会影响视 TS 封装的播放,现有的 IJKPlayer 封装的 FFmpeg 就支持播放伪装成图片的 TS 视频链接。」
正文
对,这里开始才是正文,前面的 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 包,并且是很重要的一个包,也是因为它没被正确读取,所以导致了播放出现没有画面的情况。
那么这个包是什么,为什么它会这么重要?
TS & PAT & PMT
我们前面会出现画面无法被解析,其实就是因为我们说被「丢失」的包导致的,它恰好就是 TS 里的 PAT 包 :
「所以由于 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 数据,从而导致加载到节目表而导致没有画面。
❞开始适配
基于这个逻辑,我觉得应该是首先解决 PAT 包被忽略的问题,所以如下代码所示,在mpegts.c的read_packet里我添加了if((*data)[0] == 0x47 && (*data)[188] != 0x47)的判断,如果包是以0x47开头,但是下一个包不是0x47,那么就在包内重新去寻找一个能「首尾相接」的0x47TS 包,然后重新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在这方面的检测没有那么严谨。
❞最后
上述播放调整是基于 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 的一些基础概念都有了一定的认识,最后总结一下;
娜娜项目网每日更新创业和副业教程
网址:nanaxm.cn 点击前往娜娜项目网
站 长 微 信: nanadh666