无 Flash 时代,让直播拥抱 H5

GitChat 作者:villainhr
原文:无 Flash 时代,让直播拥抱 H5
关注微信公众号:「GitChat 技术杂谈」 一本正经的讲技术

直播是脱离于文字、图片来说,另外一种社交的方式。各大平台也在深耕这一领域,淘宝直播,花椒,映客,Now 直播,企鹅电竞。本人就职于腾讯 Now 直播前端开发,感觉直播能够尝试的领域真的太多太多,但是,Web 在这块一直是一个痛点。由于没有现成操作流的接口,只能简简单单的通过添加 video.src 尴尬的播放几段回放…… 这样造成的后果就是,在 Web 上,我们根本体会不到实时流畅的观看体验。

而且,根据 8 月份腾讯财报内容,直播贡献的收入增长的飞快。现在,我们也想让 Web 体会一把能够实时观看直播的方式,这应该怎么做呢?W3C 提出了 MSE 的标准,表义上来说就是,让前端能够操作视频流。HLS.js,FLV.js 本身也是基于 MSE 开发的。MSE 的出现,不仅能让 Web 接上直播,而且还可以根据协议自己控制相关的延迟率。

那直播,又和我们今天的主题 MSE/video 有啥关系呢?

在没有 MSE 的时候,直播形式要么在 flash 中播放,要么在客户端播放,要么利用 HLS 来手机端播放。不仅 HTML5 原生播放器的场景几乎可以说是没有,而且 H5 播放的延时性还非常高。最多我们也只能控制一下 视频播放 的表层工作,比如,暂停,播放,快进。例如:

<audio id="demo" src="audio.mp3"></audio>
<div>
  <button onclick="document.getElementById('demo').play()">播放声音</button>
  <button onclick="document.getElementById('demo').pause()">暂停声音</button>
  <button onclick="document.getElementById('demo').volume+=0.1">提高音量</button>
  <button onclick="document.getElementById('demo').volume-=0.1">降低音量</button>
</div>

这样,感觉和写 HTML 没啥区别,我们也并不能对流做一下神奇的操作,比如,跳帧,音视频同步,拿到 I/B/P 帧生成视频图像之类的。这其实只是给了我们另外一个界面的 UI API 而已,并不能让 所有能用代码写的程序,都可以用 JavaScript 来写 这一非常宏伟的目标。

后面,各台平台支持了 MSE,前端开发者从此也可以进行音视频的相关开发。因为,MSE 的主要工作是可以创建 media stream,并且喂给 video/audio 进行播放。从此,前端可以和写 C++ Java 的人有了共同的话题–二进制流的操作。

MSE 简介

MSE 是实际上是一系列 API 的集合。它的全称为:Media Source Extensions,看名字差不多都可以知道,MSE 就是一系列接口的拓展集合,里面包括了一系列 API:Media Source,Source Buffer 等。

我们来看一下 MSE 是如何完成基本流的处理的。

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
  console.log("The Media Source Extensions API is not supported.")
}

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp9"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  fetch(videoUrl)
    .then(function(response) {
      return response.arrayBuffer();
    })
    .then(function(arrayBuffer) {
      sourceBuffer.addEventListener('updateend', function(e) {
        if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
          mediaSource.endOfStream();
        }
      });
      sourceBuffer.appendBuffer(arrayBuffer);
    });
}

上面的代码完成了相关的获取流和处理流的两个部分。其中,主要利用的是 MS 和 Source Buffer 来完成的。URL.revokeObjectURL 主要是用来生成一个内联链接的,例如:blob:http://villainhr/demo_123d1dticn1@df1

MSE 中主要内容就是 MS 和 SourceBuffer,我们接下来着重介绍一下。

MediaSource

基本 API

整个 MS 内容可以直接参考 W3C

[Constructor]
interface MediaSource : EventTarget {
    readonly attribute SourceBufferList    sourceBuffers;
    readonly attribute SourceBufferList    activeSourceBuffers;
    readonly attribute ReadyState          readyState;
             attribute unrestricted double duration;
             attribute EventHandler        onsourceopen;
             attribute EventHandler        onsourceended;
             attribute EventHandler        onsourceclose;
    SourceBuffer addSourceBuffer(DOMString type);
    void         removeSourceBuffer(SourceBuffer sourceBuffer);
    void         endOfStream(optional EndOfStreamError error);
    void         setLiveSeekableRange(double start, double end); 
    void         clearLiveSeekableRange();
    static boolean isTypeSupported(DOMString type);
};

我们先从静态属性来看一下。

isTypeSupported

isTypeSupported 主要是用来检测 MS 是否支持某个特定的编码和容器盒子。例如:

MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E, mp4a.40.2"')

那我怎么查看我想要使用到的 MIME 呢?

如果你有现成的 video 文件,可以直接使用 FFmpeg 进行分析:ffmpge -i video.mp4。不过,这个只是给你文件的相关描述,例如:

Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'video.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 1
    compatible_brands: isomavc1
  Duration: 00:00:03.94, start: 0.000000, bitrate: 69 kb/s
    Stream #0:0(und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 61 kb/s (default)
    Metadata:
      handler_name    : SoundHandler

那实际怎么得到,像上面一样的 video/mp4; codecs="avc1.42E01E, mp4a.40.2" 的 MIME 内容呢?具体映射主要参考:MIME doc 即可。

SourceBuffer 的处理

SourceBuffer 是 MS 下的一个子集,相当于就是具体的音视频轨道,具体内容是啥以及干啥的,我们在后面有专题进行介绍。在 MS 层,提供了相关的 API 可以直接对 SB 进行相关的创建,删除,查找等。

addSourceBuffer

该是用来返回一个具体的视频流 SB,接受一个 mimeType 表示该流的编码格式。例如:

var mimeType = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
var sourceBuffer = mediaSource.addSourceBuffer(mimeType);

