低延遲視頻流播放方案探索


好久不見,接近四個月沒更新博客了!

去年最后一篇文章介紹了我們的 Electron 桌面客戶端的一些優化措施,這篇文章也跟我們正在開發的 Electron 客戶端有一定關系。最近我們正在預研在 Electron 頁面中實時播放會議視頻流的方案。

 

 

視頻會議界面是最后一塊沒有被 Web 取代的頁面, 它完全用原生開發的,所以開發效率比較低,比如要做一些動畫效果開發很痛苦,難以響應多變的產品需求。所以我們在想: 能不能將 Web 頁面端來播放底層庫 WebRTC 接收到的視頻流或者為什么不直接通過瀏覽器的 WebRTC API 來進行通訊呢

先回答后者,因為我們視頻會議這塊的邏輯處理、音視頻處理已經被抽取成獨立的、跨平台的模塊,統一進行維護;另外瀏覽器的 WebRTC API 提供的接口非常高級,就像一個黑盒一樣,無法定制化、擴展,遇到問題也很難診斷和處理, 受限於瀏覽器。最大的原因還是變動有點大,時間上不允許。

因此目前只能選前者,即底層庫給 Electron 頁面推送視頻流,在頁面實時播放。 再此之前,筆者幾乎沒有接觸過音視頻開發,我能想到的是通過類似直播的方式,底層庫作為”主播端”, Web 頁面作為”觀眾端”。

 

因為視頻流只是在本地進行轉發,所以我們不需要考慮各種復雜的網絡情況、帶寬限制。唯一的要求是低延遲,低資源消耗:

  • 我們視頻會議語音和視頻是分離的。 只有一路混合語音,通過 SIP 傳輸。而會議視頻則可能存在多路,使用 WebRTC 進行傳輸。我們不需要處理語音(由底層庫直接播放), 這就要求我們的視頻播放延遲不能太高, 出現語音和視頻不同步。
  • 不需要考慮瀏覽器兼容性。Electron 瀏覽器版本為 Chrome 80
  • 本地轉發,不需要考慮網絡情況、帶寬限制




最近因為工作需要才有機會接觸到音視頻相關的知識,我知道的只是皮毛,所以文章肯定存在不少問題,敬請斧正。下面,跟着音視頻小白的我,一起探索探索有哪些方案。




目錄




① 典型的Web直播方案

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')

const config = {
// RMTP 服務器, 用於RTMP 推流和拉流
rtmp: {
port: 1935, // 1935 是RTMP的標准端口
chunk_size: 0,
gop_cache: false,
ping: 30,
ping_timeout: 60,
},
// HTTP / WebSocket 流,暴露給 flv.js
http: {
port: 8000,
allow_origin: '*',
},
}

var nms = new NodeMediaServer(config)
nms.run()




RTMP 推流

ffmpeg 是音視頻開發的必備神器,本文將通過它來捕獲攝像頭,進行各種轉換和處理,最后進行視頻流推送。 下面看看怎么用 ffmpeg 進行 RTMP 推流。

首先進行視頻采集,下面命令列舉所有支持的設備類型:

本文的所有命令都在 macOS 下面執行, 其他平台用法差不多,自行搜索

$ ffmpeg -devices
Devices:
D. = Demuxing supported
.E = Muxing supported
--
D avfoundation AVFoundation input device
D lavfi Libavfilter virtual input device
E sdl,sdl2 SDL2 output device

 

macOS 下通常使用 avfoundation 進行設備采集, 下面列舉當前終端所有支持的輸入設備:

$ fmpeg -f avfoundation -list_devices true -i ""
[AVFoundation input device @ 0x7f8487425400] AVFoundation video devices:
[AVFoundation input device @ 0x7f8487425400] [0] FaceTime HD Camera
[AVFoundation input device @ 0x7f8487425400] [1] Capture screen 0
[AVFoundation input device @ 0x7f8487425400] AVFoundation audio devices:
[AVFoundation input device @ 0x7f8487425400] [0] Built-in Microphone
[AVFoundation input device @ 0x7f8487425400] [1] Boom2Device

 

