04月26, 2020

Web前端点播直播入门

序言

《360前端星计划》由360的前端团队和校园招聘团队合办,面向在校应届大学生,为培养最优秀最有潜力的前端人才举办的前端技术系列培训&人才选拔项目。 到今年(2020)已经连续举办6年,也就是第6届了:https://study.qiyun.360.cn/course/festar2020

Web前端发展到现在,涵盖业务形态已经不止是宣传网页或基于Vue\React实现一些WEB交互系统。

延展出了更多的细分领域,比如数据可视化、媒体处理、小程序...

今年(2020)也是结合自己的一些实践,较往年增加了一个《Web前端点播直播入门》的课程(PPT视频)。

本文章将课程分享内容整理为文字版,希望多一个途径,为该领域做一点贡献。

正文

alt

今天我们一起看一下Web前端点播直播相关的一些基础知识。

提纲

alt

今天这个内容,大概会分为三个方向:

首先,我们会看一下“什么是视频”,通过这一部分内容了解一下媒体数据存储和应用的基本原理。

然后,中间我们再通过“一些好玩的Web端API”的具体的应用示例,来了解一下通过WEB端API可以在浏览器端实现哪些方向的具体应用。

最后,我们再了解一下Web端点播直播在业务场景上的一些差异,还有在浏览器端的播放上,有哪些播放方案可以选择。然后具体媒体播放这块,WEB端播放能力的定界又大概是什么样的。

什么是视频

alt

这一部分内容我们会分为五个话题,分别是:

  1. 格式与内容
  2. 视频数据
  3. 音频数据
  4. 传输协议
  5. 播放器原理

我们分别来了解一下针对视频媒体数据,存储编码是怎么去做的、传输又有哪些常见的传输协议、具体应用在播放器里面又大概是怎么样的一个解析播放原理。

格式与内容

alt

说到视频,大家肯定都不会陌生,每个人的手机或电脑上肯定都会有一些视频内容。

像下面这个截图,就是我自己电脑里一些用于测试的视频内容。

大家可以看到,有MP4、有RMVB、有FLV、有录屏产生的MOV等等。

而我们通常聊天的时候,聊到的“MP4、RMVB...”文件扩展名,映射到媒体处理领域的话,可以理解为是媒体处理领域的媒体封装格式,也叫做媒体容器类型。

这里说的媒体封装格式,它不等于音视频的编码格式的。因为我们通常说说的音视频编码格式,是指音视频数据使用了谁家的编码器来做的编码处理。

而封装格式是指我们把编码后的音视频数据、以及一些附加信息,通过什么样的标准约定做的打包,以打包成一个独立的物理文件,来便于我们的存储以及传输的。

媒体封装文件内容,我们可以通过一些命令行工具或在线工具来看到一些相应的Meta描述信息。

比如把FLV文件拖拽到一个WEB版在线工具,就能看到一个基本的描述信息。

通过头描述信息就告诉我们,这个视频文件是一个 FlashVideo(FLV)、媒体内容时长(Duration)是多久,这个文件是有视频的(视频编码格式为AVC、分辨率 Width Height 为 512px 288px、码率BitRate 为 259 Kbps、帧率 FrameRate为12 fps),也是有音频的(音频编码格式为AAC、采样率 SamplingRate为44.1KHz、声道数Channels为2)...

而在点播场景的话,媒体数据内容通常还会包含一部分索引信息。有了索引信息,我们如果在看一个影片的过程中,拖拽进度条到某个进度对应时间点,播放器就能比较快速的确定出来这个时间点多对应到音视频帧编码数据在二进制存储的索引位置是什么地方。

当然视频数据自然也通常会包含视频数据、音频数据,音频数据也是可选(不一定存在的)。

另外,还有一块特别实用的,就是附加增强数据。可以让我们存储一些视频数据音频数据之外的信息,我们可以自定义一些数据信息到媒体编码内容中,扩展应用场景、增强业务能力。比如可以在编码或转码环节增加AI内容识别能力,将识别到的数据(比如人物轮廓),再在相应帧时间点对应位置增加相对应的轮廓、语义化描述数据....

下面,我们再具体看一下媒体数据编码中,视频数据、音频数据的存储原理。

视频数据

计算机画面的呈现,在显示器上是基于RGB(红绿蓝)颜色空间模型来做的。也就是每个像素点,都需要RGB三个数据描述出来的。

而在视频领域大多都是基于YUV颜色空间对RGB做抽样后进行存储的。

