从0到1实现Web端H.265播放器:视频解码篇

avatar
前端 @阿里巴巴

作者:青立

前言

回顾

什么是H.265?

本文在这里就不对H.265做介绍了。感兴趣的朋友可以看下面的文章了解详情。(第一篇是我们在2019年3月发布的文章,距今已有2年,时间过得真快) 《Web端H.265播放器研发解密》

WebAssembly的发展

看了上面那篇2年前的文章应该清楚了浏览器对于H.265支持程度。好消息是经过两年发展,Webassembly发布了1.1版本,增加了很多新特性,性能也有了提升。坏消息是浏览器依然不支持H.265,估计以后也不可能会支持了。所以呢两年后的今天如果我们要在浏览器里播放H.265还是需要借用Webassembly+FFmpeg的能力。本文也不多加介绍了,细节看下面的链接吧。 Webassembly FFmpeg

undefined

现状

这篇文章的目的是?

H.265播放器(Videox.js)在淘宝直播落地已经近两年了。之前的架构设计主要针对的是直播的场景,播放m3u8和flv的直播流,由于直播落地的场景是B端主播中控台,使用场景是可以预览画面即可,故而对帧率要求不高。但是今年的短视频业务面向的多是C端用户,需要在Web场景下播放1080P/720P的H.265视频,那么必须满足短视频主流分辨率+码率流畅播放的要求。同时业务上还要支持多视频格式如(mp4/fmp4)的需求,所以综合评估后对原有架构进行了升级。既然有了升级自然就需要沉淀下经验。按照一贯套路我就来水一篇文章了。当然这两年内业界也有大量H.265播放器的实践落地,我写这篇文章也是借这次重构的机会分享自己的一些经验,希望能帮助各位少踩些坑。

视频演示

如下将演示新版播放器播放 1分钟1080p/25fps/H.265 MP4视频,具体视频参数如下:

undefined

  1. 预加载1000000帧(即整个视频),完全解码不播放的内存占用、CPU占用、解码间隔时间

embed: 1_1.mp4

因为整个解码过程没有进行播放,所以解码间隔=单帧解码耗时。

从上面视频能看出来,一个几十M的文件完全解码能达到4.6G的内存占用,CPU占用高达300以上(4核)。当然,这是完全不做限制,火力全开解码。但也能得出结论:无干扰情况下平均解码一帧1080p仅需要13ms(基于mbp2015版)。

旧版直播播放器解码720p需要26ms(基于mbp2015版),而新版播放器播1080p目前的13ms还不是极限,后续将继续探索优化空间。

  1. 预加载10帧并解码,后续边播边解的相关数据

embed: 2_1.mp4

演示1太过极端不符合日常使用的场景,但因为极限情况平均解码只需要13ms,而视频帧率是25(即间隔40ms),所以可以隔一段时间喂几帧到解码器,这样平衡了播放和解码的速率之后,CPU占用降到120左右、内存占用降低到了300M。同时还能流畅播放。不过播放策略有很多种,各位有更好的方案也欢迎和我交流。

架构设计

整体架构设计

undefined

上图所示为新播放器基本骨架,包含了主要模块。模块间互相独立,各自接收通用协议的参数。比如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码流。

  1. 通过Loader读取到Annex-B码流的Uint8Array数据
  2. 通过postMessge将数据发送给Worker线程的WASM包解码
  3. WASM通过回调函数传回YUV数据给Worker再通过postMessage传给主线程Canvas

undefined

实操步骤

如何将 FFmpeg 编译成 WASM 包

接下来就进入正题了,第一步,先编译FFmpeg做精简,为啥呢?因为FFmpeg不光是个C库,还是非常庞大的C库。我们要在Web上使用它就需要移除一些无用的模块,好在FFmpeg提供了相应配置的能力,使用根目录configure文件按如下步骤操作即可。

1. 准备

emsdk就是用来把FFmpeg编译成wasm包的工具

  • 官网FFmpeg 下载源码版的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声明文件。 undefined

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 分量

undefined

如上图所示,我们编写代码把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就是入口文件里需要对外暴露的方法了。记得前面加_

构建产物如下: undefined

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的数据 undefined

解码后:从WASM传递给JS主线程的数据 undefined

上图对比可以看出解码后的数据量有多么恐怖,所以就像在开始的视频里所演示的,解码完成后的内存管理十分重要。

以上就是H.265视频解码篇的全部内容了。音频解码同样可以复用上面的链路去解码,也可以使用浏览器自带的decodeAudioData。音频播放则是使用AudioContext。目前主流的音频编码格式浏览器都支持。最后希望上面的经验分享能够帮大家少踩点坑。另外除了播放H.265以外,FFmpeg也可以做很多视频处理的工作。大家可以思维发散畅想可能的应用场景,后续也将带来更多播放器系列文章。