我們將使用 FaceTime HD Camera 這個輸入設備來采集視頻,並推送 RTMP 流:

$ ffmpeg -f avfoundation -r 30 -i "FaceTime HD Camera" -c:v libx264 -preset superfast -tune zerolatency -an -f flv rtmp://localhost/live/test

 

稍微解釋一下上面的命令:

  • -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 提供的工具套件之一) 播放器來測試推流和拉流是否正常:

$ ffplay rtmp://localhost/live/test

 

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 through Media 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>
<video id="video"></video>
<button id="play">play</button>
<script>
if (flvjs.isSupported()) {
const videoElement = document.getElementById('video');
const play = document.getElementById('play');

const flvPlayer = flvjs.createPlayer(
{
type: 'flv',
isLive: true,
hasAudio: false,
url: 'ws://localhost:8000/live/test.flv',
},
{
enableStashBuffer: true,
},
);

flvPlayer.attachMediaElement(videoElement);

play.onclick = () => {
flvPlayer.load();
flvPlayer.play();
};
}
</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
.createServer((req, res) => {
const url = req.url || '/'
if (!url.startsWith('/push/')) {
res.statusCode = 404
// ...
return
}

const id = url.slice(6)

// 禁止超時
res.connection.setTimeout(0)

// 轉發出去
req.on('data', (c) => {
this.broadcast(id, c)
})

req.on('end', () => {
/* ... */
})
})
.listen(port)




接着通過 WebSocket 將流轉發出去, 頁面可以通過 ws://localhost:PORT/pull/{id} 拉取視頻流:

/**
* 使用 webSocket 拉取流
*/
this.wss = new ws.Server({
server: this.server,
// 通過 /pull/{id} 拉流
verifyClient: (info, cb) => {
if (info.req.url && info.req.url.startsWith('/pull')) {
cb(true)
} else {
cb(false, undefined, '')
}
},
})

this.wss.on('connection', (client, req) => {
const url = req.url
const id = url.slice(6)

console.log(`${prefix}new player attached: ${id}`)

let buzy = false
const listener = {
id,
onMessage: (data) => {
// 推送
if (buzy) {
return
}

buzy = true
client.send(data, { binary: true }, function ack() {
buzy = false
})
},
}

this.attachListener(listener)

client.on('close', () => {
console.log(`${prefix} player dettached: ${id}`)
this.detachListener(listener)
})
})




推送

這里同樣使用 ffmpeg 作為推送示例:

$ ffmpeg -f avfoundation -r 30 -i "FaceTime HD Camera" -f mpegts -codec:v mpeg1video -an -bf 0 -b:v 1500k -maxrate 2500k http://localhost:9999/push/test

稍微解釋一下 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>
<script type="text/javascript" src="jsmpeg.js"></script>
<script type="text/javascript">
const canvas = document.getElementById('video-canvas')
const url = 'ws://localhost:9999/pull/test'
var player = new JSMpeg.Player(url, {
canvas: canvas,
audio: false,
pauseWhenHidden: false,
videoBufferSize: 8 * 1024 * 1024,
})
</script>

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')

this.window = this

this.addEventListener('message', (evt) => {
const data = evt.data

switch (data.type) {
// 創建播放器
case 'create':
const { url, canvas, ...config } = data.data
this.id = url
this.player = new JSMpeg.Player(url, {
canvas,
audio: false,
pauseWhenHidden: false,
videoBufferSize: 10 * 1024 * 1024,
...config,
})

break

// 銷毀播放器
case 'destroy':
try {
if (this.player) {
this.player.destroy()
}
this.postMessage({ type: 'destroyed' })
} catch (err) {
console.log(LOGGER_FREFIX + '銷毀失敗: ', global.id, err)
this.postMessage({
type: 'fatal',
data: err,
})
}

break
}
})

// 就緒
this.postMessage({ type: 'ready', data: {} })




再來看看主進程, 通過 transferControlToOffscreen() 生成離屏渲染畫布,讓 JSMpeg 可以無縫遷移到 Worker:

const video = document.getElementById('video')
const wk = new Worker('./jsmpeg.worker.js')

