实现一个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端口。
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中收到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层
解码完毕后,需要将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);