[MPEG

影音项目开发中,为了节省影音下载带宽,研究后决定使用串流技术,现今浏览器主流的串流技术为 HLSMPEG-DASH,HLS 为 Apple 所创,Safari 能原生支持,(video tag 直接塞 m3u8 档),而之所以选择 MPEG-DASH,就是因为支持度较高,有支持 MediaSource 的浏览器就能使用,据说 YouTube , NetFlix 等大厂也都选择 MPEG-DASH,基本上 HLS , DASH 的概念是差不多的,都是利用转档程序将影片转出一个MPD档(HLS则为 m3u8档),依据实践方式不同可以将影片转成好几个小片段,再透过MPD档的资讯来依序载入,本篇则是透过 Range Header 来对影片档做 http code 206 的 partial request,如此只需要一个影片档,就能部分载入影片的片段。

DASH industry forum 有提供 dash.js 的 library 可以使用,但因为工作上需弹性修改的缘故,不使用第三方函数库,所以直接自己实践简单的 dash 播放器。

制作 mpd 档

需要下载 mp4box 来转档 :

1
brew install mp4box (for macOS)

转档命令:

1
mp4box -dash 3000 -frag 1000 -rap -bs-switching no -out ./video/input_dash.mpd input.mp4

详细命令可参考GPAC网站

实践播放器

GitHub

此实践采用以 ES6 撰写

写出构造函数,设定基本属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class  {
constructor(mpdFileUrl) {
this._element = document.createElement('video');
this.mpdSrc = mpdFileUrl;
this._element.id = 'ONEAD-dash-video';
this._element.style.width ='640px';
this._element.style.height ='360px';
this._element.controls = true;
this.autoPlay(true);
this.initPlay = true;
this.lastBufferEndTime = 0;
this.segmentIndex = 0;
this.isFetching = false;
this.onBufferUpdateFunc = this.onBufferUpdate.bind(this);
this.init();
}
}

初始化第一步骤,对 mpd 来源做 request

1
2
3
4
5
6
7
8
9
10
11
12
init() {
this.getMPDFile();
}
getMPDFile() {
if (this.mpdSrc && this.mpdSrc.indexOf('mpd') > -1) {
let xhr = new XMLHttpRequest();
xhr.open("GET", this.mpdSrc, true);
xhr.send();
xhr.addEventListener('load', this.onMPDFileLoad.bind(this));
xhr.addEventListener('error', this.onMPDFileError.bind(this));
}
}

mpd 是 xml 的结构,所以可以使用 DOMParser来做解析,主要需要的资讯只有BaseURL, mimeType, codecs, range等,BaseUrl 是影片来源,mimeType 是影片格式,codecs 是此影片的编码格式,range 则是描述每段 segment 的 文件大小范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
onMPDFileLoad(event) {
if (event.target.readyState === event.target.DONE) {
let tempoutput = event.target.response;
let parser = new DOMParser();
this.xmlData = parser.parseFromString(tempoutput, 'text/xml');
console.log("parsing mpd file...");
console.log(this.xmlData);
this.mpdInfo = this.parseMPDInfo();
this.setupMediaSource();
}
}
onMPDFileError(e) {
console.error('Error retrieving manifest from ' + e);
}
parseMPDInfo() {
let mpdData;
let ini = this.xmlData.querySelectorAll("Initialization");
let rep = this.xmlData.querySelectorAll("Representation");
let segments = this.xmlData.querySelectorAll("SegmentURL");
let mediaRangeArr = [];
for (let i = 0; i < segments.length; i++) {
mediaRangeArr.push(segments[i].getAttribute('mediaRange'));
}
mpdData = {
fileUrl: 'video/'+rep[0].querySelector("BaseURL").textContent.toString(),
mimeType: rep[0].getAttribute('mimeType'),
codecs: rep[0].getAttribute("codecs"),
initRange: ini[0].getAttribute("range"),
mediaRangeArr: mediaRangeArr,
rangeArrLength: mediaRangeArr.length
}
console.log(mpdData);
return mpdData;
}

DASH主要透过 MediaSource 来实践,new 出来之后需透过 URL.createObjectUrl() 来建出一个 blob:xxxx 格式的数据,将其设置到 video 的 source 以便支持串流。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
setupMediaSource() {
this.mediaSource = new MediaSource();

let url = URL.createObjectURL(this.mediaSource);
this.pause();
this._element.src = url;
this.mediaSource.addEventListener('sourceopen', this.onSourceOpen.bind(this), false);
}
onSourceOpen() {
try {

// 因有的 mp4 文件用 mp4box 转出来的 codecs 会多 mp4s.01,mp4s.02,导致无法播放,所以先使用指定的 codecs
URL.revokeObjectURL(this._element.src);
this.videoSourceBuffer = this.mediaSource.addSourceBuffer(`${this.mpdInfo.mimeType}; codecs=avc1.42C01E,mp4a.40.2`);
console.log('<=====setupInitVideoBuffer======>');
this.fetchBuffer(this.mpdInfo.initRange, this.onInitReadyStateChange);
} catch (e) {
console.log('Exception calling addSourceBuffer for video', e);
return;
}
}

使用 range request 的方式来做 fetch,responseType 要设定成 arraybuffer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fetchBuffer(range, onReadyStateChange) {
if (range && !this.isFetching) {
this.isFetching = true;
console.log("%cFetch mediaRange:" + range, "color:green")
let xhr = new XMLHttpRequest();
xhr.open('GET', this.mpdInfo.fileUrl, true);
xhr.setRequestHeader('Range', 'bytes=' + range);
xhr.responseType = 'arraybuffer';
xhr.send();
try {
xhr.addEventListener('readystatechange', onReadyStateChange.bind(this), false);
} catch (e) {
console.error('Exception while appending', e);
return;
}
}
}

利用 updateend来监听buffer已更新至video

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//append 初始 video buffer
onInitReadyStateChange(event) {
if (event.target.readyState === event.target.DONE) {
try {
this.isFetching = false;
this.videoSourceBuffer.appendBuffer(new Uint8Array(event.target.response));
this.videoSourceBuffer.addEventListener('updateend', this.onBufferUpdateFunc, false);
} catch (e) {
console.error("Exception while appending initialization content", e);
return;
}
}
}
// 侦测 buffer 注入完成后,
onBufferUpdate() {
this.bufferUpdated = true;
if (this.initPlay) {
console.log('init buffer update');
this.getStarted();
this.initPlay = false;
this.play();
} else {
console.log('%cappendBuffer ' + this.mpdInfo.mediaRangeArr[this.segmentIndex] + " DONE!", "color:orange");
this.play();
}
this.videoSourceBuffer.removeEventListener("updateend", this.onBufferUpdateFunc);
}
getStarted() {
//fetch first mediaRange (segmentIndex = 0)
this.fetchNextSegment();
this._element.addEventListener("timeupdate", this.timeUpdateAndbufferCheck.bind(this));
}

主要是透过 video 的 timeupdate 事件去监听何时要下载下一段,这边我的做法是,当当前播放时间已经超过最后一段的segment 结束秒数的前5秒,就下载下一段。

可以透过mediaSource.sourceBuffers[0].buffered.end(0) 来取得当前 buffer 的结束秒数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
timeUpdateAndbufferCheck() {
//这个变量防止 buffer 还没载入完就载入下一段,造成错误(moblie下载比较慢,边下载边timeupdate,又会去载入下一段造成同时appendBuffer的错误)
if (this.bufferUpdated) {
if (this.segmentIndex < this.mpdInfo.rangeArrLength - 1) {
// 当前下载最后一段 segment 的前 5 秒下载下一段 segment,之后实际测试后会再微调
if (this._element.currentTime >= this.lastBufferEndTime - 5) {
//if (this._element.currentTime >= this.timeToDownloadNextBuffer()) {
this.bufferUpdated = false;
console.log("%cReady to fetch next segment at " + (this.lastBufferEndTime - 5) + " s", "color:yellow")
this.segmentIndex++;
this.fetchNextSegment();
this.lastBufferEndTime = this.mediaSource.sourceBuffers[0].buffered.end(0);
}
} else {
//全部载完之后就停止监测,避免重播时又重拉一次
this._element.removeEventListener('timeupdate', this.timeUpdateAndbufferCheck, false);
}
}
//mediaSource.endOfStream() must be invoked before playback ends, in order to see ended event emitted by <video> element.
//when last segment updateend, trigger endOfStream
if (this.segmentIndex === this.mpdInfo.rangeArrLength - 1) {
this.videoSourceBuffer.addEventListener('updateend', this.lastBufferUpdateEnd.bind(this), false);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
timeToDownloadNextBuffer() {
//下载下一段 segment 的时机1:播放到当前 segment 的 0.25 倍时(之所以设定这么低是因为一段 segment 只有3秒,让网速慢的设备有足够的反应时间)
let timeToDownload = (this.mediaSource.sourceBuffers[0].buffered.end(0) - this.lastBufferEndTime) * 0;
//下载下一段 segment 的时机2:当前影片秒数为已下载的最后一段 segment 结束秒数 的 0.7 倍时
//let timeToDownload = this.mediaSource.sourceBuffers[0].buffered.end(0) * 0.7;
return timeToDownload;
}
fetchNextSegment() {
if (this.mpdInfo.mediaRangeArr[this.segmentIndex] !== undefined && this.mpdInfo.fileUrl) {
this.fetchBuffer(this.mpdInfo.mediaRangeArr[this.segmentIndex], this.onFetchNextSegmentReadyStateChange);
}
}
onFetchNextSegmentReadyStateChange(event) {
if (event.target.readyState === event.target.DONE) {
this.isFetching = false;
try {
this.videoSourceBuffer.appendBuffer(new Uint8Array(event.target.response));
this.videoSourceBuffer.addEventListener('updateend', this.onBufferUpdateFunc, false);
//在低网速的时候,有机会会发生已经载入下一段segment,但影片还是停住(video 处于播放状态,
//但buffer不够多 readystate != 4 时会卡住),
//此时必须将 currentTime 重设,即可载入下一段
if (this.segmentIndex !== 0 && this.readyState < 3) {
console.log('have not enough buffer to play. readystate: ', this.readyState);
this._element.currentTime = this._element.currentTime;
}
} catch (e) {
console.error('Exception while appending', e);
}
}
}

当下载到最后一段完成时, 需调用 endOfStream(),才能监听到 video 的 end 事件

1
2
3
4
5
6
7
lastBufferUpdateEnd() {
if (!this.videoSourceBuffer.updating && this.mediaSource.readyState === 'open') {
this.mediaSource.endOfStream();
console.log("%cmediaSouce endOfStream()", "color:pink")
this._element.removeEventListener('updatend', this.lastBufferUpdateEnd);
}
}

其余方便操作的API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
    duration() {
return parseInt(this._element.duration.toString());
}
currentTime() {
return parseInt(this._element.currentTime.toString());
}
play() {
this._element.play();
}
pause() {
this._element.pause();
}
mute() {
this._element.muted = true;
}
unMute() {
this._element.muted = false;
}
volume(value) {
this._element.volume = value;
}
getVolume() {
return this._element.volume;
}
setCurrentTime(time) {
this._element.currentTime = time;
}
get readyState() {
return this._element.readyState;
}
isPlaying() {
return !this._element.paused;
}
autoPlay(value) {
this._element.autoplay = value;
}
ifHasVideoSrc() {
return this._element.src !== null;
}
}
const mpdVideo = new DashVideo('./video/input_dash.mpd');
document.body.appendChild(mpdVideo._element);

参考数据

http://www.sparrowjang.com/2016/08/09/MPEG-DASH-video-concept/

http://www.instructables.com/id/Making-Your-Own-Simple-DASH-MPEG-Server-Windows-10/

https://developer.mozilla.org/en-US/Apps/Fundamentals/Audio_and_video_delivery/Setting_up_adaptive_streaming_media_sources

https://developer.mozilla.org/en-US/Apps/Fundamentals/Audio_and_video_delivery/Setting_up_adaptive_streaming_media_sources

https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/samples/dn551368(v=vs.85))