wk.onmessage = (evt) => {
const data = evt.data
switch (data.type) {
case 'ready':
// 創建 OffscreenCanvas 對象
const oc = video.transferControlToOffscreen()

wk.postMessage(
{
type: 'create',
data: {
canvas: oc,
url: 'ws://localhost:9999/pull/test',
},
},
[oc] // 注意這里
)

break
}
}




簡單說一下 Broadway.js

還有一個類似 JSMpeg 的解決方案 ———— Broadwayjs。 它是一個 H.264 解碼器, 通過 Emscripten 工具從 Android 的 H.264 解碼器轉化而成。它支持接收 H.264 裸流,不過也有一些限制:不支持 weighted prediction for P-frames & CABAC entropy encoding

 

推送示例:

$ ffmpeg -f avfoundation -r 30 -i "FaceTime HD Camera" -f rawvideo -c:v libx264 -pix_fmt yuv420p -vprofile baseline -tune zerolatency -coder 0 -bf 0 -flags -loop -wpredp 0 -an http://localhost:9999/push/test




客戶端示例:

const video = document.getElementById('video')
const url = `ws://localhost:9999/pull/test`
const player = new Player({
canvas: video,
})
const ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'

ws.onmessage = function (evt) {
var data = evt.data
if (typeof data !== 'string') {
player.decode(new Uint8Array(data))
} else {
console.log('get command from server: ', data)
}
}

完整代碼看這里

經過測試,同等質量和尺寸的視頻流 JSMpeg 和 Broadway CPU 消耗差不多。但是 Broadway 視頻流不受碼率限制,沒有花屏和崩潰現象。當然, 對於高質量視頻, ffmpeg 轉換和 Broadway 播放, 資源消耗都非常驚人。

 

其他類似的方案:

  • wfs html5 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) => {
// ...
const parsed = new URL('http://host' + url)
let id = parsed.searchParams.get('id'),
width = parsed.searchParams.get('width'),
height = parsed.searchParams.get('height')

const nwidth = parseInt(width)
const nheight = parseInt(height)

const frameSize = (nwidth * nheight * 3) >> 1

// 按照字節大小切割流
const stream = req.pipe(new Splitter(frameSize))

stream.on('data', (c) => {
this.broadcast(id, c)
})
// ...
})

Splitter 根據固定字節大小切割 Buffer。

 

如果渲染 YUV ? 可以參考 JSMpeg WebGL 渲染器Broadway.js WebGL 渲染器。 具體如何渲染就不展開了, 下面直接將 Broadway.js 的 YUVCanvas.js 直接拿過來用:

const renderer = new YUVCanvas({
canvas: video,
type: 'yuv420',
width: width,
height: height,
})

// 通過 WebSocket 接收 YUV 幀. 並抽取出 YUV 分量
function onData(data) {
const ylen = width * height
const uvlen = (width / 2) * (height / 2)

renderer.render(
buff.subarray(0, ylen),
buff.subarray(ylen, ylen + uvlen),
buff.subarray(ylen + uvlen, ylen + uvlen + uvlen),
true
)
}




需要注意的是:JSMpeg 和 Broadway 的 Canvas 渲染都要求視頻的寬度必須是 8 的倍數。不符合這個要求的會報錯,《IVWEB 玩轉 WASM 系列-WEBGL YUV 渲染圖像實踐》 處理了這個問題。

 

最后看看 ffmpeg 推送示例:

$ ffmpeg -f avfoundation -r 30 -i "FaceTime HD Camera" -f rawvideo -c:v rawvideo -pix_fmt yuv420p "http://localhost:9999/push?id=test&width=320&height=240"

 

完整代碼看這里




下面看看簡單資源消耗對比。 筆者設備是 15 款 Macboook pro, 視頻源采集自攝像頭,分辨率 320x240、像素格式 uyvy422、幀率 30。

下表 J 表示 JSMpegB 表示 BroadwayY 表示 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 由瀏覽器來調度播放的速率,丟掉積累的幀,保持低延遲播放。




本文完

擴展閱讀


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM