IVWEB玩转wasm系列-揭秘wasm+h265直播播放器

avatar
@腾讯科技(深圳)有限公司

WebAssembly,因其接近机器码的特性和更小的文件体积,使用wasm文件相较于js会有编译和加载时间更少的优势,加上利用Emscripten等编译器工具可以将c/c++编译成wasm文件极大的拓展了前端的视野,从2017年推出以来,就一直是前端开发者们关注的热点。> 现在,我们已经可以看到很多基于wasm游戏/音视频/web文件处理等方面的web应用。IVWEB团队负责NOW直播等直播场景的业务开发,在探索wasm技术落地的背景下,实现了一款基于wasm的直播播放器。

1. 背景知识

wasm+h265 播放器,这是一个不太友好的标题,除了播放器三个字,wasm 和 h265 又是什么,ivweb团队为什么要做它?

1.1 是什么

WebAssembly(以下简称 wasm)已经推出数年,很多优秀的开发者已经开始在他们的项目用用上wasm来提高密集运算下的代码性能,推荐阅读这篇文章来了解wasm是什么。

h265是什么?人们在音视频领域的不断探索得到了很好的反馈,两大视频标准制定组织ITU-T与ISO/IEC每隔几年都会制定出新一代视频编码标准,H.26x系列和MPEG系列在不断的更新。目前应用广泛的H.264标准正在被新一代编码标准HEVC替代。高效率视频编码HEVC(High Efficiency Video Coding)也被称为 H.265(以下简称h265),它在保留了H.264的基础上对一部分技术(动态宏块划分/更多的帧内预测模式/更好的运动补偿处理)加以改进,使得它具有更好的质量和更高的压缩率,相同的图像质量(PSNR)的视频,码率可以降低30%-50%。

1.2 为什么

虽然我们团队负责直播业务,但作为一个 web 开发,在直播视频内容的工作领域,真的只能做一点微不足道的工作——掌握并使用基于浏览器封装的播放器video。当我们眼馋于 h265 带来的如此多的提升后,却发现当前主流的播放器(除 safari 外)受限于解码能力并不支持 h265 的播放,包括flv.js在内的web解码播放方案目前也不支持 h265,我们的首选 video 标签方案被排除出 h265 的使用场景范围。**我不信!我不听!我不管!**一波三连否定后我们决定继续探索新的方案。

连麦混流直播流编码类型测试时间流量大小
H26515 (min)68.1MB
H26415 (min)137MB

经过15 分钟的测试,使用 h265 会比 h264 的直播流节省30%的流量,连麦混流可以节省50%。(普通直播流目前腾讯云为NOW直播h265设置码率转换因子为70,理论上节省30%)

1.3 怎么办

当 wasm 推出后,热门的应用领域就包含了音视频的编码解码,github 上开源了视频裁剪/视频解码的 webassembly 方案,淘宝直播和花椒直播也在今年探索了 wasm+h265 播放器方案,在播放器领域已经有前人的成功实践。基于对播放器各个模块的分析,几个核心模块(数据io/渲染层/控制层)都可以基于 web 的成熟技术完成,而视频解码可以基于成熟编码技术完成,基于ffmpeg的解码代码经过 Emscripten 编译到 wasm 文件引入 web 中。

2. 抽丝剥茧,一帧数据的历程

视频数据封装在流媒体中,当前常用的流媒体协议有 RTMP/HLS/HTTP-FLV,其中 HLS 和 HTTP-FLV 通过 HTTP 传输,数据可以直接交给 js 处理,这一节我们从流媒体出发,抽丝剥茧地分析播放器每一步对数据的处理,整理出 wasm + h265 直播播放器的框架。

2.1 数据流 io

第一次接触到流媒体的是播放器的 io 层,播放器在这里请求数据,并交给 io controller,用于之后的解码播放。以NOW直播为例,腾讯云为我们提供了HTTP-FLV 流媒体,音视频数据被封装在 FLV 容器中,通过 HTTP 传输。要获取流媒体中的数据,web提供了流式数据的处理方案,利用 XMLHttpRequest 或者 Fetch Api 可以直接读取数据流。以下是一个简单的 fetch 数据流例子:

fetch(url, {
  method: 'GET',
  responseType: 'arraybuffer'
})
  .then(res => {
    return res.body.getReader();
  });

将 url 替换成直播流地址,获取到是一个可读流,通过 read 方法可以读取数据。

const stream = new ReadableStream({
  start(controller) {
    const push = () => {
      reader.read()
        .then((res) => {
          if (res.done) {
            // 流结束处理
          }

          // res.value 数据读取处理,交给controller
          this.emit('data', res.value);

           push();
        });
    };

    push();
  }
});

