[MPEG-DASH]實作 Dash Player

影音專案開發中,為了節省影音下載頻寬,研究後決定使用串流技術,現今瀏覽器主流的串流技術為 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 DashVideo {
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();
// createObjectURL() is merely a means of connecting a media element's src attribute with a MediaSource instance.
let url = URL.createObjectURL(this.mediaSource);
this.pause();
this._element.src = url;
this.mediaSource.addEventListener('sourceopen', this.onSourceOpen.bind(this), false);
}
onSourceOpen() {
try {
// Call this method when finished using object URL to let the browser not to keep the reference to the file any longer
// 因有的 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))