wasm 视频解码渲染实现

时间:2022-12-05 10:53:32

实现一个wasm视频解码渲染的小demo,网页端集成emcc编译的ffmpeg库,实现视频解码,使用WebGL实现视频渲染。demo中包含了一个基于mongoose的微型Web服务器,用于网页的Web服务和视频流传输,基本无需额外搭建环境以及编译第三方库,可以简单地移植到嵌入式系统中用于网页视频播放视频。学习过程中主要参考了大神代码和文章

编译WebAssembly版本的FFmpeg(ffmpeg.wasm):(2)使用Emscripten编译 - 腾讯云开发者社区-腾讯云

demo地址

wasm_websocket_player: wasm 解码渲染demo

1.编译

1.1 ffmpeg emcc版本编译

首先需要获取emcc用于编译,Mac下可以直接通过brew install来获取。下一步就是通过emcc,将ffmpeg编译对应的静态库。注意这里需要将ffmpeg中平台相关以及汇编相关的选项禁掉,毕竟这里最终都是在js虚拟机中执行,硬件加速相关的操作都需要去掉。下面是demo中编译ffmpeg使用的命令,源文件在demo的third_party文件下。

mkdir ffmpeg-emcc
cd FFmpeg_new
#make clean
emconfigure ./configure --cc="emcc" --cxx="em++" --ar="emar" \
--ranlib=emranlib --prefix=../ffmpeg-emcc/ \
--enable-cross-compile --target-os=none \
--arch=x86_32 --cpu=generic --enable-gpl \
--disable-avdevice  \
--disable-postproc --disable-avfilter \
--disable-programs \
--disable-everything --enable-avformat  \
 --enable-decoder=hevc --enable-decoder=h264 --enable-decoder=h264_qsv \
 --enable-decoder=hevc_qsv \
 --enable-decoder=aac \
 --disable-ffplay --disable-ffprobe  --disable-asm \
 --disable-doc --disable-devices --disable-network \
 --disable-hwaccels \
 --disable-debug \
 
 --enable-protocol=file --disable-indevs --disable-outdevs \
 --enable-parser=hevc --enable-parser=h264

emmake make -j4
emmake make install

1.2 客户端源代码编译

ffmpeg静态链接库生成后,下一步就可以编译demo中客户端相关的源码,包括我们自己调用ffmpeg库的代码,c层与js层交互的代码,以及ffmpeg静态链接库,最终生成一个js文件和一个.wasm库,在网页中我们通过调用生成的js文件进行解码。下面是编译命令,源文件在demo工程的client文件下的build_with_emcc.sh。

export TOTAL_MEMORY=67108864

CURR_DIR=$(pwd)
export FFMPEG_PATH=$CURR_DIR/../third_party/ffmpeg-emcc

emcc --bind ../common/video_decoder.cc ../common/h264_reader.cc ../common/frame_queue.cc main.cc\
    -std=c++11 \
    -s USE_PTHREADS=1\
    -g \
    -I "${FFMPEG_PATH}/include" \
    -L ${FFMPEG_PATH}/lib \
    -lavutil -lavformat -lavcodec \
    -s WASM=1 -Wall \
    -s EXPORTED_FUNCTIONS="['_malloc','_free']" \
    -s ASSERTIONS=0 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s TOTAL_MEMORY=167772160 \
    -o ${PWD}/player.js

最终会生成player.js以及player.wasm文件。

1.3 demo中server的编译与demo运行

demo中提供了一个微型Web server,提供http服务以及websocket数据传输。考虑到demo主要用于嵌入式平台,这里选择了mongoose作为Web服务器,只需要在源代码中引入一个.c文件和一个.h文件即可使用,无需复杂的编译和依赖库。demo中使用了一个本地h264文件,server收到客户端请求后会读取这个本地文件,通过avformat读取每帧h264,实际使用中可以将这块的代码更换为当前设备的采集和编码。目前调试是在Mac的arm64版本上编译,直接运行server目录下cmake即可。

可以直接在server目录下运行run.sh,即可完成客户端编译,服务端编译以及相关文件的拷贝。目前写死使用8000端口。

wasm 视频解码渲染实现

2.解码流程实现

2.1 js传递视频流给wasm解码

wasm内存分配与释放

这里首先介绍一下js与底层wasm的交互方式。一般视频流数据数量较小,可以直接为其分配内存空间,这里我们直接通过在js层调用_malloc和_free进行分配和释放内存,这些内存可以被wasm代码所使用。这里首先分配wasm可以使用的内存,下一步就是将js的Uint8Array数据拷贝给这块内存,这样wasm中的代码就可以操作这块内存了。

js传递数据给wasm

这里可以在C++层通过EMSCRIPTEN_BINDINGS对C++函数进行封装,基本数据类型可以使用普通的C/C++数据类型,传入js所分配的内存,在C/C++层直接使用uintptr_t类型即可。下面使用我们deocder类来进行说明。
decoder类的C++类,emscripten::val lambda类型可以将一个js函数传入wasm作为回调函数。

class StreamDecoderWrapper{
public:

    StreamDecoderWrapper(){}
    ~StreamDecoderWrapper(){}

    void OpenAvcDecoder(emscripten::val lambda){
        ... ...
        decoder.OpenWithCodecID(AV_CODEC_ID_H264);
        decoder.RegisterDecodeCallback([lambda, this](AVFrame *frame)->int{

            ... ...
            auto frame_wrapper = std::make_shared<VideoFrameWrapper>()->Alloc(AVMEDIA_TYPE_AUDIO, out_frame);
            ... ...
            lambda(frame_wrapper);
            return 0;
        });
    }

    void DecodeVideoPacket(uintptr_t buf_p, int size){

        uint8_t *data = reinterpret_cast<uint8_t *>(buf_p);
        ... ...
    }

    void CloseDecoder(){
        ... ...
    }

private:
    ... ...
};

注册StreamDecoderWrapper,让js代码可以识别这个类。这个操作类似jni的动态注册,将字符串与C++类名和方法名对应,这样在js层中可以直接使用这个字符串创建对象并调用方法。

#include <emscripten/bind.h>
#ifndef NDEBUG
#include <sanitizer/lsan_interface.h>
#endif

#include "stream_decoder_wrapper.h"

using namespace emscripten;

EMSCRIPTEN_BINDINGS(module){
    ... ...

    class_<StreamDecoderWrapper>("StreamDecoderWrapper")
    .constructor<>()
    .function("openAvcDecoder", &StreamDecoderWrapper::OpenAvcDecoder)
    .function("decodeVideoPacket", &StreamDecoderWrapper::DecodeVideoPacket)
    .function("closeDecoder",  &StreamDecoderWrapper::CloseDecoder);

    ... ... 
}

js层调用wasm类StreamDecoderWrapper,可以完全当作是一个js类,通过new创建对象并调用方法。

class StreamDecoderWrapperJS{

    #stream_decoder_inner = null;

    StreamDecoderWrapperJS(){
    }

    openAvcDecoder(frame_callback){
        this.#stream_decoder_inner = new Module.StreamDecoderWrapper()
        this.#stream_decoder_inner.openAvcDecoder((videoFrameWrapperJS)=>{
            ... ...
            frame_callback(videoFrameWrapperJS)

            videoFrameWrapperJS.delete();
        })
    }

    decodeVideoPacket(data, size, headsize){

        ... ...
        let data_array = new Uint8Array(data)
        let data_slice = data_array.slice(headsize, headsize+size)
        let data_len = size;
        let buf = _malloc(data_len);

        HEAPU8.set(data_slice, buf);

        this.#stream_decoder_inner.decodeVideoPacket(buf, data_len)

        _free(buf);

        ... ...
    }

    closeDecoder(){
        this.#stream_decoder_inner.closeDecoder();
    }
}

2.2 解码

wasm 视频解码渲染实现

在wasm中收到js传来的buffer数据后,就可以进行下一步解码。代码如下,可以看到这里都是普通C/C++的数据类型,js层传来的buf_p在这里直接就是一个uint8_t类型的buffer,拿到正确数据交给ffmpeg进行解码即可。

void DecodeVideoPacket(uintptr_t buf_p, int size){

        uint8_t *data = reinterpret_cast<uint8_t *>(buf_p);
        if(data && (size != 0)){
            ... ...
            decoder.Decode(data, size);
            ... ...
        }
}

ffmpeg解码代码这里就不再赘述,还不太了解的朋友可以参考ffmpeg中doc下的例子。这里需要明确,AVPacket用于封装视频流buffer,AVFrame用于封装解码后的YUV数据,AVFrame中的数据可以通过 av_frame_move_ref 方法移动其内部存放的buffer,av_frame_unref给buffer减引用,引用为0就销毁buffer。后续在js层使用完毕后释放对象时,我们会使用这些方法,否则会造成浏览器内存泄露。

2.3 wasm数据传递给js层

wasm 视频解码渲染实现

解码完毕后,需要将YUV数据传递回js层,用于渲染。同样,这里也是通过注册C++类,映射一个对应的js类,在js层操作这个类,不同的是上一个我们创建的解码器会存在较长时间,而这里创建的视频帧类在使用完毕后需要立刻释放。

