# 探索网页视频播放

# 原文说明

本文相关的代码使用 WASM、FFmpeg、WebGL、Web Audio 等组件实现了一个简易的支持 H265 的 Web 播放器,作为探索、验证,just for fun。

# 如何使用

# 安装 Emscripten

cd /
mkdir env
cd /env
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh
echo 'source "/env/emsdk/emsdk_env.sh"' >> $HOME/.bash_profile

# 下载 FFmpeg

cd /
mkdir project
cd /project
mkdir wasmvideo
cd wasmvdideo
git clone https://github.com/FFmpeg/FFmpeg.git --depth=1 -b release/3.3
mv FFmpeg ffmpeg

# 下载项目代码

git clone https://github.com/sonysuqin/WasmVideoPlayer.git --depth=1

# 开始编译

cd WasmVideoPlayer
./build_decoder.sh

# 警告 1

emcc: warning: EXTRA_EXPORTED_RUNTIME_METHODS is deprecated, please use EXPORTED_RUNTIME_METHODS instead [-Wdeprecated]

修改如下

rm -rf libffmpeg.wasm libffmpeg.js
# 设置总内存为64MB
export TOTAL_MEMORY=67108864
# 设置导入方法
export EXPORTED_FUNCTIONS="[ \
    '_initDecoder', \
    '_uninitDecoder', \
    '_openDecoder', \
    '_closeDecoder', \
    '_sendData', \
    '_decodeOnePacket', \
    '_seekTo', \
    '_main',
    '_malloc',
    '_free'
]"

echo "Running Emscripten..."
emcc decoder.c dist/lib/libavformat.a dist/lib/libavcodec.a dist/lib/libavutil.a dist/lib/libswscale.a \
    -O3 \
    -I "dist/include" \
    -s WASM=1 \
    -s TOTAL_MEMORY=${TOTAL_MEMORY} \
    -s EXPORTED_FUNCTIONS="${EXPORTED_FUNCTIONS}" \
    -s EXPORTED_RUNTIME_METHODS="['addFunction']" \
    -s RESERVED_FUNCTION_POINTERS=14 \
    -s FORCE_FILESYSTEM=1 \
    -o libffmpeg.js
echo "Finished Build"

# 报错 1

TypeError: Failed to execute 'compile' on 'WebAssembly': Incorrect response MIME type. Expected 'application/wasm'.

修改 nginx 配置

http {
    types {
        application/wasm wasm;
    }
}

查看视频信息

mv video/h265_high.mp4 video/h265_high.bak
ffmpeg -i video/h265_high.mp4
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'video/h265_high.bak':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2mp41
  Duration: 00:07:47.98, start: 0.000000, bitrate: 437 kb/s
    Stream #0:0(und): Video: hevc (Main) (hvc1 / 0x31637668), yuv420p(tv, progressive), 852x480 [SAR 640:639 DAR 16:9], 400 kb/s, 25 fps, 25 tbr, 12800 tbn, 25 tbc (default)
    Metadata:
      handler_name    : VideoHandler
    Stream #0:1(und): Audio: aac (HE-AAC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 32 kb/s (default)
    Metadata:
      handler_name    : SoundHandler

ffmpeg -i video/h265_high.bak -c copy -t 60 video/h265_high.mp4
ffmpeg -i video/h265_high.mp4
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'video/h265_high.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2mp41
    encoder         : Lavf58.29.100
  Duration: 00:01:00.08, start: 0.000000, bitrate: 368 kb/s
    Stream #0:0(und): Video: hevc (Main) (hvc1 / 0x31637668), yuv420p(tv, progressive), 852x480 [SAR 640:639 DAR 16:9], 331 kb/s, 25 fps, 25 tbr, 12800 tbn, 25 tbc (default)
    Metadata:
      handler_name    : VideoHandler
    Stream #0:1(und): Audio: aac (HE-AAC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 32 kb/s (default)
    Metadata:
      handler_name    : SoundHandler

# 播放器原理学习

# 依赖

Wasm FFmpeg

主要使用 FFmpeg 来做解封装(demux)和解码(decoder),由于使用了 FFmpeg(3.3),理论上可以播放绝大多数格式的视频,这里只针对 H265 编码、MP4 封装,在编译时可以只按需编译最少的模块,从而得到比较小的库。