YUV颜色空间,也就是相对于RGB设计的一种新的颜色空间模型。其中基于 UV 对应目标像素点在颜色模型上的两个向量值,Y就是对应像素点的亮度值。

如果我们把UV数据舍弃,只保留Y,就相当于黑白电视的效果。 如果把U或者V只舍弃一部分的话,可以通过差值将中间的一部分缺失还原回来。

比如我们常见的视频画面编码时,是基于YUV420的,也就是每四个像素点取一个U、一个V,而中间缺少的U\V在播放场景解码时再通过差值补充回来,虽然必定会存在一些色值偏差,但结合人眼的色域识别范围、基本就被大脑强大的自动修复能力给识别为一个连贯的视觉内容了,基本不会产生很大的影响。

如果我们还是基于YUV颜色空间模型,不做任何抽样,也就是上图YUV444的示例,和RGB的数据存储量就没有什么区别了。

除了在颜色色值上的抽样存储,来减少数据使用过程中对内存或存储空间的压力之外,还有帧内预测、针间预测来提高我们画面数据的复用率。

比如我们有两帧画面,前一帧和后一帧画面内容一模一样,那么我们就可以基于帧间预测感知出来,然后只存储传输其中的一帧数据和对应复用标识。 而如果我们同一帧画面内部,比如我们当前看到一个幻灯片的画面,大部分区域是白色的,那么每一块连续的白色区域,我们按色块而不再对应到具体的像素点进行数据描述的话,那么这个数据存储的节省量也是很大的。

所以,通常结合帧内预测+帧间预测做画面数据的复用,可以更有效的做视频画面的数据压缩。

基于上面大致的画面帧原理设计描述,视频帧数据,就大概可以划分为3类:P帧(前向预测帧)、B帧(双向预测帧)、I帧(参考帧);根据对应帧类型的差异,可能要相对的依赖其他帧数据,才能在解码时候还原出一个完整的视频帧画面;根据预测方式和舍弃数据量的大小,那么解码时候要还原的计算量也自然不同,通常如果要保持解码效率,允许舍弃画面帧的场景就可以选择还原运算量较大的比如B帧或P帧进行舍弃。

而基于前面画面帧的通用标准设计,再集合各编码器厂商的一些算法优化或专利池技术,那么就产生了生产出的相应的编码器了。比如市面常见的 H.264(AVC)、H.265(HEVC)、VP8、VP9...

音频数据

进入到音频数据这边,我们知道,声音是自然界中,不同振幅频率的震动产生的一种机械波。

而这些震荡机械波再驱动空气,传输到我们的耳朵里,也就让我们感受到了自然界的这些个声音。

声音的数字化表现就是一个随时间进度连续的一维模拟信号波形。

通常对自然界中这个连续的一维声音波形进行采样,得到一个脉冲编码调制(PCM)的数字化数据存储。在应用时通常交由声卡,进行计算还原为连续的模拟声音信号波形,再交由扬声器,通过振荡器模拟对应振幅和频率,驱动空气传输到我们的耳朵里,从而让我们又能重新感受到类似自然界原有的声音。

而音频数据的存储压缩方式,通常为预测(与画面数据原理类似)、变换两种。

基于这些通用的标准,再集各厂商自己的一些技术积累、不同于其他厂商的实现方案、专利技术池,也就构成了各自不同的音频编码器(AAC、MP3...)。

传输协议

再来看一下传输协议。

传统场景-直播

在传统流媒体(也就是直播)业务场景中,有基于HTTP协议的HLS、FLV,也有RTMP、RTP/RTSP、TS、MMS...

其中,基于HTTP的HLS,是苹果为充分利用现有CDN设施而发明的一种“流媒体”协议标准。 这里的“流媒体”是带引号的,因为HLS本身实际是基于文件切片*.ts存储,加上文本形式的m3u8播放列表实现的。并不是真正意义的流媒体。也正是因为这个实现方式的特殊性,他的延迟也相对较大。

还有,基于HTTP的FLV,最早是Adobe为解决FLASH场景中*.swf媒体文件过大不利于传输而开发的一套协议标准。

目前,上面HLS、FLV虽然都是基于HTTP协议的,但在浏览器端是不一定支持的(比如:原生浏览器标准并不支持FLV、而HLS只是苹果系或移动端部分版本浏览器支持尚可)。

还有另外一些,像RTMP、RTP/RTSP、传统视讯领域的 TS、微软的 MMS等等,这些都是浏览器端原生不支持的协议类型。

