苏苏的博客

简约至极

MSE播放器与P2P

由于疫情原因,这个寒假格外长,闲赋在家,看了看之前的TODO LIST ,一个YouTube的播放器问题吸引了我,趁时间,捡起来研究研究,于是便有了这片文章

要想做一个YouTube播放器,首先是API部分,这个有官方的API,不用我们自己爬数据,但是有请求量限制,不过这个也好说,加一层缓存,既提高了性能也降低了源请求量,等流量真的大了,还可以做多密钥负载均衡.

https://developers.google.cn/youtube/v3/docs/

提供的API很多,但是我们只需要实现自己需要的就OK了.

总体来说就是加密钥,处理缓存,中转代理,我们选用GO语言来编写,这个处理起来十分容易.不得不说GO语言的网络库封装的非常好用.

有了API下面就是前端界面,我们直接VUE全家桶搞起来,UI界面懒得慢慢打磨直接用个muse-ui框架,看起来还是很简洁的.

播放地址解析

播放器部分是我们的重点.

Data API 是不提供资源的播放地址的,播放地址也是敏感信息,我们需要自己实现.

根据页面分析和抓包,再参考广大互联网群众的智慧,我们完成了 https://github.com/suconghou/youtubevideoparser 这个库

根据视频ID解析出播放地址,但是播放地址由于某种原因我们是无法直接播放的,所以这需要一个合适的中转.

如果中转整个视频,CDN很不友好,文件太大,CDN节点也不会为我们缓存.并且分析播放器我们也知道官方的播放器也不是一整个加载的,而是分片加载.

如何分片是个问题,因为一旦采取了分片,意味着我们要使用MSE去处理视频.

尝试了一下随便range分片结合mse,实践证明是行不通的.

那官方的播放器是怎么做的呢,很明显关键点在这个分片的算法,不是随意分片的.

但是找遍了源码也没找到这个分片的规则定义在哪?

最后突然想到突破点可能是在视频文件本身,顺着这个思路问题解决.

中间浪费了大量时间.这里不得不吐槽一下广大科普MSE的文章.

大部分文章都提到了fragment mp4,都知道range请求,一片一片去addSourceBuffer

但是对于range的划分原则,.mpd索引的原理和sidx segment丝毫未提.

换句话说就是不是你想addSourceBuffer就能addSourceBuffer的,你必须取到一块完整的fragment才能addSourceBuffer,如何从视频中知道哪一字节至哪一字节是一个完整的fragment

我们需要从视频的sidx segment中提取,.mpd索引就是预先根据sidx segment分析出来的结果.

持有mpd索引,我们播放时就不需要再去下载视频中的sidx segment现场分析了.

如何解析sidx segment

关于MSE的基本使用,这里不再赘述,网上入门的文章太多了,这里主要展开讨论一下mp4文件与sidx segment,无论是mp4还是fragment mp4,我们都可以将mp4文件拆解为基本的box

常见的box类型有

  • ftyp 确定文件的类型
  • moov 保存了媒体的时间信息、trak信息和媒体索引等。
  • mdat 所有媒体数据统一存放在mdat中
  • free
  • sidx
  • moof

一个moov box中,可能还会嵌入其他的box,这个我们现在用不到,可以不用考虑.

我们只看顶层的box,使用qtfaststart -l file.mp4 可以查看他的顶层box

对于普通的mp4

ftyp (32 bytes)
moov (135554 bytes)
free (8 bytes)
mdat (35973570 bytes)

对于fragment mp4

ftyp (24 bytes)
moov (644 bytes)
sidx (236 bytes)
moof (1816 bytes)
mdat (160194 bytes)
moof (1816 bytes)
mdat (159649 bytes)
moof (1816 bytes)
mdat (159874 bytes)
moof (1816 bytes)
mdat (159595 bytes)
moof (1816 bytes)
mdat (159878 bytes)
moof (1816 bytes)
mdat (159986 bytes)
moof (1816 bytes)
mdat (159591 bytes)
moof (1816 bytes)
mdat (159778 bytes)
moof (1816 bytes)
mdat (159701 bytes)
moof (1816 bytes)
mdat (159727 bytes)
moof (1816 bytes)
mdat (159990 bytes)
moof (1816 bytes)
mdat (159586 bytes)
moof (1816 bytes)
mdat (159737 bytes)
moof (1816 bytes)
mdat (159811 bytes)
moof (1816 bytes)
mdat (159974 bytes)
moof (1816 bytes)
mdat (159582 bytes)
moof (2828 bytes)
mdat (126902 bytes)

只有一个moof+mdat的组合才能被addSourceBuffer

sidx box 中记录了各个moof+mdat组成的segment的精确byte position

我们只需要考虑解析sidx box里的数据,其他各个box的解析,不属于本文范畴.

这里有一个工具可以查看 https://archive.codeplex.com/?p=mp4explorer

我们的主要任务是解析sidx box然后就能得出各个range分片了.

根据规范定义

https://dashif-documents.azurewebsites.net/Guidelines-TimingModel/master/Guidelines-TimingModel.html#addressing-indexed-indexstructure

其格式描述如下

aligned(8) class SegmentIndexBox extends FullBox('sidx', version, 0) {
  unsigned int(32) reference_ID;
  unsigned int(32) timescale;

  if (version==0) {
    unsigned int(32) earliest_presentation_time;
    unsigned int(32) first_offset;
  }
  else {
    unsigned int(64) earliest_presentation_time;
    unsigned int(64) first_offset;
  }

  unsigned int(16) reserved = 0;
  unsigned int(16) reference_count;

  for (i = 1; i <= reference_count; i++)
  {
    bit (1) reference_type;
    unsigned int(31) referenced_size;
    unsigned int(32) subsegment_duration;
    bit(1) starts_with_SAP;
    unsigned int(3) SAP_type;
    unsigned int(28) SAP_delta_time;
  }
}