io controller 在收到 fetch 到视频数据后,先写入缓存池,达到播放器设定的阈值后(解码启动的阈值不能太小,至少需要 ffmpeg 设置的 probesize 大小,ffmpeg 需要循环读取多帧数据来确定视频帧率/码率/高宽度等信息 ),才将数据交给上层控制器。

2.2 wasm 数据交互

播放器核心控制器收到 io 层的数据后正式开始解码处理,之前提到过解码器是用 Emscripten 编译成的 .wasm 文件和 .js 胶水代码,胶水代码提供了 Module 全局对象来访问 wasm 的生命周期/内存模型等。播放器 io 层中获取到的 buffer 数据要怎么传递给解码器呢,解码器的解码后的音视频帧数据又怎么回传给播放器呢?

2.2.1 播放器(js) 到 解码器(wasm)

数据交互的核心是要理解 wasm 的内存模型。在生成wasm的时候,我们会设置wasm的内存大小,在以下编译脚本中,先为 wasm 设置了一个64M的内存大小。

export TOTAL_MEMORY=67108864

emcc $WASM_SOURCE/decoder_stream.c ... \
    ...
    -s TOTAL_MEMORY=${TOTAL_MEMORY} \
       ...
    -o $LIB_PATH/libffmpeg_live.js

内存模型是一个arraybuferr,通过胶水代码提供的 Module 全局对象提供的视图(view)可以查看,wasm 的视图与 js 中typedArray 是对应的,视图类型多样,有HEAP8(Int8Array)/HEAPU8(Uint8Array)/HEAP16(Int16Array)/HEAPU16(Uint16Array) 等。如下图所示:

当播放器(js)需要向解码器(wasm)传递数据时,需要三步操作:

  1. 将数据写入wasm数据模型

  2. 传递数据指针和数据长度

  3. wasm 根据指针和长度读取数据

    const offset = Module._malloc(buffer.length); // _malloc申请一块内存,并返回指针
    
    Module.HEAPU8.set(buffer, offset); // 数据写入分配的内存空间
    
    Module._sendData(offset, buffer.length); // 传递指针和数据长度
    
    Module._free(offset); // 注意:使用完后需要回收内存
    
    int sendData(uint8_t *buff, const int size)
    {
     ...
     memcpy(pValid, buff, size); // 写入数据区
     pValid += size; // 更新数据区结尾指针
     ...
    
     logger("数据大小: %d \n", size);
    }
    

2.2.2 解码器(wasm) 到 播放器(js)

明白了从播放器到解码器的数据传输,从解码器(wasm)向播放器(js)传递数据其实就是一个反向操作,也需要三步操作:

  1. 传入播放器回调函数

  2. 解码器调用播放器回调函数传递数据

  3. js 读取数据指针和数据长度解析出数据

void initDecoder(VideoCallback videoCallback ...)
{
        ...
    if (decoder == NULL)
    {
        decoder->videoCallback = videoCallback;
        ...
    }
}

int decodeVideoFrame() {
    ...
    decoder->videoCallback(out_buffer, buffer_size); // 通过回调传递数据
    ...
}
function videoCallback() {
    const videoBuffer = Module.HEAPU8.subarray(buff, buff + size);
    const data = new Uint8Array(videoBuffer);
}

2.3 decode 解码核心

作为一个即不会写c语言又不懂音视频解码的web开发,在完成解码器的时候,学习了非常多雷霄骅博士的解码入门文章,感谢雷博士在音视频基础普及上的努力。

对于直播流的解码,解码器主要做了件事:

  1. 获取视频流 metadata 信息

  2. 视频解封装

  3. 解码视频,视频格式化YUV420

  4. 解码音频,音频格式化PCM

  5. 数据回调

以下是核心解封装/解码流程:

首先是获取从内存读取视频流,得到视频的基本信息(高/宽/视频解码器/音频解码器/音频采样率/采样格式等),用于之后渲染层的音频/视频参数设置。

解封装是为了从flv容器中取出视频流和音频流,通过av_read_frame()方法存放到一个叫AVPacket的结构体中,packet中包含数据的显示时间(pts),解码时间(dts)和数据流id(stream_id),通过stream_id可以确定是视频流还是音频流。

解码是解压缩,视频解码是从压缩数据中解出每一帧图像数据,音频解码是从压缩数据中解出音频数据,解码出的数据需要进行格式化处理方便之后渲染器渲染。在视频解码中,我们从h265的视频流中取出真正的视频帧,视频帧进行高宽度转换和数据格式处理。

