影音專案開發中,為了節省影音下載頻寬,研究後決定使用串流技術,現今瀏覽器主流的串流技術為 HLS 及 MPEG-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
實作播放器
此實作採用以 ES6 撰寫
寫出建構式,設定基本屬性
1 | class DashVideo { |
初始化第一步驟,對 mpd 來源做 request
1 | init() { |
mpd 是 xml 的結構,所以可以使用 DOMParser來做解析,主要需要的資訊只有BaseURL
, mimeType
, codecs
, range
等,BaseUrl 是影片來源,mimeType 是影片格式,codecs 是此影片的編碼格式,range 則是描述每段 segment 的 檔案大小範圍。
1 | onMPDFileLoad(event) { |
DASH主要透過 MediaSource
來實作,new 出來之後需透過 URL.createObjectUrl()
來建出一個 blob:xxxx
格式的資料,將其設置到 video 的 source 以便支援串流。
1 | setupMediaSource() { |
使用 range request 的方式來做 fetch,responseType 要設定成 arraybuffer
1 | fetchBuffer(range, onReadyStateChange) { |
利用 updateend
來監聽buffer已更新至video1
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
24timeUpdateAndbufferCheck() {
//這個變數防止 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 | timeToDownloadNextBuffer() { |
當下載到最後一段完成時, 需呼叫 endOfStream(),才能監聽到 video 的 end 事件1
2
3
4
5
6
7lastBufferUpdateEnd() {
if (!this.videoSourceBuffer.updating && this.mediaSource.readyState === 'open') {
this.mediaSource.endOfStream();
console.log("%cmediaSouce endOfStream()", "color:pink")
this._element.removeEventListener('updatend', this.lastBufferUpdateEnd);
}
}
其餘方便操作的API1
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/