手把手教你实现一个FLV直播播放器

播放器

随着网络与流媒体的飞速发展,直播已经深入到我们每个人的生活中了。但是因为原生的video 元素只支持几种固定的格式,在web上实现直播变成了一个困难问题。随着HTML5 提出MSE(Media Source Extensions),让video元素可以支持播放js处理过后的视频流,这给我们带来了在web上实现直播的方案。当前web浏览器实现直播的方式主要有两种,一种HLS直播,另一种便是本文要讲的FLV直播。接下来我们来看一下FLV直播技术实现的流程图:

流程图

image.png

由流程图可知,FLV直播可以概括为四大步:

  1. Loader:与服务器建立http长链接,进行拉流,并将拉取到的数据存储起来。
  2. Demux: 将拉取到的数据按照FLV的格式进行解封装,解出h.264裸码流。
  3. Remux: 将解封装后的数据按照Fmp4的格式进行封装,生成Fmp4流。
  4. Play: 将Fmp4通过MSE的append给video,进行播放。

接下来我们讲一下这四步具体实现:

技术实现

Loader

用来根据流地址获取到音视频流的buffer数据,并对其进行预处理,转换成Unit8Array的格式。

拉流获取buffer

首先,实现直播的第一步是我们要从服务端获取到直播的数据。FLV拉流的原理就是通过与服务端建立http长链接,然后流式的拉取封装成.flv格式的音视频数据。这里需要注意的是长链接的数据读取是需要递归调用的:

const buffer = [];
let Reader = null;
// 拉取
const fetch = (url) => {
    window.fetch(url, {
      method: 'GET',
      mode: 'cors',
    }).then((res)=>{
      Reader = res.body.getReader();
      // 读取
      readerBuffer();
    }).catch((err)=>{
       console.log('拉流失败',err)
    })
}