// 向解码器投喂数据
avcodec_send_packet(videoCodecCtx, packet);

// 接收解码器输出的数据帧
avcodec_receive_frame(videoCodecCtx, avframe);

// 创建缓冲区空间
uint8_t *out_buffer = (uint8_t *)av_malloc(buffer_size);

// 向缓存填充数据
av_image_fill_arrays(
    frame->data,         // 帧数据
    frame->linesize,     // 单行数据大小
    out_buffer,          // 缓冲区
    AV_PIX_FMT_YUV420P,  // 像素数据格式
    ...                  // 高度宽度
)

// 创建格式转换上下文
struct SwsContext *sws_ctx = sws_getContext(
    width,                  // 输入宽度
    height,                               // 输入高度
    videoCodecCtx->pix_fmt, // 输入数据
    videoWidth,             // 输出宽度
    videoHeight,            // 输出高度
    AV_PIX_FMT_YUV420P,            // 输出数据格式
    SWS_BICUBIC,            // 格式转换算法类型
    ...
);

// 格式转换
sws_scale(
    sws_ctx,  // 格式转换上下文
    (uint8_t const *const *)avframe->data  // 输入数据
    ...
);

注意: 选择不同的格式转换算法性能会不同。

最后计算当前帧的pts(播放时间戳),通过数据回调,将帧数据返回给播放器处理。

// 计算播放时间戳 pts
double timestamp = (double)avFrame->pts * av_q2d(decoder->->streams[videoStreamIdx]->time_base);

// 回调函数返回帧数据
decoder->videoCallback(out_buffer, buffer_size, timestamp);

解码器需要利用cpu软解视频流,越大的码率的视频解码占用的cpu越高,为了避免解码阻塞主线程工作,我们把解码器作为一个单独的web worker运行。

2.4 渲染层,与用户见面

解码后的数据会交给渲染层完成与用户的见面,从解码器获取的数据格式:

const frame = {
    type,           // 0 视频帧 / 1 音频帧
    timestamp,    // pts 毫秒
    data          // 帧数据
}

音频帧和视频帧不停的输入到渲染缓存池中,经过音视频同步处理(下文涉及),从缓存池中取出数据数据,交给webcl去完成渲染。取出音频数据交给音频播放器播放。

解码后的音频数据格式是PCM,这是一种对音频信号的数字化表示,以固定的频率从音频模信号上取出数值并转换成用固定位数表示的数字信号,频率和位数就是音频采样率和采样位数。例如:44100HZ 16bit 表示1s采样44100次,音频采样数据的振幅被分成2^16个等级。

音频数据可以使用Web Audio Api播放, 在web audio中,将音频数据视为一个流,流经过一个一个的音频节点(丰富的音频处理器),最终到达终点 destination 输出到扬声器。整套流程类似于 gulp 的 pipe,对于 PCM 音频数据的播放,我们只需要增加一个用于 PCM 到 AudioBuffer 的转换节点。

play(frame) {
    ...
    // 格式化PCM
    const data = format(frame);
    const audioBuffer = audioCtx.createBuffer(
    channels,   // 声道数
    length,     // 数据长度
    sampleRate  // 采样率
  );

  ...

    // 数据写入AudioBuffer
  for (let channel = 0; channel < channels; channel++) {
    let offset = channel;
    const audioData = audioBuffer.getChannelData(channel);

    for (let i = 0; i < length; i++) {
           audioData[i] = result[offset];

      offset += channels;
    }
    }

    ...
    bufferSource.buffer = audioBuffer;
  bufferSource.connect(this.gainNode);
  bufferSource.start(播放时间);
    ...
}

2.5 整体框架

到这里,我们已经从数据的流动上了解了播放的各个核心组成部分,这里总结出播放器的完整框架:

3. 继续探索

实现直播流的解码播放还远远不够,我们的目标是要在生产环境中使用,这样才能蹭上h265带来的提升。为此,针对生产环境应用这一大诉求,我们做了一些探索。

3.1 首帧时长

首帧时长是指打开页面到出现第一帧画面的时间,首帧时长受资源加载时间和解码器缓存阈值设定等多个因素影响,首先我们看一下这个过程中资源加载发生了什么:

除去html解析和之后的解码渲染,播放器串行加载的资源有四个,先加载播放器资源player.js,在player.js中初始化解码worker,加载worker资源,worker中加载编译好的wasm文件,最后拉取视频流。

