用一個 flv.js 播放監控的例子,帶你深撅直播流技術

楊成功發表於2021-12-23

本文記錄一下在使用 flv.js 播放監控視訊時踩過的各種各樣的坑。雖然官網給的 Getting Started 只有短短几行程式碼,跑一個能播視訊的 demo 很容易,但是播放時各種各樣的異常會搞到你懷疑人生。

究其原因,一方面 GitHub 上文件比較晦澀,說明也比較簡陋;另一方面是受“視訊播放”思維的影響,沒有對的足夠認識以及缺乏處理流的經驗。

下面我將自己踩過的坑,以及踩坑過程中補充的相關知識,詳細總結一下。

大綱預覽

本文介紹的內容包括以下方面:

  • 直播與點播
  • 靜態資料與流資料
  • 為什麼選 flv?
  • 協議與基礎實現
  • 細節處理要點
  • 樣式定製

點播與直播

啥是直播?啥是點播?

直播就不用說了,抖音普及之下大家都知道直播是幹嘛的。點播其實就是視訊播放,和我們們嗶哩嗶哩看視訊一摸一樣沒區別,就是把提前做好的視訊放出來,就叫點播。

點播對於我們前端來說,就是拿一個 mp4 的連結地址,放到 video 標籤裡面,瀏覽器會幫我們處理好視訊解析播放等一些列事情,我們可以拖動進度條選擇想看的任意一個時間。

但是直播不一樣,直播有兩個特點:

  1. 獲取的是流資料
  2. 要求實時性

先看一下什麼叫流資料。大部分沒有做過音視訊的前端同學,我們常接觸的資料就是 ajax 從介面獲取的 json 資料,特別一點的可能是檔案上傳。這些資料的特點是,它們都屬於一次性就能拿到的資料。我們一個請求,一個響應,完整的資料就拿回來了。

但是流不一樣,流資料獲取是一幀一幀的,你可以理解為是一小塊一小塊的。像直播流的資料,它並不是一個完整的視訊片段,它就是很小的二進位制資料,需要你一點一點的拼接起來,才有可能輸出一段視訊。

再看它的實時性。如果是點播的話,我們直接將完整的視訊儲存在伺服器上,然後返回連結,前端用 video 或播放器播就行了。但是直播的實時性,就決定了資料來源不可能在伺服器上,而是在某一個客戶端。

資料來源在客戶端,那麼又是怎麼到達其他客戶端的呢?

這個問題,請看下面這張流程圖:

process

如圖所示,發起直播的客戶端,向上連著流媒體伺服器,直播產生的視訊流會被實時推送到服務端,這個過程叫做推流。其他客戶端同樣也連線著這個流媒體伺服器,不同的是它們是播放端,會實時拉取直播客戶端的視訊流,這個過程叫做拉流

推流—> 伺服器-> 拉流,這是目前流行的也是標準的直播解決方案。看到了吧,直播的整個流程全都是流資料傳輸,資料處理直面二進位制,要比點播複雜了幾個量級。

具體到我們業務當中的攝像頭實時監控預覽,其實和上面的完全一致,只不過發起直播的客戶端是攝像頭,觀看直播的客戶端是瀏覽器而已。

靜態資料與流資料

我們常接觸的文字,json,圖片等等,都屬於靜態資料,前端用 ajax 向介面請求回來的資料就是靜態資料。

像上面說到的,直播產生的視訊和音訊,都屬於流資料。流資料是一幀一幀的,它的本質是二進位制資料,因為很小,資料像水流一樣連綿不斷的流動,因此非常適合實時傳輸。

靜態資料,在前端程式碼中有對應的資料型別,比如 string,json,array 等等。那麼流資料(二進位制資料)的資料型別是什麼?在前端如何儲存?又如何操作?

首先明確一點,前端是可以儲存和操作二進位制的。最基本的二進位制物件是 ArrayBuffer,它表示一個固定長度,如:

let buffer = new ArrayBuffer(16) // 建立一個 16 位元組 的 buffer,用 0 填充
alert(buffer.byteLength) // 16

ArrayBuffer 只是用於儲存二進位制資料,如果要操作,則需要使用 檢視物件