实际上,SB 的操作才是真正影响到 video/audio 播放的内容。

function sourceOpen (_) {
  var mediaSource = this;
  var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
  fetchAB(assetURL, function (buf) {
    sourceBuffer.addEventListener('updateend', function (_) {
      mediaSource.endOfStream();
      video.play();
    });
    // 通过 fetch 添加视频 Buffer
    sourceBuffer.appendBuffer(buf);
  });
};

它通过 appendBuffer 直接添加视频流,实现播放。不过,在使用 addSourceBuffer 创建之前,还需要保证当前浏览器是否支持该编码格式。当然,不支持也行,顶多是当前 MS 报错,断掉当前 JS 线程。

removeSourceBuffer

用来移除某个 sourceBuffer。比如当前流已经结束,那么你就没必要再保留当前 SB 来占用空间,可以直接移除。具体格式为:

mediaSource.removeSourceBuffer(sourceBuffer);

sourceBuffers

sourceBuffers 是 MS 实例上的一个属性,它返回的是一个 SourceBufferList 的对象,里面可以获取当前 MS 上挂载的所有 SB。不过,只有当 MS 为 open 状态的时候,它才可以访问。具体使用为:

let SBs = mediaSource.sourceBuffers;

那我们怎么获取到具体的 SB 对象呢?因为,其返回值是 SourceBufferList 对象,具体格式为:

interface SourceBufferList : EventTarget {
    readonly attribute unsigned long length;
             attribute EventHandler  onaddsourcebuffer;
             attribute EventHandler  onremovesourcebuffer;
    getter SourceBuffer (unsigned long index);
};

简单来说,你可以直接通过 index 来访问具体的某个 SB:

let SBs = mediaSource.sourceBuffers;

let SB1 = SBs[0];

SBL 对象还提供了 addsourcebufferremovesourcebuffer 事件,如果你想监听 SB 的变化,可以直接通过 SBL 来做。这也是为什么 MS 没有提供监听事件的一个原因。

所以,删除某一个 SB 就可以通过 SBL 查找,然后,利用 remove 方法移除即可:

let SBs = mediaSource.sourceBuffers;

let SB1 = SBs[0];

mediaSource.removeSourceBuffer(SB1);

另外,MS 上,还有另外一个 SBL。基本内容为:

activeSourceBuffers

activeSourceBuffers 实际上是 sourceBuffers 的子集,返回的同样也是 SBL 对象。为什么说也是子集呢?

因为 ASBs 包含的是当前正在使用的 SB。因为前面说了,每个 SB 实际上都可以具体代表一个 track,比如,video track,audio track,text track 等等,这些都算。那怎么标识正在使用的 SB 呢?

很简单,不用标识啊,因为,控制哪一个 SB 正在使用是你来决定的。如果非要标识,就需要使用到 HTML 中的 video 和 audio 节点。通过

audioTrack = media.audioTracks[index]
videoTrack = media.videoTracks[index]

// media 为具体的 video/audio 的节点
// 返回值就是 video/audio 的底层 tracks

audioTrack = media.audioTracks.getTrackById( id )
videoTrack = media.videoTracks.getTrackById( id )

videoTrack.selected // 返回 boolean 值,标识是否正在被使用

上面的代码只是告诉你,正在使用 的含义是什么。对于,我们实际编码的 SB 来说,并没有太多关系,了解就好。上面说了 ASBs 返回值也是一个 SBL。所以,使用方式可以直接参考 SBL 即可。

状态切换

要说道状态切换,我们得先知道 MS 一共有几个状态值。MS 本身状态并不复杂,一共只有三个状态值:

enum ReadyState {
    "closed",
    "open",
    "ended"
};
  • closed: 当前的 MS 并没有和 HTMLMedia 元素连接。

  • open: MS 已经和 HTMLMedia 连接,并且等待新的数据被添加到 SB 中去。

  • ended: 当调用 endOfStream 方法时会触发,并且此时依然和 HTMLMedia 元素连接。

记住,closed 和 ended 到的区别关键点在于有没有和 HTMLMedia 元素连接。

其对应的还有三个监听事件:

  • sourceopen: 当状态变为 open 时触发。常常在 MS 和 HTMLMedia 绑定时触发。

  • sourceended: 当状态变为 ended 时触发。

  • sourceclose: 当状态变为 closed 时触发。

那哪种条件下会触发呢?

sourceopen 触发

sourceopen 事件相同于是一个总领事件,只有当 sourceopen 时间触发后,后续对于 MS 来说,才是一个可操作的对象。

通常来说,只有当 MS 和 video 元素成功绑定时,才会正常触发:

let mediaSource = new MediaSource();
vidElement.src = URL.createObjectURL(mediaSource);

其实这简单的来说,就是给 MS 添加 HTML media 元素。其整个过程为:

  1. 先延时 media 元素的 load 事件,将 delaying-the-load-event-flag 设置为 false

  2. readyState 设置为 open。

  3. 触发 MS 的 sourceopen 事件

sourceended 触发

sourceended 的触发条件其实很简单,只有当你调用 endOfStream 的时候,会进行相关的触发。

mediaSource.endOfStream();

这个就没啥需要过多讲的了。

sourceclose 的触发

sourceclose 是在 media 元素和 MS 断开的时候,才会触发。那这个怎么断开呢?

难道直接将 media 的元素的 src 直接设置为 null 就 OK 了吗?

要是这样,我就日了狗了。MS 会这么简单么?实际上并不,如果要手动触发 sourceclose 事件的话,则需要下列步骤:

  1. 将 readyState 设置为 closed

  2. 将 MS.duration 设置为 NaN

  3. 移除 activeSourceBuffers 上的所有 Buffer

  4. 触发 activeSourceBuffers 的 removesourcebuffer 事件

  5. 移除 sourceBuffers 上的 SourceBuffer。

  6. 触发 sourceBuffers 的 removesourcebuffer 事件

  7. 触发 MediaSource 的 sourceclose 事件