视频帧frame的C++类。其中wasm中的内存并不需要拷贝,可以直接通过emscripten::typed_memory_view 映射,在js层直接使用映射得到的内存句柄即可。这里把YUV的内存都进行了映射,同时还能返回视频帧的宽高和stride等信息。

#ifndef _VIDEO_FRAME_WRAPPER_H_
#define _VIDEO_FRAME_WRAPPER_H_

#ifdef __cplusplus
extern "C" {
#endif

#include <libavutil/frame.h>
#include <libavutil/imgutils.h>

#ifdef __cplusplus
}
#endif

#include <memory>
#include <iostream>
#include <emscripten/val.h>

class VideoFrameWrapper : public std::enable_shared_from_this<VideoFrameWrapper>{

public:
    VideoFrameWrapper(){}
    ~VideoFrameWrapper(){
        Free();
    }

    int type() const { return type_; }
    uint8_t *data() const { return frame_->data[0]; }
    int linesizeY() const { return frame_->linesize[0]; }
    int linesizeU() const { return frame_->linesize[1]; }
    int linesizeV() const { return frame_->linesize[2]; }
    int width() const { return frame_->width; }
    int height() const { return frame_->height; }
    int format() const { return frame_->format; }

    double pts() const { return frame_->pts; }

    int data_ptr() const { return (int)(frame_->data[0]); }  // NOLINT
    int size() const {
        return av_image_get_buffer_size(
            AV_PIX_FMT_YUV420P, frame_->width, frame_->height, 1);
    }
    emscripten::val GetBytes() {
        return emscripten::val(
            emscripten::typed_memory_view(size(), frame_->data[0]));
    }
    emscripten::val GetBytesY() {
        return emscripten::val(
            emscripten::typed_memory_view(size(), frame_->data[0]));
    }
    emscripten::val GetBytesU() {
        return emscripten::val(
            emscripten::typed_memory_view(size(), frame_->data[1]));
    }
    emscripten::val GetBytesV() {
        return emscripten::val(
            emscripten::typed_memory_view(size(), frame_->data[2]));
    }

    std::shared_ptr<VideoFrameWrapper> Alloc(AVMediaType type, AVFrame *frame) {
        type_ = type;
        frame_ = frame;
        return shared_from_this();
    }

    void Free() {
        type_ = AVMEDIA_TYPE_UNKNOWN;
        if (frame_ != nullptr) {
          av_frame_unref(frame_);
          av_frame_free(&frame_);
          frame_ = nullptr;
          std::cout << "Frame::Free 1 this="<< (std::hex) <<this <<std::endl;
        }
    }

private:
    int type_;
    AVFrame *frame_;
};

#endif

VideoFrameWrapper 传递给js层。这里首先创建一个AVFrame,将解码后的内存转给这个AVFrame,之后创建VideoFrameWrapper,将其作为一个shared_ptr返回给js层。可见wasm可以将shared_ptr传递给js,那么js中也需要对shared_ptr进行管理。

void OpenAvcDecoder(emscripten::val lambda){
        std::cout<<"StreamDecoderWrapper::OpenAvcDecoder create"<<std::endl;

        decoder.OpenWithCodecID(AV_CODEC_ID_H264);
        decoder.RegisterDecodeCallback([lambda, this](AVFrame *frame)->int{

            AVFrame *out_frame = av_frame_alloc();
            av_frame_move_ref(out_frame, frame);
            auto frame_wrapper = std::make_shared<VideoFrameWrapper>()->Alloc(AVMEDIA_TYPE_AUDIO, out_frame);
            
            lambda(frame_wrapper);
            return 0;
        });
    }

VideoFrameWrapper注册js对象。注意,注册的时候要加一个smart_ptr,这个类在js层也会对对象进行引用操作。同时这里还注册了可以直接访问的属性。

#include <emscripten/bind.h>
#ifndef NDEBUG
#include <sanitizer/lsan_interface.h>
#endif

#include "file_decoder_wrapper.h"
#include "stream_decoder_wrapper.h"
#include "video_frame_wrapper.h"

using namespace emscripten;