传统场景-点播

点播场景通常都是基于HTTP协议,再配合Header或http参数中进行range信息传递,来达成播放过程中的seek需求(拖拽进度条,通过时间和前面说的索引数据,找到二进制文件中的索引区间,以加载进度时间对应空间区间的媒体数据)。

Web端业务场景

Web端相对于传统业务场景,可以选择的协议类型局限于浏览器所暴露支持的API。

前面也有提到,也就是我们只能基于类似 HTTP(S)、WebSocket - WS(S)、WebRTC - P2P...

这些个浏览器API所支持的数据传输协议类型进行数据的传递或获取。

播放器原理

我们这里要说的播放器原理介绍,是一个比较泛泛的、笼统的,也就是不局限于WEB端的。

总的来说不管什么业务场景、什么播放器,大体都少不了这些步骤:

1) 解协议

首先,就是解协议的事情,也就是怎么加载获取到要播放的媒体数据。

加载获取到媒体数据之后,接下来就是要对数据进行解封装。

2) 解封装

通过解封装,通过前面说的不同容器类型,要按对应标准解包装,拿到前面介绍过的基础类型的视频帧、音频帧数据,再对数据进行解复用。

3) 解码

通过解码,还原压缩后的数据,得到接下来能用于渲染的原始数据。

比如,画面,通常得到每一帧的YUV数据;声音,通常得到每一帧的PCM数据。

4) 渲染

有了YUV和PCM数据,如果是WEB端业务场景,那么就可以基于WebGL将YUV绘制渲染成显示器中可见的视觉画面信息,通过WebAudioAPI将PCM数据通过声卡扬声器呈现出对应能听到的声音信息。

以上解协议、解封装、解码、渲染,这1、2、3、4,四个步骤依次完成之后,一个播放器的基础流程也就完成了。

当然,一个播放器不止包含这四个步骤的逻辑和能力,还有UI层、还有自身产品设计的一些特性等等。

小结

到这里“什么是视频”这一节就笼统的介绍完了。

通过这一节的介绍,相信大家对“媒体数据的存储方式(容器、打包)、音视频基础数据(YUV、PCM)、 网络传输协议都有哪些类型(哪些适用于WEB端)、播放器的整个工作流程大体是什么样的”,能有一个大致的掌握或了解了。

前面这一章节的内容,相对于我们Web前端从业人员来说,主观感受可能会觉得跟我们的职责距离比较远。

所以接下来一节,我们将通过“好玩的Web端API”,看一下前端开发者在浏览器端通过媒体处理相关的一些API具体可以做哪些事情。

好玩的Web端API

本章节内容,首先会涉及到浏览器端媒体兼容判断。前面在介绍容器以及音视频编码的时候也提到过:如果我们的播放场景不能解要播放的媒体数据容器或者没有对应解码器的话,那相应媒体数据在该播放场景也就是播不了的;浏览器端也一样具备同样的这个问题。

另外,本章节会再通过一些具体的实例,来看一些好玩的具体应用。比如:怎么实现一个简单的交互式视频、怎么在浏览器端播放本地视频文件、怎么播放硬件资源的媒体流(也就是怎么调用摄像头麦克风)、怎么基于摄像头麦克风实现视频录制;还有,有了硬件及录制的媒体流数据,就可以通过网络传输,那么也就涉及到怎么使用JS拉取并播放网络上的媒体数据。

判断浏览器端视频兼容情况

代码:

let videoEl = document.createElement("video");
let types = {
  'mp4': 'audio/mp4',
  'MP4': 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
  'webm': 'video/webm; codecs="vp8, vorbis"',
  'ogg': 'video/ogg; codecs="theora, vorbis"',
  'm3u8': 'application/vnd.apple.mpegURL',
  'ts': 'video/mp2t; codecs="avc1.42E01E,mp4a.40.2"'
};

Object.keys(types).forEach(key => {
  const type = types[key];
  const ret = videoEl.canPlayType(type) || '不支持';
  console.log(key + ': ' + ret);
});

示例网址:https://code.h5jun.com/rapuq/1/edit?js,console

通过上面几行简单的代码,就可以实现当前浏览器环境媒体兼容类型的判定。

可以看到,首先我们基于 let videoEl = document.createElement("video"); 创建了一格 video 元素。

然后,通过VideoElement 本身的canPlayType方法,也就是 videoEl.canPlayType(type) 来对指定的type类型进行兼容判定,以此确定当前Video标签是否能播放相应媒体类型。

