前言
下文是面向C端用户的如何实现H265解决思路,但如果仅是面向少数人的场景,其实现在Chrome已经对WebRTC H265有了实验进展,可以在新的浏览器侧使用下方命令开启H265在WebRTC原生支持。
# 执行之前请关闭浏览器所有进程
# Mac指令
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --enable-features=WebRtcAllowH265Receive --force-fieldtrials=WebRTC-Video-H26xPacketBuffer/Enabled
# Window
# window没有自带终端打开应用方式,简单的方法就是在chrome快捷方式后面增加这一段参数 --enable-features=WebRtcAllowH265Receive --force-fieldtrials=WebRTC-Video-H26xPacketBuffer/Enabled
通过这个方式我们可以不通过dc传输H265的数据包而是直接通过像其他编码数据一样使用mediatrack进行传输。 如果 这个方式不方便,也可以考虑自己编译chromium去将这个实验属性默认打开,然后提供相应的安装包给到相应人员也可以。
如果本文有为你提供一些帮助或思路上的学习,求个点赞
准备工作
在正式开始开发之前,我们需要有一些准备工作,为了让h265的数据通过DataChannel通道传输,我这边已经在P2P另外一侧做了传输处理,我们建立一下WebRTC连接看一下基础参数是否满足我们的要求
通过WebRTC的系统参数,我们得到了一个码率正常的DataChannel通道。这样我们准备工作就完成了,可以开始解码工作了。
硬解码工作
解码又分软解和硬解。软解指的是软件能力,硬解是硬件能力。如果是硬件能支持到解码,我们一般倾向于使用硬件解码,在速度和性能上会有优势。那么在网页上我们怎么使用硬解码的能力呢?
通过调研,我们了解到了WebCodecs API,通过这个API下的VideoDecoder, 我们看到了视频解码的相关功能,我们看下如何使用。
可以看到他有一些限制条件,在chrome和edge的支持度是比较好的,在火狐浏览器和Safari的支持性是受限的,这样我们需要做好判断,在兼容性不太好的情况下,我们要适时的的转到软编码。
然后我们看到他在https的安全环境才可以调用,所以局域网调用的时候也会受限。
最后我们看到他只适用于web worker线程下。所以我们需要先建一个work线程,让他可以隔离调用。其实也容易理解,解码是耗时的,要隔离开js的主线程。
接下来就是如何调用。我们通过对文档的梳理, 了解一下他们之间的关系。
通过类图我们了解,我们需要先初始化一个VideoDecoder类用于用于解码调用,然后每个视频数据我们新建一个EncodeVideoChunk类来供VideoDecoder调用,这样我们就可以梳理出我们程序应该怎么编写了。
主线程核心代码
async initWorker() {
try {
const response = await fetch(WORK_DIR); // WORK_DIR 是下面worker线程文件位置
if (response.ok) {
this.worker = new Worker(WORK_DIR);
this.worker.onerror = (event) => {
Log.error('Worker error', event);
}
this.worker.onmessage = (event) => {
const { data } = event;
if (data.type === 'frame') {
this.videoInstance?.renderFrame(data.frame);
}
};
} else {
Log.error('Worker file not found');
}
} catch (error) {
Log.error('Error fetching worker file', error);
}
}
worker线程的主要代码我贴下面
const hevcCodec = 'hev1.1.1.L93.B0';
let decoder;
let isNativeSupport = MediaSource?.isTypeSupported(`video/mp4; codecs="${hevcCodec}"`);
const initDecoder = async h265Frame => {
const frameData = new Uint8Array(h265Frame);
const nalUnitType = (frameData[4] >> 1) & 0x3f;
if (nalUnitType <= 10) return;
decoder = new VideoDecoder({
output: frame => {
postMessage({ type: 'frame', frame });
frame.close();
},
error: e => {
console.error('解码器错误:', e);
}
});
const codecConfig = {
codec: hevcCodec
};
VideoDecoder.isConfigSupported(codecConfig).then(supported => {
if (!supported) {
console.error('不支持的编解码器配置:', codecConfig);
return;
} else {
// console.log('支持的编解码器配置:', codecConfig);
postMessage({ type: 'success' });
decoder.configure(codecConfig);
}
});
};
const decodeAndRenderH265 = async h265Frame => {
if (!decoder) {
await initDecoder(h265Frame);
}
if (!decoder) return;
const chunk = new EncodedVideoChunk({
type: 'key',
timestamp: performance.now(),
duration: 0,
data: h265Frame
});
decoder.decode(chunk);
};
onmessage = async event => {
const { data } = event;
if (data.type === 'decode') {
await decodeAndRenderH265(data.h265Frame);
}
};
代码不多,看起来也是较为容易理解,但是可能也有几处需要理解的地方,我们继续讨论一下
代码解释一
const hevcCodec = 'hev1.1.1.L93.B0';
在这里代表的h265的编码codec字符串,详细说明可以参考官方文档, h265相关配置项,这里根据官方文档解释一下
h265下,首个编码标识可以是hev1和hvc1, hev1一般用于流媒体中,hvc1一般配置在单个文件下.
后面四个逗号分隔的参数分别代表的含义是:
第一个 1(Profile):代表视频使用的配置文件(Profile)。Profile 定义了一组编码工具的子集,规定了视频编码时可以使用哪些编码技术和方法。不同的 Profile 对应不同的应用需求和复杂度,例如 Main Profile、Main 10 Profile 等。
第二个 1(Level):表示视频的级别(Level)。Level 对视频的分辨率、帧率、码率等参数进行了限制,用于衡量视频的复杂度和对解码设备性能的要求。不同的 Level 有不同的上限值,以确保设备能够正常解码视频。
L93:其中 L 代表 Level,93 是具体的 Level 编号。在 HEVC 标准中,Level 编号越高,视频的分辨率、帧率、码率等参数上限就越高,对解码设备的性能要求也越高。
B0:B 通常代表层级(Tier),0 表示该视频使用的是 Main Tier。在 HEVC 中,Tier 分为 Main Tier 和 High Tier,High Tier 支持更高的编码效率和更复杂的编码工具,但对解码设备的性能要求也更高。
这些配置项对应的指标在我们拉取H265原始mediatrack可以通过offer的sdp参数知道我们需要选取什么参数
参考文章: H265 编码 —Profile、Level、Tier
代码解释二
const nalUnitType = (frameData[4] >> 1) & 0x3f;
这段代码的目的是获取当前这个数据是不是正常NAL的数据,下面来解释一下,我们需要先了解一下我们传输的数据,先打印一下我们数据看一下长什么样子
我们将这个8 位无符号整型数组作为字符串打印一下
0,0,0,1,64,1,12,1,255,255,1,64,0,0,3,0,128,0,0,3,0,0,3,0,123,172,9,0,0,0,1,66,1,1,1,64,0,0,3,0,128,0,0,3,0,0,3,0,123,160,2,28,128,30,5,141,174,73,8,217,174,92,205,1,0,0,3,0,1,0,0,3,0,60,63,139,18,128,0,0,0,1,68,1,192,226,74,192,204,144,0,0,0,1,38,1,175,17,128,86,245,60,170,179,16,127,186,217,146,172,108,53,77,140,118,101,138,132,49,160,80,17,225,200,158,57,10,70,34,15,79,10,98,110,163,96,128,156,156,18,198,141,15,129,131,180,68,63,62,225,150,75,79,154,152,231,41,97,27,42,161,182,166,126,149,98,240,249,172,38,3,149,85,67,131,189,49,50,225,248,97,113,159,188,96,28,135,131,122,160,32,75,100,16,31,71,131,27,31,219,240,208,220,70,196,65// 这里将数组字符串化然后截取一段来看一下H265的数据
暂时看不出来这个数据是怎么代表视频数据的,这里我们需要先了解一下背景知识,H265的数据传输过程中两个核心概念。视频编码层(Video Coding Layer, VCL)和网络适配层(Network Abstraction Layer, NAL)。
视频编码层我们可以先不用了解,较为复杂,我也还不懂,暂时可以理解通过对视频源进行处理获取到一段数据,然后交付给视频编码层
视频编码层拿到这些数据需要对数据进行封装,封装依据下方的一些规则去封装。
H.265/HEVC 标准
规范文档:由 ITU - T 的 H.265 标准和 ISO/IEC 的 MPEG - H Part 2 标准共同定义,标准名称为 HEVC(High Efficiency Video Coding)。
NAL 单元格式
起始码:同样使用 3 字节的 0x000001 或 4 字节的 0x00000001 作为起始码。
NAL 单元头部:占用 2 个字节。
NAL 单元负载:包含视频编码的核心数据以及各种参数集,如视频参数集(VPS)、序列参数集(SPS)和图像参数集(PPS)等,这些参数集用于指导解码器正确解码视频。
接下来可能看的有点蒙,因为引进了一些新的概念。可以先略过继续向下看先,如果感兴趣建议先了解一下NAL层的基础概念,了解一下这篇文章有助于理解
通过上方的规则,我们了解到,我们拿到的字符串前面的 0,0,0,1 是起始码,后面跟着的两个字节为NAL单元头部,然后跟着的是NAL单元的负载。遇到下一个起始码作为结束,接下来,我们截取上面的字符串的一段做为一个NAL单元来继续解释
0,0,0,1,64,1,12,1,255,255,1,64,0,0,3,0,128,0,0,3,0,0,3,0,123,172,9 // 接下来又是0,0,0,1的起始码了
/*
* h265 nal头部(2字节)
* 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
* |F| Type | layerID | TID | NAL payload ……
* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
*/
我们知道前面四个数字是起始码了,那么 64, 1就是我们的NAL单元格式了,对应我们上面代码的nalUnitType。
我们这个是10进制,我们将他们转成二进制后,可以得到,01000000 00000001 ,根据我们获取到规范,我们知道,将数据右移一位得到的数据就是 00100000, 然后对0x3f(00111111)进行与操作,得到数据得到后6位数据。00100000, 得十进制32。
为什么要得出这个数呢,H264的的nal单位有多种NALU类型,但是我们在解码之前需要先确定码流的相关信息,只有解码器拿到 VPS,SPS,PPS 后才可以解码 H265 的数据。他们对应的type类型数值都在32-63这个范围区间
软解码工作
相对硬解码,软解码就复杂了一点,这里主要是参考借用了淘宝的H265解码方案,借用FFmpeg的能力编译出来WebAssembly供js侧调用。下面我们看下具体流程应该是如何进行,和对他们细节进行一些解读理解。
下面我们拆解一下他们的每一步的细节。
安装编译Wasm编译环境Emscripten
这里参考官网就可以,这里我贴一下主要的命令
# Get the emsdk repo
git clone https://github.com/emscripten-core/emsdk.git
# Enter that directory
cd emsdk
# Fetch the latest version of the emsdk (not needed the first time you clone)
git pull
# Download and install the latest SDK tools.
./emsdk install latest
# Make the "latest" SDK "active" for the current user. (writes .emscripten file)
./emsdk activate latest
# Activate PATH and other environment variables in the current terminal
source ./emsdk_env.sh
下载FFmpeg代码
git clone https://git.ffmpeg.org/ffmpeg.git
cd ffmpeg
git checkout -b 4.1 origin/release/4.1 // 网上主要使用这个版本,其他版本不确定会不会存在问题
接下来我们需要编译ffmpeg库,因为编译完之后的静态文件要另外使用,我们在ffmpeg仓库的同级目录新建一个h265-decoder-wasm目录,然后我们将后续的逻辑编写在这个目录下,也就是上面那个仓库的出处。
编译FFmpeg静态库
echo "Beginning Build:"
rm -r ./ffmpeg-lite
mkdir -p ./ffmpeg-lite # dist目录
cd ../ffmpeg # src目录,ffmpeg源码
rm -rf ffbuild/.config ffbuild/config.fate ffbuild/config.log ffbuild/config.mak ffbuild/config.sh
make clean
emconfigure ./configure --cc="emcc" --cxx="em++" --ar="emar" --ranlib="emranlib" --prefix=$(pwd)/../h265-decoder-wasm/ffmpeg-lite --enable-cross-compile --target-os=none --arch=x86_64 --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 \
--disable-indevs \
--disable-outdevs \
--disable-filters \
--enable-decoder=hevc \
--enable-parser=hevc \
make
make install
这里的emconfigure命令看起来很复杂,我们拆解一下
这里有很多disable-xxx 标识,其实都出自于ffmpeg的configure文件。我们浏览这个文件可以看到这些命令的出处,不过我们可能需要对ffmpeg有一些了解,ffmpeg这个仓库比较大,我们开发H265解码,仓库有很多我们用不上的功能,我们就可以配置禁用这些模块,从而减少我们编译出来的静态库的大小。
--cc="emcc" --cxx="em++" --ar="emar" --ranlib="emranlib":指定编译和链接工具为 Emscripten 工具,emcc是 C 编译器,em++是 C++ 编译器,emar用于创建静态库,emranlib用来生成静态库索引。
--prefix 指定静态库输出目录
--enable-decoder=hevc --enable-parser=hevc 开始H265(HEVC) 支持
通过上述命令,我们会在我们的仓库下获取一个编译后的到的文件夹ffmpeg-lite
代表这一步的工作已经完成,我们开始下一步。
编写调用静态库代码
编译出来了静态库之后我们还不能直接调用,因为他还是c代码的产物,不能供浏览器直接调用,因为他虽然提供基础的能力方法,但是没有入口文件作为一个统一的接口文件供emscripten编译,这里有些概念需要先了解一下 我们刚刚编译出来的静态库,有两个文件夹我们需要关注一下,一个是include文件夹,里面都是以.h后缀的头文件,他的作用可以类比typescript的类型声明文件 xxx.d.ts。另外一个是lib文件夹,他是以.a后缀的静态库文件,类似于npm包经过混淆打包的一个文件,虽然里面有很多功能,但是经过混淆基本不可见,借助类型声明文件,我们就知道他的功能函数名称。
编写入口文件的过程类比一下,就是我们使用一个npm包,通过npm包对外导出的类型文件。这个入口文件相当于做一个中间层,供视频解码的时候去调用。调用流程也很简单
我们先看一下入口文件怎么写的
#include <libavcodec/avcodec.h>
#include <libavutil/imgutils.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 定义一个函数指针类型,用于回调 YUV 数据
typedef void(*OnBuffer)(unsigned char* data_y, int size, int pts);
// 定义错误码枚举
typedef enum ErrorCode {
kErrorCode_Success = 0,
kErrorCode_FFmpeg_Error = 1
} ErrorCode;
AVCodec *codec = NULL;
AVCodecContext *dec_ctx = NULL;
AVCodecParserContext *parser_ctx = NULL;
AVPacket *pkt = NULL;
AVFrame *frame = NULL;
OnBuffer decoder_callback = NULL;
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);
}
int decode_packet(AVCodecContext* ctx, AVFrame* frame, AVPacket* pkt)
{
int ret = 0;
// 发送packet到解码器
ret = avcodec_send_packet(dec_ctx, pkt);
if (ret < 0) {
return ret;
}
// 从解码器接收frame
while (ret >= 0) {
ret = avcodec_receive_frame(dec_ctx, frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
break;
} else if (ret < 0) {
// handle error
break;
}
// 输出yuv buffer数据
output_yuv_buffer(frame);
}
return ret;
}
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;
}
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);
}
}
}
// 主函数
int main(int argc, char** argv) {
return 0;
}
这里面的概念比较太多,我们另外再开一篇文章解读这一段C的代码,wasm编译入口文件解析不破坏本文的结构,假设建立在你已经理解上方的代码之后,接下来我们就可以通过这个文件来编译出来WebAssembly代码供javascript来进行调用了。
使用Emscripten来进行编译的脚本,
rm -rf dist/libhevcdecoder.wasm dist/libhevcdecoder.js // 如果已经存在编译产物先清空
export TOTAL_MEMORY=67108864 // WASM 是线性内存,内存需要初始化的时候明确分配的,这里以字节为单位,换算为64MB
export EXPORTED_FUNCTIONS="[ \
'_init_decoder', \
'_decode_buffer', \
'_main', \
'_malloc', \
'_free'
]" // 对外部js调用的方法
echo "Running Emscripten..."
emcc decode_hevc.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 TOTAL_MEMORY=${TOTAL_MEMORY} \
-s EXPORTED_FUNCTIONS="${EXPORTED_FUNCTIONS}" \
-s EXTRA_EXPORTED_RUNTIME_METHODS="['addFunction']" \
-s RESERVED_FUNCTION_POINTERS=14 \
-s FORCE_FILESYSTEM=1 \
-o dist/libhevcdecoder.js
echo "Finished Build"
这里调用了emscripten 编译的脚本能力。关于Emscripten的入门可以看看下方的参考文档。如果想关闭混淆可以在上方的编译函数加多一行 -g
Wasm调用逻辑
const global = self;
class DecoderManager {
loaded = false;
decoder = new Decoder();
cachePackets = [];
load() {
// 表明wasm文件的位置
global.Module = {
locateFile: wasm => `./ffmpeg/${wasm}`
};
global.importScripts('./ffmpeg/libhevcdecoder.js');
// 初始化之后,执行一次push,把缓存的packet送到decoder里
global.Module.onRuntimeInitialized = () => {
console.log('wasm加载完成');
this.loaded = true;
this.decoder.init(global.Module);
};
this.decoder.on('decoded-frame', this.handleYUVBuffer);
}
decode(frame) {
this.decoder.decode(frame);
}
// eslint-disable-next-line class-methods-use-this
handleYUVBuffer = frame => {
global.postMessage({
type: 'decoded-frame',
data: frame
});
};
}
这里的逻辑基本就很明确了,上方的 Module和onRuntimeInitialized之类的方法声明基本都是Emscripten对外编译的时候自动添加的一些变量和函数钩子,在handleYUVBuffer方法下,我们就可以拿到前面函数对外暴漏的YUV420P格式存储数据,我们就可以进行下一步的渲染工作了
渲染
渲染就是将我们的YUV数据转换成RGB数据,然后渲染到屏幕上,这里会借用到WebGl的能力,用于内容篇幅也较长,我们放置在下一篇内容里面进行进一步了解:WebRTC系列 WebGL 绘制YUV 画面