EMSCRIPTEN_BINDINGS(module){
    ... ...    

    class_<VideoFrameWrapper>("VideoFrameWrapper")
    .smart_ptr<std::shared_ptr<VideoFrameWrapper>>("shared_ptr<VideoFrameWrapper>")
    .property("type", &VideoFrameWrapper::type)
    .property("data", &VideoFrameWrapper::data_ptr)
    .property("linesizeY", &VideoFrameWrapper::linesizeY)
    .property("linesizeU", &VideoFrameWrapper::linesizeU)
    .property("linesizeV", &VideoFrameWrapper::linesizeV)
    .property("width", &VideoFrameWrapper::width)
    .property("height", &VideoFrameWrapper::height)
    .property("format", &VideoFrameWrapper::format)
    .property("pts", &VideoFrameWrapper::pts)
    .property("size", &VideoFrameWrapper::size)
    .function("getBytes", &VideoFrameWrapper::GetBytes)
    .function("getBytesY", &VideoFrameWrapper::GetBytesY)
    .function("getBytesU", &VideoFrameWrapper::GetBytesU)
    .function("getBytesV", &VideoFrameWrapper::GetBytesV);
}

js层调用。这里js层可以读取到回调对象的属性,还可以将其作为一个js对象传递,最终这个对象调用delete进行释放。

 openAvcDecoder(frame_callback){
        this.#stream_decoder_inner = new Module.StreamDecoderWrapper()
        this.#stream_decoder_inner.openAvcDecoder((videoFrameWrapperJS)=>{
            let w = videoFrameWrapperJS.width;
            let h = videoFrameWrapperJS.height;

            frame_callback(videoFrameWrapperJS)

            videoFrameWrapperJS.delete();
        })
    }

3.WebGL渲染

js层得到YUV的内存句柄就可以使用WebGL进行渲染。浏览器端WebGL可以直接将canvas作为画布,不需要EGL之类的复杂操作。外部获取canvas标签后,直接用其获取context,后续OpenGL操作在这个context上进行即可。

class WebGLPlayer {
  constructor(canvas) {
    this.canvas = canvas;
    this.gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
    ... ...
  }
}

shader编译,这里和一般OpenGL的shader操作一样,编译顶点和片元shader,获取顶点坐标和纹理坐标索引,获取YUV三个纹理的索引。

#init() {
    if (!this.gl) {
      console.log("[ERROR] WebGL not supported");
      return;
    }

    const gl = this.gl;
    gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);

    const program = gl.createProgram();

    const vertexShaderSource = [
      "attribute highp vec3 aPos;",
      "attribute vec2 aTexCoord;",
      "varying highp vec2 vTexCoord;",
      "void main(void) {",
      "  gl_Position = vec4(aPos, 1.0);",
      "  vTexCoord = aTexCoord;",
      "}",
    ].join("\n");
    const vertexShader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vertexShader, vertexShaderSource);
    gl.compileShader(vertexShader);
    {
      const msg = gl.getShaderInfoLog(vertexShader);
      if (msg) {
        console.log("[ERROR] Vertex shader compile failed");
        console.log(msg);
      }
    }

    const fragmentShaderSource = [
      "precision highp float;",
      "varying lowp vec2 vTexCoord;",
      "uniform sampler2D yTex;",
      "uniform sampler2D uTex;",
      "uniform sampler2D vTex;",
      "const mat4 YUV2RGB = mat4(",
      "  1.1643828125,             0, 1.59602734375, -.87078515625,",
      "  1.1643828125, -.39176171875,    -.81296875,     .52959375,",
      "  1.1643828125,   2.017234375,             0,  -1.081390625,",
      "             0,             0,             0,             1",
      ");",
      "void main(void) {",
      "  // gl_FragColor = vec4(vTexCoord.x, vTexCoord.y, 0., 1.0);",
      "  gl_FragColor = vec4(",
      "    texture2D(yTex, vTexCoord).x,",
      "    texture2D(uTex, vTexCoord).x,",
      "    texture2D(vTex, vTexCoord).x,",
      "    1",
      "  ) * YUV2RGB;",
      "}",
    ].join("\n");
    const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fragmentShader, fragmentShaderSource);
    gl.compileShader(fragmentShader);
    {
      const msg = gl.getShaderInfoLog(fragmentShader);
      if (msg) {
        console.log("[ERROR] Fragment shader compile failed");
        console.log(msg);
      }
    }

    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program);
    gl.useProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      console.log("[ERROR] Shader link failed");
    }

    const vertices = new Float32Array([
      // positions      // texture coords
      -1.0, -1.0, 0.0,  0.0, 1.0,  // bottom left
       1.0, -1.0, 0.0,  1.0, 1.0,  // bottom right
      -1.0,  1.0, 0.0,  0.0, 0.0,  // top left
       1.0,  1.0, 0.0,  1.0, 0.0,  // top right
    ])
    const verticesBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, verticesBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

    const vertexPositionAttribute = gl.getAttribLocation(program, "aPos");
    gl.enableVertexAttribArray(vertexPositionAttribute);
    gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT, false, 20, 0);

    const textureCoordAttribute = gl.getAttribLocation(program, "aTexCoord");
    gl.enableVertexAttribArray(textureCoordAttribute);
    gl.vertexAttribPointer(textureCoordAttribute, 2, gl.FLOAT, false, 20, 12);

    gl.y = new Texture(gl);
    gl.u = new Texture(gl);
    gl.v = new Texture(gl);
    gl.y.bind(0, program, "yTex");
    gl.u.bind(1, program, "uTex");
    gl.v.bind(2, program, "vTex");
  }