到这里,三个状态事件基本就介绍完了。不过,感觉只有 sourceopen 才是最有用的一个。

track 的切换

track 这个概念其实是音视频播放的轨道,它和 MS 没有太大的关系。不过,和 SB 还是有一点关系的。因为,某个一个 SB 里面可能会包含一个 track 或者说是几个 track。所以,推荐某一个 SB 最好包含一个值包含一个 track,这样,后面的 track 也方便更换。

在 track 中的替换里,有三种类型,audio,video,text 轨道。

video 切换

切换的含义有两种,一种是移除原有的,一种是添加新的。这里,我们需要分两部分来讲解。

移除原有不需要 track

  1. 从 activeSourceBuffers 移除与当前 track 相关的 SB

  2. 触发 activeSourceBuffers 的 removesourcebuffer 事件

添加指定的 track

  1. 从 activeSourceBuffers 添加指定的 SourceBuffer

  2. 触发 activeSourceBuffers 的 addsourcebuffer 事件

audio 切换

audio 的切换和 video 的过程一模一样。这里我就不过多赘述了。

MS duration 修正机制

MS 的 duration 实际上就是 media 中播放的时延。通常来说,A/V track 实际上是两个独立的播放流,这中间必定会存在先关的差异时间。但是,media 播放机制永远会以最长的 duration 为准。

这种情况对于 live stream 的播放,特别适合。因为 liveStream 是不断动态添加 buffer,但是 buffer 内部会有一定的时长的,而 MS 就需要针对这个 buffer 进行动态更新。

整个更新机制为:

  1. 当前 MS.duration 更新为 new duration。

  2. 如果 new duration 比 sourceBuffers 中的最大的 pts 小,这时候就会报错。

  3. 让最后一个的 sample 的 end time 为所后 timeRanges 的 end time。

  4. 将 new duration 设置为当前 SourceBuffer 中最大的 endTime。

  5. 将 video/audio 的播放时长(duration) 设置为最新的 new duration。

SourceBuffer

SourceBuffer 则是 MS 子属中最重要的内容。也就是说,所有的 media track 的内容都是存储在 SB 里面的。

那 SB 里面又有哪些内容呢?

直接看接口吧:

interface SourceBuffer : EventTarget {
             attribute AppendMode          mode;
    readonly attribute boolean             updating;
    readonly attribute TimeRanges          buffered;
             attribute double              timestampOffset;
    readonly attribute AudioTrackList      audioTracks;
    readonly attribute VideoTrackList      videoTracks;
    readonly attribute TextTrackList       textTracks;
             attribute double              appendWindowStart;
             attribute unrestricted double appendWindowEnd;
             attribute EventHandler        onupdatestart;
             attribute EventHandler        onupdate;
             attribute EventHandler        onupdateend;
             attribute EventHandler        onerror;
             attribute EventHandler        onabort;
    void appendBuffer(BufferSource data);
    void abort();
    void remove(double start, unrestricted double end);
};

其中,SB 中有一个很重要的概念–mode。该字段决定了 A/V segment 是怎样进行播放的。

播放模式

mode 的取值有两个,一个是 segments,一个是 sequence

segments 表示 A/V 的播放时根据你视频播放流中的 pts 来决定,该模式也是最常使用的。因为音视频播放中,最重要的就是 pts 的排序。因为,pts 可以决定播放的时长和顺序,如果一旦 A/V 的 pts 错开,有可能就会造成 A/V sync drift。

sequence 则是根据空间上来进行播放的。每次通过 appendBuffer 来添加指定的 Buffer 的时候,实际上就是添加一段 A/V segment。此时,播放器会根据其添加的位置,来决定播放顺序。还需要注意,在播放的同时,你需要告诉 SB,这段 segment 有多长,也就是该段 Buffer 的实际偏移量。而该段偏移量就是由 timestampOffset 决定的。整个过程用代码描述一下就是:

sb.appendBuffer(media.segment);
sb.timestampOffset += media.duration;

另外,如果你想手动更改 mode 也是可以的,不过需要注意几个先决条件:

  1. 对应的 SB.updating 必须为 false.

  2. 如果该 parent MS 处于 ended 状态,则会手动将 MS readyState 变为 open 的状态。

如何界定 track

这里先声明一下,track 和 SB 并不是一一对应的关系。他们的关系只能是 SB : track = 1: 1 or 2 or 3。即,一个 SB可能包含,一个 A/V track(1),或者,一个 Video track ,一个Audio track(2),或者 再额外加一个 text track(3)。

上面也说过,推荐将 track 和 SB 设置为一一对应的关系,应该这样比较好控制,比如,移除或者同步等操作。具体编码细节我们有空再说,这里先来说一下,SB 里面怎么决定 track 的播放。

track 最重要的特性就是 pts ,duration,access point flag。track 中 最基本的单位叫做 Coded Frame,表示具体能够播放的音视频数据。它本身其实就是一些列的 media data,并且这些 media data 里面必须包含 pts,dts,sampleDuration 的相关信息。在 SB 中,有几个基本内部属性是用来标识前面两个字段的。

  • last decode timestamp: 用来表示最新一个 frame 的编码时间(pts)。默认为 null 表示里面没有任何数据

  • last frame duration: 表示 coded frame group 里面最新的 frame 时长。

  • highest end timestamp: 相当于就是最后一个 frame 的 pts + duration

  • need random access point flag: 这个就相当于是同步帧的意思。主要设置是根据音视频流 里面具体字段决定的,和前端这边编码没关系。

  • track buffer ranges: 该字段表示的是 coded frame group 里面,每一帧对应存储的 pts 范围。