实现我们的解析之前,我们需要对dataview的getUint8,getUint16,getUint32等有一个了解,以及位运算如何实现getUint24等,这是对于mp4文件格式所需要做的解析,对于webm格式,是完全不一样的,在webm中,类似于sidx的被称作Cues,解析起来稍微复杂一些.

EBML解析器

整个webm文件,是按照一个叫作EBML(Extensible Binary Meta Language)文件格式的规范存储的,其是一个Matroska的子集

参考

https://www.webmproject.org/docs/container/

https://www.matroska.org/technical/diagram.html

EBML格式不像JSON格式那样易于理解,其是一个二进制格式,并且引入了一个概念vint(Unsigned Integer Values of Variable Length)

vint是可变长度无符号整型,比传统32/64位整型更加节省空间。vint有三个部分构成: VINT_WIDTH,VINT_MARKER,VINT_DATA。VINT_MAKRER指的是二进制数据中第一个1的位置;VINT_WIDTH指的是在VINT_MARKER之前的0的个数(可以是0),VINT_WIDTH+1表示对应的vint占用的字节数目

具体可参考其规范 https://github.com/cellar-wg/ebml-specification/blob/master/specification.markdown#variable-size-integer

按照这些规范,我们终于写成了我们的解析库 https://github.com/suconghou/mediaparse ,能够从视频文件中提取出所有的分片和其对应的起始播放时间,最终实现了mse播放

处理MSE播放的问题

解决意外中断的缓存问题

我们下载使用的是之前写的一个多线程下载器 https://github.com/suconghou/fastloadjs , 为了cdn缓存友好,我们不使用 http的range header , 而是将range信息放在了path里,后端由于是中转的数据,并且是流中转,不是下载完然后再响应,本地测试时,速度较慢,不能保证每次都能顺利中转完数据,尽管这一分片的数据可能不到1MB;但是我们为了前端缓存和CDN缓存,每次响应的都是强缓存.如果中转过程中由于break pipetimeout等原因,后端中转流中断,但是响应头已是缓存,前端的重试机制可能都是拿到的缓存.所以我们加了一个响应大小和请求期望大小的检测,如果对不上,将会抛出short read error,然后重试的时候利用fetch的缓存策略,force-cache/relaod来控制是否跳过缓存.这个时候fetch的缓存策略显的太棒了.

SourceBuffer的限制

addSourceBuffer并不是无限制的;当我们不断调用addSourceBuffer后将会得到QuotaExceededError,这就需要我们对缓存区有个控制策略,不能无限缓冲,也不能只addSourceBuffer而不sourceBuffer.remove,如果被remove了,这一段的视频就无法播放了.所以我们的策略是如果缓存区满了则停止缓冲,如果缓冲区不够用,则将当前播放点之前的数据释放.如果用户seek到之前的播放时间点时,再从内存中cachefill回来.中间不会有网络下载.我们所有的buffer全是存在内存中的,这个我们后面还会用到.所以播放1080P长视频时需要确保你的内存别太小.

部署到GAE

本来打算将此代码部署在GAE配合cloudflare的.但是发现GAE不让新增部署了,只有一个原先的python项目还能更新代码.一气之下,写了个python版的提供API, https://github.com/suconghou/u2proxyapi

视频解析也复刻了一版 python的 https://github.com/suconghou/u2parse

配合cf workers 中转流量 https://github.com/suconghou/u2worker

后来发现这个方案不太好,cf workers 不解析只中转有部分视频中转失败,可能是解析时的IP和去下载资源时的IP不一致导致的.于是干脆为cf workers也写了一个视频解析 https://github.com/suconghou/ujparse ,这样cf workers就能完全自主解析和中转了.

部署到vercel

再后来GAE更新了策略,没有信用卡都不让更新代码了,遂弃坑;发现了vercel,整合了之前的go代码和前端代码来个自动部署,十分方便 https://github.com/suconghou/ustream

添加P2P支持

基本功能完成,但是我还想玩点新花样,为视频加速;于是便开始设计P2P传播方案.

我们的P2P不同于普遍意义上的点对点链接.而是一个多对多连接.每一个用户都保持对N个用户的链接,可以向他们发送广播,由于P2P的建立必然是有一方主动发起链接.另一方被动等待.所以我们需要制定哪些用户主动发起链接,哪些用户是等待着.我们引入了ws用于节点发现和信令交换

每当客户端链接后,向其他已在线用户广播此用户上线. 然后又将所有已在线用户发送给此用户.

此用户将新建N个RTCPeerConnection实例,等待被连接.我们的原则是新上线的用户始终是等待者.其他已在线用户发现新用户上线后都会主动发起链接.如果已上线的用户发生刷新则他又会变成新上线,原先他主动链接的那些用户又会主动联系他.

按照此模式,我们写成了 https://github.com/suconghou/libwebrtc 和一个信令服务器 https://github.com/suconghou/signalserver 为提高P2P的成功率,我们还编译部署了 coturn 用作stunserver

https://github.com/suconghou/fastloadjs 融入了此P2P引擎,制定了一些发现和传输协议在P2P节点间共享视频分片数据.

需要注意的是,只有播放资源是同一个资源时,两端的数据才会共享.由于Chrome播放的是webm资源,Firefox播放的是mp4资源,他们之间是不会共享的. 播放的是不同清晰度的视频的两个客户端也不会共享.

完.