WebRTC-H265软编码WASM 编码入口文件解析

242 阅读11分钟

前言

本文是针对前文对WebRTC-H265的软解码入口文件补充解释,建议了解前面文章的内容再阅读本篇内容

WebRTC在Web侧支持H265(硬解码&软解码)

我们看到的ffmpeg静态库调用的编译入口文件,我们考虑篇幅的问题,将其放置在这里去进行去了解一下这个入口文件的构成

首先我们将整体代码放置在此处先

#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);

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;
}

按照结构去划分,这里面的代码一共分为以下模块

  1. 头部文件的引用

  2. 全局变量的声明

  3. 核心的解码函数

  4. 入口函数

我们分别来进行解析一下

头部文件的引用

#include <libavcodec/avcodec.h>
#include <libavutil/imgutils.h>
#include <stdio.h> // 输入输出功能
#include <stdlib.h> // 提供通用的实用函数,例如动态内存分配(malloc、free)、程序退出(exit)、随机数生成(rand)等。
#include <string.h> // 提供字符串处理函数,例如字符串复制(strcpy)、比较(strcmp)、长度计算(strlen)等。

这里面一共引入五个内容,我们关注一下前面两个头文件,他是ffmpeg提供的两个我们用于解码的文件

libavcodec/avcodec.h

这个文件是ffmpeg的核心头文件之一,里面包含了我们由前文编译得到的编解码的API,用于处理音视频的编解码操作。(ffmpeg里面经常可以看到很多av开头的变量名和文件名,这里的av代表的audio和video的合并缩写)

libavutil/imgutils.h

这个文件的作用是ffmpeg里面的一些公共方法的使用头文件

全局变量的声明

// 定义一个函数指针类型,用于回调 YUV 数据
typedef void(*OnBuffer)(unsigned char* data_y, int size, int pts);

AVCodec *codec = NULL; // 编解码器的xin'xi
AVCodecContext *dec_ctx = NULL; // 编解码器的上下文
AVCodecParserContext *parser_ctx = NULL; // 解析器的上下文
AVPacket *pkt = NULL; // ffmpeg的包概念,代表一个比特流数据包
AVFrame *frame = NULL; // 解码后得到的原始数据帧
OnBuffer decoder_callback = NULL;

要了解一下上面的全局变量的作用,我们得了解一下ffmpeg的解码流程是如何的进行的

image.png

我们从DataChannel消息,每一个数据包的都是有NALU头部,NALU的负载(视频本身的内容)组成的原始码流,通过上文我们知道他是一个二进制的数据流。我们需要使用AVCodecParserContext去解析成AVpacket包供AVCodecContext去解码成原始帧AVFrame。

核心解码函数

各个函数调用的逻辑

image.png

init_decoder

void init_decoder(OnBuffer callback) {
    // 找到hevc解码器, hevc就是h265英文代指
    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;
}

这里的av开头的方法出自于FFmpeg库。详细的解释看建议看官方文档和博客了解。

这个方法在worker线程的软编码器代码初始化就会被调用。通过外部声明一个函数后作为参数callback传入。

这里声明callback变量作为入参传入内部代码函数指针,这里的addFunction方法是由Emscripten提供的,详细可以看这里的文档

通过addFunction这个方法,我们我们声明的handleYUVData作为一个函数指针传入到wasm作为一个函数指针调用。viii 这里代表的意思,第一个参数代表void,代表函数无返回,i代表的32位的int类型。

这里的调用还可以看到前面多了一个下横线,这个是Emscripten在导出WASM的时候的默认行为,用于区分导出的函数和其他符号。

decode_buffer

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);
        }
    }
}

这里调用了av_parser_parse2方法,我们看一下官方文档是怎么定义的

一共接收9个参数,我们分别解析一下

