Media Source 系列 - 播放 m3u8 文件
前一篇文章 《使用 Media Source Extensions 播放视频》 我们大致写了 Media Source Extensions, MSE 的基本介绍和使用。这篇文章是在之前基础上完成的,文章将实现如何借助 MSE 来播放流文件比如 m3u8 或者 dash。
自己之前在知乎上回答过这个问题
有简单说一些基本实现思路,但是没有贴实现的代码,因为已经有很多前端开源的播放器了比如 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”属性值相匹配。 |
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();
}