直播是脫離於文字、圖片來說,另外一種社交的方式。各大平臺也在深耕這一領域,淘寶直播,花椒,映客,Now 直播,企鵝電競。本人就職於騰訊 Now 直播前端開發,感覺直播能夠嘗試的領域真的太多太多,但是,Web 在這塊一直是一個痛點。由於沒有現成操作流的接口,只能簡簡單單的通過添加 video.src 尷尬的播放幾段回放. 這樣造成的後果就是,在 Web 上,我們根本體會不到實時流暢的觀看體驗。
而且,根據 8 月份騰訊財報內容,直播貢獻的收入增長的飛快。現在,我們也想讓 Web 體會一把能夠實時觀看直播的方式,這應該怎麼做呢?W3C 提出了 MSE 的標準,表義上來說就是,讓前端能夠操作視頻流。HLS.js,FLV.js 本身也是基於 MSE 開發的。MSE 的出現,不僅能讓 Web 接上直播,而且還可以根據協議自己控制相關的延遲率。
那直播,又和我們今天的主題 MSE/video 有啥關係呢?
在沒有 MSE 的時候,直播形式要麼在 flash 中播放,要麼在客戶端播放,要麼利用 HLS 來手機端播放。不僅 HTML5 原生播放器的場景幾乎可以說是沒有,而且 H5 播放的延時性還非常高。最多我們也只能控制一下 視頻播放 的表層工作,比如,暫停,播放,快進。例如:
<audio id="demo" src="audio.mp3"></audio>
<div>
<button onclick="document.getElementById('demo').play()">播放聲音</button>
<button onclick="document.getElementById('demo').pause()">暫停聲音</button>
<button onclick="document.getElementById('demo').volume+=0.1">提高音量</button>
<button onclick="document.getElementById('demo').volume-=0.1">降低音量</button>
</div>
這樣,感覺和寫 HTML 沒啥區別,我們也並不能對流做一下神奇的操作,比如,跳幀,音視頻同步,拿到 I/B/P 幀生成視頻圖像之類的。這其實只是給了我們另外一個界面的 UI API 而已,並不能讓 所有能用代碼寫的程序,都可以用JavaScript來寫 這一非常宏偉的目標。
後面,各臺平臺支持了 MSE,前端開發者從此也可以進行音視頻的相關開發。因為,MSE 的主要工作是可以創建 media stream,並且餵給 video/audio 進行播放。從此,前端可以和寫 C++ Java 的人有了共同的話題--二進位流的操作。
MSE 簡介MSE 是實際上是一系列 API 的集合。它的全稱為: MediaSourceExtensions,看名字差不多都可以知道,MSE 就是一系列接口的拓展集合,裡面包括了一系列 API:Media Source,Source Buffer 等。
我們來看一下 MSE 是如何完成基本流的處理的。
var vidElement = document.querySelector('video');
if (window.MediaSource) {
var mediaSource = new MediaSource();
vidElement.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
console.log("The Media Source Extensions API is not supported.")
}
function sourceOpen(e) {
URL.revokeObjectURL(vidElement.src);
var mime = 'video/webm; codecs="opus, vp9"';
var mediaSource = e.target;
var sourceBuffer = mediaSource.addSourceBuffer(mime);
var videoUrl = 'droid.webm';
fetch(videoUrl)
.then(function(response) {
return response.arrayBuffer();
})
.then(function(arrayBuffer) {
sourceBuffer.addEventListener('updateend', function(e) {
if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
mediaSource.endOfStream();
}
});
sourceBuffer.appendBuffer(arrayBuffer);
});
}
上面的代碼完成了相關的獲取流和處理流的兩個部分。其中,主要利用的是 MS 和 Source Buffer 來完成的。URL.revokeObjectURL 主要是用來生成一個內聯連結的,例如: blob:http://villainhr/demo_123d1dticn1@df1。
MSE 中主要內容就是 MS 和 SourceBuffer,我們接下來著重介紹一下。
MediaSourceMediaSource 是用來提供可播放的 media data,它可以直接和 video/audio 元素連接並提供音視頻流,進行播放。
基本 API整個 MS 內容可以直接參考 W3C:
[Constructor]
interface MediaSource : EventTarget {
readonly attribute SourceBufferList sourceBuffers;
readonly attribute SourceBufferList activeSourceBuffers;
readonly attribute ReadyState readyState;
attribute unrestricted double duration;
attribute EventHandler onsourceopen;
attribute EventHandler onsourceended;
attribute EventHandler onsourceclose;
SourceBuffer addSourceBuffer(DOMString type);
void removeSourceBuffer(SourceBuffer sourceBuffer);
void endOfStream(optional EndOfStreamError error);
void setLiveSeekableRange(double start, double end);
void clearLiveSeekableRange();
static boolean isTypeSupported(DOMString type);
};
我們先從靜態屬性來看一下。
isTypeSupportedisTypeSupported 主要是用來檢測 MS 是否支持某個特定的編碼和容器盒子。例如:
MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E, mp4a.40.2"')
那我怎麼查看我想要使用到的 MIME 呢?
如果你有現成的 video 文件,可以直接使用 FFmpeg 進行分析: ffmpge-i video.mp4。不過,這個只是給你文件的相關描述,例如:
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'video.mp4':
Metadata:
major_brand : isom
minor_version : 1
compatible_brands: isomavc1
Duration: 00:00:03.94, start: 0.000000, bitrate: 69 kb/s
Stream #0:0(und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 61 kb/s (default)
Metadata:
handler_name : SoundHandler
那實際怎麼得到,像上面一樣的 video/mp4;codecs="avc1.42E01E, mp4a.40.2" 的 MIME 內容呢?具體映射主要參考:MIME doc 即可。
SourceBuffer 的處理SourceBuffer 是 MS 下的一個子集,相當於就是具體的音視頻軌道,具體內容是啥以及幹啥的,我們在後面有專題進行介紹。在 MS 層,提供了相關的 API 可以直接對 SB 進行相關的創建,刪除,查找等。
addSourceBuffer該是用來返回一個具體的視頻流 SB,接受一個 mimeType 表示該流的編碼格式。例如:
var mimeType = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
var sourceBuffer = mediaSource.addSourceBuffer(mimeType);
實際上,SB 的操作才是真正影響到 video/audio 播放的內容。
function sourceOpen (_) {
var mediaSource = this;
var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
fetchAB(assetURL, function (buf) {
sourceBuffer.addEventListener('updateend', function (_) {
mediaSource.endOfStream();
video.play();
});
// 通過 fetch 添加視頻 Buffer
sourceBuffer.appendBuffer(buf);
});
};
它通過 appendBuffer 直接添加視頻流,實現播放。不過,在使用 addSourceBuffer 創建之前,還需要保證當前瀏覽器是否支持該編碼格式。當然,不支持也行,頂多是當前 MS 報錯,斷掉當前 JS 線程。
removeSourceBuffer用來移除某個 sourceBuffer。比如當前流已經結束,那麼你就沒必要再保留當前 SB 來佔用空間,可以直接移除。具體格式為:
mediaSource.removeSourceBuffer(sourceBuffer);
sourceBufferssourceBuffers 是 MS 實例上的一個屬性,它返回的是一個 SourceBufferList 的對象,裡面可以獲取當前 MS 上掛載的所有 SB。不過,只有當 MS 為 open 狀態的時候,它才可以訪問。具體使用為:
let SBs = mediaSource.sourceBuffers;
那我們怎麼獲取到具體的 SB 對象呢?因為,其返回值是 SourceBufferList 對象,具體格式為:
interface SourceBufferList : EventTarget {
readonly attribute unsigned long length;
attribute EventHandler onaddsourcebuffer;
attribute EventHandler onremovesourcebuffer;
getter SourceBuffer (unsigned long index);
};
簡單來說,你可以直接通過 index 來訪問具體的某個 SB:
let SBs = mediaSource.sourceBuffers;
let SB1 = SBs[0];
SBL 對象還提供了 addsourcebuffer 和 removesourcebuffer 事件,如果你想監聽 SB 的變化,可以直接通過 SBL 來做。這也是為什麼 MS 沒有提供監聽事件的一個原因。
所以,刪除某一個 SB 就可以通過 SBL 查找,然後,利用 remove 方法移除即可:
let SBs = mediaSource.sourceBuffers;
let SB1 = SBs[0];
mediaSource.removeSourceBuffer(SB1);
另外,MS 上,還有另外一個 SBL。基本內容為:
activeSourceBuffersactiveSourceBuffers 實際上是 sourceBuffers 的子集,返回的同樣也是 SBL 對象。為什麼說也是子集呢?
因為 ASBs 包含的是當前正在使用的 SB。因為前面說了,每個 SB 實際上都可以具體代表一個 track,比如,video track,audio track,text track 等等,這些都算。那怎麼標識正在使用的 SB 呢?
很簡單,不用標識啊,因為,控制哪一個 SB 正在使用是你來決定的。如果非要標識,就需要使用到 HTML 中的 video 和 audio 節點。通過
audioTrack = media.audioTracks[index]
videoTrack = media.videoTracks[index]
// media 為具體的 video/audio 的節點
// 返回值就是 video/audio 的底層 tracks
audioTrack = media.audioTracks.getTrackById( id )
videoTrack = media.videoTracks.getTrackById( id )
videoTrack.selected // 返回 boolean 值,標識是否正在被使用
上面的代碼只是告訴你, 正在使用 的含義是什麼。對於,我們實際編碼的 SB 來說,並沒有太多關係,了解就好。上面說了 ASBs 返回值也是一個 SBL。所以,使用方式可以直接參考 SBL 即可。
狀態切換要說道狀態切換,我們得先知道 MS 一共有幾個狀態值。MS 本身狀態並不複雜,一共只有三個狀態值:
enum ReadyState {
"closed",
"open",
"ended"
};
closed: 當前的 MS 並沒有和 HTMLMedia 元素連接
open: MS 已經和 HTMLMedia 連接,並且等待新的數據被添加到 SB 中去。
ended: 當調用 endOfStream 方法時會觸發,並且此時依然和 HTMLMedia 元素連接。
記住,closed 和 ended 到的區別關鍵點在於有沒有和 HTMLMedia 元素連接。
其對應的還有三個監聽事件:
sourceopen: 當狀態變為 open 時觸發。常常在 MS 和 HTMLMedia 綁定時觸發。
sourceended: 當狀態變為 ended 時觸發。
sourceclose: 當狀態變為 closed 時觸發。
那哪種條件下會觸發呢?
sourceopen 觸發
sourceopen 事件相同於是一個總領事件,只有當 sourceopen 時間觸發後,後續對於 MS 來說,才是一個可操作的對象。
通常來說,只有當 MS 和 video 元素成功綁定時,才會正常觸發:
let mediaSource = new MediaSource();
vidElement.src = URL.createObjectURL(mediaSource);
其實這簡單的來說,就是給 MS 添加 HTML media 元素。其整個過程為:
先延時 media 元素的 load 事件,將 delaying-the-load-event-flag 設置為 false
將 readyState 設置為 open。
觸發 MS 的 sourceopen 事件
sourceended 觸發
sourceended 的觸發條件其實很簡單,只有當你調用 endOfStream 的時候,會進行相關的觸發。
mediaSource.endOfStream();
這個就沒啥需要過多講的了。
sourceclose 的觸發
sourceclose 是在 media 元素和 MS 斷開的時候,才會觸發。那這個怎麼斷開呢?
難道直接將 media 的元素的 src 直接設置為 null 就 OK 了嗎?
要是這樣,我就日了狗了。MS 會這麼簡單麼?實際上並不,如果要手動觸發 sourceclose 事件的話,則需要下列步驟:
將 readyState 設置為 closed
將 MS.duration 設置為 NaN
移除 activeSourceBuffers 上的所有 Buffer
觸發 activeSourceBuffers 的 removesourcebuffer 事件
移除 sourceBuffers 上的 SourceBuffer。
觸發 sourceBuffers 的 removesourcebuffer 事件
觸發 MediaSource 的 sourceclose 事件
到這裡,三個狀態事件基本就介紹完了。不過,感覺只有 sourceopen 才是最有用的一個。
track 的切換track 這個概念其實是音視頻播放的軌道,它和 MS 沒有太大的關係。不過,和 SB 還是有一點關係的。因為,某個一個 SB 裡面可能會包含一個 track 或者說是幾個 track。所以,推薦某一個 SB 最好包含一個值包含一個 track,這樣,後面的 track 也方便更換。
在 track 中的替換裡,有三種類型,audio,video,text 軌道。
video 切換切換的含義有兩種,一種是移除原有的,一種是添加新的。這裡,我們需要分兩部分來講解。
移除原有不需要 track
從 activeSourceBuffers 移除與當前 track 相關的 SB
觸發 activeSourceBuffers 的 removesourcebuffer 事件
添加指定的 track
從 activeSourceBuffers 添加指定的 SourceBuffer
觸發 activeSourceBuffers 的 addsourcebuffer 事件
audio 切換audio 的切換和 video 的過程一模一樣。這裡我就不過多贅述了。
MS duration 修正機制MS 的 duration 實際上就是 media 中播放的時延。通常來說,A/V track 實際上是兩個獨立的播放流,這中間必定會存在先關的差異時間。但是,media 播放機制永遠會以最長的 duration 為準。
這種情況對於 live stream 的播放,特別適合。因為 liveStream 是不斷動態添加 buffer,但是 buffer 內部會有一定的時長的,而 MS 就需要針對這個 buffer 進行動態更新。
整個更新機制為:
當前 MS.duration 更新為 new duration。
如果 new duration 比 sourceBuffers 中的最大的 pts 小,這時候就會報錯。
讓最後一個的 sample 的 end time 為所後 timeRanges 的 end time。
將 new duration 設置為當前 SourceBuffer 中最大的 endTime。
將 video/audio 的播放時長(duration) 設置為最新的 new duration。
SourceBufferSourceBuffer 則是 MS 子屬中最重要的內容。也就是說,所有的 media track 的內容都是存儲在 SB 裡面的。
那 SB 裡面又有哪些內容呢?
直接看接口吧:
interface SourceBuffer : EventTarget {
attribute AppendMode mode;
readonly attribute boolean updating;
readonly attribute TimeRanges buffered;
attribute double timestampOffset;
readonly attribute AudioTrackList audioTracks;
readonly attribute VideoTrackList videoTracks;
readonly attribute TextTrackList textTracks;
attribute double appendWindowStart;
attribute unrestricted double appendWindowEnd;
attribute EventHandler onupdatestart;
attribute EventHandler onupdate;
attribute EventHandler onupdateend;
attribute EventHandler onerror;
attribute EventHandler onabort;
void appendBuffer(BufferSource data);
void abort();
void remove(double start, unrestricted double end);
};
其中,SB 中有一個很重要的概念-- mode。該欄位決定了 A/V segment 是怎樣進行播放的。
播放模式mode 的取值有兩個,一個是 segments,一個是 sequence。
segments 表示 A/V 的播放時根據你視頻播放流中的 pts 來決定,該模式也是最常使用的。因為音視頻播放中,最重要的就是 pts 的排序。因為,pts 可以決定播放的時長和順序,如果一旦 A/V 的 pts 錯開,有可能就會造成 A/V sync drift。
sequence 則是根據空間上來進行播放的。每次通過 appendBuffer 來添加指定的 Buffer 的時候,實際上就是添加一段 A/V segment。此時,播放器會根據其添加的位置,來決定播放順序。還需要注意,在播放的同時,你需要告訴 SB,這段 segment 有多長,也就是該段 Buffer 的實際偏移量。而該段偏移量就是由 timestampOffset 決定的。整個過程用代碼描述一下就是:
sb.appendBuffer(media.segment);
sb.timestampOffset += media.duration;
另外,如果你想手動更改 mode 也是可以的,不過需要注意幾個先決條件:
對應的 SB.updating 必須為 false.
如果該 parent MS 處於 ended 狀態,則會手動將 MS readyState 變為 open 的狀態。
如何界定 track這裡先聲明一下,track 和 SB 並不是一一對應的關係。他們的關係只能是 SB : track = 1: 1 or 2 or 3。即,一個 SB可能包含,一個 A/V track(1),或者,一個 Video track ,一個Audio track(2),或者 再額外加一個 text track(3)。
上面也說過,推薦將 track 和 SB 設置為一一對應的關係,應該這樣比較好控制,比如,移除或者同步等操作。具體編碼細節我們有空再說,這裡先來說一下,SB 裡面怎麼決定 track 的播放。
track 最重要的特性就是 pts ,duration,access point flag。track 中 最基本的單位叫做 Coded Frame,表示具體能夠播放的音視頻數據。它本身其實就是一些列的 media data,並且這些 media data 裡面必須包含 pts,dts,sampleDuration 的相關信息。在 SB 中,有幾個基本內部屬性是用來標識前面兩個欄位的。
lastdecode timestamp: 用來表示最新一個 frame 的編碼時間(pts)。默認為 null 表示裡面沒有任何數據
lastframe duration: 表示 coded frame group 裡面最新的 frame 時長。
highestendtimestamp: 相當於就是最後一個 frame 的 pts + duration
need random access point flag: 這個就相當於是同步幀的意思。主要設置是根據音視頻流 裡面具體欄位決定的,和前端這邊編碼沒關係。
track buffer ranges: 該欄位表示的是 coded frame group 裡面,每一幀對應存儲的 pts 範圍。
這裡需要特別說一下 last frame duration 的概念,其實也就是 CodedFrameDuration 的內容。
CodedFrameDuration 針對不同的 track 有兩種不同的含義。一種是針對 video/text 的 track,一種是針對 audio 的 track:
video/text: 其播放時長(duration)直接是根據 pts 直接的差值來決定,和你具體播放的 samplerate 沒啥關係。雖然,官方也有一個計算 refsampelDuration 的公式: duration=timescale/fps,不過,由於視頻的幀率是動態變化的,沒什麼太大的作用。
audio: audio 的播放時長必須是嚴格根據採樣頻率來的,即,其播放時間必須和你自己定製的 timescale 以及 sampleRate 一致才行。針對於 AAC,因為其採樣頻率常為 44100Hz,其固定播放時長則為: duration=1024/sampleRate*timescale
所以,如果你在針對 unstable stream 做同步的話,一定需要注意這個坑。有時候,dts 不同步,有可能才是真正的同步。
我們再回到上面的子 title 上-- 如果界定track。一個 SB 裡面是否擁有一個或者多個 track,主要是根據裡面的視頻格式來決定的。打個比方,比如,你是在編碼 MP4 的流文件。它裡面的 track 內容,則是根據 moov box 中的 trak box 來判斷的。即,如果你的 MP4 文件只包含一個,那麼,裡面的 track 也有隻有一個。
SB buffer 的管理SB 內部的狀態,通常根據一個屬性: updating 值來更新。即,它只有 true 或者 false 兩種狀態:
SB 內部的 buffer 管理主要是通過 appendBuffer(BufferSourcedata) 和 remote() 兩個方法來實現的。當然,並不是所有的 Buffer 都能隨便添加給指定的 SB,這裡面是需要條件和相關順序的。
下圖是相關的支持 MIME:
這裡需要提醒大家一點,MSE 只支持 fmp4 的格式。具體內容可以參考: FMP4 基本解析。上面提到的 IS 和 MS 實際上就是 FMP4 中不同盒子的集合而已。
這裡簡單闡述一下:
Initialization segmentsFMP4 中的 IS 實際上就是: ftyp+moov。裡面需要包含指定的 track ID,相關 media segment 的解碼內容。下面為基本的格式內容:
[ftyp] size=8+24
major_brand = isom
minor_version = 200
compatible_brand = isom
compatible_brand = iso2
compatible_brand = avc1
compatible_brand = mp41
[mdat]
[moov]
[mvhd]
timescale = 1000
duration = 13686
duration(ms) = 13686
[trak]
[trak]
[udta]
具體內容編碼內容,我們就放到後面來講解,具體詳情可以參考:W3C Byte Stream Formats。我們可以把 IS 類比為一個文件描述頭,該頭可以指定該音視頻的類型,track 數,時長等。
Media SegmentMS 是具體的音視頻流數據,在 FMP4 格式中,就相當於為 moof+mdat 兩個 box。MS 需要包含已經打包和編碼時間後的數據,其會參考最近的 IS 頭內容。
相關格式內容,可以直接參考 MP4 格式解析。
在了解了 MS 和 IS 之後,我們就需要使用相應的 API 添加/移除 buffer 了。
這裡,需要注意一下,在添加 Buffer 的時候,你需要了解你所採用的 mode 是哪種類型, sequence 或者 segments。這兩種是完全兩種不同的添加方式。
segments
這種方式是直接根據 MP4 文件中的 pts 來決定播放的位置和順序,它的添加方式極其簡單,只需要判斷 updating === false,然後,直接通過 appendBuffer 添加即可。
if (!sb.updating) {
let MS = this._mergeBuffer(media.tmpBuffer);
sb.appendBuffer(MS); // ****
media.duration += lib.duration;
media.tmpBuffer = [];
}
sequence
如果你是採用這種方式進行添加 Buffer 進行播放的話,那麼你也就沒必要了解 FMP4 格式,而是了解 MP4 格式。因為,該模式下,SB 是根據具體添加的位置來進行播放的。所以,如果你是 FMP4 的話,有可能就有點不適合了。針對 sequence 來說,每段 buffer 都必須有自己本身的指定時長,每段 buffer 不需要參考的 baseDts,即,他們直接可以毫無關聯。那 sequence 具體怎麼操作呢?
簡單來說,在每一次添加過後,都需要根據指定 SB 上的 timestampOffset。該屬性,是用來控制具體 Buffer 的播放時長和位置的。
if (!sb.updating) {
let MS = this._mergeBuffer(media.tmpBuffer);
sb.appendBuffer(MS); // ****
sb.timestampOffset += lib.duration; // ****
media.tmpBuffer = [];
}
上面兩端打 * 號的就是重點內容。該方式比較容易用來直接控制 buffer 片段的添加,而不用過度關注相對 baseDTS 的值。
控制播放片段如果要在 video 標籤中控制指定片段的播放,一般是不可能的。因為,在加載整個視頻 buffer 的時候,視頻長度就已經固定的,剩下的只是你如果在 video 標籤中控制播放速度和音量大小。而在 MSE 中,如何在已獲得整個視頻流 Buffer 的前提下,完成底層視頻 Buffer 的切割和指定時間段播放呢?
這裡,需要利用 SB 下的 appendWindowStart 和 appendWindowEnd 這兩個屬性。
他們兩個屬性主要是為了設置,當有視頻 Buffer 添加時,只有符合在 [start,end] 之間的 media frame 才能 append,否則,無法 append。例如:
sourceBuffer.appendWindowStart = 2.0;
sourceBuffer.appendWindowEnd = 5.0;
設置添加 Buffer 的時間戳為 [2s,5s] 之間。 appendWindowStart 和 appendWindowEnd 的基準單位為 s。該屬性值,通常在添加 Buffer 之前設置。
SB 內存釋放SB 內存釋放其實就和在 JS 中,將一個變量指向 null 一樣的過程。
var a = new ArrayBuffer(1024 * 1000);
a = null; // start garbage collection
在 SB 中,簡單的來說,就是移除指定的 time ranges' buffer。需要用到的 API 為:
remove(double start, unrestricted double end);
具體的步驟為:
如果,你想直接清空 Buffer 重新添加的話,可以直接利用 abort() API 來做。它的工作是清空當前 SB 中所有的 segment,使用方法也很簡單,不過就是需要注意不要和 remove 操作一起執行。更保險的做法就是直接,通過 updating===false 來完成:
if(sb.updating===false){
sb.abort();
}
這時候,abort 的主要流程為:
確保 MS.readyState==="open"
將 appendWindowStart 設置為 pts 原始值,比如,0
將 appendWindowEnd 設置為正無限大,即, Infinity。
到這裡,整個流程差不多就已經介紹完了。實際代碼,可以參考一下,w3c 的 example 下面,我們主要來檢查一下,實際 HTMLMediaElement 和 MSE 之間又有啥不乾淨的關係。
HTMLMediaElement 播放設定HTMLMediaElement 是一個集合概念,裡面包含了 Video 和 Audio 元素。也可以說,A/V 兩個元素其實就是繼承了 HTMLMediaElement 的原型對象。
我們先來看一下 HTMLMediaElement 上面具有哪些屬性:
interface HTMLMediaElement : HTMLElement {
// error state
readonly attribute MediaError? error;
// network state
attribute DOMString src;
attribute MediaProvider? srcObject;
readonly attribute DOMString currentSrc;
attribute DOMString? crossOrigin;
const unsigned short NETWORK_EMPTY = 0;
const unsigned short NETWORK_IDLE = 1;
const unsigned short NETWORK_LOADING = 2;
const unsigned short NETWORK_NO_SOURCE = 3;
readonly attribute unsigned short networkState;
attribute DOMString preload;
readonly attribute TimeRanges buffered;
void load();
CanPlayTypeResult canPlayType(DOMString type);
// ready state
const unsigned short HAVE_NOTHING = 0;
const unsigned short HAVE_METADATA = 1;
const unsigned short HAVE_CURRENT_DATA = 2;
const unsigned short HAVE_FUTURE_DATA = 3;
const unsigned short HAVE_ENOUGH_DATA = 4;
readonly attribute unsigned short readyState;
readonly attribute boolean seeking;
// playback state
attribute double currentTime;
void fastSeek(double time);
readonly attribute unrestricted double duration;
object getStartDate();
readonly attribute boolean paused;
attribute double defaultPlaybackRate;
attribute double playbackRate;
readonly attribute TimeRanges played;
readonly attribute TimeRanges seekable;
readonly attribute boolean ended;
attribute boolean autoplay;
attribute boolean loop;
void play();
void pause();
// controls
attribute boolean controls;
attribute double volume;
attribute boolean muted;
attribute boolean defaultMuted;
// tracks
[SameObject] readonly attribute AudioTrackList audioTracks;
[SameObject] readonly attribute VideoTrackList videoTracks;
[SameObject] readonly attribute TextTrackList textTracks;
TextTrack addTextTrack(TextTrackKind kind, optional DOMString label = "", optional DOMString language = "");
};
上面這些屬性都是非常精華和重要的內容。不過,說實話,真 TM 多。。。
更完整的可以直接參考 W3C 官方文檔的說明:HTMLMedia。
video 播放事件的迷video 的播放事件可以說是比整個 HTMLMediaElment 屬性更噁心的內容。大家可以先看一下基本事件: playing , waiting , seeking , seeked , ended , loadedmetadata , loadeddata , canplay , canplaythrough , durationchange , timeupdate , play , pause , ratechange , volumechange , suspend , emptied , stalled, canplaythrough。
有時候看見這些,腦子都是炸的。不過,說歸說,生活還是要過的,就像娶了老婆,總不能說 薪資保密不交錢吧。
這裡的事件大部分是圍繞的 HME(HTMLMediaElment)中的 readyState 的。
其基本的內容有:
// ready state
const unsigned short HAVE_NOTHING = 0;
const unsigned short HAVE_METADATA = 1;
const unsigned short HAVE_CURRENT_DATA = 2;
const unsigned short HAVE_FUTURE_DATA = 3;
const unsigned short HAVE_ENOUGH_DATA = 4;
readyState 本身是代表當前播放片段的 Buffer 內容。我們先來說一下,每個值代表的含義,這樣,也就能夠理解上面具體事件到底什麼時候能夠觸發,以及為什麼能夠觸發。
HAVE_NOTHING = 0: 當前 video 沒有包含任何可使用的數據。即,它沒有和任何流綁定在一起。此時,啥事件都不會觸發。
HAVE_METADATA = 1: 得到視頻流的基本數據,比如,視頻編碼格式,視頻 duration 等。不過,還沒有得到實際的數據,當前還不能無法播放。
HAVECURRENTDATA = 2: 擁有當前視頻播放數據,但並不包括下一幀的數據。即,很有可能 Video 在播放完當前的幀後就停止。並且,若且唯若 readyState >= HAVE_CURRENT_DATA 才可以完成播放。
HAVEFUTUREDATA = 3: 這是比上一個狀態,數據更豐富的一個狀態。這時,不僅擁有當前視頻播放數據,還包括下一幀的播放數據。
HAVEENOUGHDATA = 4: 表示當前 mediaSource 中的視頻流 Buffer 已經滿了。即,可以流暢的播放一段時間的數據。
就這 5 個狀態,實際上映射的是上面的 6 個事件(這裡還不包括網絡狀態的事件。。。): loadedmetadata , loadeddata , canplay , canplaythrough , playing, waiting。
這裡簡述一下,具體觸發事件和其所對應的狀態吧:
loadedmetadata: 當 readyState === 1 時,觸發。表示已經獲得相關的視頻元數據。
loadeddata: 當 readyState === 2,觸發。表示當前已經可以播放了。該事件其實和 loadedmetadata 區別不是特別大。
canplay: 當 readyState === 3,觸發。此時,已經有一部分數據,但並不代表可以完整的播放到音頻的結束,中間可能會存在暫停和緩存的操作。
canplaythrough: 當 readyState === 4,觸發。此時,video 可以一直播放到視頻流的結束。相當於已經下好一段完整的視頻。
剩下兩個事件則主要在視頻播放途中會經常觸發-- waiting 和 playing,和具體某個 readyState 狀態的聯繫就不太大了。
playing: 當在視頻由於缺少 media source 而暫停緩存以及手動暫停,又重新播放時,會觸發該事件。簡單來說就是,readyState >= HAVEFUTUREDATA 以及 paused=== false 條件下, playing 事件可以觸發。
waiting: 由於沒有下一幀的數據,導致視頻進行 buffer 加載,造成 Video 的暫停(但並不是用戶的暫停)。該事件的觸發條件為:readyState <= HAVECURRENTDATA && paused===false。需要注意的是,用戶手動暫停是不會觸發該事件的,這和 pause 事件有著本質的區別。
上面這 6 個事件是完全和 video readyState 底層狀態相關的,而和上層用戶操作沒有任何關聯的。除了這 6 個事件以外的其它事件,我們這裡簡單做個分類即可:
上面這些是一些比較常見和常用的事件,當然,還有一些錯誤處理的事件,這裡就不贅述了。詳情可以參考:Video 事件總結。
video 為啥不能自動播放?在一些官網上,有時候需要在首屏加上一個 video,使其在罩層下面播放。非常簡單的想法就是直接加上 autoplay 屬性,例如:
<video src="audio.ogg" controls autoplay loop>
<p>你的瀏覽器不支持audio標籤</p>
</video>
這樣,在大部分 PC 上播放都沒有問題。但是,In China,有兩個天王 APP,一個是 QQ,一個是 WX。這兩位爸爸裡面用的是 TBS 和 QB 來做 webview,內核雖然用到了 blink,但是名字改了,改動總是有的。
所以,有時候在 WX 和 QQ 上,上面的 video 標籤並不能直接播放。為什麼呢?
母雞。。。內核又不是我寫的。但基本上可以根據官方文檔上猜測一下:
因為,Video 內部有一個叫做 autoplaying flag,如果為 true 的話,它會阻塞 Document 的 load 事件,也就是延長渲染的時間。WX 可能為了用戶體驗,手動取消的 autoplay 這一過程。那我們有沒有什麼其他辦法,做到強制觸發呢?
嗯,有的。在前面我們了解過關於 readyState 相關的事件,藉助他們可以完成自動播放的一個 trick。
video.addEventListener('canplay',function(e){
// now the readyState has already larger than 3
// then, using JS to play the video
video.play();
})
實際上,Video 自動播放,可以直接映射為 HTMLMedia 的 load 方法。如果,你沒有給 Video 添加 autoplay 的屬性,可以嘗試使用 load 方法來直接播放。這樣,既可以避免無意識延長 document.onload 的觸發,又可以比較靈活的進行腳本配置。
window.onload = function(){
video.load();
}
當你調用 load 方法時,我們得清楚為什麼它能這樣做?難道它可以手動修改 readyState 狀態,還是其它底層的操作呢?
使用 load() 方法進行加載時。
如果,video 元素已經開始加載其它數據,則中斷當前 video 元素資源獲取
移除當前 video 元素的所有處理程序。
開始通過 network 獲取資源
將 playbackRate 設置為 defaultPlaybackRate
將 autoplaying flag 設置為 true(這就是重點!和手動設置 autoplay 效果一致)
觸發後面資源獲取的流程
用 JS 來 seek videovideo 本身有 seeking 和 seeked 事件來作為用戶 seek 操作的監聽函數。
seeking: 當用戶開始拖動進度條的時候觸發
seeked: 當用戶拖動完進度條時觸發。
不過,最常用的還是 seeked 時間,只需要監聽用戶最後一次鬆手的位置。
但是,這樣平白無故監聽兩個事件幹嘛呢?
吃飽了嗎?
我們要先明確我們的需求,即,能不能用 JS 來完成 seek 的操作呢?
可以,HM(HTMLMedia) 提供給我們了幾個非常有用的 API:
seeking[boolean]:返回當前用戶是否正在 seek 操作
seekable[TimeRanges]: 返回可供用戶 seek 的 timeRanges 範圍。
fastSeek(time): 跳轉到指定時間附近。為啥說附近呢?因為,該 seek 的位置的精確度主要是根據 speed 來決定的。但是如果,當前 video 沒有數據,該方法是不會生效的。
currentTime: 如果想要 seek 到精確時間,可以直接利用該屬性來設置。
上面幾個 API 可以基本完成 seek 的邏輯判斷和相關操作。最簡單的 seek 代碼為:
function playSound(time) {
sfx.currentTime = time;
sfx.play();
}
試一試控制條如果你在添加 video 的使用,額外加上了 controls 的屬性。那麼,在 Video 播放的時候,會在下方出現一個控制條。這個就是我們接下來想要來探索的一塊領域。
從原始播放條上,我們基本上可以了解到幾個功能:
考慮到有一些刷劇的同學喜歡用倍速來看劇,我們這裡還可以提供變速播放的功能。綜上所述,我們實現的基本功能有 5 個。
而 HM 也同樣在 JS 上提供了相關的方法和屬性給我們進行使用:
media.paused
media.ended
media.defaultPlaybackRate [ = value ]
media.playbackRate [ = value ]
media.played
media.play()
media.pause()
media.currentTime;
media.volume
media.muted
media.defaultMuted
利用上述這些方法,我們基本上可以完成自定義控制條的設置。後面,我們根據幾個基本的場景來做一下 JS code 的模擬。
調節音量和靜音
這裡主要使用上面的 volume 和 muted 屬性。
function volumeControl(degree,video) {
// the degree is always between, if not ,it will throw IndexSizeError exception
degree = degree < 0 ? 0 || degree;
degree = degree > 1 ? 1 || degree;
video.volume = degree;
}
function toggleMute(video) {
video = !video.muted;
}
這裡,額外再介紹一個和靜音有關的屬性 defaultMuted,該屬性是用來決定音頻播放的默認屬性。
設置倍速播放
在倍速播放的時候,需要了解三個播放模式。一個是 fast-forwarding, reversing, normal playback。看起來有點懵厚,我保證看了中文之後,你一定想說句 MMP。
fast-forwarding:快進
reversing:快退
normal playback:正常播放
在設置倍速的時候,需要額外注意,當用戶從 fast-forwarding 到 normal playback 時,播放器會直接讀取默認的 defaultPlaybackRate 而不是你通常設置的 playbackRate。所以,我們不論是在 seek 還是在其它播放模式的時候,最好是將默認的 rate 一併設置。
function switchRate(ratetype,video) {
// ratetype could only be larger than 0
video.defaultPlaybackRate = video.playbackRate = ratetype;
}
模擬用戶 seek
在用戶 seek 過程中,最主要的問題在於,在 seek 完成之後,播放器的狀態是不固定的,即,有可能播放器還在加載視頻數據,並且加載完成之後不能繼續播放。我們需要解決的一個邏輯問題主要在這裡。
function seekTime(curTime,video) {
// curTime should always less than the duration of video
curTime = curTime > video.duration ? video.duration : curTime;
video.currentTime = curTime;
if(video.readyState <=2 ){
video.pause();
}
video.addEventListener('canplay',function(e){
e.target.play();
})
}
暫停/播放
這個算是 video 播放器的基本功能,實現起來也是很簡單的。
function togglePlay(video) {
video.paused ? video.play() : video.pause();
}
media 與 MSE 的聯繫前面簡單說了一下,HTMLMediaElement 內部的相關屬性和事件。但本文並不是為了教大家如何做一個 UI 控制,而是深入到 MSE 那一層,能夠做到任意控制視頻流 Buffer。雖然,HME 和 MSE 分別位於不同的層級,但是,內部還是有一定的聯繫。這兩者之間聯繫的主要還是在於 Buffered 的 TimeRange 對象和相關的 track。
在 HME 中,提供了:
media.buffered:獲取 HME 中,可播放的 time Range 片段。如果簡簡單單通過 src 來獲取資源,那麼,buffered 一般只會返回一個 range,從 0 ~ end of duration。如果,你在獲取視頻的時候,有涉及 seek 的操作,那麼,這個就有有可能會返回多個 range。當然,如果你是通過 MSE 來完成 appendBuffer 的話,那麼,這裡的 HME.buffered 就和 MSE.SB.buffered 中的內容是完全一致的。
audioTracks/videoTracks: 返回 mediasource 中具體播放的 tracklist。這裡的屬性和 MSE.SB.audioTracks/videoTracks 一致。
上面這兩個屬性,就是 HME 和 MSE 的主要聯繫。不過,videoTracks 裡面又有哪些內容可以給前端開發者使用呢?
audioTracks/videoTracks這裡,我們簡單介紹一下裡面的基本內容:
interface AudioTrackList : EventTarget {
readonly attribute unsigned long length;
getter AudioTrack (unsigned long index);
AudioTrack? getTrackById(DOMString id);
attribute EventHandler onchange;
attribute EventHandler onaddtrack;
attribute EventHandler onremovetrack;
};
interface AudioTrack {
readonly attribute DOMString id;
readonly attribute DOMString kind;
readonly attribute DOMString label;
readonly attribute DOMString language;
attribute boolean enabled;
};
interface VideoTrackList : EventTarget {
readonly attribute unsigned long length;
getter VideoTrack (unsigned long index);
VideoTrack? getTrackById(DOMString id);
readonly attribute long selectedIndex;
attribute EventHandler onchange;
attribute EventHandler onaddtrack;
attribute EventHandler onremovetrack;
};
interface VideoTrack {
readonly attribute DOMString id;
readonly attribute DOMString kind;
readonly attribute DOMString label;
readonly attribute DOMString language;
attribute boolean selected;
};
仔細看一下,AudioTrack 和 VideoTrack 內容其實沒啥太大的差別,這兩個就一起介紹了。接下來,我們從 List 到 Track 來進行介紹。
List
TrackList 本身是用來存放 mediasource 中的 tracks,裡面可能包含不止一個 track。比如,audio 中,可能還分左聲道,右聲道等等。並且,有些 track 只是作為備用,並沒有實際播放出來,這個也會存放在 List 當中。在 List 中獲取 track 的方式有兩種,一種是直接根據 track 的 id,還有一種是根據序號來獲取:
audiolist[0]; // get the first track in the audio track list
audilist.getTrackById('12fncissa1@d3');
不過,說實在的,就算你獲取到指定的 track 之後,你也並不能做啥子事情(除了 selected 屬性)。頂多是獲取相關 track 的信息內容。雖然說是雞肋,但又有點用。。。
並且,List 上還提供了相關的事件,來對你的 track 操作進行監聽。
Track
具體的 track 裡面的內容全是一些描述屬性,並沒有啥可操作的內容。如下:
readonly attribute DOMString id;
readonly attribute DOMString kind;
readonly attribute DOMString label;
readonly attribute DOMString language;
attribute boolean selected;
selected 標識當前 track 是否被使用。該屬性,也可以用來被設定。
track.selected = false;
kind 這個欄位信息,實際上是從 mediasource,即,音視頻源文件中提取出來的,比如,ftyp + moov box 中。主要內容有:
CategoryDefinitionApplies to..."alternative"A possible alternative to the main track, e.g., a different take of a song (audio), or a different angle (video).Audio and video."captions"A version of the main video track with captions burnt in. (For legacy content; new content would use text tracks.)Video only."descriptions"An audio description of a video track.Audio only."main"The primary audio or video track.Audio and video."main-desc"The primary audio track, mixed with audio descriptions.Audio only."sign"A sign-language interpretation of an audio track.Video only."subtitles"A version of the main video track with subtitles burnt in. (For legacy content; new content would use text tracks.)Video only."translation"A translated version of the main audio track.Audio only."commentary"Commentary on the primary audio or video track, e.g., a director’s commentary.Audio and video."" (empty string)No explicit kind, or the kind given by the track’s metadata is not recognized by the user agent.Audio and video.這裡,其實看了也沒啥用。。。你又不是真的做音視頻開發的。過度了解真的有害身心健康。
label 是該 track 上的描述信息。
language欄位屬性,必須是 BCP47 格式才行,比如: und 這樣的格式。如果,不是 BCP 47 格式的話,則會被當作空值。
業務實踐到這裡,相信大家已經對直播流所需要的技術都已經了解,我們接下來主要來實踐一下 MSE 在 H5 播放器中具體的應用和實踐。H5 播放器所需的流程其實就兩個環節:
websocket 提供原始的直播流。比如,RTMP 的直播流,或者 WS-FLV 的直播流。但是,裡面得到的純流大部分是 flv 格式,我們的 video 是不能直接播放的。這時候,就需要把純流給 remux/demux 進行轉換,生成可播放的 mp4 流。具體轉流的過程我這裡就介紹了,這不在本篇文章的範圍之內。
MSE 將可播放的流,在底層餵給 Video 進行播放。這一個環節,按理說我們是看不到的,不過可以直接映射到 SourceBuffer 添加 media segment 的環節上來。
這裡,大家可能會有點懵,接下來,我們具體用代碼來實踐一下。
MSE 管理環節前面已經介紹過,MSE 這裡主要做的內容是生成指定 MIME 的 SourceBuffer,得到特定的 SB 後,就可以添加視頻流進行播放了。模擬代碼為:
// the implementation is as follows
class MSE {
constructor(video) {
this.videoEle = video;
this.mediaSource;
this.tmpBuffer = []; // in order to save video buffer
this.initMSE(); // get the global var of MSE
}
initMSE() {
let mediaSource = this.mediaSource = new MediaSource();
this.videoEle.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', e => {
URL.revokeObjectURL(this.videoEle.src);
});
}
// after getting the mime, then init the specific SB
initSB(mime) {
if (this.mediaSource.readyState === 'open') {
try {
this.SB = this.mediaSource.addSourceBuffer(MIME);
} catch (error) {
console.log(error)
throw new Error("MSE couldn't support the MIME: " + MIME);
}
}
}
// when getting the new video buffer, checking the sb.updating, if it isn't using, append new one
appendSB(buffer) {
this.tmpBuffer.push(buffer);
let sb = this.SB;
if (!sb.updating) {
sb.appendBuffer(this._mergeBuffer(this.tmpBuffer));
this.tmpBuffer = []; // clear the buffer
}
}
// just a cheap function
_mergeBuffer(boxes) {
let boxLength = boxes.reduce((pre, val) => {
return pre + val.byteLength;
}, 0);
let buffer = new Uint8Array(boxLength);
let offset = 0;
boxes.forEach(box => {
buffer.set(box, offset);
offset += box.byteLength;
});
return buffer;
}
}
// bind the video.src with MSE
let MSEController = new MSE(video);
ws.initMsg(MIME=>{
MSEController.initSB(MIME);
});
ws.laterMsg(buffer=>{
MSEController.appendSB(buffer);
})
主要,執行就是:
letMSEController=newMSE(video): 將 MSE 和 video.src 綁定在一起
MSEController.initSB(MIME);: 添加指定的 sourceBuffer
MSEController.appendSB(buffer);: 當獲得數據時,將 Buffer 添加到指定的 SB 中。
這裡,關於 MSE 和 Video 的基本內容已經介紹完了。從 MSE 底層只是到 Video 基本流程控制,我們都已經完全過了一遍,剩下的內容主要就是格式之間的轉換和 Buffer 相關處理。
本篇文章大概整體分析了一遍 H5 直播需要了解的基本技術。不過,這並不是全部,還有直播協議的相關優化,視頻格式的解碼分析,瀏覽器視頻調試等等。如果大家對這些內容感興趣的話,可以關注我的公眾號獲取--前端小吉米
更直接的辦法是,在線直播觀看《騰訊 TLC 直播大會》,期間我會詳詳細細的講述,如何零基礎入門 HTML5 直播。