这里需要特别说一下 last frame duration 的概念,其实也就是 Coded Frame Duration 的内容。

Coded Frame Duration 针对不同的 track 有两种不同的含义。一种是针对 video/text 的 track,一种是针对 audio 的 track:

  • video/text: 其播放时长(duration)直接是根据 pts 直接的差值来决定,和你具体播放的 samplerate 没啥关系。虽然,官方也有一个计算 refsampelDuration 的公式:duration = timescale / fps,不过,由于视频的帧率是动态变化的,没什么太大的作用。

  • audio: audio 的播放时长必须是严格根据采样频率来的,即,其播放时间必须和你自己定制的 timescale 以及 sampleRate 一致才行。针对于 AAC,因为其采样频率常为 44100Hz,其固定播放时长则为:duration = 1024 / sampleRate * timescale

所以,如果你在针对 unstable stream 做同步的话,一定需要注意这个坑。有时候,dts 不同步,有可能才是真正的同步。

我们再回到上面的子 title 上– 如果界定 track。一个 SB 里面是否拥有一个或者多个 track,主要是根据里面的视频格式来决定的。打个比方,比如,你是在编码 MP4 的流文件。它里面的 track 内容,则是根据 moov box 中的 trak box 来判断的。即,如果你的 MP4 文件只包含一个,那么,里面的 track 也有只有一个。

SB buffer 的管理

SB 内部的状态,通常根据一个属性:updating 值来更新。即,它只有 true 或者 false 两种状态:

  • true:当前 SB 正在处理添加或者移除的 segment

  • false:当前 SB 处于空闲状态。当且仅当 updating = false 的时候,才可以对 SB 进行额外的操作。

SB 内部的 buffer 管理主要是通过 appendBuffer(BufferSource data)remote() 两个方法来实现的。当然,并不是所有的 Buffer 都能随便添加给指定的 SB,这里面是需要条件和相关顺序的。

  • 该 buffer,必须满足 MIME 限定的类型

  • 该 buffer,必须包含 initialization segments(IS) 和 media segments(MS).

下图是相关的支持 MIME

image.png-107.1kB

这里需要提醒大家一点,MSE 只支持 fmp4 的格式。具体内容可以参考: FMP4 基本解析。上面提到的 IS 和 MS 实际上就是 FMP4 中不同盒子的集合而已。

这里简单阐述一下:

Initialization segments

FMP4 中的 IS 实际上就是:ftyp + moov。里面需要包含指定的 track ID,相关 media segment 的解码内容。下面为基本的格式内容:

[ftyp] size=8+24
  major_brand = isom
  minor_version = 200
  compatible_brand = isom
  compatible_brand = iso2
  compatible_brand = avc1
  compatible_brand = mp41
[mdat] 
[moov] 
  [mvhd] 
    timescale = 1000
    duration = 13686
    duration(ms) = 13686
  [trak] 
  [trak] 
  [udta] 

具体内容编码内容,我们就放到后面来讲解,具体详情可以参考:W3C Byte Stream Formats。我们可以把 IS 类比为一个文件描述头,该头可以指定该音视频的类型,track 数,时长等。

Media Segment

MS 是具体的音视频流数据,在 FMP4 格式中,就相当于为 moof + mdat 两个 box。MS 需要包含已经打包和编码时间后的数据,其会参考最近的 IS 头内容。

相关格式内容,可以直接参考 MP4 格式解析

在了解了 MS 和 IS 之后,我们就需要使用相应的 API 添加/移除 buffer 了。

这里,需要注意一下,在添加 Buffer 的时候,你需要了解你所采用的 mode 是哪种类型,sequence 或者 segments。这两种是完全两种不同的添加方式。

segments

这种方式是直接根据 MP4 文件中的 pts 来决定播放的位置和顺序,它的添加方式极其简单,只需要判断 updating === false,然后,直接通过 appendBuffer 添加即可。

if (!sb.updating) {
    let MS = this._mergeBuffer(media.tmpBuffer);

    sb.appendBuffer(MS); // ****

    media.duration += lib.duration; 
    media.tmpBuffer = [];
}

sequence

如果你是采用这种方式进行添加 Buffer 进行播放的话,那么你也就没必要了解 FMP4 格式,而是了解 MP4 格式。因为,该模式下,SB 是根据具体添加的位置来进行播放的。所以,如果你是 FMP4 的话,有可能就有点不适合了。针对 sequence 来说,每段 buffer 都必须有自己本身的指定时长,每段 buffer 不需要参考的 baseDts,即,他们直接可以毫无关联。那 sequence 具体怎么操作呢?

简单来说,在每一次添加过后,都需要根据指定 SB 上的 timestampOffset。该属性,是用来控制具体 Buffer 的播放时长和位置的。

if (!sb.updating) {
    let MS = this._mergeBuffer(media.tmpBuffer);

    sb.appendBuffer(MS); // ****

    sb.timestampOffset += lib.duration; // ****
    media.tmpBuffer = [];
}

上面两端打 * 号的就是重点内容。该方式比较容易用来直接控制 buffer 片段的添加,而不用过度关注相对 baseDTS 的值。

控制播放片段

如果要在 video 标签中控制指定片段的播放,一般是不可能的。因为,在加载整个视频 buffer 的时候,视频长度就已经固定的,剩下的只是你如果在 video 标签中控制播放速度和音量大小。而在 MSE 中,如何在已获得整个视频流 Buffer 的前提下,完成底层视频 Buffer 的切割和指定时间段播放呢?

这里,需要利用 SB 下的 appendWindowStartappendWindowEnd 这两个属性。

他们两个属性主要是为了设置,当有视频 Buffer 添加时,只有符合在 [start,end] 之间的 media frame 才能 append,否则,无法 append。例如:

sourceBuffer.appendWindowStart = 2.0;
sourceBuffer.appendWindowEnd = 5.0;

设置添加 Buffer 的时间戳为 [2s,5s] 之间。appendWindowStartappendWindowEnd 的基准单位为 s。该属性值,通常在添加 Buffer 之前设置。

SB 内存释放

SB 内存释放其实就和在 JS 中,将一个变量指向 null 一样的过程。

var a = new ArrayBuffer(1024 * 1000);
a = null; // start garbage collection

在 SB 中,简单的来说,就是移除指定的 time ranges’ buffer。需要用到的 API 为:

remove(double start, unrestricted double end);

具体的步骤为:

  • 找到具体需要移除的 segment。

  • 得到其开始(start)的时间戳(以 s 为单位)

  • 得到其结束(end)的时间戳(以 s 为单位)

  • 此时,updating 为 true,表明正在移除

  • 完成之后,出发 updateend 事件

如果,你想直接清空 Buffer 重新添加的话,可以直接利用 abort() API 来做。它的工作是清空当前 SB 中所有的 segment,使用方法也很简单,不过就是需要注意不要和 remove 操作一起执行。更保险的做法就是直接,通过 updating===false 来完成:

if(sb.updating===false){
    sb.abort();
}

这时候,abort 的主要流程为:

  • 确保 MS.readyState===”open”

  • 将 appendWindowStart 设置为 pts 原始值,比如,0

  • 将 appendWindowEnd 设置为正无限大,即,Infinity

到这里,整个流程差不多就已经介绍完了。实际代码,可以参考一下,w3c 的 example 下面,我们主要来检查一下,实际 HTMLMediaElement 和 MSE 之间又有啥不干净的关系。

HTMLMediaElement 播放设定

HTMLMediaElement 是一个集合概念,里面包含了 VideoAudio 元素。也可以说,A/V 两个元素其实就是继承了 HTMLMediaElement 的原型对象。

我们先来看一下 HTMLMediaElement 上面具有哪些属性:

interface HTMLMediaElement : HTMLElement {

  // error state
  readonly attribute MediaError? error;

  // network state
  attribute DOMString src;
  attribute MediaProvider? srcObject;
  readonly attribute DOMString currentSrc;
  attribute DOMString? crossOrigin;
  const unsigned short NETWORK_EMPTY = 0;
  const unsigned short NETWORK_IDLE = 1;
  const unsigned short NETWORK_LOADING = 2;
  const unsigned short NETWORK_NO_SOURCE = 3;
  readonly attribute unsigned short networkState;
  attribute DOMString preload;
  readonly attribute TimeRanges buffered;
  void load();
  CanPlayTypeResult canPlayType(DOMString type);

  // ready state
  const unsigned short HAVE_NOTHING = 0;
  const unsigned short HAVE_METADATA = 1;
  const unsigned short HAVE_CURRENT_DATA = 2;
  const unsigned short HAVE_FUTURE_DATA = 3;
  const unsigned short HAVE_ENOUGH_DATA = 4;
  readonly attribute unsigned short readyState;
  readonly attribute boolean seeking;

  // playback state
  attribute double currentTime;
  void fastSeek(double time);
  readonly attribute unrestricted double duration;
  object getStartDate();
  readonly attribute boolean paused;
  attribute double defaultPlaybackRate;
  attribute double playbackRate;
  readonly attribute TimeRanges played;
  readonly attribute TimeRanges seekable;
  readonly attribute boolean ended;
  attribute boolean autoplay;
  attribute boolean loop;
  void play();
  void pause();

  // controls
  attribute boolean controls;
  attribute double volume;
  attribute boolean muted;
  attribute boolean defaultMuted;

  // tracks
  [SameObject] readonly attribute AudioTrackList audioTracks;
  [SameObject] readonly attribute VideoTrackList videoTracks;
  [SameObject] readonly attribute TextTrackList textTracks;
  TextTrack addTextTrack(TextTrackKind kind, optional DOMString label = "", optional DOMString language = "");
};

上面这些属性都是非常精华和重要的内容。不过,说实话,真 TM 多。。。

更完整的可以直接参考 W3C 官方文档的说明:HTMLMedia

video 播放事件的迷

video 的播放事件可以说是比整个 HTMLMediaElment 属性更恶心的内容。大家可以先看一下基本事件:playing , waiting , seeking , seeked , ended , loadedmetadata , loadeddata , canplay , canplaythrough , durationchange , timeupdate , play , pause , ratechange , volumechange , suspend , emptied , stalledcanplaythrough

有时候看见这些,脑子都是炸的。不过,说归说,生活还是要过的,就像娶了老婆,总不能说薪资保密不交钱吧。

这里的事件大部分是围绕的 HME(HTMLMediaElment)中的 readyState 的。

其基本的内容有:

 // ready state
  const unsigned short HAVE_NOTHING = 0;
  const unsigned short HAVE_METADATA = 1;
  const unsigned short HAVE_CURRENT_DATA = 2;
  const unsigned short HAVE_FUTURE_DATA = 3;
  const unsigned short HAVE_ENOUGH_DATA = 4;

readyState 本身是代表当前播放片段的 Buffer 内容。我们先来说一下,每个值代表的含义,这样,也就能够理解上面具体事件到底什么时候能够触发,以及为什么能够触发。

  • HAVE_NOTHING = 0: 当前 video 没有包含任何可使用的数据。即,它没有和任何流绑定在一起。此时,啥事件都不会触发。

  • HAVE_METADATA = 1: 得到视频流的基本数据,比如,视频编码格式,视频 duration 等。不过,还没有得到实际的数据,当前还不能无法播放。

  • HAVE_CURRENT_DATA = 2: 拥有当前视频播放数据,但并不包括下一帧的数据。即,很有可能 Video 在播放完当前的帧后就停止。并且,当且仅当 readyState >= HAVE_CURRENT_DATA 才可以完成播放。

  • HAVE_FUTURE_DATA = 3: 这是比上一个状态,数据更丰富的一个状态。这时,不仅拥有当前视频播放数据,还包括下一帧的播放数据。

  • HAVE_ENOUGH_DATA = 4: 表示当前 mediaSource 中的视频流 Buffer 已经满了。即,可以流畅的播放一段时间的数据。

就这 5 个状态,实际上映射的是上面的 6 个事件(这里还不包括网络状态的事件。。。):loadedmetadata , loadeddata , canplay , canplaythrough ,playing,waiting

这里简述一下,具体触发事件和其所对应的状态吧:

  • loadedmetadata: 当 readyState === 1 时,触发。表示已经获得相关的视频元数据。

  • loadeddata: 当 readyState === 2,触发。表示当前已经可以播放了。该事件其实和 loadedmetadata 区别不是特别大。

  • canplay: 当 readyState === 3,触发。此时,已经有一部分数据,但并不代表可以完整的播放到音频的结束,中间可能会存在暂停和缓存的操作。

  • canplaythrough: 当 readyState === 4,触发。此时,video 可以一直播放到视频流的结束。相当于已经下好一段完整的视频。

剩下两个事件则主要在视频播放途中会经常触发–waitingplaying,和具体某个 readyState 状态的联系就不太大了。

  • playing: 当在视频由于缺少 media source 而暂停缓存以及手动暂停,又重新播放时,会触发该事件。简单来说就是,readyState >= HAVE_FUTURE_DATA 以及 paused=== false 条件下,playing 事件可以触发。

  • waiting: 由于没有下一帧的数据,导致视频进行 buffer 加载,造成 Video 的暂停(但并不是用户的暂停)。该事件的触发条件为:readyState <= HAVE_CURRENT_DATA && paused===false。需要注意的是,用户手动暂停是不会触发该事件的,这和 pause 事件有着本质的区别。

上面这 6 个事件是完全和 video readyState 底层状态相关的,而和上层用户操作没有任何关联的。除了这 6 个事件以外的其它事件,我们这里简单做个分类即可:

image.png-92.5kB

上面这些是一些比较常见和常用的事件,当然,还有一些错误处理的事件,这里就不赘述了。详情可以参考:Video 事件总结

video 为啥不能自动播放?

在一些官网上,有时候需要在首屏加上一个 video,使其在罩层下面播放。非常简单的想法就是直接加上 autoplay 属性,例如:

<video src="audio.ogg" controls autoplay loop>
<p>你的浏览器不支持audio标签</p>
</video>

这样,在大部分 PC 上播放都没有问题。但是,In China,有两个天王 APP,一个是 QQ,一个是 WX。这两位爸爸里面用的是 TBS 和 QB 来做 webview,内核虽然用到了 blink,但是名字改了,改动总是有的。

所以,有时候在 WX 和 QQ 上,上面的 video 标签并不能直接播放。为什么呢?

母鸡。。。内核又不是我写的。但基本上可以根据官方文档上猜测一下:

因为,Video 内部有一个叫做 autoplaying flag,如果为 true 的话,它会阻塞 Document 的 load 事件,也就是延长渲染的时间。WX 可能为了用户体验,手动取消的 autoplay 这一过程。那我们有没有什么其他办法,做到强制触发呢?

嗯,有的。在前面我们了解过关于 readyState 相关的事件,借助他们可以完成自动播放的一个 trick。

video.addEventListener('canplay',function(e){
    // now the readyState has already larger than 3
    // then, using JS to play the video
    video.play();
})

实际上,Video 自动播放,可以直接映射为 HTMLMedia 的 load 方法。如果,你没有给 Video 添加 autoplay 的属性,可以尝试使用 load 方法来直接播放。这样,既可以避免无意识延长 document.onload 的触发,又可以比较灵活的进行脚本配置。

window.onload = function(){
    video.load();
}

当你调用 load 方法时,我们得清楚为什么它能这样做?难道它可以手动修改 readyState 状态,还是其它底层的操作呢?

使用 load() 方法进行加载时。

  1. 如果,video 元素已经开始加载其它数据,则中断当前 video 元素资源获取

  2. 移除当前 video 元素的所有处理程序。

  3. 开始通过 network 获取资源

  4. 将 playbackRate 设置为 defaultPlaybackRate

  5. autoplaying flag 设置为 true(这就是重点!和手动设置 autoplay 效果一致)

  6. 触发后面资源获取的流程

用 JS 来 seek video

video 本身有 seekingseeked 事件来作为用户 seek 操作的监听函数。

  • seeking: 当用户开始拖动进度条的时候触发

  • seeked: 当用户拖动完进度条时触发

不过,最常用的还是 seeked 时间,只需要监听用户最后一次松手的位置。

但是,这样平白无故监听两个事件干嘛呢?

吃饱了吗?

我们要先明确我们的需求,即,能不能用 JS 来完成 seek 的操作呢?

可以,HM(HTMLMedia) 提供给我们了几个非常有用的 API:

  • seeking[boolean]:返回当前用户是否正在 seek 操作

  • seekable[TimeRanges]: 返回可供用户 seek 的 timeRanges 范围。

  • fastSeek(time): 跳转到指定时间附近。为啥说附近呢?因为,该 seek 的位置的精确度主要是根据 speed 来决定的。但是如果,当前 video 没有数据,该方法是不会生效的。

  • currentTime: 如果想要 seek 到精确时间,可以直接利用该属性来设置。

上面几个 API 可以基本完成 seek 的逻辑判断和相关操作。最简单的 seek 代码为:

function playSound(time) {
  sfx.currentTime = time;
  sfx.play();
}
试一试控制条

如果你在添加 video 的使用,额外加上了 controls 的属性。那么,在 Video 播放的时候,会在下方出现一个控制条。这个就是我们接下来想要来探索的一块领域。