使用 Emscripten 编译 FFmpeg 主要参考下面这个网页,做了一些修改: https://blog.csdn.net/Jacob_job/article/details/79434207

WebGL

H5 使用 Canvas 来绘图,但是默认的 2d 模式只能绘制 RGB 格式,使用 FFmpeg 解码出来的视频数据是 YUV 格式,想要渲染出来需要进行颜色空间转换,可以使用 FFmpeg 的 libswscale 模块进行转换,为了提升性能,这里使用了 WebGL 来硬件加速,主要参考了这个项目,做了一些修改: https://github.com/p4prasoon/YUV-Webgl-Video-Player

Web Audio

FFmpeg 解码出来的音频数据是 PCM 格式,可以使用 H5 的 Web Audio Api 来播放,主要参考了这个项目,做了一些修改: https://github.com/samirkumardas/pcm-player

# 模块结构

# 线程模型

主要有三个线程:

  • 主线程(Player):界面控制、播放控制、下载控制、音视频渲染、音视频同步;
  • 解码线程(Decoder Worker):音视频数据的解封装、解码;
  • 下载线程(Downloader Worker):下载某个 chunk。 线程之间通过 postMessage 进行异步通信,在需要传输大量数据(例如视频帧)的地方,需要使用 Transferable 接口来传输,避免大数据的拷贝损耗性能。

# 对外接口

  • play:开始播放;
  • pause:暂停播放;
  • resume:恢复播放;
  • stop:停止播放;
  • fullscreen:全屏播放;

# 下载控制

为防止播放器无限制地下载文件,在下载操作中占用过多的 CPU,浪费过多带宽,这里在获取到文件码率之后,以码率一定倍数的速率下载文件。

# 缓冲控制

缓存控制对这个播放器的意义重大,在这个时间点,WASM 还无法使用多线程以及多线程的同步,FFmpeg 的同步读数据接口必须保证返回数据。所以这里有两个措施,1:在未获取到文件元信息之前的数据缓存;2:解码帧缓存。必须控制好这两个缓存,才能保证任何时候 FFmpeg 需要读取数据时都能够返回数据,在数据不足时停止解码,进入 Buffer 状态,数据足够时继续解码播放,返回 Play 状态,保证 FFmpeg 不会报错退出播放。

# Downloader

这个模块很简单,只是单纯为了不在主线程做太多事情而分离,功能主要有:

通过 Content-Length 字段获取文件的长度; 通过 Range 字段下载一个 chunk。 如上面提到的,Player 会进行速率控制,因此需要把文件分成 chunk,按照 chunk 方式进行下载。下载的数据先发给 Player,由 Player 转交给 Decoder(理论上应该直接交给 Decoder,但是 Downloader 无法直接与 Decoder 通信)。 对流式的数据,则使用 Fetch。

发送:
this.cacheBuffer = Module._malloc(chunkSize);

Decoder.prototype.sendData = function (data) {
    var typedArray = new Uint8Array(data);
    Module.HEAPU8.set(typedArray, this.cacheBuffer); //拷贝
    Module._sendData(this.cacheBuffer, typedArray.length); //传递
};

接收:

this.videoCallback = Module.addFunction(function (buff, size, timestamp) {
  var outArray = Module.HEAPU8.subarray(buff, buff + size); //拷贝
  var data = new Uint8Array(outArray);
  var objData = {
    t: kVideoFrame,
    s: timestamp,
    d: data
  };
  // 第二个参数用于转移上下文
  self.postMessage(objData, [objData.d.buffer]); //发送给Player
});

需要把回调通过 openDecoder 方法传入 C 层,在 C 层调用。

# 项目资料

https://github.com/sonysuqin/WasmVideoPlayer
https://github.com/ErosZy/WXInlinePlayer
https://github.com/liang520/webAssemblyPlayer
https://github.com/langhuihui/jessibuca
https://github.com/cisco/openh264
https://github.com/udevbe/tinyh264
https://github.com/strukturag/libde265
https://github.com/goldvideo/decoder_wasm
https://github.com/goldvideo/h265player