回顧
什么是H.265?
本文在這里就不對H.265做介紹了。感興趣的朋友可以看下面的文章了解詳情。(第一篇是我們在2019年3月發布的文章,距今已有2年,時間過得真快)《Web端H.265播放器研發解密》[1]
WebAssembly的發展
看了上面那篇2年前的文章應該清楚了瀏覽器對於H.265支持程度。好消息是經過兩年發展,Webassembly發布了1.1版本,增加了很多新特性,性能也有了提升。壞消息是瀏覽器依然不支持H.265,估計以后也不可能會支持了。所以呢兩年后的今天如果我們要在瀏覽器里播放H.265還是需要借用Webassembly+FFmpeg的能力。本文也不多加介紹了,細節看下面的鏈接吧。Webassembly[3]FFmpeg[4]
現狀
這篇文章的目的是?
H.265播放器(Videox.js)在淘寶直播落地已經近兩年了。之前的架構設計主要針對的是直播的場景,播放m3u8和flv的直播流,由於直播落地的場景是B端主播中控台,使用場景是可以預覽畫面即可,故而對幀率要求不高。但是今年的短視頻業務面向的多是C端用戶,需要在Web場景下播放1080P/720P的H.265視頻,那么必須滿足短視頻主流分辨率+碼率流暢播放的要求。同時業務上還要支持多視頻格式如(mp4/fmp4)的需求,所以綜合評估后對原有架構進行了升級。既然有了升級自然就需要沉淀下經驗。按照一貫套路我就來水一篇文章了。當然這兩年內業界也有大量H.265播放器的實踐落地,我寫這篇文章也是借這次重構的機會分享自己的一些經驗,希望能幫助各位少踩些坑。
視頻演示
如下將演示新版播放器播放 1分鍾1080p/25fps/H.265 MP4視頻,具體視頻參數如下:
-
預加載1000000幀(即整個視頻),完全解碼不播放的內存占用、CPU占用、解碼間隔時間
因為整個解碼過程沒有進行播放,所以解碼間隔=單幀解碼耗時。
從上面視頻能看出來,一個幾十M的文件完全解碼能達到4.6G的內存占用,CPU占用高達300以上(4核)。當然,這是完全不做限制,火力全開解碼。但也能得出結論:無干擾情況下平均解碼一幀1080p僅需要13ms(基於mbp2015版)。
舊版直播播放器解碼720p需要26ms(基於mbp2015版),而新版播放器播1080p目前的13ms還不是極限,后續將繼續探索優化空間。
-
預加載10幀並解碼,后續邊播邊解的相關數據
演示1太過極端不符合日常使用的場景,但因為極限情況平均解碼只需要13ms,而視頻幀率是25(即間隔40ms),所以可以隔一段時間喂幾幀到解碼器,這樣平衡了播放和解碼的速率之后,CPU占用降到120左右、內存占用降低到了300M。同時還能流暢播放。不過播放策略有很多種,各位有更好的方案也歡迎和我交流。
架構設計
整體架構設計
上圖所示為新播放器基本骨架,包含了主要模塊。模塊間互相獨立,各自接收通用協議的參數。比如Loader傳遞給Demuxer的數據為ArrayBuffer,經Demuxer統一解封裝成Packet格式Buffer數據(Annex-B)喂給Renderer。上圖用MP4舉例(HVCC為H.265碼流格式之一),替換成flv、ts格式也是遵循這個流程。Renderer負責decoder調度,音畫同步、音視頻播放等,可以說是播放器最核心的模塊。UI View則主要用來繪制播放器控件UI,如進度條等。本文不打算詳細介紹每個功能,僅對decoder做細節解構,其它有關聯的模塊僅簡單說明和實現。
DEMO架構
因為沒有Demuxer,所以直接用Loader讀取Annex-B碼流。
-
通過Loader讀取到Annex-B碼流的Uint8Array數據
-
通過postMessge將數據發送給Worker線程的WASM包解碼
-
WASM通過回調函數傳回YUV數據給Worker再通過postMessage傳給主線程Canvas
實操步驟
如何將 FFmpeg 編譯成 WASM 包
接下來就進入正題了,第一步,先編譯FFmpeg做精簡,為啥呢?因為FFmpeg不光是個C庫,還是非常龐大的C庫。我們要在Web上使用它就需要移除一些無用的模塊,好在FFmpeg提供了相應配置的能力,使用根目錄configure文件按如下步驟操作即可。
1. 准備
-
編譯前我們需要去emscripten官網[7]下載最新版emsdk
emsdk就是用來把FFmpeg編譯成wasm包的工具
-
官網FFmpeg[8] 下載源碼版的FFmpeg(本文基於4.1)
2. 編譯FFmpeg靜態庫
創建 make_decoder.sh
echo "Beginning Build:"
rm -r ./ffmpeg-lite
mkdir -p ./ffmpeg-lite # dist目錄
cd ../ffmpeg # src目錄,ffmpeg源碼
make clean
emconfigure ./configure --cc="emcc" --cxx="em++" --ar="emar" --ranlib="emranlib" --prefix=$(pwd)/../ffmpeg-wasm/ffmpeg-lite --enable-cross-compile --target-os=none --arch=x86_32 --cpu=generic \
--enable-gpl --enable-version3 \
--disable-swresample --disable-postproc --disable-logging --disable-everything \
--disable-programs --disable-asm --disable-doc --disable-network --disable-debug \
--disable-iconv --disable-sdl2 \ # 三方庫
--disable-avdevice \ # 設備
--disable-avformat \ # 格式
--disable-avfilter \ # 濾鏡
--disable-decoders \ # 解碼器
--disable-encoders \ # 編碼器
--disable-hwaccels \ # 硬件加速
--disable-demuxers \ # 解封裝
--disable-muxers \ # 封裝
--disable-parsers \ # 解析器
--disable-protocols \ # 協議
--disable-bsfs \ # bit stream filter,碼流轉換
--disable-indevs \ # 輸入設備
--disable-outdevs \ #輸出設備
--disable-filters \ # 濾鏡
--enable-decoder=hevc \
--enable-parser=hevc
make
make install
因為wasm支持的能力還是比較有限,一些FFmpeg用來優化性能的模塊都需要禁用(比如硬件加速、匯編等)。本文也僅介紹解碼。所以播放涉及的功能只用到了hevc-decoder(hevc=h265),其它的通通禁掉。
執行make_decoder.sh在ffmpeg-lite文件夾內生成簡化后的FFmpeg靜態庫和對應的.h聲明文件。
3. 編寫入口文件
編譯完依賴庫不代表就直接能用了,還需要自己動手寫入口文件的代碼去調用FFmpeg的接口,這一步就需要你稍微懂一點點c語言了。我們起個名字叫decoder.c
初始化解碼器
首先我們調用init_decoder初始化解碼器,依次初始化codec、dec_ctx、parser、frame、pkt。frame和pkt作為全局變量用來給后面交換數據使用。init_decoder接收一個JS回調函數作為入參。后面通過這個回調函數給JS worker線程回傳數據。回調函數聲明定義了三個入參,依次是數據開始地址、長度、以及pts。本文暫不涉及pts,不傳也可以。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <libavcodec/avcodec.h>
#include <libavutil/imgutils.h>
typedef void(*OnBuffer)(unsigned char* data_y, int size, int pts);
AVCodec *codec = NULL;
AVCodecContext *dec_ctx = NULL;
AVCodecParserContext *parser_ctx = NULL;
AVPacket *pkt = NULL;
AVFrame *frame = NULL;
OnBuffer decoder_callback = NULL;
void init_decoder(OnBuffer callback) {
// 找到hevc解碼器
codec = avcodec_find_decoder(AV_CODEC_ID_HEVC);
// 初始化對應的解析器
parser_ctx = av_parser_init(codec->id);
// 初始化上下文
dec_ctx = avcodec_alloc_context3(codec);
// 打開decoder
avcodec_open2(dec_ctx, codec, NULL);
// 分配一個frame內存,並指明yuv 420p格式
frame = av_frame_alloc();
frame->format = AV_PIX_FMT_YUV420P;
// 分配一個pkt內存
pkt = av_packet_alloc();
// 暫存回調
decoder_callback = callback;
}
uint8轉AVPacket
這一步就是接收JS的視頻數據給到av_parser_parse2方法,av_parser_parse2接收任意長度的buffer數據,並從buffer中解析出avpacket結構直到沒有數據為止。avpacket存放了壓縮的媒體數據,如果是視頻類型,則通常表示一幀,音頻數據表示N幀。下面節選了一段FFmpeg源碼注釋
This structure stores compressed data. It is typically exported by demuxers and then passed as input to decoders, or received as output from encoders and then passed to muxers. For video, it should typically contain one compressed frame. For audio it may contain several compressed frames. Encoders are allowed to output empty packets, with no compressed data, containing only side data (e.g. to update some stream parameters at the end of encoding).
void decode_buffer(uint8_t* buffer, size_t data_size) { // 入參是js傳入的uint8array數據以及數據長度
while (data_size > 0) {
// 從buffer中解析出packet
int size = av_parser_parse2(parser_ctx, dec_ctx, &pkt->data, &pkt->size,
buffer, data_size, AV_NOPTS_VALUE, AV_NOPTS_VALUE, 0);
if (size < 0) {
break;
}
buffer += size;
data_size -= size;
if (pkt->size) {
// 解碼packet
decode_packet(dec_ctx, frame, pkt);
}
}
}
解碼AVPacket,接收AVFrame
拿到avpacket之后,需要調用avcodec_send_packet把數據扔給解碼器解碼,上面已經說到了音頻數據一個packet可能包含了多個幀(即avframe),所以通過一個while循環調用avcodec_receive_frame從解碼器中取出avframe數據。直到它返回AVERROR(EAGAIN)、AVERROR_EOF或錯誤。avframe包含的就是解碼后的數據了。
AVERROR(EAGAIN)表示packet數據消費完了,需要新數據。而AVERROR_EOF則是當你輸入的pkt->data為NULL時會觸發。解碼器一般會緩存幾幀的數據,當你想拿到這些數據時就需要傳遞NULL的pkt給解碼器。
avcodec_send_packet是4.x版本的新解口,3.x是avcodec_decode_video2和avcodec_decode_audio4。前者如上面所說,輸入一次,輸出多次。后者則是當pkt數據不足以產生frame的時候,需要在后續數據到來時合並數據並重新調用方法進行解碼。
int decode_packet(AVCodecContext* ctx, AVFrame* frame, AVPacket* pkt)
{
int ret = 0;
// 發送packet到解碼器
ret = avcodec_send_packet(dec, pkt);
if (ret < 0) {
return ret;
}
// 從解碼器接收frame
while (ret >= 0) {
ret = avcodec_receive_frame(dec, frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
break;
} else if (ret < 0) {
// handle error
break;
}
// 輸出yuv buffer數據
output_yuv_buffer(frame);
}
return ret;
}
AVFrame轉YUV uint8
拿到解碼后的avframe數據后我們需要把它的傳遞給JS,但因為avframe的數據是個雙層數組。而我們需要把它轉換成uint8再傳給JS線程。
YUV 圖像有兩種存儲格式:
-
緊縮格式(packed formats): Y、U、V 三通道像素值依次排列,即 Y0 U0 V0 Y1 U1 V1 ...
-
平面格式(planar formats): 先排列 Y 的所有像素值,再排列 U,最后排列 V YUV420p 中使用平面格式,水平 2:1 取樣,垂直 2:1 采樣,即每 4 個 Y 分量對應一個 U、V 分量
如上圖所示,我們編寫代碼把avframe數據依次copy到yuv_buffer中,並使用decoder_callback傳給JS線程
實際上你這一步怎么存都可以,但在渲染的時候你得依據存的順序取出數據並按420p的方式渲染
void output_yuv_buffer(AVFrame *frame) {
int width, height, frame_size;
uint8_t *yuv_buffer = NULL;
width = frame->width;
height = frame->height;
// 根據格式,獲取buffer大小
frame_size = av_image_get_buffer_size(frame->format, width, height, 1);
// 分配內存
yuv_buffer = (uint8_t *)av_mallocz(frame_size * sizeof(uint8_t));
// 將frame數據按照yuv的格式依次填充到bufferr中。下面的步驟可以用工具函數av_image_copy_to_buffer代替。
int i, j, k;
// Y
for(i = 0; i < height; i++) {
memcpy(yuv_buffer + width*i,
frame->data[0]+frame->linesize[0]*i,
width);
}
for(j = 0; j < height / 2; j++) {
memcpy(yuv_buffer + width * i + width / 2 * j,
frame->data[1] + frame->linesize[1] * j,
width / 2);
}
for(k =0; k < height / 2; k++) {
memcpy(yuv_buffer + width * i + width / 2 * j + width / 2 * k,
frame->data[2] + frame->linesize[2] * k,
width / 2);
}
// 通過之前傳入的回調函數發給js
decoder_callback(yuv_buffer, frame_size, frame->pts);
av_free(yuv_buffer);
}
以上就是入口文件的所有代碼,我盡量用最簡化的代碼呈現。總共包含了init_decoder、decode_buffer、decode_packet、output_yuv_buffer。其它不關鍵的部分都省略了,比如(close_decoder、異常處理等)
注意:因為編譯時沒有包含demux、bsfs。所以decoder_buffer接收的buffer數據必須是annexb碼流。
4. 編譯WASM包
終於到了本小節的尾聲,把入口文件+依賴庫編譯成wasm包。這一步比較簡單,依然是創建一個build_decoder.sh,按下面的代碼編寫,然后執行即可。
export TOTAL_MEMORY=67108864
export EXPORTED_FUNCTIONS="[ \
'_init_decoder', \
'_decode_buffer'
]"
echo "Running Emscripten..."
# 入口文件+3個依賴庫文件
emcc decoder.c ffmpeg-lite/lib/libavcodec.a ffmpeg-lite/lib/libavutil.a ffmpeg-lite/lib/libswscale.a \
-O2 \
-I "ffmpeg-lite/include" \
-s WASM=1 \
-s ASSERTIONS=1 \
-s LLD_REPORT_UNDEFINED \
-s NO_EXIT_RUNTIME=1 \
-s DISABLE_EXCEPTION_CATCHING=1 \
-s TOTAL_MEMORY=${TOTAL_MEMORY} \
-s EXPORTED_FUNCTIONS="${EXPORTED_FUNCTIONS}" \
-s EXTRA_EXPORTED_RUNTIME_METHODS="['addFunction', 'removeFunction']" \
-s RESERVED_FUNCTION_POINTERS=14 \
-s FORCE_FILESYSTEM=1 \
-o ./wasm/libffmpeg.js
echo "Finished Build"
EXPORTED_FUNCTIONS就是入口文件里需要對外暴露的方法了。記得前面加_
構建產物如下:
libffmpeg.js就是wasm包的JS入口文件
JS如何加載並調用WASM包方法
Worker部分
本環節到了我們的主場領域,編寫JS代碼(采用了TypeScript語法,應該不影響閱讀吧)。由於WASM代碼需要跑在worker線程。所以下面代碼的環境變量只能在worker中訪問
decoder.ts
export class Decoder extends EventEmitter<IEventMap> {
M: any
init(M: any) {
// M = self.Module 即wasm環境變量
this.M = M
// 創建wasm的回調函數,viii表示有3個int參數
const callback = this.M.addFunction(this._handleYUVData, 'viii')
// 通過我們上面decoder.c文件的方法傳入回調
this.M._init_decoder(callback)
}
decode(packet: IPacket) {
const { data } = packet
const typedArray = data
const bufferLength = typedArray.length
// 申請內存區,並放入數據
const bufferPtr = this.M._malloc(bufferLength)
this.M.HEAPU8.set(typedArray, bufferPtr)
// 解碼buffer
this.M._decode_buffer(bufferPtr, bufferLength)
// 釋放內存區
this.M._free(bufferPtr)
}
private _handleYUVData = (start: number, size: number, pts: number) => {
// 回調傳回來的第一個參數是yuv_buffer的內存起始索引
const u8s = this.M.HEAPU8.subarray(start, start + size)
const output = new Uint8Array(u8s)
this.emit('decoded-frame', {
data: output,
pts,
})
}
}
decoder-manager.ts
因為Worker線程加載wasm文件是異步的,需要在onRuntimeInitialized之后才能調用wasm方法,所以寫了一個簡單的manager管理decoder。
import { Decoder } from './decoder'
const global = self as any
export class DecoderManager {
loaded = false
decoder = new Decoder()
cachePackets: IPacket[] = []
load() {
// 表明wasm文件的位置
global.Module = {
locateFile: (wasm: string) => './wasm/' + wasm,
}
global.importScripts('./wasm/libffmpeg.js')
// 初始化之后,執行一次push,把緩存的packet送到decoder里
global.Module.onRuntimeInitialized = () => {
this.loaded = true
this.decoder.init(global.Module)
this.push([])
}
this.decoder.on('decoded-frame', this.handleYUVBuffer)
}
push(packets: IPacket[]) {
// 沒加載就緩存起來,加載了就先取緩存
if (!this.loaded) {
this.cachePackets = this.cachePackets.concat(packets)
} else {
if (this.cachePackets.length) {
this.cachePackets.forEach((frame) => this.decoder.decode(frame))
this.cachePackets = []
}
packets.forEach((frame) => this.decoder.decode(frame))
}
}
handleYUVBuffer = (frame) => {
global.postMessage({
type: 'decoded-frame',
data: frame,
})
}
}
const manager = new DecoderManager()
manager.load()
self.onmessage = function(event) {
const data = event.data
const type = data.type
switch (type) {
case 'decode':
manager.push(data.data)
break
}
}
JS主線程部分
這一步為加載worker代碼並進行通信。加載worker的流程很簡單,使用webpack+worker-loader即可,然后用fetch遞歸讀取數據並發送給worker線程,編碼器接收到數據就會進行解碼。
import Worker from 'worker-loader!../worker/decoder-manager'
const worker = new Worker()
const url = 'http://xx.com' // 碼流地址
fetch(url)
.then((res) => {
if (res.body) {
const reader = res.body.getReader()
const read = () => {
// 遞歸讀取buffer數據
reader.read().then((json) => {
if (!json.done) {
worker.postMessage({
type: 'decode',
data: [{
data: json
}],
})
read()
}
})
}
read()
}
})
結語
按照上面的代碼就可以實現一個簡易的H.265解碼器,如下是用JS仿照前文所列舉的AVPacket和AVFrame結構打印出來的數據:
解碼前:從JS主線程傳遞給WASM的數據
解碼后:從WASM傳遞給JS主線程的數據
上圖對比可以看出解碼后的數據量有多么恐怖,所以就像在開始的視頻里所演示的,解碼完成后的內存管理十分重要。
以上就是H.265視頻解碼篇的全部內容了。音頻解碼同樣可以復用上面的鏈路去解碼,也可以使用瀏覽器自帶的decodeAudioData。音頻播放則是使用AudioContext。目前主流的音頻編碼格式瀏覽器都支持。最后希望上面的經驗分享能夠幫大家少踩點坑。另外除了播放H.265以外,FFmpeg也可以做很多視頻處理的工作。大家可以思維發散暢想可能的應用場景,后續也將帶來更多播放器系列文章。
盡請期待:《從0到1實現Web端H.265播放器:mp4/fmp4 demux篇》 《從0到1實現Web端H.265播放器:YUV數據渲染篇》 ...
參考資料
[1]
《Web端H.265播放器研發解密》: https://fed.taobao.org/blog/taofed/do71ct/web-player-h265/
[3]
Webassembly: https://webassembly.org/
[4]
FFmpeg: https://zh.wikipedia.org/wiki/FFmpeg
[7]
emscripten官網: https://emscripten.org/docs/getting_started/downloads.html
[8]
FFmpeg: https://ffmpeg.org/download.html