在示例代码的媒体类型定义中,我们首先声明了一个 'audio/mp4',浏览器判定后告诉我们一个结果'maybe'(也许支持)。

后面,我们又通过'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'声明了一个video/mp4媒体容器类型,并且另外设置了编码器及其版本型号,此时浏览器针对该声明的canPlayType判定结果就是一个相对更明确的'probably'

如果不支持的媒体类型,比如当前示例中基于chrome浏览器对m3u8或ts的判定结果,就是一个空字串。

这里的浏览器判定结果为什么不是一个结果为 true或者falseBoolean值呢?

其实也好理解,就是前面介绍容器及编码类型的时候提到的话题。

如果只有容器类型,实际上这个容器内的数据是否合法的媒体数据、是依赖什么类型版本的解码器才能解码播放的,是不确定的(比如我们可以随便把一个物理文件的扩展名改为.mp4),也就是依赖的解码器不确定,此时浏览器的判定也只能根据媒体容器类型做一个大致的猜测。

在实际的业务场景中,通常在使用canPlayType时即便设置了解码器及版本,接下来要播放的媒体数据中的编码是否真的遵从了这个设置,也还是需要留意的。

基于Video时间轴控制实现交互式视频

示例网址:https://code.h5jun.com/toji/4/edit?js,output

所谓的交互式视频,也就是当我们观看的影片剧情发展到一个转折点的时候,可以由观众选择接下来的剧情发展方向是什么样的。

我们示例中这个剧情大致是:一个男生去参加一个聚会,遇到了两位认识的女生(作为观众就可以选择是否要过去寒暄聊天),后面又遇到一个尬聊的宅男(接下来观众就可以选择直接和该宅男尬聊还是喝一口广告饮料,变得才思泉涌、从容应对)。

是怎么做到控制剧情的跳转的呢?通过右键选择‘显示控件’或者直接看代码大致就能明白了。

let video = $('video');
video.ontimeupdate = ()=>{
  let {currentTime} = video;
  show(currentTime > 64 ? '.s2' : '.s1');
  hide(currentTime > 64 ? '.s1' : '.s2');
  if(
     (currentTime > 64 && currentTime < 65) || 
     (currentTime > 113 && currentTime < 114)
  ){
    video.pause();
  }
};

let ppBtn = $('paly_pause');
video.onplay = ()=>{
  ppBtn.innerText = '暂停';
};
video.onpause = ()=>{
  ppBtn.innerText = '播放';
};
ppBtn.onclick = ()=>{
  video[video.paused ? 'play' : 'pause' ]();
};
$('start').onclick = ()=>{
  video.currentTime = 1;
  video.play();
};
$('step').onclick = ()=>{
  video.currentTime = 60;
  video.play();
};
$('dream').onclick = ()=>{
  video.currentTime = 83;
  video.play();
};
$('drink').onclick = ()=>{
  video.currentTime = 116;
  video.play();
};

hide('.s2');
function show(sel){
  document.querySelectorAll(sel).forEach(el=>{
    el.style.display='inline'
  });
}
function hide(sel){
  document.querySelectorAll(sel).forEach(el=>{
    el.style.display='none'
  });
}
function $(id){
  return document.getElementById(id);
}

实际就是基于video的事件ontimeupdate和属性currentTime,来实现的。

事件ontimeupdate可以让我们感知到播放进度的变化,当剧情发展到相应时间节点我们再通过video. pause()控制暂停播放,并显示可操作的交互按钮出来。

而属性currentTime可以让我们读取或改写当前的播放进度值,控制剧情跳转到对应时间点之后再video.play()启动播放,也就完成了剧情跳转的操作。

技术原理非常简单,但目前这种交互式视频的业务应用场景也还是很多的,比如示例是一个宣传广告;也有基于该原理实现的游戏(密室逃脱、探险...),也有交互式拍摄的影视作品...

基于 FileReader API 播放本地文件

示例网址:https://code.h5jun.com/zupo/8/edit?js,output

这个示例的代码实现也非常简单。

let iptFileEl = document.querySelector('input[type="file"]');
let videoEl = document.querySelector('video');

iptFileEl.onchange = e =>{
  let file = iptFileEl.files && iptFileEl.files[0];
  playFile(file);
};

function playFile(file){
  if(file){
    let fileReader = new FileReader();
    fileReader.onload = evt => {
      if(FileReader.DONE == fileReader.readyState){
        videoEl.src = fileReader.result;
      }else{
        console.log('FileReader Error:', evt);
      }
    }
    fileReader.readAsDataURL(file);
  }else{
    videoEl.src = '';
  }
}

