前言

回顾

什么是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视频,具体视频参数如下:

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

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

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

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

  1. 预加载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码流。

  1. 通过Loader读取到Annex-B码流的Uint8Array数据

  2. 通过postMessge将数据发送给Worker线程的WASM包解码

  3. 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);// 打开decoderavcodec_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中解析出packetint 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) {// 解码packetdecode_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;}// 从解码器接收framewhile (ret >= 0) {ret = avcodec_receive_frame(dec, frame);if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {break;} else if (ret < 0) {// handle errorbreak;}// 输出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;// Yfor(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);}// 通过之前传入的回调函数发给jsdecoder_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: anyinit(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 } = packetconst typedArray = dataconst bufferLength = typedArray.length// 申请内存区,并放入数据const bufferPtr = this.M._malloc(bufferLength)this.M.HEAPU8.set(typedArray, bufferPtr)// 解码bufferthis.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 = falsedecoder = 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 = truethis.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.dataconst type = data.typeswitch (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

最后

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点个「在看」,让更多的人也能看到这篇内容(喜欢不点在看,都是耍流氓 -_-)

  2. 欢迎加我微信「huab119」拉你进技术群,长期交流学习...

  3. 关注公众号「前端劝退师」,持续为你推送精选好文,也可以加我为好友,随时聊骚。

点个在看支持我吧,转发就更好了

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

  1. Web端H.265播放器研发解密

    音视频编解码对于前端工程师是一个比较少涉足的领域,涉及到流媒体技术中的文本.图形.图像.音频和视频多种理论知识的学习,才能够应用到具体实践中,本团队在多媒体领域深耕两年多,才算是有一定产出,我们自研w ...

  2. Web端H.265播放器研发解密 1

    音视频编解码对于前端工程师是一个比较少涉足的领域,涉及到流媒体技术中的文本.图形.图像.音频和视频多种理论知识的学习,才能够应用到具体实践中,本团队在多媒体领域深耕两年多,才算是有一定产出,我们自研w ...

  3. 让你在浏览器也能享受H.265播放器的高清画面

    大家好,我是TJ 关注TJ君,回复"武功秘籍"免费获取计算机宝典书籍 喜欢研究视频的小伙伴,想必对H.265已经非常了解,作为ITU-T VCEG继H.264之后所制定的新的视频编 ...

  4. 【操作说明】全能型H.265播放器如何使用

    本播放器集成了公司业务的接口,包含了实播,回放,云台控制和回放速度控制,截图和全屏功能 可以根据type直接初始化接口地址 如果是第三方业务对接,也可以单独配置接口地址 正确使用H.265播放器需要按 ...

  5. Webassembly 学习3 -- 打造web端的aac 播放器

    1.引言 aac 是很常见的音频格式,压缩率比mp3 还高,H5 支持从audio 标签文件读取aac 文件并播放,但不支持从网络流中直接读取.这里借助webassembly 技术,将aac 转码成p ...

  6. Discuz论坛web网页mp3音频播放器源码下载

    最近在做一个DZ论坛的搭建,在前几天的时候遇到Flash+Js的幻灯片的问题,特写了一篇<JS+Flash焦点图轮播源代码(2屏,3屏,多屏)>来记录论坛搭建过程遇到的一些技术性问题,以便 ...

  7. php项目网页音乐播放器插件,基于HTML5 canvas和Web Audio的音频播放器插件

    wavesurfer.js是一款基于HTML5 canvas和Web Audio的音频播放器插件.通过wavesurfer.js你可以使用它来制作各种HTML5音频播放器,它可以在各种支持 Web A ...

  8. H.265流媒体播放器EasyPlayer手机端播放画面出现强制拉伸现象的解决办法

    我们在前期的文章中介绍过关于H5网页播放器EasyPlayer的功能更新,包括已经实现网页端实时录像.在iOS上实现低延时直播等.EasyPlayer流媒体播放器性能稳定.播放流畅,属于高可靠.高可用 ...

  9. android 桌面视频播放器,ZY-Player ,一款跨平台桌面端视频资源播放器

    前段时间为大家整理了一波免费高清的影视站,详见<酷站分享,免费看高清VIP电影>,影视资源站当然要多多益善.除此之外,各种第三方优秀的播放器也可多收藏一些,譬如今天介绍的这款ZY Play ...

  10. 包体 400 KB,首开 0.2 s,真有这样的播放器 SDK!

    2017 年,马克·扎克伯格接受 CNBC 采访时说:「视频是大趋势,未来我们会在视频尤其是短视频上投更多资源.往后五年你会发现,在互联网大家最乐于传播的一定是短视频.」 10 秒时代 现在,似乎没人 ...

最新文章

  1. 单应性Homograph估计:从传统算法到深度学习
  2. 如何对局域网内的无线设备进行管理和流控?
  3. 在php中实现时间戳,怎么在PHP中对时间戳进行转化
  4. ubuntu14.04源无法更新--jdk安装出错解决方法
  5. 【XSY2470】lcm 数学
  6. 乐播投屏显示服务器错误是什么意思,乐播投屏怎么用 乐播投屏常见问题汇总分享...
  7. Mac 下制作win7启动U盘启动PE
  8. wsimport命令生成webService java客户端代码
  9. Vue 使用 Echarts 显示热力地图信息
  10. matlab 各类符号意义
  11. 2cm有多长实物图_2cm 2cm有多长实物图
  12. Visual studio 无法连接网络解决方法
  13. curl证书过期_定时检测SSL证书过期情况并发送通知
  14. 百行代码手撸扫雷(下)c/c++
  15. 政务云市场开启“狂飙”:一项前三,两项跃升!
  16. snipaste如何滚动截图_这三款软件,满足你对截图的所有需求
  17. 聊聊前端开发日常的协作工具(全)
  18. 浅析 Transformer Stage 在 DataStage 作业中的用法及功能实现
  19. python中strip的用法_Python中你不知道的strip()函数的妙用
  20. c++ 进制转换 十六进制转八进制

热门文章

  1. Uniapp 微信小程序登陆页面
  2. php网上花店管理系统的论文,网上花店管理系统
  3. 舆情监测产品的过去与现在
  4. 2017第121届中国进出口商品交易会(广交会)-第三期会刊(参展商名录)
  5. 关于bootstrap自适应屏幕宽度学习
  6. mac编译linux开源软件,Mac 端超好用的免费开源软件,我推荐这几款
  7. outlook2019配置QQ邮箱
  8. 用java怎么做微信公众号,用Java搭建微信公众号(一)构建基础请求框架
  9. 如何彻底卸载Anaconda?
  10. java进制转换的简单方法