檢視物件,不儲存任何資料,作用是將 ArrayBuffer 的資料做了結構化的處理,便於我們操作這些資料,說白了它們是操作二進位制資料的介面。

檢視物件包括:

  • Uint8Array:每個 item 1 個位元組
  • Uint16Array:每個 item 2 個位元組
  • Uint32Array:每個 item 4 個位元組
  • Float64Array:每個 item 8 個位元組

按照上面的標準,一個 16 位元組 ArrayBuffer,可轉化的檢視物件和其長度為:

  • Uint8Array:長度 16
  • Uint16Array:長度 8
  • Uint32Array:長度 4
  • Float64Array:長度 2

這裡只是簡單介紹流資料在前端如何儲存,為的是避免你在瀏覽器看到一個長長的 ArrayBuffer 不知道它是什麼,記住它一定是二進位制資料。

為什麼選 flv?

前面說到,直播需要實時性,延遲當然越短越好。當然決定傳輸速度的因素有很多,其中一個就是視訊資料本身的大小。

點播場景我們最常見的 mp4 格式,對前端是相容性最好的。但是相對來說 mp4 的體積比較大,解析會複雜一些。在直播場景下這就是 mp4 的劣勢。

flv 就不一樣了,它的頭部檔案非常小,結構簡單,解析起來又塊,在直播的實時性要求下非常有優勢,因此它成了最常用的直播方案之一。

當然除了 flv 之外還有其他格式,對應直播協議,我們一一對比一下:

  • RTMP: 底層基於 TCP,在瀏覽器端依賴 Flash。
  • HTTP-FLV: 基於 HTTP 流式 IO 傳輸 FLV,依賴瀏覽器支援播放 FLV。
  • WebSocket-FLV: 基於 WebSocket 傳輸 FLV,依賴瀏覽器支援播放 FLV。
  • HLS: Http Live Streaming,蘋果提出基於 HTTP 的流媒體傳輸協議。HTML5 可以直接開啟播放。
  • RTP: 基於 UDP,延遲 1 秒,瀏覽器不支援。

其實早期常用的直播方案是 RTMP,相容性也不錯,但是它依賴 Flash,而目前瀏覽器下 Flash 預設是被禁用的狀態,已經被時代淘汰的技術,因此不做考慮。

HLS 協議也很常見,對應視訊格式就是 m3u8。它是由蘋果推出,對手機支援非常好,但是致命缺點是延遲高(10~30 秒),因此也不做考慮。

RTP 不必說,瀏覽器不支援,剩下的就只有 flv 了。

但是 flv 又分為 HTTP-FLVWebSocket-FLV,它兩看著像兄弟,又有什麼區別呢?

前面我們說過,直播流是實時傳輸,連線建立後不會斷,需要持續的推拉流。這種需要長連線的場景我們首先想到的方案自然是 WebSocket,因為 WebSocket 本來就是長連線實時互傳的技術。

不過呢隨著 js 原生能力擴充套件,出現了像 fetch 這樣比 ajax 更強的黑科技。它不光支援對我們更友好的 Promise,並且天生可以處理流資料,效能很好,而且使用起來也足夠簡單,對我們開發者來說更方便,因此就有了 http 版的 flv 方案。

綜上所述,最適合瀏覽器直播的是 flv,但是 flv 也不是萬金油,它的缺點是前端 video 標籤不能直接播放,需要經過處理才行。

處理方案,就是我們今天的主角:flv.js

協議與基礎實現

前面我們說到,flv 同時支援 WebSocket 和 HTTP 兩種傳輸方式,幸運的是,flv.js 也同時支援這兩種協議。

選擇用 http 還是 ws,其實功能和效能上差別不大,關鍵看後端同學給我們什麼協議吧。我這邊的選擇是 http,前後端處理起來都比較方便。

接下來我們介紹 flv.js 的具體接入流程,官網在這裡

假設現在有一個直播流地址:http://test.stream.com/fetch-media.flv,第一步我們按照官網的快速開始建一個 demo:

import flvjs from 'flv.js'
if (flvjs.isSupported()) {
  var videoEl = document.getElementById('videoEl')
  var flvPlayer = flvjs.createPlayer({
    type: 'flv',
    url: 'http://test.stream.com/fetch-media.flv'
  })
  flvPlayer.attachMediaElement(videoEl)
  flvPlayer.load()
  flvPlayer.play()
}

首先安裝 flv.js,程式碼的第一行是檢測瀏覽器是否支援 flv.js,其實大部分瀏覽器是支援的。接下來就是獲取 video 標籤的 DOM 元素。flv 會把處理後的 flv 流輸出給 video 元素,然後在 video 上實現視訊流播放。

接下來是關鍵之處,就是建立 flvjs.Player 物件,我們稱之為播放器例項。播放器例項通過 flvjs.createPlayer 函式建立,引數是一個配置物件,常用如下:

  • type:媒體型別,flvmp4,預設 flv
  • isLive:可選,是否是直播流,預設 true
  • hasAudio:是否有音訊
  • hasVideo:是否有視訊
  • url:指定流地址,可以是 https(s) or ws(s)

上面的是否有音訊,視訊的配置,還是要看流地址是否有音視訊。比如監控流只有視訊流沒有音訊,那即便你配置 hasAudio: true 也是不可能有聲音的。

播放器例項建立之後,接下來就是三步走:

  • 掛載元素:flvPlayer.attachMediaElement(videoEl)
  • 載入流:flvPlayer.load()
  • 播放流:flvPlayer.play()

基礎實現流程就這麼多,下面再說一下處理過程中的細節和要點。

細節處理要點

上面說了基本的用法,下面說一下實踐中的關鍵問題。

暫停與播放

點播中的暫停與播放很容易,播放器下面會有一個播放/暫停按鍵,想什麼時候暫停都可以,再點播放的時候會接著上次暫停的地方繼續播放。但是直播中就不一樣了。

正常情況下直播應該是沒有播放/暫停按鈕以及進度條的。因為我們看的是實時資訊,你暫停了視訊,再點播放的時候是不能從暫停的地方繼續播放的。為啥?因為你是實時的嘛,再點播放的時候應該是獲取最新的實時流,播放最新的視訊。

具體到技術細節,前端的 video 標籤預設是帶有進度條和暫停按鈕的,flv.js 將直播流輸出到 video 標籤,此時如果點選暫停按鈕,視訊也是會停住的,這與點播邏輯一致。但是如果你再點播放,視訊還是會從暫停處繼續播放,這就不對了。

那麼我們換個角度,重新審視一下直播的播放/暫停邏輯。

直播為什麼需要暫停?拿我們視訊監控來說,一個頁面會放好幾個攝像頭的監控視訊,如果每個播放器一直與伺服器保持連線,持續拉流,這會造成大量的連線和消耗,流失的都是白花花的銀子。

那我們是不是可以這樣:進去網頁的時候,找到想看的攝像頭,點選播放再拉流。當你不想看的時候,點選暫停,播放器斷開連線,這樣是不是就會節省無用的流量消耗。

因此,直播中的播放/暫停,核心邏輯是拉流/斷流

理解到這裡,那我們的方案應該是隱藏 video 的暫停/播放按鈕,然後自己實現播放和暫停的邏輯。

還是以上述程式碼為例,播放器例項(上面的 flvPlayer 變數)不用變,播放/暫停程式碼如下:

const onClick = isplay => {
  // 引數 isplay 表示當前是否正在播放
  if (isplay) {
    // 在播放,斷流
    player.unload()
    player.detachMediaElement()
  } else {
    // 已斷流,重新拉流播放
    player.attachMediaElement(videoEl.current)
    player.load()
    player.play()
  }
}

異常處理

用 flv.js 接入直播流的過程會遇到各種問題,有的是後端資料流的問題,有的是前端處理邏輯的問題。因為流是實時獲取,flv 也是實時轉化輸出,因此一旦發生錯誤,瀏覽器控制檯會迴圈連續的列印異常。

如果你用 react 和 ts,滿屏異常,你都無法開發下去了。再有直播流本來就可能發生許多異常,因此錯處理非常關鍵。

官方對異常處理的說明不太明顯,我簡單總結一下:

首先,flv.js 的異常分為兩個級別,可以看作是 一級異常二級異常