首先,在UI上我们需要一个 input[type='file']的表单输入框,用于供用户选择本地媒体文件。我们通过给input增加accept="video/*"来告诉浏览器这个输入框只允许选择video文件。

然后,再在代码中检测了这个inputonchange事件,当判定有选择媒体文件的时候,直接通过FileReaderAPI的readAsDataURL简单粗暴的将文件读取为dataURL内容,并通过videoEl.src=fileReader.result赋值给video播放器,进行播放。

基于 getUserMedia 调用摄像头或麦克风

这里我们再看一个更加实用好玩的API:基于 getUserMedia 开启并得到摄像头或麦克风的视频流进行播放。

示例网址:https://code.h5jun.com/lasiq/2/edit?js,output

首先,这里也需要页面上有一个video标签,也就是截图右侧对应的黑色区域。

另外,有一些操作按钮。比如,点击play播放按钮,对应到代码逻辑中,会去打开摄像头,并获取媒体流基于video进行播放;在这里我们通过{ audio: false, video: true }告诉浏览器需要的只是视频内容,并不需要音频(也就是麦克风)。

浏览器的 getUserMedia会给我们返回一个MediaStream对象,通过video.srcObject = stream赋值给播放器也就可以进行播放了。

代码的最前面我们声明了一个getUserMediaPromise函数,针对不同浏览器场景或不同版本的API做了一下兼容包装。

通过MediaStream.getTracks()可以拿到当前启动的媒体轨道,在这个示例里我们只启用了一个视频轨道,所以也比较简单粗暴的使用streamTrack = stream.getTracks()[0];做了轨道对象的引用。

当点击stop停止按钮时,停止对应轨道源数据依赖,来关闭相应硬件设备的调用。

const getUserMediaPromise = options => new Promise((resolve, reject) => {
  const nvgt = window.navigator;
  if(nvgt) {
    if(nvgt.mediaDevices && nvgt.mediaDevices.getUserMedia) {
      return nvgt.mediaDevices.getUserMedia(options).then(resolve, reject);
    }
    const getUserMedia = nvgt.getUserMedia || nvgt.webkitGetUserMedia || nvgt.mozGetUserMedia;
    if(getUserMedia) {
      return getUserMedia(options, resolve, reject)
    }
  }
  reject('当前环境不支持获取媒体设备。');
});

let streamTrack;
const video = document.querySelector('video');
document.querySelector('#play').onclick = () => {
  getUserMediaPromise({
    audio: false,
    video: true
  }).then(stream => {
    video.srcObject = stream;
    streamTrack = stream.getTracks()[0];
  },
  err => {
    console.log('getUserMedia error: [' + err.name + '] ' + err.message)
  });
};

document.querySelector('#stop').onclick = () => {
  streamTrack && streamTrack.stop();
};

const box = document.querySelector('div');
document.querySelector('#sketch').onclick = () => {
  box.className = box.className ==='' ? 'sketch' : '';
};

有了摄像头媒体流数据,可以做很多好玩的东西。

比如该示例最右侧给了一个 sketch按钮,通过简单的CSS滤镜就可以实现一个‘伪素描’效果。

示例下面也给了另外一个网址:https://code.h5jun.com/qisi/1/edit?js,output ,相对上面代码又额外引入了一个人脸识别库,可以纯浏览器端实现一个简单的人脸识别框选效果。

基于 getUserMedia、MediaRecorder 实现录像

示例网址:https://code.h5jun.com/rihaq/1/edit?js,output

这里相对前面获取硬件媒体流的代码实现,只是多了 MediaRecorder 的依赖及相应逻辑实现。

const getUserMediaPromise = options => new Promise((resolve, reject) => {
  const nvgt = window.navigator;
  if(nvgt) {
    if(nvgt.mediaDevices && nvgt.mediaDevices.getUserMedia) {
      return nvgt.mediaDevices.getUserMedia(options).then(resolve, reject);
    }
    const getUserMedia = nvgt.getUserMedia || nvgt.webkitGetUserMedia || nvgt.mozGetUserMedia;
    if(getUserMedia) {
      return getUserMedia(options, resolve, reject)
    }
  }
  reject('当前环境不支持获取媒体设备。');
});

const video = document.querySelector('#preview');

