| 6 min read

前一篇文章 《使用 Media Source Extensions 播放视频》 我们大致写了 Media Source Extensions, MSE 的基本介绍和使用。这篇文章是在之前基础上完成的,文章将实现如何借助 MSE 来播放流文件比如 m3u8 或者 dash。

自己之前在知乎上回答过这个问题

有支持M3U8格式的HTML5播放器吗?

有简单说一些基本实现思路,但是没有贴实现的代码,因为已经有很多前端开源的播放器了比如 hls.js, 不过今天这篇文章会贴出一些基本的代码来实现这块逻辑;

了解 m3u8 文件

HLS, HTTP Live Streaming 苹果公司针对iPhone、iPod、iTouch和iPad等移动设备而开发的基于HTTP协议的流媒体解决方案。在App Store中的视频相关的应用,基本都是应用的此种技术。该技术基本原理是将视频文件或视频流切分成小片(ts)并建立索引文件(m3u8)。

参考上图,HLS 的架构基本都是会将一个完整的视频分割成不同的小视频,然后通过索引文件 m3u8 建立起联系;

我们可以看下 自己使用 ffmpeg 手动转换的 文件 index.m3u8

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:17
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:11.910889,
index0.ts
#EXTINF:16.601022,
index1.ts
#EXTINF:5.088756,
index2.ts
#EXTINF:9.051311,
index3.ts
#EXTINF:7.466289,
index4.ts
#EXTINF:14.724022,
index5.ts
#EXTINF:13.848089,
index6.ts
#EXTINF:3.462022,
index7.ts
#EXTINF:14.306911,
index8.ts
#EXTINF:10.844889,
index9.ts
#EXTINF:6.131533,
index10.ts
#EXTINF:7.508000,
index11.ts
#EXTINF:10.052378,
index12.ts
#EXT-X-ENDLIST

标签说明

标签 含义
EXTM3U 每个M3U文件第一行必须是这个tag
EXT-X-TARGETDURATION 指定最大的媒体段时间长(秒)。所以 #EXTINF 中指定的时间长度必须小于或是等于这个最大值
EXTINF 指定每个媒体段(ts)的持续时间(秒),仅对其后面的URI有效
EXT-X-STREAM-INF 指定一个包含多媒体信息的 media URI 作为PlayList,一般做M3U8的嵌套使用,它只对紧跟后面的URI有效,格式如下: BANDWIDTH:带宽,必须有;

PROGRAM-ID:该值是一个十进制整数,惟一地标识一个在PlayList文件范围内的特定的描述。一个PlayList 文件中可能包含多个有相同ID的此tag;

CODECS:视频编码格式,不是必须的;

RESOLUTION:分辨率;

AUDIO:这个值必须和AUDIO类别的“EXT-X-MEDIA”标签中“GROUP-ID”属性值相匹配。
VIDEO:同上

EXT-X-ENDLIST 表示PlayList的末尾了,它可以在PlayList中任意位置出现,但是只能出现一个
EXT-X-MEDIA 被用来在PlayList中表示相同内容的不用语种/译文的版本,比如可以通过使用3个这种tag表示3中不用语音的音频,或者用2个这个tag表示不同角度的video在PlayLists中。这个标签是独立存在的,属性包含: URI:如果没有,则表示这个tag描述的可选择版本在主PlayList的EXT-X-STREAM-INF中存在;

TYPE: AUDIO and VIDEO;

GROUP-ID:具有相同ID的MEDIAtag,组成一组样式;

LANGUAGE:确定使用的主要语言;

NAME:人类可读的语言的翻译;

DEFAULT:YES或是NO,默认是No,如果是YES,则客户端会以这种选项来播放,除非用户自己进行选择;

AUTOSELECT:YES或是NO,默认是No,如果是YES,则客户端会根据当前播放环境来进行选择(用户没有根据自己偏好进行选择的前提下)

了解 关于 m3u8 格式 中标记的含义。

前端在解析 m3u8 的时候主要是通过正则表达式,然后获取基本的信息。这里不做具体的介绍了,我们可以使用类库 m3u8-parser

var playManifest = {};
function fetchM3u8() {
  var parser = new m3u8Parser.Parser();
  var m3u8url = './video/index.m3u8';
  fetch(m3u8url, {
  })
  .then(function(response) {
    return response.text();
  }).then(function(data) {
    parser.push(data);
    parser.end();
    playManifest = parser.manifest;
  })
}

