天天看點

HLS + ffmpeg 實作動态碼流視訊服務

作者:音視訊流媒體技術

一、簡介

如下圖,包含三部分,右邊一列為邊緣節點;中間一列代表資料中心;左邊一列是項目為客戶提供的一系列web管理工具:

HLS + ffmpeg 實作動态碼流視訊服務

具體來說在我們項目中有一堆邊緣節點,每個節點上部署一台強大的GPU伺服器及N個網絡攝像頭,伺服器持續記錄攝像頭的高清碼流,同時跑模型持續分析視訊内容;邊緣伺服器與資料中心之間有一條網絡鍊路,但「帶寬非常小」,通常隻作資料、控制指令下發用;資料中心部署了一系列web服務,為不同使用者提供審閱系統運作情況及釋出操作邊緣節點指令的端口。

那麼問題來了,終端使用者通常并不關心攝像頭錄制到的視訊,但「偶爾」需要抽檢部分視訊檔案确定系統正在正常運作,出問題的時候算法團隊需要導出原始高清視訊作進一步分析,怎麼破?提煉一下關鍵條件:

  1. 數量衆多的邊緣節點,每天生成海量視訊
  2. 邊緣節點到資料中心帶寬有限
  3. 需要提供不同清晰度的視訊滿足不同場景需要

最直覺的方案是,将節點上的視訊不斷推送到資料中心,用戶端直接通路存儲在資料中心的視訊資料,但這明顯不符合場景要求,因為邊緣節點到資料中心的帶寬非常小,沒辦法支援高清視訊檔案的持續傳輸,而且抽檢頻率很低,全傳回來了大多數也是用不上的。

第二種方案可以選擇按需排程,即由客戶明确發出抽調指令,指定時間範圍、邊緣節點清單、攝像頭清單、清晰度,資料中心按需同步。這種方案有兩個問題,一是延遲大,指令從用戶端發出後,得等資料中心到邊緣節點撈完資料,才能開始推送視訊,開始響應;二是需要實作一套排程系統,實作一堆提高可用性的邏輯,比如監控帶寬防止打滿、實作斷點續輸、異步任務生命周期管理等。

第三種,也就是本文闡述的技術方案:使用 ffmpeg 動态調整視訊碼率、分辨率;使用 HLS 分段傳輸視訊内容。

如果讀者想到更多可能性,還請聯系作者讨論讨論。

二、核心技術

2.1 ffmpeg 簡介

ffmpeg 是一個非常有名的高性能音視訊處理工具,它可以輕松實作視訊轉碼、分割、碼率調整、分辨率調整、中繼資料解析、幀包解析等等,能滿足大多數視訊處理場景。網上已經有很多相關的讨論文章,本文就不贅述了。

貼幾個連結:

  1. ffmpeg 下載下傳安裝
  2. ffmpeg 鏡像,同時支援CPU、GPU版本
  3. 阮一峰的 《ffmpeg 視訊處理入門教程》
  4. 使用GPU硬體加速ffmpeg視訊轉碼

2.2 HLS 協定簡介

HLS 全稱 Http Live Stream,是蘋果推出的基于http的流媒體傳輸協定,原理是将一個大的,完整的視訊檔案拆解成多個小檔案,每次隻播放其中的一個小檔案。選用HLS主要有如下的考量:

  1. 協定底層使用http傳輸内容,這在大B環境下是個極大的利好,因為大多數企業内網防火牆有很嚴格的管控,唯獨對80端口開放性較高
  2. 支援按需加載,也就是說播到那就加載那一塊小檔案,播多少下載下傳多少,不用download整個視訊
  3. 天然支援定點播放(seeking)

❝ 當時在做技術選型時也考慮過DASH,雖然DASH更有協定上的先進性,但當下覆寫度低,相容性沒HLS好,貿然選用怕是得給後面埋坑啊。

相關學習資料推薦,點選下方連結免費報名,先碼住不迷路~】

【免費分享】音視訊學習資料包、大廠面試題、技術視訊和學習路線圖,資料包括(C/C++,Linux,FFmpeg webRTC rtmp hls rtsp ffplay srs 等等)有需要的可以點選加群免費領取~

HLS + ffmpeg 實作動态碼流視訊服務

三、 代碼實作

核心代碼其實特别簡單,隻需實作兩個接口:

  1. 擷取索引檔案接口,原理上使用 ffprobe 分析視訊 I 幀分布時間點,生成 m3u8 檔案
  2. 擷取分片視訊,原理上使用ffmpeg 分割、轉碼,再通過http輸出切割後的視訊檔案

示例:

import Router from 'koa-router';
import { exec, spawn } from 'child_process';
import config from 'config';
import path from 'path';