let cameraStream;
const opencameraBtn = document.querySelector('#opencamera');
const closecameraBtn = document.querySelector('#closecamera');
const recordBtn = document.querySelector('#record');
const stopRecordBtn = document.querySelector('#stoprecord');
const playBtn = document.querySelector('#play');
const downloadBtn = document.querySelector('#download');

opencameraBtn.onclick = () => getUserMediaPromise({
  audio: false,
  video: true
}).then(
  stream => {
    cameraStream = video.srcObject = stream;
    opencameraBtn.disabled = true;
    closecameraBtn.disabled = false;
    recordBtn.disabled = false;
  },
  err => {
    console.log('getUserMedia error: [' + err.name + '] ' + err.message)
  }
);

closecameraBtn.onclick = () => {
  cameraStream && cameraStream.getTracks()[0].stop();
  cameraStream = null;
  opencameraBtn.disabled = false;
  closecameraBtn.disabled = true;
  stopRecordBtn.onclick();
};

let mediaRecorder;
let recordedBlobs;
const mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', ''].find(type => {
  return MediaRecorder.isTypeSupported(type);
});
// console.log('mimeType', mimeType);
recordBtn.onclick = () => {
  recordedBlobs = [];
  try {
    mediaRecorder = new MediaRecorder(cameraStream, { mimeType });
  } catch(e) {
    alert('Exception while creating MediaRecorder: ' + e + '. mimeType: ' + mimeType);
    return;
  }
  recordBtn.disabled = true;
  stopRecordBtn.disabled = false;
  playBtn.disabled = true;
  downloadBtn.disabled = true;
  mediaRecorder.onstop = evt => {
    console.log('Recorder stopped');
  };
  mediaRecorder.ondataavailable = function(event) {
    if (event.data && event.data.size > 0) {
      recordedBlobs.push(event.data);
    }
  };
  mediaRecorder.start(20); // 单次收集数据毫秒时长,ondataavailable 触发频率时长间隔
};


const recordedVideo = document.querySelector('#recorded');
stopRecordBtn.onclick = () => {
  mediaRecorder && mediaRecorder.stop();
  mediaRecorder = null;
  // console.log('Recorded Blobs: ', recordedBlobs);
  recordedVideo.controls = true;
  playBtn.disabled = false;
  downloadBtn.disabled = false;
  stopRecordBtn.disabled = true;
  if(!cameraStream) {
    recordBtn.disabled = true;
  }
};

const getRecordedBlobUrl = () => {
  const superBuffer = new Blob(recordedBlobs, {type: mimeType.split(';')[0]});
  return window.URL.createObjectURL(superBuffer);
};

playBtn.onclick = () => {
  recordedVideo.src = getRecordedBlobUrl();
}

downloadBtn.onclick = () => {
  var a = document.createElement('a');
  a.style.display = 'none';
  a.href = getRecordedBlobUrl();
  a.download = 'test.webm';
  document.body.appendChild(a);
  a.click();
  setTimeout(function() {
    document.body.removeChild(a);
    window.URL.revokeObjectURL(url);
  }, 100);
}

也就是除了和前面呼叫启摄像头一样的逻辑之外,又另外增加了录制控制,当点击开始录制按钮时,通过 new MediaRecorder(cameraStream, { mimeType }) 生成了一个媒体录制实例,通过 cameraStream参数将摄像机媒体流应用于录制中。

当mediaRecorder 实例上的ondataavailable事件发现有可用媒体数据时,将拿到的数据push到数组recordedBlobs中,在内存里对录制的数据进行存储。

当点击播放或下载的时候,通过 new Blob(recordedBlobs, ...) 将数据生成一个blob虚拟文件,并创建一个对应的引用路径,将路径赋值给video.src或A表现进行播放或下载。

当然这里我们也可以直接将录制的二进制数据通过网络发送出去,不停的发送,播放端不停的获取,也就实现了一个“直播”交互。(真正的直播实现往往并不是这种方案,而是相对更可靠的WebRTC)。

但当下基于HTTP的FLV、HLS的媒体数据播放,也必须得借助JS的异步拉取数据进行处理、播放。

基于MediaSource播放JS拉取的媒体数据

要基于VideoElement播放JS异步拉取处理后的数据,这里就涉及到了一个新的API,也就是MediaSource

const video = document.querySelector('video');
const fetchMp4 = (url, cb) => { 
  const xhr = new XMLHttpRequest();
  xhr.open('get', url);
  xhr.responseType = 'arraybuffer';
  xhr.onload = function () {
    cb(xhr.response);
  };
  xhr.send();
};