再有,flv.js 有一個特殊之處,它的 事件錯誤 都是用列舉來表示,如下:

  • flvjs.Events:表示事件
  • flvjs.ErrorTypes:表示一級異常
  • flvjs.ErrorDetails:表示二級異常

下面介紹的異常和事件,都是基於上述列舉,你可以理解為是列舉下的一個 key 值。

一級異常有三類:

  • NETWORK_ERROR:網路錯誤,表示連線問題
  • MEDIA_ERROR:媒體錯誤,格式或解碼問題
  • OTHER_ERROR:其他錯誤

二級級異常常用的有三類:

  • NETWORK_STATUS_CODE_INVALID:HTTP 狀態碼錯誤,說明 url 地址有誤
  • NETWORK_TIMEOUT:連線超時,網路或後臺問題
  • MEDIA_FORMAT_UNSUPPORTED:媒體格式不支援,一般是流資料不是 flv 的格式

瞭解這些之後,我們在播放器例項上監聽異常:

// 監聽錯誤事件
flvPlayer.on(flvjs.Events.ERROR, (err, errdet) => {
  // 引數 err 是一級異常,errdet 是二級異常
  if (err == flvjs.ErrorTypes.MEDIA_ERROR) {
    console.log('媒體錯誤')
    if(errdet == flvjs.ErrorDetails.MEDIA_FORMAT_UNSUPPORTED) {
      console.log('媒體格式不支援')
    }
  }
  if (err == flvjs.ErrorTypes.NETWORK_ERROR) {
    console.log('網路錯誤')
    if(errdet == flvjs.ErrorDetails.NETWORK_STATUS_CODE_INVALID) {
      console.log('http狀態碼異常')
    }
  }
  if(err == flvjs.ErrorTypes.OTHER_ERROR) {
    console.log('其他異常:', errdet)
  }
}

除此之外,自定義播放/暫停邏輯,還需要知道載入狀態。可以通過以下方法監聽視訊流載入完成:

player.on(flvjs.Events.METADATA_ARRIVED, () => {
  console.log('視訊載入完成')
})

樣式定製

為什麼會有樣式定製?前面我們說了,直播流的播放/暫停邏輯與點播不同,因此我們要隱藏 video 的操作欄元素,通過自定義元素來實現相關功能。

首先要隱藏播放/暫停按鈕,進度條,以及音量按鈕,用 css 實現即可:

/* 所有控制元件 */
video::-webkit-media-controls-enclosure {
  display: none;
}
/* 進度條 */
video::-webkit-media-controls-timeline {
  display: none;
}
video::-webkit-media-controls-current-time-display {
  display: none;
}
/* 音量按鈕 */
video::-webkit-media-controls-mute-button {
  display: none;
}
video::-webkit-media-controls-toggle-closed-captions-button {
  display: none;
}
/* 音量的控制條 */
video::-webkit-media-controls-volume-slider {
  display: none;
}
/*  播放按鈕 */
video::-webkit-media-controls-play-button {
  display: none;
}

播放和暫停的邏輯上面講了,樣式這邊自定義一個按鈕即可。除此之外我們還可能需要一個全屏按鈕,看一下全屏的邏輯怎麼寫:

const fullPage = () => {
  let dom = document.querySelector('.video')
  if (dom.requestFullscreen) {
    dom.requestFullscreen()
  } else if (dom.webkitRequestFullScreen) {
    dom.webkitRequestFullScreen()
  }
}

其他自定義樣式,比如你要做彈幕,在 video 上面蓋一層元素自行實現就可以了。

我想學更多

為了更好的保護原創,之後的文章我會首發微信公眾號 前端砍柴人。這個公眾號只做原創,每週至少一篇高質量文章,方向是前端工程與架構,BFF層的前端邊界探索,一體化開發與交付等實踐與思考。

除此之外,我還建了一個微信群,專門提供對這個方向感興趣的同學交流與學習。群內有大廠大佬,掘金lv6大神,以及更多想研究這個方向的同學們,我們一起交流分享學習~

如果你也感興趣,想學更多,歡迎加我微信 ruidoc 拉你入群~

相關文章