const route = new Router();
const execPromise = (...arg) =>
  new Promise((r, j) => {
    exec(...arg, (err, stdout) => {
      if (err) {
        j(err);
      } else {
        r(stdout);
      }
    });
  });

// 視訊 m3u8 索引檔案接口
route.get('/videos/:videoFile', async (ctx) => {
  const { videoFile } = ctx.params;
  const videoFilePath = path.join(config.get('VIDEO_ROOT_DIR'), videoFile);
  // 調用 ffprobe 分析視訊 I 幀分布
  const cmdReadKeyframe = `ffprobe -v error -skip_frame nokey -select_streams v:0 -show_entries frame=pkt_pts_time -of csv=print_section=0 ${videoFilePath}`;
  const keyframes = (await execPromise(cmdReadKeyframe))
    .split('\n')
    .filter((d) => /^[\d\.]+$/.test(d))
    .map((d) => +d);

  // 調用 ffprobe 解析視訊原資訊
  const cmdReadMeta = `ffprobe -v quiet -print_format json -show_format -show_streams ${videoFilePath}`;
  const res = JSON.parse(await execPromise(cmdReadMeta));
  const {
    format: { duration }
  } = res;

  // 根據關鍵幀的時間分布和視訊時長,生成 m3u8 檔案
  const fragments = keyframes.map((k, i) => ({
    duration: i === keyframes.length - 1 ? duration - k : keyframes[i + 1] - k,
    start: k
  }));
  ctx.body =
     '#EXTM3U\n' +
      '#EXT-X-PLAYLIST-TYPE:VOD\n' +
      `#EXT-X-TARGETDURATION:${Math.max(...fragments.map((f) => f.duration))}\n` +
     `${fragments
        .map(
          (f) =>
            `#EXTINF:${f.duration},\n/hls_video/${videoFile}?start=${f.start}&duration=${f.duration}`
        )
        .join('\n')}\n` +
     '#EXT-X-ENDLIST';
  ctx.type = 'application/x-mpegURL';
});

// 讀取視訊片段
route.get('/hls_video/:videoFile', (ctx) => {
  const {
    params: { videoFile },
    query: { start, duration }
  } = ctx;
  const videoFilePath = path.join(config.get('VIDEO_ROOT_DIR'), videoFile);
  // ffmpeg 截取視訊片段
  const cmd = `-ss ${start} -i ${videoFilePath} -t ${duration} -vcodec copy -acodec copy -b:v 200k -f hls -bsf h264_mp4toannexb -output_ts_offset ${start} -`;
  const { stdout } = spawn('ffmpeg', cmd.split(' '));

  ctx.type = 'video/MP2T';
  // 流式輸出轉碼位元組序
  ctx.body = stdout;
});

export default route;
           

下面講解關鍵步驟。

3.1 根據 I 幀分割視訊

HLS 的原理是将一個大的視訊拆解為多個小視訊按需傳輸,拆解政策的好壞直接影響視訊還原度、轉碼效率、響應時間、緩存命中率等。可選的分割方案有:

  1. 根據視訊duration,均勻切割,好處是簡單直覺,但要麼轉換速度很慢導緻響應時間高,要麼還原度低,切出來的視訊可能存在好幾秒偏差(參考 《FFmpeg 視訊分割和合并》)
  2. 根據視訊 I 幀所在時間點進行切割,好處是還原度高,不容易掉幀;缺點是需要先用 ffprobe 周遊視訊中所有關鍵幀的位置,可能存在性能問題。

❝ 提示:

簡單科普一下,經過壓縮編碼的視訊主要包含了連續n個幀,其中有一種叫做 **I 幀(**Intra-coded picture),特點是壓縮率低但是内容完整,不需要其他幀輔助推算;除了I幀外常見的還有 「P‑幀」 (Predicted picture) —— 向前搜尋幀,「B‑frame」 (Bidirectional predicted picture) 雙向搜尋幀,它們的壓縮率高,但是需要依靠附近的I幀内容推算圖像。

通常 ffmpeg 截取視訊時大緻上不是按 -ss 和 -t 指定的起始結束時間精确的裁剪,而是找到開始、結束時間點上最近的關鍵幀,截取兩幀之間的資料,這會導緻實際結果跟預期結果可能有好幾秒的偏差。當然,為了精确裁剪,有一個變通的方法是将視訊所有幀都先轉成I幀再截取,但成本高,耗時大,無法滿足實時響應需求。

是以本文才會選擇根據I幀的時間點分割視訊,雖然在周遊I幀時會有一些性能損失,但精确度跟總體響應速度都能滿足需求。

分析I幀時間點的指令如下:

ffprobe -v error -skip_frame nokey -select_streams v:0 -show_entries frame=pkt_pts_time -of csv=print_section=0 ${videoFile}
           

關鍵參數:

  1. skip_frame: 指定跳過那些幀,這裡的值是 nokey 也就忽略了除I幀外的情況
  2. select_streams: 選擇下标為0的視訊流
  3. frame: 輸出幀包的 pkt_pts_time 字段

執行指令,會得到類似下面的輸出:

ffprobe -v error -skip_frame nokey -select_streams v:0 -show_entries frame=pkt_pts_time -of csv=print_section=0 xxx.mp4
0.000000
10.000000
20.000000
30.000000
40.000000
50.000000
60.000000
70.000000
80.000000
90.000000
100.000000
110.000000
120.000000
130.000000
140.000000
           

每一行即為關鍵幀在視訊出現的時間點。

❝ 提示:

在生産環境千萬要注意,有時候拿到的視訊 I 幀間隔極短,比如筆者就遇到過用 hiki SDK 從網絡攝像頭或NVR裝置取回的視訊,平均 0.1s 一個關鍵幀,是以分割時如果幀間間隔很短,應該考慮将多個關鍵幀時間點合并成一個切片輸出。

這一步需要調用 child_process 的 exec 函數運作 ffprobe 指令,并将結果格式化成 number 類型友善後續處理,核心代碼:

const cmdReadKeyframe = `ffprobe -v error -skip_frame nokey -select_streams v:0 -show_entries frame=pkt_pts_time -of csv=print_section=0 ${videoFilePath}`;
const keyframes = (await execPromise(cmdReadKeyframe))
  .split('\n')
  .filter((d) => /^[\d\.]+$/.test(d))
  .map((d) => +d);
           

3.2 生成M3U8索引檔案

M3U8 檔案最主要的作用是提供了HLS視訊分片的索引資訊,播放器在處理HLS視訊時首先通路這個索引檔案,找出所有切片視訊後再逐一按序播放。M3U8 常見格式如:

#EXTM3U
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-TARGETDURATION:10
#EXTINF:10,
/hls_video/20200525221257_20200525222012.mp4?start=0&duration=10
#EXTINF:10,
/hls_video/20200525221257_20200525222012.mp4?start=10&duration=10
#EXTINF:10,
#EXTINF:5.319999999999993,
/hls_video/20200525221257_20200525222012.mp4?start=430&duration=5.319999999999993
#EXT-X-ENDLIST
           

簡單介紹一下關鍵字段的含義:

  1. EXT-X-PLAYLIST-TYPE : 播放清單的類型,支援“EVENT”表示直播流,此時服務端可以不斷往這個檔案追加内容;“VOD”表示靜态視訊流 ,此時M3U8檔案不會發生變更。在我們的場景中,選擇使用“VOD”
  2. EXT-X-TARGETDURATION : 最大切片時長
  3. EXTINF :視訊切片描述字段,需要指明切片位址、時長
  4. EXT-X-ENDLIST : 視訊結束标志符,僅在 EXT-X-PLAYLIST-TYPE=VOID 時有效。

這一步需要根據上一步得到的關鍵幀時間點清單拼接出一個完整的 m3u8 檔案,核心代碼:

// 計算分片大小
// 實際應用中注意判斷,當時長小于某個門檻值時應該合并多個節點
const fragments = keyframes.map((k, i) => ({
  duration: i === keyframes.length - 1 ? duration - k : keyframes[i + 1] - k,
  start: k
}));
ctx.body =
  // m3u8 頭部資訊
  '#EXTM3U\n' +
  '#EXT-X-PLAYLIST-TYPE:VOD\n' +
  `#EXT-X-TARGETDURATION:${Math.max(...fragments.map((f) => f.duration))}\n` +
  // 周遊分片,生成分片描述
  `${fragments
  .map(
  (f) =>
   // 這裡簡單起見,直接用參數記錄分片開始、結束時間
    `#EXTINF:${f.duration},\n/hls_video/${videoFile}?start=${f.start}&duration=${f.duration}`
  )
  .join('\n')}\n` +
  // 結束标志符
  '#EXT-X-ENDLIST';
ctx.type = 'application/x-mpegURL';
           

3.3 生成視訊片段

最後,需要根據分片的開始結束時間裁剪視訊,毫無疑問這裡還是用的 ffempg ,關鍵指令:

ffmpeg -ss 10 -i xxx.mp4 -t 10 -vcodec copy -acodec copy -b:v 200k -f hls -bsf h264_mp4toannexb -output_ts_offset 10 -
           

指令參數:

  1. -ss :指定開始時間
  2. -t :指定裁剪時長
  3. -vcodec copy -acodec copy : 指定音視訊編碼規則,可根據實際場景調整,注意轉碼時間通常較長,建議盡量複制原始音視訊流
  4. -f hls : 指定視訊封裝格式為 hls
  5. -bsf h264_mp4toannexb :指定位元組流過濾器,用于将原始 mp4 封裝的位元組資料轉換為适合 hls 格式的位元組資料
  6. -output_ts_offset 10 : 這個很重要,指定分片開始時間,這個值必須嚴格按照分片在原視訊所處的時間進行設定,否則播放器無法正常播放

❝ 提示:

整行指令的意思就是從 ss 開始截取 -t 長度的片段,音視訊流直接copy,轉封裝為 hls 格式并對每個位元組流使用 h264_mp4toannexb 過濾器,完事了再設定輸出視訊的開始時間。

對應的核心代碼:

const cmd = `-ss ${start} -i ${videoFilePath} -t ${duration} -vcodec copy -acodec copy -b:v 200k -f hls -bsf h264_mp4toannexb -output_ts_offset ${start} -`;
const { stdout } = spawn('ffmpeg', cmd.split(' '));

ctx.type = 'video/MP2T';
ctx.body = stdout;
           

❝ 提示:

這裡還有一個小知識點,指令最後指定的輸出是 - ,ffmpeg 遇到這種輸出指令時會邊轉碼邊将位元組流輸出到後面的管道上,對應上例中将 spawn 指令的 stdout 流直接指派給 ctx.body ,進而盡快向用戶端發出響應。

四、性能優化

視訊處理過程非常耗費時間,在本文介紹的這種實時處理場景更需要特别關注性能,可以從「盡量提升單個視訊處理速度」 和 **盡量減少視訊處理 **兩個方面入手,下面羅列一些有效的優化手段:

4.1 使用GPU處理視訊

ffmpeg 預設情況下會用CPU執行指令,CPU運作的缺點一是會大量占用CPU資源,降低系統效率;二是CPU更擅長執行邏輯運算,執行這種視訊處理的速度遠遠比不上GPU。要用GPU跑ffmpeg 指令,必要條件:

  1. 系統帶有支援對應視訊編碼格式的硬體加速功能的顯示卡
  2. 裝好顯示卡驅動
  3. 編譯GPU版本的ffmpeg

❝ 提示:

環境配置太繁瑣了,建議有興趣的讀者在帶GPU硬體的機器上跑 ffmpeg 鏡像 試試。

滿足上述條件後,隻需在原本CPU版本的指令基礎上增加一個參數,例如使用NVIDIA顯示卡加速時 -hwaccel nvdec ,上述指令對應GPU版本:

ffmpeg -hwaccel nvdec -ss 10 -i xxx.mp4 -t 1000 -vcodec copy -acodec copy -b:v 200k -f hls -bsf h264_mp4toannexb -output_ts_offset 10 -
           

嘗試跑了幾個視訊,性能對比:

CPU 占用率 平均耗時
CPU 250% + 2m41s
GPU(基于T4) 75% - 90% 55s

❝ 提示:

測試樣本非常小,是以資料并不具有普适性,此處隻是為了表達,GPU真的很快。

4.2 緩存

第二個優化政策是使用緩存,減少直接處理視訊。

對服務端而言,可以将裁剪後的視訊持久化為硬碟檔案,下次通路相同參數時直接輸出檔案。如果視訊内容有可能發生變化,建議每次比對時校驗視訊MD5值。

對浏覽器端而言則可以啟用強緩存,推薦的方案是視訊名稱以MD5命名,然後通過 max-age 頭設定很長的緩存過期時間。

❝ 提示:

注意盡量避免使用 must-revalidate ,因為每次到服務端驗證,服務端還是得執行視訊編解碼操作,頻繁驗證性能反而會降低。

4.3 其他優化政策

還存在很多優化政策,可以根據實際情況啟用,例如:

  • 「使用CDN」:CDN能夠實作一個節點服務一群使用者,那麼理論上使用者A通路某個視訊後,同區内使用者B/C/D 通路相同視訊就可以直接取CDN節點上的副本,避免到源節點通路。
  • 「使用H265編碼傳輸」:同一個視訊,H265編碼能比H264節省約1/3的空間,是以H265的網絡性能相比之下會好很多。不過問題在于浏覽器預設普遍不支援播放H265視訊,是以這個方案對用戶端要求較高,不具有普适性。
  • 「避免視訊轉碼」:轉碼過程需要對視訊每一幀執行解碼、轉碼,性能消耗極大,相比視訊裁剪、修改碼率、修改分辨率、轉封裝等操作算是小弟了,是以建議在儲存視訊的時候就直接儲存成最後使用者使用的編碼格式,避免實時轉碼。

原文 https://zhuanlan.zhihu.com/p/366733999