从原始播放条上,我们基本上可以了解到几个功能:

  • 播放

  • 暂停

  • seek

  • 音量控制

  • 全屏(这个看情况实现)

考虑到有一些刷剧的同学喜欢用倍速来看剧,我们这里还可以提供变速播放的功能。综上所述,我们实现的基本功能有 5 个。

而 HM 也同样在 JS 上提供了相关的方法和属性给我们进行使用:

media.paused
media.ended
media.defaultPlaybackRate [ = value ]
media.playbackRate [ = value ]
media.played
media.play()
media.pause()
media.currentTime;
media.volume
media.muted
media.defaultMuted

利用上述这些方法,我们基本上可以完成自定义控制条的设置。后面,我们根据几个基本的场景来做一下 JS code 的模拟。

调节音量和静音

这里主要使用上面的 volumemuted 属性。

function volumeControl(degree,video) {
  // the degree is always between, if not ,it will throw IndexSizeError  exception
  degree = degree < 0 ? 0 || degree;
  degree = degree > 1 ? 1 || degree;

  video.volume = degree;
}
function toggleMute(video) {
  video = !video.muted;
}

这里,额外再介绍一个和静音有关的属性 defaultMuted,该属性是用来决定音频播放的默认属性。

设置倍速播放

在倍速播放的时候,需要了解三个播放模式。一个是 fast-forwardingreversingnormal playback。看起来有点懵厚,我保证看了中文之后,你一定想说句 MMP。

  • fast-forwarding:快进

  • reversing:快退

  • normal playback:正常播放

在设置倍速的时候,需要额外注意,当用户从 fast-forwarding 到 normal playback 时,播放器会直接读取默认的 defaultPlaybackRate 而不是你通常设置的 playbackRate。所以,我们不论是在 seek 还是在其它播放模式的时候,最好是将默认的 rate 一并设置。

function switchRate(ratetype,video) {
  // ratetype could only be larger than 0
  video.defaultPlaybackRate = video.playbackRate = ratetype;
}

模拟用户 seek

在用户 seek 过程中,最主要的问题在于,在 seek 完成之后,播放器的状态是不固定的,即,有可能播放器还在加载视频数据,并且加载完成之后不能继续播放。我们需要解决的一个逻辑问题主要在这里。

function seekTime(curTime,video) {
  // curTime should always less than the duration of video
  curTime = curTime > video.duration ? video.duration : curTime;

  video.currentTime = curTime;

  if(video.readyState <=2 ){
    video.pause();
  }

  video.addEventListener('canplay',function(e){
    e.target.play();
  })
}

暂停/播放

这个算是 video 播放器的基本功能,实现起来也是很简单的。

function togglePlay(video) {
  video.paused ? video.play() : video.pause();
}
media 与 MSE 的联系

前面简单说了一下,HTMLMediaElement 内部的相关属性和事件。但本文并不是为了教大家如何做一个 UI 控制,而是深入到 MSE 那一层,能够做到任意控制视频流 Buffer。虽然,HME 和 MSE 分别位于不同的层级,但是,内部还是有一定的联系。这两者之间联系的主要还是在于 Buffered 的 TimeRange 对象和相关的 track。

在 HME 中,提供了:

  • media.buffered:获取 HME 中,可播放的 time Range 片段。如果简简单单通过 src 来获取资源,那么,buffered 一般只会返回一个 range,从 0 ~ end of duration。如果,你在获取视频的时候,有涉及 seek 的操作,那么,这个就有有可能会返回多个 range。当然,如果你是通过 MSE 来完成 appendBuffer 的话,那么,这里的 HME.buffered 就和 MSE.SB.buffered 中的内容是完全一致的。

  • audioTracks/videoTracks: 返回 mediasource 中具体播放的 tracklist。这里的属性和 MSE.SB.audioTracks/videoTracks 一致。

上面这两个属性,就是 HME 和 MSE 的主要联系。不过,videoTracks 里面又有哪些内容可以给前端开发者使用呢?

audioTracks/videoTracks

这里,我们简单介绍一下里面的基本内容:

interface AudioTrackList : EventTarget {
  readonly attribute unsigned long length;
  getter AudioTrack (unsigned long index);
  AudioTrack? getTrackById(DOMString id);

  attribute EventHandler onchange;
  attribute EventHandler onaddtrack;
  attribute EventHandler onremovetrack;
};
interface AudioTrack {
  readonly attribute DOMString id;
  readonly attribute DOMString kind;
  readonly attribute DOMString label;
  readonly attribute DOMString language;
  attribute boolean enabled;
};
interface VideoTrackList : EventTarget {
  readonly attribute unsigned long length;
  getter VideoTrack (unsigned long index);
  VideoTrack? getTrackById(DOMString id);
  readonly attribute long selectedIndex;

  attribute EventHandler onchange;
  attribute EventHandler onaddtrack;
  attribute EventHandler onremovetrack;
};
interface VideoTrack {
  readonly attribute DOMString id;
  readonly attribute DOMString kind;
  readonly attribute DOMString label;
  readonly attribute DOMString language;
  attribute boolean selected;
};

仔细看一下,AudioTrack 和 VideoTrack 内容其实没啥太大的差别,这两个就一起介绍了。接下来,我们从 List 到 Track 来进行介绍。

List

TrackList 本身是用来存放 mediasource 中的 tracks,里面可能包含不止一个 track。比如,audio 中,可能还分左声道,右声道等等。并且,有些 track 只是作为备用,并没有实际播放出来,这个也会存放在 List 当中。在 List 中获取 track 的方式有两种,一种是直接根据 track 的 id,还有一种是根据序号来获取:

audiolist[0]; // get the first track in the audio track list
audilist.getTrackById('12fncissa1@d3');

