自從HTML5提供了video標籤,在網頁中播放視頻已經變成一個很是簡單的事,只要一個video標籤,src屬性設置爲視頻的地址就完事了。因爲src指向真實的視頻網絡地址,在早期通常網站資源文件不怎麼經過referer設置防盜鏈,當咱們拿到視頻的地址後能夠隨意的下載或使用(每次放假回家,就會有親戚找我幫忙從一些視頻網站上下東西)。javascript
目前的雲存儲服務商大部分都支持referer防盜鏈。其原理就是在訪問資源時,請求頭會帶上發起請求的頁面地址,判斷其不存在(表示直接訪問資源地址)或不在白名單內,即爲盜鏈。
但是從某個時間開始咱們打開調試工具去看各大視頻網站的視頻src會發現,它們通通變成了這樣的形式。html
拿b站的一個視頻來看,紅框中的視頻地址,這個blob是個什麼東西?。java
其實這個Blob URL也不是什麼新技術,國內外出來都有一陣子了,可是網上的相關的文章很少也不是很詳細,今天就和你們一塊兒分享學習一下。jquery
最先是數據庫直接用Blob來存儲二進制數據對象,這樣就不用關注存儲數據的格式了。在web領域,Blob對象表示一個只讀原始數據的類文件對象,雖然是二進制原始數據可是相似文件的對象,所以能夠像操做文件對象同樣操做Blob對象。ios
ArrayBuffer對象用來表示通用的、固定長度的原始二進制數據緩衝區。咱們能夠經過new ArrayBuffer(length)來得到一片連續的內存空間,它不能直接讀寫,但可根據須要將其傳遞到TypedArray視圖或 DataView 對象來解釋原始緩衝區。實際上視圖只是給你提供了一個某種類型的讀寫接口,讓你能夠操做ArrayBuffer裏的數據。TypedArray需指定一個數組類型來保證數組成員都是同一個數據類型,而DataView數組成員能夠是不一樣的數據類型。git
TypedArray視圖的類型數組對象有如下幾個:github
Blob與ArrayBuffer的區別是,除了原始字節之外它還提供了mime type做爲元數據,Blob和ArrayBuffer之間能夠進行轉換。web
File對象其實繼承自Blob對象,並提供了提供了name , lastModifiedDate, size ,type 等基礎元數據。
建立Blob對象並轉換成ArrayBuffer:ajax
//建立一個以二進制數據存儲的html文件 const text = "<div>hello world</div>"; const blob = new Blob([text], { type: "text/html" }); // Blob {size: 22, type: "text/html"} //以文本讀取 const textReader = new FileReader(); textReader.readAsText(blob); textReader.onload = function() { console.log(textReader.result); // <div>hello world</div> }; //以ArrayBuffer形式讀取 const bufReader = new FileReader(); bufReader.readAsArrayBuffer(blob); bufReader.onload = function() { console.log(new Uint8Array(bufReader.result)); // Uint8Array(22) [60, 100, 105, 118, 62, 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 60, 47, 100, 105, 118, 62] };
建立一個相同數據的ArrayBuffer,並轉換成Blob:數據庫
//咱們直接建立一個Uint8Array並填入上面的數據 const u8Buf = new Uint8Array([60, 100, 105, 118, 62, 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 60, 47, 100, 105, 118, 62]); const u8Blob = new Blob([u8Buf], { type: "text/html" }); // Blob {size: 22, type: "text/html"} const textReader = new FileReader(); textReader.readAsText(u8Blob); textReader.onload = function() { console.log(textReader.result); // 一樣獲得div>hello world</div> };
更多Blob和ArrayBuffer的相關內容能夠參看下面的資料:
video標籤,audio標籤仍是img標籤的src屬性,不論是相對路徑,絕對路徑,或者一個網絡地址,歸根結底都是指向一個文件資源的地址。既然咱們知道了Blob實際上是一個能夠看成文件用的二進制數據,那麼只要咱們能夠生成一個指向Blob的地址,是否是就能夠用在這些標籤的src屬性上,答案確定是能夠的,這裏咱們要用到的就是URL.createObjectURL()。
const objectURL = URL.createObjectURL(object); //blob:http://localhost:1234/abcedfgh-1234-1234-1234-abcdefghijkl
這裏的object參數是用於建立URL的File對象、Blob 對象或者 MediaSource 對象,生成的連接就是以blob:開頭的一段地址,表示指向的是一個二進制數據。
其中localhost:1234是當前網頁的主機名稱和端口號,也就是location.host,並且這個Blob URL是能夠直接訪問的。須要注意的是,即便是一樣的二進制數據,每調用一次URL.createObjectURL方法,就會獲得一個不同的Blob URL。這個URL的存在時間,等同於網頁的存在時間,一旦網頁刷新或卸載,這個Blob URL就失效。
經過URL.revokeObjectURL(objectURL) 能夠釋放 URL 對象。當你結束使用某個 URL 對象以後,應該經過調用這個方法來讓瀏覽器知道不用在內存中繼續保留對這個文件的引用了,容許平臺在合適的時機進行垃圾收集。
若是是以文件協議打開的html文件(即url爲file://開頭),則地址中 http://localhost:1234會變成null,並且此時這個Blob URL是沒法直接訪問的。
有時咱們經過input上傳圖片文件以前,會但願能夠預覽一下圖片,這個時候就能夠經過前面所學到的東西實現,並且很是簡單。
html
<input id="upload" type="file" /> <img id="preview" src="" alt="預覽"/>
javascript
const upload = document.querySelector("#upload"); const preview = document.querySelector("#preview"); upload.onchange = function() { const file = upload.files[0]; //File對象 const src = URL.createObjectURL(file); preview.src = src; };
這樣一個圖片上傳預覽就實現了,一樣這個方法也適用於上傳視頻的預覽。
如今咱們有一個網絡視頻的地址,怎麼能將這個視頻地址變成Blob URL是形式呢,思路確定是先要拿到存儲這個視頻原始數據的Blob對象,可是不一樣於input上傳能夠直接拿到File對象,咱們只有一個網絡地址。
咱們知道平時請求接口咱們可使用xhr(jquery裏的ajax和axios就是封裝的這個)或fetch,請求一個服務端地址能夠返回咱們相應的數據,那若是咱們用xhr或者fetch去請求一個圖片或視頻地址會返回什麼呢?固然是返回圖片和視頻的數據,只不過要設置正確responseType才能拿到咱們想要的格式數據。
function ajax(url, cb) { const xhr = new XMLHttpRequest(); xhr.open("get", url); xhr.responseType = "blob"; // "text"-字符串 "blob"-Blob對象 "arraybuffer"-ArrayBuffer對象 xhr.onload = function() { cb(xhr.response); }; xhr.send(); }
注意XMLHttpRequest和Fetch API請求會有跨域問題,能夠經過跨域資源共享(CORS)解決。
看到responseType能夠設置blob和arraybuffer咱們應該就有譜了,請求返回一個Blob對象,或者返回ArrayBuffer對象轉換成Blob對象,而後經過createObjectURL生成地址賦值給視頻的src屬性就能夠了,這裏咱們直接請求一個Blob對象。
ajax('video.mp4', function(res){ const src = URL.createObjectURL(res); video.src = src; })
用調試工具查看視頻標籤的src屬性已經變成一個Blob URL,表面上看起來是否是和各大視頻網站形式一致了,可是考慮一個問題,這種形式要等到請求徹底部視頻數據才能播放,小視頻還好說,要是視頻資源大一點豈不爆炸,顯然各大視頻網站不可能這麼幹。
解決這個問題的方法就是流媒體,其帶給咱們最直觀體驗就是使媒體文件能夠邊下邊播(像我這樣的90後男性最先體會到流媒體好處的應該是源於那款快子頭的播放器),web端若是要使用流媒體,有多個流媒體協議能夠供咱們選擇。
HLS (HTTP Live Streaming), 是由 Apple 公司實現的基於 HTTP 的媒體流傳輸協議。HLS以ts爲傳輸格式,m3u8爲索引文件(文件中包含了所要用到的ts文件名稱,時長等信息,能夠用播放器播放,也能夠用vscode之類的編輯器打開查看),在移動端大部分瀏覽器都支持,也就是說你能夠用video標籤直接加載一個m3u8文件播放視頻或者直播,可是在pc端,除了蘋果的Safari,須要引入庫來支持。
用到此方案的視頻網站好比優酷,能夠在視頻播放時經過調試查看Network裏的xhr請求,會發現一個m3u8文件,和每隔一段時間請求幾個ts文件。
可是除了HLS,還有Adobe的HDS,微軟的MSS,方案一多就要有個標準點的東西,因而就有了MPEG DASH。
DASH(Dynamic Adaptive Streaming over HTTP) ,是一種在互聯網上傳送動態碼率的Video Streaming技術,相似於蘋果的HLS,DASH會經過media presentation description (MPD)將視頻內容切片成一個很短的文件片斷,每一個切片都有多個不一樣的碼率,DASH Client能夠根據網絡的狀況選擇一個碼率進行播放,支持在不一樣碼率之間無縫切換。
Youtube,B站都是用的這個方案。這個方案索引文件一般是mpd文件(相似HLS的m3u8文件功能),傳輸格式推薦的是fmp4(Fragmented MP4),文件擴展名一般爲.m4s或直接用.mp4。因此用調試查看b站視頻播放時的網絡請求,會發現每隔一段時間有幾個m4s文件請求。
不論是HLS仍是DASH們,都有對應的庫甚至是高級的播放器方便咱們使用,但咱們實際上是想要學習一點實現。其實拋開掉索引文件的解析拿到實際媒體文件的傳輸地址,擺在咱們面前的只有一個如何將多個視頻數據合併讓video標籤能夠無縫播放。
與之相關的一篇B站文章推薦給感興趣的朋友: 咱們爲何使用DASH
video標籤src指向一個視頻地址,視頻播完了再將src修改成下一段的視頻地址而後播放,這顯然不符合咱們無縫播放的要求。其實有了咱們前面Blob URL的學習,咱們可能就會想到一個思路,用Blob URL指向一個視頻二進制數據,而後不斷將下一段視頻的二進制數據添加拼接進去。這樣就能夠在不影響播放的狀況下,不斷的更新視頻內容並播放下去,想一想是否是有點流的意思出來了。
要實現這個功能咱們要經過MediaSource來實現,MediaSource接口功能也很純粹,做爲一個媒體數據容器能夠和HTMLMediaElement進行綁定。基本流程就是經過URL.createObjectURL建立容器的BLob URL,設置到video標籤的src上,在播放過程當中,咱們仍然能夠經過MediaSource.appendBuffer方法往容器裏添加數據,達到更新視頻內容的目的。
實現代碼以下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <video controls></video> </body> </html> <script> // 封裝獲取視頻原始數據請求 const get = (url, cb) => { // 兼容IE五、IE6 let xhr = window.XMLHttpRequest ? new window.XMLHttpRequest() : new ActiveXObject() xhr.onreadystatechange = function() { if (xhr.readyState === 4 && xhr.status === 200) { cb(xhr.response) } } xhr.responseType = 'arraybuffer' // 指定返回數據類型,這裏若是使用blob類型會有問題,至於爲何還不清楚 xhr.open('GET', url) xhr.send() } // 獲取video DOM let vDOM = document.querySelector('video') let assetsURl = 'http://127.0.0.1:8888/frag_bunny.mp4' let mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"' if ('MediaSource' in window && MediaSource.isTypeSupported(mimeCodec)) { let mediaSource = new MediaSource() vDOM.src = URL.createObjectURL(mediaSource) // sourceopen事件是在給video.src賦值以後觸發 mediaSource.addEventListener('sourceopen', sourceopen) } else { console.error('Unsupported MIME type or codec: ', mimeCodec) } function sourceopen() { let sourceBuffer = this.addSourceBuffer(mimeCodec) get(assetsURl, buf => { sourceBuffer.appendBuffer(buf) // sourceended事件是在用戶主動調用終止或者視頻數據解析、播放錯誤時被觸發 sourceBuffer.addEventListener('updateend', () => { mediaSource.endOfStream() vDOM.play() }) }) } </script>
當視頻比較大時咱們改進一下,發起帶range頭的請求分片獲取文件片斷,而後追加到mediaSource,監聽當前片斷是否播放完,發起上述請求獲取下一個片斷,並且用戶還可能點擊進度條到其餘播放點,這一點本文沒有進行處理
<!DOCTYPE html> <html> <head> <meta charset="utf-8"/> </head> <body> <video controls></video> <script> var video = document.querySelector('video'); var assetURL = 'http://127.0.0.1:8888/frag_bunny.mp4'; // Need to be specific for Blink regarding codecs // ./mp4info frag_bunny.mp4 | grep Codec var mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'; var totalSegments = 5; var segmentLength = 0; var segmentDuration = 0; var bytesFetched = 0; var requestedSegments = []; for (var i = 0; i < totalSegments; ++i) requestedSegments[i] = false var mediaSource = null if ('MediaSource' in window && MediaSource.isTypeSupported(mimeCodec)) { mediaSource = new MediaSource() //console.log(mediaSource.readyState); // closed video.src = URL.createObjectURL(mediaSource) mediaSource.addEventListener('sourceopen', sourceOpen) } else { console.error('Unsupported MIME type or codec: ', mimeCodec) } var sourceBuffer = null; function sourceOpen () { sourceBuffer = mediaSource.addSourceBuffer(mimeCodec); getFileLength(assetURL, function (fileLength) { console.log((fileLength / 1024 / 1024).toFixed(2), 'MB'); //totalLength = fileLength; segmentLength = Math.round(fileLength / totalSegments); //console.log(totalLength, segmentLength); fetchRange(assetURL, 0, segmentLength, appendSegment); requestedSegments[0] = true; // ontimeupdate 事件在視頻/音頻(audio/video)當前的播放位置發送改變時觸發 video.addEventListener('timeupdate', checkBuffer); // 在用戶能夠開始播放視頻/音頻(audio/video)時觸發 video.addEventListener('canplay', function () { segmentDuration = video.duration / totalSegments; video.play(); }); // 在用戶開始移動/跳躍到新的音頻/視頻(audio/video)播放位置時觸發 video.addEventListener('seeking', seek); }); }; // 獲取文件大小 function getFileLength (url, cb) { var xhr = new XMLHttpRequest() xhr.open('head', url) xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { cb(xhr.getResponseHeader('content-length')) } } xhr.send() } // 獲取文件片斷 function fetchRange (url, start, end, cb) { var xhr = new XMLHttpRequest() xhr.open('GET', url) xhr.responseType = 'arraybuffer' xhr.setRequestHeader('Range', 'bytes=' + start + '-' + end) xhr.onreadystatechange = function () { // 發送了一個帶有Range頭的get請求,服務器完成了它返回206,而不是200 if (xhr.readyState === 4 && xhr.status === 206) { console.log('fetched bytes: ', start, end) bytesFetched += end - start + 1 cb(xhr.response) } } xhr.send() } // 將文件二進制片斷追加到mediaSource中 function appendSegment (chunk) { sourceBuffer.appendBuffer(chunk) } function checkBuffer (_) { var currentSegment = getCurrentSegment(); if (currentSegment === totalSegments && haveAllSegments()) { console.log('last segment', mediaSource.readyState); mediaSource.endOfStream(); video.removeEventListener('timeupdate', checkBuffer); } else if (shouldFetchNextSegment(currentSegment)) { requestedSegments[currentSegment] = true; console.log('time to fetch next chunk', video.currentTime); fetchRange(assetURL, bytesFetched, bytesFetched + segmentLength, appendSegment); } //console.log(video.currentTime, currentSegment, segmentDuration); }; function seek (e) { console.log(e); if (mediaSource.readyState === 'open') { sourceBuffer.abort(); console.log(mediaSource.readyState); } else { console.log('seek but not open?'); console.log(mediaSource.readyState); } }; function getCurrentSegment () { return ((video.currentTime / segmentDuration) | 0) + 1; }; function haveAllSegments () { return requestedSegments.every(function (val) { return !!val; }); }; function shouldFetchNextSegment (currentSegment) { return video.currentTime > segmentDuration * currentSegment * 0.8 && !requestedSegments[currentSegment]; }; </script> </body> </html>
效果:
這段代碼修改自MDN的MediaSource詞條中的示例代碼。
此時咱們已經基本實現了一個簡易的流媒體播放功能,若是願意能夠再加入m3u8或mpd文件的解析,設計一下UI界面,就能夠實現一個流媒體播放器了。
最後提一下一個坑,不少人跑了MDN的MediaSource示例代碼,可能會發現使用官方提供的視頻是沒問題的,可是用了本身的mp4視頻就會報錯,這是由於fmp4文件擴展名一般爲.m4s或直接用.mp4,但倒是特殊的mp4文件。
一般咱們使用的mp4文件是嵌套結構的,客戶端必需要從頭加載一個 MP4 文件,纔可以完整播放,不能從中間一段開始播放。而Fragmented MP4(簡稱fmp4),就如它的名字碎片mp4,是由一系列的片斷組成,若是服務器支持 byte-range 請求,那麼,這些片斷能夠獨立的進行請求到客戶端進行播放,而不須要加載整個文件。
咱們能夠經過這個網站判斷一個mp4文件是否爲Fragmented MP4,網站地址。
咱們經過FFmpeg或Bento4的mp4fragment來將普通mp4轉換爲Fragmented MP4,兩個工具都是命令行工具,按照各自系統下載下來對應的壓縮包,解壓後設置環境變量指向文件夾中的bin目錄,就可使用相關命令了。
Bento4的mp4fragment,沒有太多參數,命令以下:
mp4fragment video.mp4 video-fragmented.mp4
FFmpeg會須要設置一些參數,命令以下:
ffmpeg -i video.mp4 -movflags empty_moov+default_base_moof+frag_keyframe video-fragmented.mp4
Tips:網上大部分的資料中轉換時是不帶default_base_moof這個參數的,雖然能夠轉換成功,可是經測試若是不添加此參數網頁中MediaSource處理視頻時會報錯。
視頻的切割分段可使用Bento4的mp4slipt,命令以下:
mp4split video.mp4 --media-segment video-%llu.mp4 --pattern-parameters N