幾年前,我開始從事於完全用JavaScript編寫的MPEG1視訊解碼器上。現在,我終於找到了清理該庫的時間,改善其效能、使其具有更高的錯誤恢復能力和模組化能力,並新增MP2音訊解碼器和MPEG-TS解析器。這使得該庫不僅僅是一個MPEG解碼器,而是一個完整的視訊播放器。
在本篇博文中,我想談一談我在開發這個庫時遇到的挑戰和各種有趣的事情。你將在官方網站上找到demo、原始碼和文件以及為什麼要使用JSMpeg:
重構
最近,我需要為一位客戶在JSMpeg中實現音訊流傳輸,然後我才意識到該庫處於一種多麼可憐的狀態。從其首次釋出以來,它已經有很多發展了。在過去的幾年裡,WebGL渲染器、WebSocket客戶端、漸進式載入、基準測試裝置等等已被加入。但所有這些都儲存在一個單一的、龐大的類中,條件判斷隨處可見。
我決定首先通過分離它的邏輯元件來梳理清楚其中的混亂。我還總結了完成實現需要哪些:解複用器、MP2解碼器和音訊輸出:
- 原始碼(Sources): AJAX, 漸進式AJAX和WebSocket
- 解複用器(Demuxer): MPEG-TS (Transport Stream)
- 解碼器(Decoder): MPEG1視訊& MP2音訊
- 渲染器(Render): Canvas2D & WebGL
- 音訊輸出:WebAudio
加上一些輔助類:
- 一個位快取(Bit Buffer),用於管理原始資料
- 一個播放器(Player),整合其他元件
每個元件(除了Sources之外)都有一個.write(buffer)方法來為其提供資料。這些元件可以“連線”到接收處理結果的目標元件上。流經該庫的完整流程如下所示:
1 2 3 |
/ -> MPEG1 Video Decoder -> Renderer Source -> Demuxer \ -> MP2 Audio Decoder -> Audio Output |
JSMpeg目前有3種不同的Source實現(AJAX\AJAX漸進式和WebSocket),還有2種不同的渲染器(Canvas2D和WebGL)。該庫的其他部分對這此並不瞭解 – 即視訊解碼器不關心渲染器內部邏輯。採用這種方法可以輕鬆新增新的元件:更多的Source,解複用器,解碼器或輸出。
我對這些連線在庫中的工作方式並不完全滿意。每個元件只能有一個目標元件(除了多路解複用器,每個流有都有一個目標元件)。這是一個折衷。最後,我覺得:其他部分會因為沒有充分的理由而過度工程設計並使得庫過於複雜化。
WebGL渲染
MPEG1解碼器中計算密集度最高的任務之一是將MPEG內部的YUV格式(準確地說是Y’Cr’Cb)轉換為RGBA,以便瀏覽器可以顯示它。簡而言之,這個轉換看起來像這樣:
1 2 3 4 5 6 7 8 |
for (var i = 0; i < pixels.length; i+=4 ) { var y, cb, cr = /* fetch this from the YUV buffers */; pixels[i + 0 /* R */] = y + (cb + ((cb * 103) >> 8)) - 179; pixels[i + 1 /* G */] = y - ((cr * 88) >> 8) - 44 + ((cb * 183) >> 8) - 91; pixels[i + 2 /* B */] = y + (cr + ((cr * 198) >> 8)) - 227; pixels[i + 4 /* A */] = 255; } |
對於單個1280×720視訊幀,該迴圈必須執行921600次以將所有畫素從YUV轉換為RGBA。每個畫素需要對目標RGB陣列寫入3次(我們可以預先填充alpha元件,因為它始終是255)。這是每幀270萬次寫入操作,每次需要5-8次加、減、乘和位移運算。對於一個60fps的視訊,我們每秒鐘完成10億次以上的操作。再加上JavaScript的開銷。JavaScript可以做到這一點,計算機可以做到這一點,這一事實仍然讓我大開眼界。
使用 WebGL ,這種顏色轉換(以及隨後在螢幕上顯示)可以大大加快。逐畫素的少量操作對 GPU 而言是小菜一碟。GPU 可以並行處理多個畫素,因為它們是獨立於任何其他畫素的。執行在 GPU 上的 WebGL 著色器(shader)甚至不需要這些煩人的位移 – GPU 喜歡浮點數:
1 2 3 4 5 6 7 8 9 10 11 12 |
void main() { float y = texture2D(textureY, texCoord).r; float cb = texture2D(textureCb, texCoord).r - 0.5; float cr = texture2D(textureCr, texCoord).r - 0.5; gl_FragColor = vec4( y + 1.4 * cb, y + -0.343 * cr - 0.711 * cb, y + 1.765 * cr, 1.0 ); } |
使用 WebGL,顏色轉換所需的時間從 JS 總時間的 50% 下降到僅需 YUV 紋理上傳時間的約 1% 。
我遇到了一個與 WebGL 渲染器偶然相關的小問題。JSMpeg 的視訊解碼器不會為每個顏色平面生成三個 Uint8Arrays ,而是一個 Uint8ClampedArrays 。它是這樣做的,因為 MPEG1 標準規定解碼的顏色值必須是緊湊的,而不是分散的。讓瀏覽器通過 ClampedArray 進行交織比在 JavaScript 中執行更快。
依然存在於某些瀏覽器(Chrome和Safari)中的缺陷會阻止WebGL直接使用Uint8ClampedArray。因此,對於這些瀏覽器,我們必須為每個幀的每個陣列建立一個Uint8Array檢視。這個操作非常快,因為沒有需要真實複製的事情,但我仍然希望不使用它。
JSMpeg會檢測到這個錯誤,並僅在需要時使用該解決方法。我們只是嘗試上傳一個固定陣列並捕獲此錯誤。令人遺憾的是,這種檢測會觸發控制檯中的一個非靜默的警告,但這總比沒有好吧。
1 2 3 4 5 6 7 8 9 10 11 |
WebGLRenderer.prototype.allowsClampedTextureData = function() { var gl = this.gl; var texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, 1, 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, new Uint8ClampedArray([0]) ); return (gl.getError() === 0); }; |
對直播流媒體的WebAudio
很長一段時間裡,我假設為了向WebAudio提供原始PCM樣本資料而沒有太多延遲或爆破音,你需要使用ScriptProcessorNode。只要你從指令碼處理器獲得回撥,你就可以及時複製解碼後的取樣資料。這確實有效。我試過這個方法。它需要相當多的程式碼才能正常工作,當然這是計算密集型和不優雅的作法。
幸運的是,我最初的假設是錯誤的。
WebAudio上下文維護自己的計時器,它有別於JavaScript的Date.now()或performance.now()。 此外,你可以根據上下文的時間指導你的WebAudio源在未來的準確時間呼叫start()。有了這個,你可以將非常短的PCM緩衝器串在一起,而不會有任何瑕疵。
你只需計算下一個緩衝區的開始時間,就可以連續新增所有之前的緩衝區的時間。總是使用 WebAudio Context 自己的時間來做這件事是很重要的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
var currentStartTime = 0; function playBuffer(buffer) { var source = context.createBufferSource(); /* load buffer, set destination etc. */ var now = context.currentTime; if (currentStartTime < now) { currentStartTime = now; } source.start(currentStartTime); currentStartTime += buffer.duration; } |
不過需要注意的是:我需要獲得佇列音訊的精確剩餘時間。我只是簡單地將它作為當前時間和下一個啟動時間的區別來實現:
1 2 |
// Don't do that! var enqueuedTime = (currentStartTime - context.currentTime); |
我花了一段時間才弄明白,這行不通。你可以看到,上下文的 currentTime 只是每隔一段時間才更新一次。它不是一個精確的實時值。
1 2 3 4 |
var t1 = context.currentTime; doSomethingForAWhile(); var t2 = context.currentTime; t1 === t2; // true |
因此,如果需要精確的音訊播放位置(或者基於它的任何內容),你必須恢復到 JavaScript 的 performance.now() 方法。
iOS 上的音訊解鎖
你將要愛上蘋果時不時扔到 Web 開發人員臉上的麻煩。其中之一就是在播放任何內容之前都需要在頁面上解鎖音訊。總的來說,音訊播放只能作為對使用者操作的響應而啟動。你點選了一個按鈕,音訊則播放了。
這是有道理的。我不反駁它。當你訪問某個網頁時,你不希望在未經通知的情況下發出聲音。
是什麼讓它變得糟糕透頂呢?是因為蘋果公司既沒有提供一種利索的解鎖音訊的方法,也沒有提供一種方法來查詢 WebAudio Context 是否已經解鎖。你所要做的就是播放一個音訊源並不斷檢查是否正在順序播放。儘管如此,在播放之後你還不能馬上檢查。是的,你必須等一會!
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 |
WebAudioOut.prototype.unlock = function(callback) { // This needs to be called in an onclick or ontouchstart handler! this.unlockCallback = callback; // Create empty buffer and play it var buffer = this.context.createBuffer(1, 1, 22050); var source = this.context.createBufferSource(); source.buffer = buffer; source.connect(this.destination); source.start(0); setTimeout(this.checkIfUnlocked.bind(this, source, 0), 0); }; WebAudioOut.prototype.checkIfUnlocked = function(source, attempt) { if ( source.playbackState === source.PLAYING_STATE || source.playbackState === source.FINISHED_STATE ) { this.unlocked = true; this.unlockCallback(); } else if (attempt < 10) { // Jeez, what a shit show. Thanks iOS! setTimeout(this.checkIfUnlocked.bind(this, source, attempt+1), 100); } }; |
通過 AJAX 逐步載入
比如說你有一個 50MB 的檔案要通過 AJAX 載入。該視訊剛開始載入時沒有問題。你甚至可以檢查其當前的進度(下載量 vs 總量),並且呈現出一個很好的載入動畫。你不能做的只是當剩餘檔案還在載入時訪問已下載的資料。
對於增加分塊 ArrayBuffers 到 XMLHttpRequest 中有一些提議,但是還沒有通過瀏覽器實現。較新的 fetch API (我仍然不明白它的目的)提出了一些相似的特點,但還是沒有通過瀏覽器的支援。然而,我們仍可以在 JavaScript 中使用 Range-Requests 來做分塊下載。
HTTP 標準實現了一個 Range 頭部,允許你僅僅抓取一個資源的部分內容。如果你只需要一個大檔案的前 1024 個位元組,你可以在請求的標頭檔案中設定標頭 Range:bytes = 0-1024 。然而在我們開始之前,我們必須知道檔案的大小。我們可以使用 HEAD 請求代替一個 GET 請求來實現。這樣返回的僅僅是資源的 HTTP 頭部,而沒有主體內容。幾乎所有的網頁伺服器都支援範圍請求。我知道的一個例外是 PHP 內建的開發伺服器。
JSMpeg 通過 AJAX 下載的預設塊大小為 1mb 。JSMpeg 還為每個請求附加一個自定義的 GET 引數給 URL(例如 video.ts?0-1024 ),這樣每個塊從根本上都有自己的 URL ,並且可以和糟糕的快取代理一起使用。
有了這個,你可以在第一個塊到達後立即開始播放檔案。此外,只有在需要時才會下載更多的塊。如果有人只觀看視訊的前幾秒,那麼只有前幾秒資料才會被下載。JSMpeg 通過測量載入塊所花費的時間來實現這一點,新增了大量的安全邊界,並將其與已載入的塊的剩餘持續時間進行比較。
在 JSMpeg 中,解複用器會盡可能快地切分流。它還要解析每個資料包的顯示時間戳(PTS)。然而,視訊和音訊解碼器僅實時地增加其播放位置。最後的解複用的 PTS 和解碼器當前 PTS 之間的差值是已下載塊的剩餘播放時間。播放器定期使用此 headroom 時間呼叫 Source 的 resum() 方法:
1 2 3 4 5 |
// It's a silly estimate, but it works var worstCaseLoadingTime = lastChunkLoadingTime * 8 + 2; if (worstCaseLoadingTime > secondsHeadroom) { loadNextChunk(); } |
音訊&視訊同步
JSMpeg 嘗試儘可能平滑地播放音訊。它在對音訊取樣資料入隊時不會引入任何間隙或壓縮。視訊播放將以音訊的播放位置作為參考。它是這樣做的,因為即使是最微小的間隙或不連續性,在音訊中也比在視訊中更為明顯。如果視訊幀延遲幾毫秒或丟失,還不是那麼容易被察覺。
大多數情況下,JSMpeg依賴於MPEG-TS容器的播放時間戳(PTS)進行播放,而不是自己計算播放時間。這意味著,MPEG-TS檔案中的PTS必須一致且準確。從我從網際網路上所收集的資訊來看,情況並非總是如此的。但現代編碼器似乎已經認識到了這一點。
一個複雜因素是PTS並不總是從0開始。例如,如果你連線到了WebCam並執行了一段時間,則PTS可能是開啟WebCam時的啟動時間,而不是開始錄影時的時間。因此,JSMPeg會搜尋它可以找到的第一個PTS,並將其用作所有流的全域性開始時間。
MPEG1和MP2解碼器還會跟蹤每個PTS的緩衝區位置以及它們收到的所有PTS。有了這些資訊,我們可以將音訊和視訊流seek到特定的時間。
目前,JSMpeg會高興地尋找一個幀間幀並在先前解碼的幀之上解碼它。處理這個問題的正確方法是在我們要seek的幀之前將其回退到最後的關鍵幀並解碼其間的所有幀。 這是我仍然需要解決的問題。
構建工具和Javascript生態系統
我儘量避免使用構建工具。很有可能的是,你的漂亮工具集可以為你自動完成所有的事情,但在一到兩年內就會停止工作。建立你的構建環境從來都不像“只呼叫webpack”那麼簡單,或者使用“咕噥”或者“跑任務”是當今的“熱點”。它總是像這樣
(…) webpack 來自哪裡? 哦, 我需要 npm。
npm 來自哪裡? 哦,我需要 nodejs。
nodejs 來自哪裡? 哦, 我需要 homebrew。
那是什麼? gyp 編譯錯誤? 哦,我需要安裝 XCode。
哦, webpack 需要 babel 外掛?
什麼? left-pad 依賴還是沒有解決?
…
突然,你花了兩個小時的時間,下載了幾GB的工具。所有的構建一個 20kb 的庫,對於一種甚至不需要編譯的語言。2年後我如何建立這個庫?那5年呢?
我已徹底瞭解webpack並討厭之。對我而言,它的方式過於複雜了。我想了解其內部發生了什麼事。這是我編寫這個庫而不是深入WebRTC原因的一部分。
所以,JSMpeg的構建步驟是一個shell指令碼,僅需呼叫一次uglifyjs即可在2秒內更改為使用cat(或在Windows上copy)。或者,你只需在HTML中單獨載入原始檔即可。搞定。
質量,位元率以及後續計劃
在合理的位元率下,令我感到驚訝,MPEG1的質量根本不算差。看看jsmpeg.com上的演示視訊 – 當然,這是適合壓縮的示例。緩慢的動作以及不是太多的切割。儘管如此,此視訊佔用50mb,因為它是4分鐘時長,並且可提供與大多數Youtube視訊相媲美的質量,而Youtube視訊僅“小”30%。
在我的測試中,我總是可以獲得我認為使用最高2Mbit/s的“高質量”的視訊。根據你的用例(想要一個coffe cam?),你可以達到100Kbit/s甚至更低。對於位元率/幀率,這裡沒有下限。
你可以獲得1GB /月流量限制的廉價手機合同,在Raspberry Pi上新增3G加密狗和網路攝像頭,將其附加到12 V汽車電源上,將其放在莊稼地上,並獲得一個幾年內不需要任何基礎設施或維護的實時天氣攝像頭,它可以在你的智慧手機的瀏覽器上檢視,並無需安裝任何程式。
與現代編解碼器相比,MPEG1的簡單性使其在我看來非常有吸引力。它很好理解,並且有很多工具可以使用它。所有與MPEG1/MP2相關的專利都已過期。這是一個免費的格式。
你是否記得專利過期後GIF復興嗎?
2017年2月2日,週四
— Dominic Szablewski, @phoboslab