这样我们就可以拿到 m3u8 文件的基本信息了。

解析 .ts 文件

前面我们之前已经能够读取到我们的 m3u8 文件,那么也就是我们可以确切的拿到我们媒体资源,但是我们必须要解决播放 .ts 的文件。 这里写过一篇 使用 mux.js 播放 .ts 文件 ,这里我们依旧需要 引入 mux.js 来实现前端的编码工作。

首先我们需要在给 video 绑定 mse 对象的时候。

var index = 0;
// create a transmuxer:
var transmuxer = new muxjs.mp4.Transmuxer();
var remuxedSegs = [];
var remuxedBytesLength = 0;
var remuxedInitSegment = null;
var createInitSegment = true;
var sourceBuffer;

var video = document.querySelector('.js-player-m3u8');
  if (window.MediaSource) {
    var mediaSource = new MediaSource();
    video.src = URL.createObjectURL(mediaSource);
    
    mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });
    
    // 监听 transmuxer 数据添加
    transmuxer.on('data', function (segment) {
      remuxedSegs.push(segment);
      remuxedBytesLength =  segment.data.byteLength;
      if (!remuxedInitSegment) {
        remuxedInitSegment = segment.initSegment;
      }
      appendBuffer();
    });
  } else {
    console.log("The Media Source Extensions API is not supported.")
  }

在绑定 video 后,MSE 会触发 open 事件:

function sourceOpen(e) {
    URL.revokeObjectURL(video.src);
    var mime = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
    var mediaSource = e.target;
    sourceBuffer = mediaSource.addSourceBuffer(mime);
    sourceBuffer.addEventListener('updateend', updateEnd);
    var videoUrl = './video/' + playManifest.segments[index]['uri'];
    log('.js-log-m3u8', 'Fetch Segment ~' + videoUrl);
    fetch(videoUrl, {
    })
    .then(function(response) {
      return response.arrayBuffer();
    })
    .then(function(arrayBuffer) {
      // data events signal a new fMP4 segment is ready:
      transmuxer.push(new Uint8Array(arrayBuffer));
      transmuxer.flush();
    });
  }

我们在前面看到这段代码;

remuxedSegs.push(segment);
      remuxedBytesLength =  segment.data.byteLength;
      if (!remuxedInitSegment) {
        remuxedInitSegment = segment.initSegment;
      }
      appendBuffer();

这个是 transmuxer 中队数据流的监听,我们其实就是需要将数据进行重新修改,让它能够在浏览器播放。具体细节

接下来就是需要将数据往 MSE 里面填充了:


var offset = 0;
  function appendBuffer() {
      var bytes = null;
      if (createInitSegment) {
        bytes = new Uint8Array(remuxedInitSegment.byteLength + remuxedBytesLength)
        bytes.set(remuxedInitSegment, offset);
        offset += remuxedInitSegment.byteLength;
        createInitSegment = false;
      } else {
        bytes = new Uint8Array(remuxedBytesLength);
      }
      var i = offset;
      bytes.set(remuxedSegs[index].data, i);
      offset += remuxedSegs[index].byteLength;
      remuxedBytesLength = 0;
      // var sourceBuffer = mediaSource.sourceBuffers[index];
      sourceBuffer.appendBuffer(bytes);
  }

在 MSE 添加完 buffer 后,我们在触发的 updateend 事件中,绑定函数,定义 fetchNextSegment 进行下一个一个分片的请求。

// fetchNextSegment() {...}
var url = './video/' + playManifest.segments[index]['uri'];
    fetch(url, { headers: { } })
    .then(response => response.arrayBuffer())
    .then(data => {
      // transmuxer.flush();
      transmuxer.push(new Uint8Array(data));
      transmuxer.flush();
      // var sourceBuffer = mediaSource.sourceBuffers[0];
      // sourceBuffer.appendBuffer(data);
    });

同时我们通过监听 index 来判断是否完成媒体资源的加载完成,触发 Video 播放。

function updateEnd() {
    if (!sourceBuffer.updating && mediaSource.readyState === 'open'
    && index == playManifest.segments.length - 1) {
      mediaSource.endOfStream();
      video.play();
      return;
    }
    // Fetch the next segment of video when user starts playing the video.
    fetchNextSegment();
   }

Demo

Github Code

扩展阅读

You Can Speak "Hi" to Me in Those Ways