低延遲視訊流播放方案探索
好久不見,接近四個月沒更新部落格了!
去年最後一篇文章介紹了我們的 Electron 桌面客戶端的一些優化措施,這篇文章也跟我們正在開發的 Electron 客戶端有一定關係。最近我們正在預研在 Electron 頁面中實時播放會議視訊流的方案。
視訊會議介面是最後一塊沒有被 Web 取代的頁面, 它完全用原生開發的,所以開發效率比較低,比如要做一些動畫效果開發很痛苦,難以響應多變的產品需求。所以我們在想:能不能將 Web 頁面端來播放底層庫 WebRTC 接收到的視訊流?或者為什麼不直接通過瀏覽器的 WebRTC API 來進行通訊呢?
先回答後者,因為我們視訊會議這塊的邏輯處理、音視訊處理已經被抽取成獨立的、跨平臺的模組,統一進行維護;另外瀏覽器的 WebRTC API 提供的介面非常高階,就像一個黑盒一樣,無法定製化、擴充套件,遇到問題也很難診斷和處理, 受限於瀏覽器。最大的原因還是變動有點大,時間上不允許。
因此目前只能選前者,即底層庫給 Electron 頁面推送視訊流,在頁面實時播放。 再此之前,筆者幾乎沒有接觸過音視訊開發,我能想到的是通過類似直播的方式,底層庫作為”主播端”, Web 頁面作為”觀眾端”。
因為視訊流只是在本地進行轉發,所以我們不需要考慮各種複雜的網路情況、頻寬限制。唯一的要求是低延遲,低資源消耗:
- 我們視訊會議語音和視訊是分離的。 只有一路混合語音,通過 SIP 傳輸。而會議視訊則可能存在多路,使用 WebRTC 進行傳輸。我們不需要處理語音(由底層庫直接播放), 這就要求我們的視訊播放延遲不能太高, 出現語音和視訊不同步。
- 不需要考慮瀏覽器相容性。Electron 瀏覽器版本為 Chrome 80
- 本地轉發,不需要考慮網路情況、頻寬限制
最近因為工作需要才有機會接觸到音視訊相關的知識,我知道的只是皮毛,所以文章肯定存在不少問題,敬請斧正。下面,跟著音視訊小白的我,一起探索探索有哪些方案。
目錄
① 典型的Web直播方案
Web 直播有很多方案(參考這篇文章:
- RTMP (Real Time Messaging Protocol)屬於 Adobe。延時低,實時性較好。不過瀏覽器需要藉助 Flash 才能播放; 但是我們也可以轉換成 HTTP/Websocket 流餵給
flv.js
實現播放。 - RTP (Real-time Transport Protocol)WebRTC 底層就基於 RTP/RTCP。實時性非常好,適用於視訊監控、視訊會議、IP 電話。
- HLS (Http Live Streaming)蘋果提出的基於 HTTP 的流媒體傳輸協議。Safari 支援較好,高版本 Chrome 也支援,也有一些比較成熟的第三方方案。
HLS 延遲太高,不符合我們的要求,所以一開始就放棄了。搜了很多資料,很多都是介紹 RTMP 的,可見 RTMP 在國內採用有多廣泛, 因此我們打算試試:
首先是搭建 RMTP 伺服器,可以直接基於Node-Media-Server,程式碼很簡單:
const NodeMediaServer = require('node-media-server')
|
RTMP 推流
ffmpeg
是音視訊開發的必備神器,本文將通過它來捕獲攝像頭,進行各種轉換和處理,最後進行視訊流推送。 下面看看怎麼用 ffmpeg 進行 RTMP 推流。
首先進行視訊採集,下面命令列舉所有支援的裝置型別:
本文的所有命令都在 macOS 下面執行, 其他平臺用法差不多,自行搜尋
|
macOS
下通常使用avfoundation
進行裝置採集, 下面列舉當前終端所有支援的輸入裝置:
|
我們將使用FaceTime HD Camera
這個輸入裝置來採集視訊,並推送 RTMP 流:
|
稍微解釋一下上面的命令:
-f avfoundation -r 30 -i "FaceTime HD Camera"
表示從FaceTime HD Camera
中以 30 fps 的幀率採集視訊-c:v libx264
輸出視訊的編碼格式是 H.264, RTMP 通常採用H.264 編碼-f flv
指的視訊的封包格式, RTMP 一般採用 flv 封包格式。-an
忽略音訊流-preset superfast -tune zerolatency
H.264 的轉碼預設引數和調優引數。會影響視訊質量和壓縮率
封包格式(format)和編碼(codec)是音視訊開發中最基礎的概念。
封包格式: 相當於一種儲存視訊資訊的容器,將編碼好的音訊、視訊、或者是字幕、指令碼之類的檔案根據相應的規範組合在一起,從而生成一個封裝格式的檔案。常見的封包格式有 avi、mpeg、flv、mov 等
編碼格式: 編碼主要的目的是為了壓縮。從裝置採集到的音視訊流稱為裸碼流(rawvideo 格式, 即沒有經過編碼壓縮處理的資料)。舉例:一個 720p,30fps,60min 的電影,裸流大小為:12Bx1280x720x30x60x100 = 1.9T。這不管在檔案系統上儲存、還是在網路上傳輸,成本都太高了,所以我們需要編碼壓縮。 H264 是目前最常見的編碼格式之一。
RTMP 拉流
最簡單的,我們可以使用ffplay
(ffmpeg 提供的工具套件之一) 播放器來測試推流和拉流是否正常:
|
Flash 已經過時, 為了在 Web 頁面中實現 RTMP 流播放,我們還要藉助flv.js
。 flvjs 估計大家都很熟悉(花邊:如何看待嗶哩嗶哩的 flv.js 作者月薪不到 5000 元?),它是 B 站開源的 flv 播放器。按照官方的介紹:
flv.js works by transmuxing FLV file stream into ISO BMFF (Fragmented MP4) segments, followed by feeding mp4 segments into an HTML5
<video>
element throughMedia Source Extensions API
.
上面提到,flv(Flash Video) 是一個視訊封包格式,flvjs
做的就是把 flv 轉換成 Fragmented MP4(ISO BMFF) 封包格式,然後餵給Media Source Extension API, MSE, 接著我們將 MSE 掛載到<video>
就可以直接播放了, 它的架構如下:
flvjs 支援通過 HTTP Streaming、 WebSocket 或者自定義資料來源等多種形式拉取二進位制視訊流。下面示例通過 flvjs 來拉取node-media-server
的視訊流:
<script src="https://cdn.bootcss.com/flv.js/1.5.0/flv.min.js"></script>
|
完整示例程式碼在這裡
RTMP 低延遲優化
推流端
ffmpeg
推流端可以通過一些控制引數來降低推流的延遲,主要優化方向是提高編碼的效率、減少緩衝大小,當然有時候要犧牲一些程式碼質量和頻寬。 這篇文章ffmpeg 的轉碼延時測試與設定優化總結了一些優化措施可以參考一下:
- 關閉 sync-lookahead
- 降低 rc-lookahead,但別小於 10,預設是-1
- 降低 threads(比如從 12 降到 6)
- 禁用 rc-lookahead
- 禁用 b-frames
- 縮小 GOP
- 開啟 x264 的 -preset fast/faster/verfast/superfast/ultrafast 引數
- 使用-tune zerolatency 引數
node-media-server
NMS 也可以通過降低緩衝大小和關閉 GOP Cache來優化延遲。
flvjs 端
flvjs 可以開啟enableStashBuffer
來提高實時性。 實際測試中,flvjs 可能會出現’累積延遲’現象,可以通過手動 seek來糾正。
經過一番折騰,優化到最好的延遲是 400ms,往下就束手無策了(對這塊熟悉的同學可以請教一下)。而且在對接到底層庫實際推送時,播放效果並不理想,出現各種卡頓、延遲。由於時間和知識有限,我們很難定位到具體的問題在哪, 所以我們暫時放棄了這個方案。
② JSMpeg & BroadwayJS
Jerry Qu 寫得《HTML5 視訊直播(二)》給了我不少啟發,得知了JSMpeg
和Broadwayjs
這些方案
這兩個庫不依賴於瀏覽器的 video 的播放機制,使用純 JS/WASM 實現視訊解碼器,然後直接通過 Canvas2d 或 WebGL 繪製出來。Broadwayjs 目前不支援語音,JSMpeg 支援語音(基於 WebAudio)。
經過簡單的測試, 相比 RTMP, JSMpeg 和 BroadwayJS 延遲都非常低,基本符合我們的要求。下面簡單介紹一下 JSMpeg 用法。Broadwayjs 用法差不多, 下文會簡單帶過。它們的基本處理過程如下:
Relay 伺服器
因為 ffmpeg 無法向 Web 直接推流,因此我們還是需要建立一箇中轉(relay)伺服器來接收視訊推流,再通過 WebSocket 轉發給頁面播放器。
ffmpeg 支援 HTTP、TCP、UDP 等各種推流方式。HTTP 推流更方便我們處理, 因為是本地環境,這些網路協議不會有明顯的效能差別。
下面建立一個 HTTP 伺服器來接收推流,推送路徑是/push/:id
:
this.server = http
|
接著通過WebSocket
將流轉發出去, 頁面可以通過ws://localhost:PORT/pull/{id}
拉取視訊流:
/**
|
推送
這裡同樣使用 ffmpeg 作為推送示例:
|
稍微解釋一下 ffmpeg 命令
-f mpegts -codec:v mpeg1video -an
指定使用 MPEG-TS 封包格式, 並使用 mpeg1 視訊編碼,忽略音訊-bf 0
JSMpeg 解碼器暫時不能正確地處理 B 幀。所以這些將 B 幀禁用。關於什麼是 I/B/P 幀, 參考這篇文章-b:v 1500k -maxrate 2500k
設定推流的平均位元速率和最大位元速率。經過測試,JSMpeg 位元速率過高容易出現花屏和陣列越界崩潰。
另外 JSMpeg 還要求,視訊的寬度必須是 2 的倍數。ffmpeg 可以通過濾鏡(filter)或設定視訊尺寸(-s)來解決這個問題, 不過多餘轉換都要消耗一定 CPU 資源的:
ffmpeg -i in.mp4 -f mpeg1video -vf "crop=iw-mod(iw\,2):ih-mod(ih\,2)" -bf 0 out.mpg
|
視訊播放
<canvas id="video-canvas"></canvas>
|
API 很簡單,上面我們傳遞一個畫布給 JSMpeg,禁用了 Audio, 並設定了一個較大的緩衝區大小, 來應對一些位元速率波動。
完整程式碼見這裡
多程序優化
實際測試下來,JSMpeg 視訊延遲在 100ms - 200ms 之間。當然這還取決於視訊的質量、終端的效能等因素。
受限於終端效能以及解碼器效率, 對於平均位元速率(筆者粗略測試大概為 2000k)較高的視訊流,JSMpeg 有很大概率會出現花屏或者記憶體訪問越界問題(memory access out of bounds)。
因此我們不得不通過壓縮視訊的質量、降低視訊解析度等手段來降低視訊位元速率。然而這並不能根本解決問題,這是使用 JSMpeg 的痛點之一。詳見JSMpeg 的效能說明
因為解碼本身是一個 CPU 密集型的操作,且由瀏覽器來執行,CPU 佔用還是挺高的(筆者機器單個頁面單個播放器, CPU 佔用率在 16%左右),而且 JSMpeg 播放器一旦異常崩潰會難以恢復。
在我們的實際應用場景中,一個頁面可能會播放多路視訊, 如果所有視訊都在瀏覽器主程序中進行解碼渲染,頁面操作體驗會很差。 所以最好是將 JSMpeg 分離到 Worker 中,一來保證主程序可以響應使用者的互動,二來 JSMpeg 崩潰不會連累主程序。
好在將 JSMpeg 放在 Worker 中執行容易: Worker 中支援獨立 WebSocket 請求,另外 Canvas 通過transferControlToOffscreen()
方法建立OffscreenCanvas
物件並傳遞給 Worker,實現 canvas 離屏渲染。
先來看看worker.js
, 和上面的程式碼差不多,主要是新增了 worker 通訊:
importScripts('./jsmpeg.js')
|
再來看看主程序, 通過transferControlToOffscreen()
生成離屏渲染畫布,讓 JSMpeg 可以無縫遷移到 Worker:
const video = document.getElementById('video')
|
簡單說一下 Broadway.js
還有一個類似 JSMpeg 的解決方案 ————Broadwayjs。 它是一個H.264
解碼器, 通過Emscripten
工具從 Android 的 H.264 解碼器轉化而成。它支援接收 H.264 裸流,不過也有一些限制:不支援weighted prediction for P-frames
&CABAC entropy encoding
。
推送示例:
|
客戶端示例:
const video = document.getElementById('video')
|
完整程式碼看這裡
經過測試,同等質量和尺寸的視訊流 JSMpeg 和 Broadway CPU 消耗差不多。但是 Broadway 視訊流不受位元速率限制,沒有花屏和崩潰現象。當然, 對於高質量視訊, ffmpeg 轉換和 Broadway 播放, 資源消耗都非常驚人。
其他類似的方案:
- wfshtml5 player for raw h.264 streams.
③ 直接渲染 YUV
回到文章開始,其實底層庫從 WebRTC 中拿到的是 YUV 的原始視訊流, 也就是沒有經過編碼壓縮的一幀一幀的影象。上文介紹的方案都有額外的解封包、解編碼的過程,最終輸出的也是 YUV 格式的視訊幀,它們的最後一步都是將這些 YUV 格式視訊幀轉換成 RGB 格式,渲染到 Canvas 中。
那能不能將原始的 YUV 視訊幀直接轉發過來,直接在 Cavans 上渲染不就得了? 將去掉中間的解編碼過程, 效果怎樣?試一試。
此前已經有文章做過這方面的嘗試:《IVWEB 玩轉 WASM 系列-WEBGL YUV 渲染影象實踐》。我們參考它搞一個。
至於什麼是YUV
,我就不科普, 自行搜尋。 YUV 幀的大小可以根據這個公式計算出來:(width * height * 3) >> 1
,
即YUV420p
的每個畫素佔用 1.5 bytes。
因此我們只需要知道視訊的大小, 就可以切割視訊流,將視訊幀分離出來了。 下面新建一箇中轉伺服器來接收推流, 在這裡將 YUV 裸流切割成一幀一幀影象資料,下發給瀏覽器:
this.server = http.createServer((req, res) => {
|
Splitter
根據固定位元組大小切割 Buffer。
如果渲染 YUV ? 可以參考JSMpeg WebGL 渲染器,Broadway.js WebGL 渲染器。 具體如何渲染就不展開了, 下面直接將 Broadway.js 的YUVCanvas.js
直接拿過來用:
const renderer = new YUVCanvas({
|
需要注意的是:JSMpeg 和 Broadway 的 Canvas 渲染都要求視訊的寬度必須是 8 的倍數。不符合這個要求的會報錯,《IVWEB 玩轉 WASM 系列-WEBGL YUV 渲染影象實踐》處理了這個問題。
最後看看 ffmpeg 推送示例:
|
完整程式碼看這裡
下面看看簡單資源消耗對比。 筆者裝置是 15 款 Macboook pro, 視訊源採集自攝像頭,解析度 320x240、畫素格式 uyvy422、幀率 30。
下表J
表示JSMpeg
、B
表示Broadway
、Y
表示YUV
CPU (J/B/Y) | 記憶體 (J/B/Y) | 平均位元速率 (J/B/Y) | |
---|---|---|---|
ffmpeg | 9% / 9% / 5% | 12MB / 12MB / 9MB | 1600k / 200k / 27000k |
伺服器 | 0.6% / 0.6% /1.4% | 18MB / 18MB / 42MB | N/A |
播放器 | 16% / 13% / 8% | 70MB / 200MB / 50MB | N/A |
從結果來看,直接渲染 YUV 綜合佔用的資源最少。因為沒有經過壓縮,位元速率也是非常高的,不過本地環境不受頻寬限制,這個問題也不大。我們還可以利用requestAnimationFrame
由瀏覽器來排程播放的速率,丟掉積累的幀,保持低延遲播放。
本文完