const assetURL = 'https://nickdesaulniers.github.io/netfix/demo/frag_bunny.mp4';
const mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';

// 创建动态媒体源,并关联到video元素上
const mediaSource = new MediaSource(); 
video.src = URL.createObjectURL(mediaSource);

mediaSource.addEventListener('sourceopen', () => {
  const sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
  // 拉取数据
  fetchMp4(assetURL, buf => {
    sourceBuffer.addEventListener('updateend', () => {
      // 媒体流传输完毕
      mediaSource.endOfStream();
      // video.play();
    });
    // 将数据喂给 Video -- 注意这里只是一次性输入整个MP4数据
    sourceBuffer.appendBuffer(buf);
  });
});

而数据的异步拉取,在当前这个示例里,也是用了大家比较熟悉的XMLHttpRequest

跟平时AJAX请求文本数据不同的地方就是通过xhr.responseType = 'arraybuffer';设置了返回数据类型为ArrayBuffer,当xhr.onload中文件加载完毕我们就可以xhr.response得到一个ArrayBuffer数据内容。

再后面就基于mediaSource = new MediaSource() 得到一个动态媒体源对象,并且使用video.src = URL.createObjectURL(mediaSource)将这个动态媒体源与video元素进行绑定。

然后又声明了一个媒体源缓冲对象 sourceBuffer = mediaSource.addSourceBuffer(mimeCodec)

当获取到数据的时候,通过sourceBuffer.appendBuffer(buf)喂给缓冲器,进而喂给video进行播放。

有个细节, VideomediaSource的引用是一个异步的行为,所以这里监听了 sourceopen事件,在事件发生后才进行数据交互。

另外需要注意:实际业务应用中是不能完全按照这个示例来实现的。

因为这里是简单粗暴的 ajax请求了一个完整的文件,整个喂给MediaSource实现播放的,如果媒体文件过大,这里会导致长时间处于等待状态、无法播放;如果文件足够大,甚至有用户内存不足的风险。

所以,通常线上真实业务场景要考虑基于stream流的形式进行获取,同样也可以基于XMLHttpRequestfetch去实现。

小结

到这里,“好玩的Web端API”这一节就告一段落了。

本节通过具体的实例介绍了:视频通过时间轴控制实现交互、本地媒体数据应用、硬件媒体流的获取&采集、录制存储、以及通过JS MediaSource API 创建动态媒体源播放异步拉取的媒体数据。

本章节涉及内容配合网络存储、实时传输,可以试着实现好玩的应用,而且不限于点播或直播。

Web端点播直播&播放器解决方案

下面再一起看一下“Web端点播直播的区别”,以及“浏览器端常见媒体源播放器解决方案”。

Web端点播直播的区别

什么是点播?

点播,我们通过字面意思理解,就是随点随播。也就是一些已经录制好的视频内容。像各媒体服务平台的一些电影、电视剧、Vlog 等等,已经存档起来,随时想看了可以通过相应渠道途径进行播放的媒体资源。甚至,看一部分之后,也可以暂时搁置,有时间了再顺着原来观看到的位置继续播放。

点播应用的大致业务流程:创作者 => 上传 => 转码 => 存储 <=> CDN分发 <=> 观众。

也就是由创作者录制,然后上传到媒体服务平台,媒体服务平台为了便于传播或播放端的适配可能会进行转码,然后再永久化存储,当有观众播放的时候再通过CDN节点进行数据分发,提高传输效率。

什么是直播?

直播,最大不同于点播的地方就是实时性。可以理解直播是随录随传随播。中间没有点播那样完整的存储。

直播的业务流程总体和点播大同小异:创作者 => 推流 <=> 存储 <=> 转码 <=> CDN分发 <=> 观众。

可能会由创作者,通过手机或摄像头实时拍摄并进行网络传输(推流)到媒体服务平台,媒体服务平台进行短暂存储(也可能需要转码)再分发到观众端进行播放;像娱乐类观众量大的直播服务通常也少不了CDN分发来优化传输效果。

浏览器端媒体容器类型的选择

WEB端媒体容器类型的选择比较局限于浏览器自身的播放能力,或可用于转换的API支持程度。

所以,WEB端点播的媒体类型,通常都是基于HTTP-MP4较多,当然FLV、HLS也都是可以的,并且像HLS的切片存储方式,相对还更利于直播的回放录像存储。