资源加载优化是我们非常熟悉的领域,有常用优化三件套:

  • 合并资源请求(这一点暂时用不上)

  • 减少资源大小

    player.js 和 worker.js 采用常用的代码压缩,能最大限度的减少 js 资源大小。

    wasm文件包含了播放器的解码逻辑和ffmpeg库,大小达到了 2.8M,在禁用掉其他 ffmpeg 的 demuxers 和 decoders,只开启 hevc 以及 aac 之后,wasm 文件大小减少到 1.4M,cdn开启 gzip 后还有400k。

    --disable-demuxers --enable-demuxer=hevc --enable-demuxer=acc --enable-demuxer=flv \
    --disable-decoders --enable-decoder=hevc --enable-decoder=aac
    
  • 串行改并行

    利用资源预加载可以在 load player.js 的时候提前加载后面的资源。解码器 worker 可以同 player.js 并行起来,推荐阅读ivweb玩转wasm系列——Web Worker串行加载优化

    同时 flv 流资源也可以预加载,在播放器准备好之后再注入给播放器的缓存池。页面直出的场景在,flv 流的加载时间可以提前到 html 解析阶段。

    目前并行加载正在试验阶段,之后团队会有关于加载时间对比的详细文章。

影响首帧时长的第二个因素是解码器缓存阈值设定,设定这个阈值是要将视频流缓存到一定的阈值再送入播放器中。ffmpeg在解封装的时候,获取流信息 avformat_find_stream_info 方法会读取一部分音视频流来做探测(probe),在晚上探测前进行解码会出现解码失败的情况,解码时间会延后到视频流加载到一定阈值后。

ffmpeg提供 probeSize 和 analyzeduration 来控制探测数据大小和探测数据时长,当两者同时存在时,探测满足一个条件就可以完成。减少probeSize 或 analyzeduration可以降低 avformat_find_stream_info 的探测时长,从而减少首帧时长。

3.2 音画同步

播放的画面和播放的声音应该是对应起来的,否则会极大的降低用户的观看体验。参考传统播放器的音画同步方案(推荐阅读播放器技术分享(3):音画同步),我们采用视频同步到音频的方案。

音频持续播放的同时,每播放完成一段音频,就以这段音频的 pts 为主时钟,在视频帧缓存池中取出视频帧,判断视频帧的 pts 在是否在播放阈值内(-50ms - 50ms),视频帧延后于最小阈值 -50ms 就丢弃该帧,视频帧超前于最大阈值 50ms,就等下一段音频播放完成后同步过来。

// 音频播放器
bufferSource.onended = () => {
    onAudioUpdate(frame.timestamp);
};

function onAudioUpdate(timestamp) {
  ...
  const video = videoPool.shift();
  const diff = video.timestamp - timestamp;

  // 视频在同步区间,直接播放
  if (diff <= this.min && diff > -this.min) {
    renderImage(this.videoPool.shift());
  }

  // 视频滞后超过阈值,弃帧
  if (diff < -this.min) {
    emit('discardFrame', diff);

    discardFrame();
  }
  ...
}

3.3 兼容性

播放器使用到 wasm/web worker/web audio api/webGl/fetch等多个浏览器新api,在兼容性上有较大的挑战。播放器需要提供 api 来判断当前环境是否能使用。

/**
 * 当前播放器是否能够正常播放
 * @returns {Boolean} true or false;
 */
function isSupported() {
    const supportWasm = isSupportWasm();       // 是否支持wasm
    const supportWorker = isSupportWorker();   // 是否支持web worker
    const supportAudio = isSupportAudio();     // 是否支持web audio api
    const supportWebGl = isSupportWebGL();     // 是否支持webgl
    const supportAbortController = isSupportAbortController(); // 是否支持fetch abort

    return supportWasm && supportWorker && supportAudio && supportWebGl && supportAbortController;
}

4. 性能

4.1 测试机配置

MacBook Pro (13-inch, 2017, Four Thunderbolt 3 Ports) 处理器 3.1 GHz Intel Core i5 内存 8 GB 2133 MHz LPDDR3 chrome 版本 80.0.3968.0(正式版本)dev (64 位)

4.2 直播流参数

视频分辨率:540 * 960 视频帧率:24 视频码率:1400Kbps 音频码率:64Kbps

4.3 cpu和内存占用

产品平均内存占用平均CPU占用CPU波动内存占用波动
Ivweb Wasm 播放器276.95MB29.97%8% - 75%198M- 457M

5. 展望未来

未来当 chrome 等主流播放器可以原生支持 h265 的时候,我们对于wasm播放器的研究是不是就没有了意义?我的看法是 wasm 仍然可以作为 web 前端们对前沿技术的探索战场,音视频的编解码可以引入到前端领域,那么其他丰富的视频领域的新玩意也可以通过 wasm 引入到浏览器中。