// 拉流
const url = 'http://xxxxxx.flv';
fetch(url`);

buffer数据预处理

// 读取
const readerBuffer = () => {
    if(!Reader) return;
    Reader.read().then((res)=>{
       // 这里读取到的数据是二进制数据,数据类型是ArrayBuffer
       console.log('读取到数据',res.value)
       const u8a = new Uint8Array(res.value)
       buffer.push(u8a);
       if(res.done){
         console.log('读取完毕')
         return;
       }
        //递归读取
       readerBuffer();
    }).catch(()=>{
       console.log('读取失败',err)
    })
}

上节通过read() 获取到的数据类型是ArrayBuffer,这个类型是js用来存储二进制数据本身的,无法读写,我们需要用Unit8Array来对其进行读写。由上述代码我们可以看到我们将获取的数据用一个Uint8Array进行了存储 接下来我们介绍一下Uint8Array这个数据类型

Unit8Array介绍

  • Unit8 表示用一个10进制的数表示一个无符号的8位二进制数据,那么Unit8Array就是表示一个数组,他的每一项是一个Unit8。通过Unit8Array(MDN),就可以进行读写二进制数据。
  • 当然除了Unit8Array 还有Int8Array, Unit16Array 等等类型 ,因为通过他们可以表示二进制数据,所以我们称其为视图类型。

以下是Unit8Array的简易调用:

// 简单介绍一下u8a,
// u8a可以接受一个ArrayBuffer对象 或者数组
const u8a1 = new Uint8Array(buffer);
const u8a2 = new Uint8Array([23,16,7,6]);
// 那么u8a2表示的是什么呢?
Number(23).toString(2) // '10111' 
Number(16).toString(2) // '10000'
Number(7).toString(2)// '111'
Number(6).toString(2) // '110'
// 每个补足8位然后加起来,我们可以得到一个二进制字符串,便是u8a2所表示的二进制值
'00010111 00010000 00000111 00000110'

Demux

用来对流数据进行解复用/ 解封装。这一步要做的就是,读取我们拉取到的二进制数据,然后按照Flv的格式文档进行解读。

FLV格式介绍

FLV解封装

上边我们了解了FLV的标准文档,接下来就是根据文档对FLV进行解析:

FLV Header解析

现在我们举例,拉一个流来演示一下,由下图可知,我们第一次read读取到数据转换成Unit8Array有512419位,Unit8Array一位表示8位二进制即8bits = 1byte,即一字节。

image.png

// 我们从FLV文档可知,前9字节表示FLV Header。其中前三字节为Signature,而我们读取到数据前三字节为70,76,86。通过ascii码解析,得知70,76,86 正好表示FLV ,很完美
String.fromCharCode(70) // 'F'
String.fromCharCode(76) // 'L'
String.fromCharCode(86) // 'V'
// 继续解析, 第四字节是1,Version = 1 没有什么问题
// 继续解析,Flags 1字节 为5, 由文档可知,这个Flags表示是否有视频和音频,先转换为2进制字符串成,前5位(bit)都是0,都是保留位,第6位是1表示存在音频,第8位是1表示存在音频
Number(5).toString(2) // '00000101'
// 再往后四字节为[0,0,0,9] 表示HeaderSize ,到此Header解析完成

解析完Header,我们继续按照文档往下解析Body 。下面看一下FLV Body的解析过程。

FLV Body解析

FLV Body解析主要分为以下几步:

  1. 解析PreviousTagSize(4byte),第一个tag前没有tag,所以他的值恒为[0,0,0,0]
  2. 解析Tag:Tag包含TagHeader,TagData。
  • 解析TagHeader:

    • Type: 得知Tag有三种类型,分别是scriptdata Tagvideo Tagaudio Tag
    • DataSize: 得知 TagData的大小,即占多少字节
    • Timestamp: 表示该Tag的生成时间 (dts)。
  • 解析TagBody: (针对三种tag分别做解析,获取不同的信息)

    • ScriptData Tag: 可以获取到该流的基本信息,例如( width ,height, duartion)

    • Video Tag:

      • Video Meta Tag(第一个video Tag):可以获取到视频的一些编解码信息,例如编码类型,采样率等等
      • Video Data Tag:真正的AVC编码后的视频数据
    • Audio Tag:

      • Audio Meta Tag(第一个audio Tag):可以获取到音频的一些编解码信息,例如编码类型,采样率,声道等等
      • Audio Data Tag:真正的AAC编码后的视频数据

FLV解封装注意事项

我们是通过http长链接持续读取FLV数据的,但是我们没有办法保证每次获取到的数据结束点都正好是一个完整FlV Tag,所以我们要在每次Demux之后都留下最后一个Tag的buffer(数据),然后把他加在下一次读取到的数据的开始。这样我们在下一次解析的时候,就可以保证第一个Tag是一个完整的Tag,保证Demux的过程不会出错。

Remux

Remux又称作复用/封装。由第二步,我们获取到了解封装后的流信息。其中包括流信息、流数据。现在我们要把解出来的数据变成Fmp4,那么同样我们需要先了解一下Fmp4的结构。Fmp4和mp4类似,他是由一个个Box组成的。

FMP4格式介绍

FMP4的结构

  • Fmp4

    • ftyp

    • moov

      • mvhd

      • trak

        • tkhd

        • mdia

          • mdhd

          • hdlr

          • minf

            • smhd

            • dinf

              • dref

                • url
            • stbl

              • stsd

                • mp4a(acv1)

                  • esds(avcC)
              • stts

              • stsc

              • stsz

              • stco

      • mvex

        • trex
    • moof

      • mfhd

      • traf

        • tfhd
        • tfdt
        • sdtp
        • trun
    • mdat

    • moof

    • mdat

    • ....

FMP4 Box的结构

每个Box的组成结构是一样的,都是由4字节size + 4字节type + (size-8)字节数据组成的。这里我们举例第一个Box (FTYP )来解释一下。

image.png

// ftyp
const typeBuffer = new Uint8Array([
  'ftyp'.charCodeAt(0),
  'ftyp'.charCodeAt(1),
  'ftyp'.charCodeAt(2),
  'ftyp'.charCodeAt(3)
 ])

const ftypDataBuffer = new Uint8Array([
  0x69, 0x73, 0x6F, 0x6D, // major_brand: isom    isom  MP4  Base Media v1 [IS0 14496-12:2003]  ISO YES video/mp4
  0x0, 0x0, 0x0, 0x1, // minor_version: 0x01
  0x69, 0x73, 0x6F, 0x6D, // isom
  0x61, 0x76, 0x63, 0x31 // avc1
]);

 const size = typeBuffer.byteLength + ftypDataBuffer.byteLength +4;
 const sizeBuffer = new Uint8Array( [
      (size >>> 24) & 0xFF,
      (size >>> 16) & 0xFF,
      (size >>> 8) & 0xFF,
      (size) & 0xFF,
    ]}

const ftypBox = new Uint8Array(size);
ftypBox.set(sizeBuffer)
ftypBox.set(typeBuffer,4)
ftypBox.set(ftypDataBuffer,8)

转封装

转封装需要修改的Box

因为并不是每个Fmp4 Box都是变化的,有一些Box是固定头写死的数据,例如FTYP,这里就不一一介绍了。

转封装示意图

了解完Fmp4的格式之后,我们来看一下具体是如何转封装的,主要有两步:

  1. 将从FLV头(ScriptData Tag 、Video Meta Tag(第一个Video Tag)、Audio Meta Tag(第一个AudooTag))中获取到的信息提取出来,封装成FTYP、MOOV Box,构造出Fmp4头。
  2. 将剩下的Audio Tag,Video Tag中的具体音视频数据提取出来,封装成一组组的MOOF,MDAT Box。

image.png

实时转封装的实现

因为我们实现的是直播播放器因此要实时的进行转封装,这块我们是这样设计的,当我们Remux完FLV头之后,就开始进行构造Fmp4头,生成buffer后append给MSE,后续每read一次数据,就开始Demux,解析出n个Tag,然后将这n个Tag去进行remux,封装成一组 MOOF,MDAT ,然后再append给MSE,这样达到一个实时的效果。

openMux(){
    // demux
    this.demux.resetTrack();
    const tagarr = this.TagArr.slice(0,this.TagArr.length-1);
    tagarr.forEach((item)=>{
      this.demux.tagDemux(item);
    })  

    // 是否已经获取到音频meta信息
    if(this.isInitMeta){
       // 是否已经demux完成头信息
      if(this.demux.loadMeta){
          // 完成后进行remux头信息
        this.remuxMeta()
        this.isInitMeta = false;
          // remux除头信息完剩余Tag
         this.remuxBody();
      }else{
        return;
      }
    }else{
        // 已存在头信息,直接remux 音视频数据
      this.remuxBody();
}

MSE播放

通过前两步,我们获取到转封装之后的Fmp4的数据,但是我们要怎么样把这个数据传给video让他播放呢,这就引出了MSE这一个重要的Api。有了这个东西,我们就可以在video和请求之间建立一个通道,将视频的数据通过这个通道源源不断的输送给他,最后实现直播。下面是MSE播放Fmp4数据的简单实现:

// 拿到video实例
const video = document.querySelector('video');

let sourceBuffer = null;
const bufferList = [];

// 创建MeidaSource实例,与video建立连接
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);

// 监听相应事件 sourceopen 绑定到video或触发
mediaSource.addEventListener('sourceopen', ()=>{  
   // 视频的封装格式和编码信息
   //var mime = 'video/webm; codecs="opus, vp9"';
   // 这个信息我们通过Demux可以从flv的信息中获取
   const mime = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';

   const mediaSource = e.target;
   // 可以用api检测一下是否支持
   //const isSupport = mediaSource.isTypeSupported(mime)
   // 创建sourceBuffer 对象
   sourceBuffer = mediaSource.addSourceBuffer(mime);
});

// 我们可以建立一套发布订阅模式,来获取新生成fmp4的buffer
emitter.on('getbuffer',(buffer)=>{
    // 将buffer推入list
    bufferList.push(buffer);
    // 向mse中插入buffer
    addBuffer();
})
// 如果想连续添加,需要updataend之后再添加
// 如果是流式的直播,就需要先加入一点buffer让他起播,然后在updataend后,再持续注入buffer
sourceBuffer.addEventListener('updateend', function () {  
  addBuffer()
});
// 添加buffer的函数
const addBuffer = () =>{
  const sb = sourceBuffer;
  // console.log('%caddBuffer','color:#0f0;',sb.updating)
  if (sb && !sb.updating) {
    const buffer = bufferList.shift();
    if(!buffer){
      // console.log('没有buffer')
      return;
    }
    sb.appendBuffer(buffer.buffer);
  } else {
      // console.log('sourcebuff还在忙碌');
  }

  if(!sb){
    console.log('sourceBuffer还没准备好');
  }
}

那么mse是如何实现的播放,又是如何接收buffer呢?

mse如何工作的?如何接接受buffer的?

image.png

由上边Remux的过程可知,我们生成的Fmp4的buffer的顺序是 [FTYP,MOOV] (头信息),[MOOF,MDAT] (音视频数据),[MOOF,MDAT] .... 每一片MDAT里都有很多个音视频的分片,每个分片在MOOF里有有对应的描述,描述他的开始时间,和时长。那么其实我们就是要保证每个分片是紧密连接的,下一片的开始时间(dts)应该是上一片的dts加上他的时长(duration)。

然后因为mse要启播的话,必须从0开始,就意味着我们给MSE的第一个分片的dts必须是0,但是我们拉流获取到的demux解出来的第一片的开始时间(dts)的计算是从开始推流的那个时刻到该分片的时间,这与mse所需的是不匹配的,所以我们需要重新计算每一片的dts。

实现方式的话就是,我们把FLV Demux的第一个音视频数据分片的dts记为dtsBase,然后每一片的dts都去减去这个dtsBase,就得到了一个从0开始的音视频轨道。

如果buffer直接存在间隙会怎么样?

mse会从0开始播放,如果播放到某个点没有数据了,video会抛出waiting事件,等待数据的到达。

var video = document.querySelector('video');
video.bufferd // 通过该属性可以获取到buffer
video.bufferd.length > 1 // 说明有好几段buffer,即buffer之间存在间隙
video.bufferd.start(0)
video.bufferd.end(0) // 通过这两个api可以获取到该段buffer的起始时间
// 假如说就是存在间隙,但是不想waiting,就是想播下一段数据,那就是需要seek
const nextBufferStart = video.bufferd.start(1);
video.currentTime = nextBufferStart;
总结回顾

到此,我们将前几个步骤进行一个串联,一个基础FLV web直播播放器就实现完成了。但是仔细思考一下,其中还是存在一些问题的,这里留给大家去思考一个问题。如果服务端返回给我们的数据本身存在问题,音视频帧与帧之间存在间隙,dts无法对齐如何解决?

460
2
0
0
相关产品
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论