随着网络与流媒体的飞速发展,直播已经深入到我们每个人的生活中了。但是因为原生的video
元素只支持几种固定的格式,在web上实现直播变成了一个困难问题。随着HTML5 提出MSE(Media Source Extensions),让video
元素可以支持播放js处理过后的视频流,这给我们带来了在web上实现直播的方案。当前web浏览器实现直播的方式主要有两种,一种HLS直播,另一种便是本文要讲的FLV直播。接下来我们来看一下FLV直播技术实现的流程图:
由流程图可知,FLV直播可以概括为四大步:
- Loader:与服务器建立http长链接,进行拉流,并将拉取到的数据存储起来。
- Demux: 将拉取到的数据按照FLV的格式进行解封装,解出h.264裸码流。
- Remux: 将解封装后的数据按照Fmp4的格式进行封装,生成Fmp4流。
- 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,即一字节。
// 我们从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解析主要分为以下几步:
- 解析PreviousTagSize(4byte),第一个tag前没有tag,所以他的值恒为[0,0,0,0]
- 解析Tag:Tag包含TagHeader,TagData。
-
解析TagHeader:
- Type: 得知Tag有三种类型,分别是
scriptdata Tag
、video Tag
、audio Tag
。 - DataSize: 得知 TagData的大小,即占多少字节
- Timestamp: 表示该Tag的生成时间 (dts)。
- Type: 得知Tag有三种类型,分别是
-
解析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 )来解释一下。
// 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的格式之后,我们来看一下具体是如何转封装的,主要有两步:
- 将从FLV头(ScriptData Tag 、Video Meta Tag(第一个Video Tag)、Audio Meta Tag(第一个AudooTag))中获取到的信息提取出来,封装成FTYP、MOOV Box,构造出Fmp4头。
- 将剩下的Audio Tag,Video Tag中的具体音视频数据提取出来,封装成一组组的MOOF,MDAT Box。
实时转封装的实现
因为我们实现的是直播播放器因此要实时的进行转封装,这块我们是这样设计的,当我们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的?
由上边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无法对齐如何解决?