WebRTC在H5支持H265(HEVC硬解码&软解码)

825 阅读9分钟

前言

下文是面向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的主线程。

接下来就是如何调用。我们通过对文档的梳理, 了解一下他们之间的关系。

image.png

通过类图我们了解,我们需要先初始化一个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参数知道我们需要选取什么参数

image.png

参考文章: 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侧调用。下面我们看下具体流程应该是如何进行,和对他们细节进行一些解读理解。

image.png

下面我们拆解一下他们的每一步的细节。

安装编译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源码分析,目录和编译

通过上述命令,我们会在我们的仓库下获取一个编译后的到的文件夹ffmpeg-lite

代表这一步的工作已经完成,我们开始下一步。

编写调用静态库代码

编译出来了静态库之后我们还不能直接调用,因为他还是c代码的产物,不能供浏览器直接调用,因为他虽然提供基础的能力方法,但是没有入口文件作为一个统一的接口文件供emscripten编译,这里有些概念需要先了解一下 我们刚刚编译出来的静态库,有两个文件夹我们需要关注一下,一个是include文件夹,里面都是以.h后缀的头文件,他的作用可以类比typescript的类型声明文件 xxx.d.ts。另外一个是lib文件夹,他是以.a后缀的静态库文件,类似于npm包经过混淆打包的一个文件,虽然里面有很多功能,但是经过混淆基本不可见,借助类型声明文件,我们就知道他的功能函数名称。

编写入口文件的过程类比一下,就是我们使用一个npm包,通过npm包对外导出的类型文件。这个入口文件相当于做一个中间层,供视频解码的时候去调用。调用流程也很简单

image.png 我们先看一下入口文件怎么写的

    #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 画面