而Web端直播的媒体类型,大多会选择FLV或HLS。这两者的区别主要HLS是基于切片为特定时长的回放ts文件,所以这里切片内容时间越长延迟相对也就更大。FLV刚好可以弥补延时上的问题,可以做到很小的时间差。

但正如前面所说,FLV开始是设计给FLASH应用的,在WEB端原生并不支持;而HLS当下主要是苹果系浏览器或移动端场景支持尚可。

所以FLV和HLS这两种媒体类型内容的播放,我们就必须借助JS的能力播放方案上的折中支持。

播放器解决方案

主要就两种形式:浏览器支持和不支持的。

浏览器原生支持的

浏览器原生能支持的,比如基于H5 Video标签能进行播放的,直接走原生Video播放就行了。

而针对浏览器原生不支持的情况,就得具体分析了:

协议或容器类型不支持

这种情形,就需要我们使用浏览器提供的JS API能力,来进行JS解协议下载数据、解容器、重新包装为浏览器原生支持的媒体容器类型,然后通过MediaSource喂给Video。然后就是完全再借助浏览器原生Video能力,进行解码、渲染播放了。

目前Web端播放FLV、HLS,基于开源项目Flv.jsHLS.js的实现方式,就是这个转容器的方案。

解码器不支持

类似情形有HEVC(H.265)视频编码就是一个具体的例子。

由上图可见,大部分浏览器完全不支持或不能很好的支持HEVC解码。

针对这个情形,就不是JS下载数据解容器重新封装能解决的了,主要得解码。

而原生JS做解码这种大量数据计算的事情,一方面并不方便,另外效率肯定也是不行的。

而刚好JS有个 WebAssembly 的API,让我们可以把C/C++等语言的库编译为一个WASM依赖给JS调用,再配合WebWorker,就让前端解码变的可行了。

也就是JS下载数据,交由WASM解容器、解码,并输出解码后的视频帧(YUV)、音频帧(PCM)等数据,再使用JS的 WebGL&WebAudio 相关API完成渲染播放就可以了。

例如我们也有前面文章介绍过的,Web端HEVC解码播放器:http://blog.pyzy.net/post/qhww.html

有解密需求的

除了浏览器能力不足导致的原生无法播放,还有一些特殊的业务场景需要保证媒体数据的私密性。

也就是要求播放端,要对画面帧或音频帧数据解密后才能播放。

这个场景参考前两条基本方案,在解容器之后对每帧数据插入解密逻辑即可。

小结

本节通过具体的实例介绍了:点播直播业务流程的不同,Web端播放器解决方案的差异。

目前来说Web端媒体选型,必须基于浏览器所支持的能力边界:如协议类型、容器类型、解码能力,亦或可自行转容器( HLS \ FLV )、自行解码(WASM)的能力。

参考资料

本分享内容比较笼统的涉及到了Video标签的基本应用、也涉及到了媒体数据的获取(包括本地媒体数据、摄像头麦克风硬件媒体流、网络媒体资源)、也包括了一些简单的虚拟文件操作、动态媒体源、接协议解容器解码渲染等内容。

希望其中有个别点能引起你的兴趣,一起参与到相应技术的学习研究、应用研发中来。

基础API:
  https://developer.mozilla.org/zh-CN/docs/Web/Guide/HTML/Using_HTML5_audio_and_video

数据获取:
  https://developer.mozilla.org/zh-CN/docs/Web/API/MediaDevices/getUserMedia
  https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader
  https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest
  https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API

虚拟文件:
  https://developer.mozilla.org/zh-CN/docs/Web/API/Blob
  https://developer.mozilla.org/zh-CN/docs/Web/API/URL/createObjectURL

动态媒体源:
  https://developer.mozilla.org/zh-CN/docs/Web/API/Media_Source_Extensions_API

数据操作:
  https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
  https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/WebAssembly
  https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API

画音渲染:
  https://developer.mozilla.org/zh-CN/docs/Web/API/WebGL_API
  https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Audio_API

场景应用:
  https://developer.mozilla.org/zh-CN/docs/Web/API/MediaRecorder
  https://developer.mozilla.org/zh-CN/docs/Web/API/WebRTC_API

开源项目:
  http://chimee.org
  https://github.com/bilibili/flv.js
  https://github.com/video-dev/hls.js
  https://github.com/huzunjie/WasmVideoPlayer

非开源 WasmVideoPlayer 示例:
  http://lab.pyzy.net/qhww

本文链接:http://blog.pyzy.net/post/femedia.html

-- EOF --

Comments