不过,说实在的,就算你获取到指定的 track 之后,你也并不能做啥子事情(除了 selected 属性)。顶多是获取相关 track 的信息内容。虽然说是鸡肋,但又有点用。。。

并且,List 上还提供了相关的事件,来对你的 track 操作进行监听。

Track

具体的 track 里面的内容全是一些描述属性,并没有啥可操作的内容。如下:

  readonly attribute DOMString id;
  readonly attribute DOMString kind;
  readonly attribute DOMString label;
  readonly attribute DOMString language;
  attribute boolean selected;

selected 标识当前 track 是否被使用。该属性,也可以用来被设定。

track.selected = false;

kind 这个字段信息,实际上是从 mediasource,即,音视频源文件中提取出来的,比如,ftyp + moov box 中。主要内容有:

CategoryDefinitionApplies to…
“alternative”A possible alternative to the main track, e.g., a different take of a song (audio), or a different angle (video).Audio and video.
“captions”A version of the main video track with captions burnt in. (For legacy content; new content would use text tracks.)Video only.
“descriptions”An audio description of a video track.Audio only.
“main”The primary audio or video track.Audio and video.
“main-desc”The primary audio track, mixed with audio descriptions.Audio only.
“sign”A sign-language interpretation of an audio track.Video only.
“subtitles”A version of the main video track with subtitles burnt in. (For legacy content; new content would use text tracks.)Video only.
“translation”A translated version of the main audio track.Audio only.
“commentary”Commentary on the primary audio or video track, e.g., a director’s commentary.Audio and video.
“” (empty string)No explicit kind, or the kind given by the track’s metadata is not recognized by the user agent.Audio and video.

这里,其实看了也没啥用。。。你又不是真的做音视频开发的。过度了解真的有害身心健康。

label 是该 track 上的描述信息。

language字段属性,必须是 BCP 47 格式才行,比如:und 这样的格式。如果,不是 BCP 47 格式的话,则会被当作空值。

业务实践

到这里,相信大家已经对直播流所需要的技术都已经了解,我们接下来主要来实践一下 MSE 在 H5 播放器中具体的应用和实践。H5 播放器所需的流程其实就两个环节:

image.png-38.4kB

  1. websocket 提供原始的直播流。比如,RTMP 的直播流,或者 WS-FLV 的直播流。但是,里面得到的纯流大部分是 flv 格式,我们的 video 是不能直接播放的。这时候,就需要把纯流给 remux/demux 进行转换,生成可播放的 mp4 流。具体转流的过程我这里就介绍了,这不在本篇文章的范围之内。

  2. MSE 将可播放的流,在底层喂给 Video 进行播放。这一个环节,按理说我们是看不到的,不过可以直接映射到 SourceBuffer 添加 media segment 的环节上来。

这里,大家可能会有点懵,接下来,我们具体用代码来实践一下。

MSE 管理环节

前面已经介绍过,MSE 这里主要做的内容是生成指定 MIME 的 SourceBuffer,得到特定的 SB 后,就可以添加视频流进行播放了。模拟代码为:

// the implementation is as follows
class MSE {
    constructor(video) {
        this.videoEle = video;
        this.mediaSource;
        this.tmpBuffer = []; // in order to save video buffer

        this.initMSE(); // get the global var of MSE
    }
    initMSE() {
        let mediaSource = this.mediaSource = new MediaSource(); 
        this.videoEle.src = URL.createObjectURL(mediaSource);

        mediaSource.addEventListener('sourceopen', e => {
            URL.revokeObjectURL(this.videoEle.src);
        });
    }
    // after getting the mime, then init the specific SB
    initSB(mime) {
        if (this.mediaSource.readyState === 'open') {
            try {
                this.SB = this.mediaSource.addSourceBuffer(MIME);

            } catch (error) {
                console.log(error)
                throw new Error("MSE couldn't support the MIME: " + MIME);
            }
        }

    }
    // when getting the new video buffer, checking the sb.updating, if it isn't using, append new one
    appendSB(buffer) {
        this.tmpBuffer.push(buffer);
        let sb = this.SB;

        if (!sb.updating) {
            sb.appendBuffer(this._mergeBuffer(this.tmpBuffer));

            this.tmpBuffer = []; // clear the buffer
        }
    }
    // just a cheap function
    _mergeBuffer(boxes) {

        let boxLength = boxes.reduce((pre, val) => {
            return pre + val.byteLength;
        }, 0);

        let buffer = new Uint8Array(boxLength);

        let offset = 0;

        boxes.forEach(box => {
            buffer.set(box, offset);
            offset += box.byteLength;
        });

        return buffer;
    }
}

// bind the video.src with MSE
let MSEController = new MSE(video);

ws.initMsg(MIME=>{
    MSEController.initSB(MIME);
});

ws.laterMsg(buffer=>{
    MSEController.appendSB(buffer);
})

主要,执行就是:

  • let MSEController = new MSE(video): 将 MSE 和 video.src 绑定在一起

  • MSEController.initSB(MIME);: 添加指定的 sourceBuffer

  • MSEController.appendSB(buffer);: 当获得数据时,将 Buffer 添加到指定的 SB 中。

这里,关于 MSE 和 Video 的基本内容已经介绍完了。从 MSE 底层只是到 Video 基本流程控制,我们都已经完全过了一遍,剩下的内容主要就是格式之间的转换和 Buffer 相关处理。


实录:《田淮仁:手写简单的 H5 播放器实战解析》


【GitChat达人课】

  1. 前端恶棍 · 大漠穷秋 :《Angular 初学者快速上手教程
  2. Python 中文社区联合创始人 · Zoom.Quiet :《GitQ: GitHub 入味儿
  3. 前端颜值担当 · 余博伦:《如何从零学习 React 技术栈
  4. GA 最早期使用者 · GordonChoi:《GA 电商数据分析实践课

这里写图片描述

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值