在得到我们上一部抛出的封装了解码数据的VideoFrameWrapper后就可以进行渲染了。这里注意ffmpeg解码后的YUV数据不一定是连续的,一定分别拿出AVFrame的每个分量,分别映射出来,否则可能会导致花屏。最终通过gl.y.fill  gl.u.fill  gl.v.fill 分别给yuv对应纹理上传buffer。这样就完成了渲染操作。

  render(frame) {
    if (!this.gl) {
      console.log("[ERROR] Render failed due to WebGL not supported");
      return;
    }

    const gl = this.gl;
    
    let port_width = gl.canvas.width;
    let port_height = gl.canvas.height;
    
    gl.viewport(0, 0, port_width, port_height);

    gl.clearColor(0.0, 0.0, 0.0, 0.0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    const width = frame.width;
    const linesize = frame.linesize;
    const height = frame.height;
    const bytes = frame.bytes;

    const byteYLinesize = frame.linesizeY;
    const byteULinesize = frame.linesizeU;
    const byteVLinesize = frame.linesizeV;

    console.log('render width='+width+' linesizeY='+byteYLinesize)

    const len_y = byteYLinesize * height;
    const len_u = byteULinesize * height >> 1;
    const len_v = byteVLinesize * height >> 1;

    const byteY = frame.getBytesY()
    const byteU = frame.getBytesU()
    const byteV = frame.getBytesV()

    gl.y.fill(byteYLinesize, height, byteY.subarray(0, len_y));
    gl.u.fill(byteULinesize, height >> 1, byteU.subarray(0, len_u));
    gl.v.fill(byteVLinesize, height >> 1, byteV.subarray(0, len_v));

    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

    //gl.finish();
    //gl.commit();
  }

渲染播放丢帧问题

在实际操作中发现播放过程中丢帧严重,经过排查是在解码完成后直接抛出帧,由于解码时间不均匀导致有些帧播放后很快又被新帧覆盖,导致播放卡顿。目前在c层解码完毕后增加了一个delay操作,按照解码时间和帧率进行延迟等待,用于平滑渲染。此处考虑是否可以引入一个线程,或者是否有其比较好的解决方式。目前控制播放代码如下

decoder.RegisterDecodeCallback([lambda, this](AVFrame *frame)->int{

            std::cout<<"OpenAvcDecoder debug2"<<std::endl;

            AVFrame *out_frame = av_frame_alloc();
            av_frame_move_ref(out_frame, frame);
            auto frame_wrapper = std::make_shared<VideoFrameWrapper>()->Alloc(AVMEDIA_TYPE_AUDIO, out_frame);
            long delay_time = -1;
            long curr_ts = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
            if(last_out_ts != 0){
                long curr_gap = curr_ts - last_out_ts;
                if(curr_gap > 0 && curr_gap < gap){
                    delay_time = gap - curr_gap;
                } 
            }
            last_out_ts = curr_ts;
            if(delay_time > 0){
                usleep(delay_time * 1000);
                std::cout<<"OpenAvcDecoder delay_time="<<delay_time<<std::endl;
            }
            lambda(frame_wrapper);
            return 0;
        });

4.Server端交互

server端与js端通过Websocket进行数据交互,目前提供了一个简单的协议头,用于请求视频和停止视频

//json data type == 1
//video data type == 2
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 
//+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//|      'A'      |       'A'     |v=1|     type    |     rec       |
//+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//|                         payload length                          |
//+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

server端提供了一个H264FileVideoCapture类用于模拟视频采集和编码,如果需要使用自己的采集编码可以重新实现一个H264FileVideoCapture和MediaStreamer。

4.1 mongoose支持wasm多线程

wasm开启多线程,需要浏览器开启Cross-origin保护,否则直接报错。

这里需要mongoose在收到网页请求的时候,在响应头中增加设置,代码实现如下

      struct mg_http_serve_opts opts = {.root_dir = s_web_root};
      //wasm 多线程需要增加响应头
      opts.extra_headers = "Cross-Origin-Embedder-Policy:require-corp\r\nCross-Origin-Opener-Policy:same-origin\r\n";
      mg_http_serve_dir(c, (struct mg_http_message *)ev_data, &opts);