sAVCodecParserContext 的指针,前面我们已经初始化了
avctxAVCodecContext的指针,前面我们已经初始化了
poutbuf这里要传入一个无符号8位的整数指针,当解码后,解码数据将会放在这个指针对应的内存位置
poutbuf_size这里传入一个整形的指针,当解码之后会将解码之后的数据大小放在该指针位置
buf无符号8位的整数指针,需要解码的数据,该数据就是从外部dc传入的数据
buf_size解码数据对应的大小
ptsinput presentation timestamp.展示时间戳
dtsinput decoding timestamp.解码时间戳
posinput byte position in stream.字节位置

调用这个函数之后,我们定义的AVPackage的变量pkt就会被赋值,得到这个值之后我们就可以到下一步去进一步解码了

decode_packet

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;
}

avcodec_send_packet 这个方法我们看一下官方文档

这个方法接受两个指针变量。方法的作用是提供原始的数据包供给一个解码器。方法返回一个整形,如果整形为0的时候代表成功,ffmpege将错误码都设置了成负数。如果不为负数,我们将可以从解码器去获取AVFrame。

通过调用avcodec_receive_frame,我们可以实现该目的

两个入参,一个是解码器的指针,一个是AVFrame的指针,如果返回的状态吗不为负数,我们的frame形参将会赋值一个新的原始帧数据。

拿到AVFrame之后,我们就可以将其解码为YUV数据了

output_yuv_buffer

接着我们看一下YUV的数据怎么通过AVFrame来进行获取。首先我们可能需要先了解一下YUV的基础概念。可以先看看参考文档的YUV的几种基本格式。我们这边的使用的YUV的格式是较为普遍的YUV420格式。

在这个格式下,YUV的采样是根据,Y分量逐行采样,U和V分量采用隔行二分之一的采样。这样的采样相对于全采样可以减少一半的数据大小,这是我们为什么选择将他作为我们传输格式的原因,可以减少不少带宽。

我们在前面初始化的确定了YUV的解码格式是 AV_PIX_FMT_YUV420P 这个格式的最后字母P代表了YUV的存储格式为平面格式,在这个格式下,YUV三个分量会采用三个平面planner来分别存储在AVFrame内部的data字段下。例如,AVFrame->data[0] 存储 Y 分量,AVFrame->data[1] 存储 U 分量,AVFrame->data[2] 存储 V 分量。详细可以参考

我们的目标是将这个存储格式的420P的AVFrame变量,还原成正常内存需要的数据。参考下图

接下来我们看一下我们的函数是怎么写的

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);
}

这个函数的接受一个类型为AVFrame的入参,并在前面初始化了变量,width和height代表帧的尺寸,frame_size代表的是转成buffer需要的内存大小。yuv_buffer是我们的输出产物变量,用来存储连续的YUV数据

这里的三个for循环就是用于给YUV各自做赋值使用的,linesize代表的是每一行分量对应的字节数。memcpy是C的一个标准函数,作用是拷贝内存区域至另外一个内存区域。

void *memcpy(void *dest, const void *src, size_t n);

参数说明

  1. dest: 目标内存地址,数据将被复制到这里。

  2. src: 源内存地址,数据将从这里复制。

  3. n: 要复制的字节数。

这里取第一个for循环来作为说明。

for(i = 0; i < height; i++) { // 取高度的值目的是为了隔行来进行处理
        memcpy(yuv_buffer + width*i,
                frame->data[0]+frame->linesize[0]*i,
                width);
    }

目标内存地址取值是 yuv_buffer + width * i。 这里yuv_buffer是内存的起始地址。i的第一个循环是0.那么这里的值就会等于 yuv_buffer。我们将首地址来作为Y分量的拷贝地址,源内存地址取的是 frame->data[0] + frame -> linesize[0]*i。第一个循环下,第一个地址取的也是data0。第三个参数取值取的是width,也就是单一行像素的大小了。一轮循环之后,我们就得到了上图中的Y部分的图片了

接下来的UV部分也是同理,就不赘述。

参考文档

一文读懂 YUV 的采样与格式

一文掌握 YUV 图像的基本处理

FFmpeg数据结构AVFrame