音视频开发入门,可能绕不开 ffmpeg 这个项目,最近看了篇 知乎专栏,觉得这个事情很有意思。

比起直接编辑整个 ffmpeg 项目的 CLI 到前端,更符合实际需求的方式,是先基于 ffmpeg 各种 lib 二次开发出合适的功能,此时结果是可执行的二进制文件,可以用 lldb 或者 gdb 调试。然后再使用 Emscripten 编译到 Webassembly,如此一来可以解决 wasm 不易调试的问题。

跟着教程实现一个功能:解析出视频任意一帧的图像并绘制到 canvas 上。

Web demo 应用流程

demo 页的简单流程

graph TD
    P1[获取视频 buffer 并写入wasm将要使用的线性内存空间] -- 进入wasm调用 --> A

subgraph C 程序转成的 wasm
    A[avcodec 解析视频文件 buffer] --> B[解出指定时间的图像并转成 RGB 格式数据]
    B --> C[将图像等数据写入内存, 并将指针返给 js 端]
end

    C  -- 回到 js -->  D[根据指针读出数据, 构建 ImageData, 绘制到 canvas 上]

从源码编译 ffmpeg

本文写就的时候使用的是 ffmpeg n4.2-dev 版,将其源码置于项目相对目录 lib/ffmpeg 下。

ffmpeg 是一个很大的项目,包含的很多功能对于我们的需求来说,都用不上,可以通过 configure 配置留下合适的功能集。这个其实就是一个可执行的 sh 脚本,比较复杂的项目,通常在实际编译之前,可以使用 configure 根据参数和环境生成实际编译过程需要的 Makefile。

项目 Makefile

参考 ffmpeg.js 项目的一些配置

COMMON_FILTERS = scale crop overlay
COMMON_DEMUXERS = matroska ogg avi mov flv mpegps image2 mp3 concat
COMMON_DECODERS = \
	mpeg2video mpeg4 h264 hevc \
	png mjpeg \
	mp3 ac3 aac

MUXERS = mp4 null image2
ENCODERS = mjpeg

FFMPEG_CONFIGURE_ARGS = \
	--cc=emcc \
	--ar=emar \
	--enable-cross-compile \
	--target-os=none \
	--cpu=generic \
	--arch=x86 \
	--disable-runtime-cpudetect \
	--disable-asm \
	--disable-fast-unaligned \
	--disable-pthreads \
	--disable-w32threads \
	--disable-os2threads \
	--disable-debug \
	--disable-stripping \
	\
	--disable-all \
	--enable-avcodec \
	--enable-avformat \
	--enable-avutil \
	--enable-swscale \
	--enable-shared \
	--enable-protocol=file \
	$(addprefix --enable-decoder=,$(COMMON_DECODERS)) \
	$(addprefix --enable-demuxer=,$(COMMON_DEMUXERS)) \
	$(addprefix --enable-encoder=,$(ENCODERS)) \
	$(addprefix --enable-muxer=,$(MUXERS)) \
	$(addprefix --enable-filter=,$(COMMON_FILTERS)) 

# to run ffmpeg configure and emmake
lib/ffmpeg/libavcodec/libavcodec.a:
	cd lib/ffmpeg && \
	patch -p1 < ../swscale.c.patch && \
	emconfigure ./configure \
		$(FFMPEG_CONFIGURE_ARGS) \
		&& \
	emmake make

说下 lib/ffmpeg/libavcodec/libavcodec.a 这个目标,分成几步:

  • emconfigure 是 emsdk 提供的工具,执行完这一步之后,会生成 lib/ffmpeg/Makefile
  • emmake make 便是开始编译了,由于我们在前一步 configure 的时候有 --enable-avcodec,所以用这个 Makefile 编译,会生成 lib/ffmpeg/libavcodec/libavcodec.a 这个静态库文件
  • 编译原本的 ffmpeg 代码会报错,定位到 libswscale/swscale.c 文件里,为了编译通过,在编译前加了个不影响主要功能的简单的 patch

运行 make lib/ffmpeg/libavcodec/libavcodec.a,等待大约一两分钟,emscripten 编译 ffmpeg 静态库完成,闪过了一堆 warning 可以优雅地无视掉。接下来就是编写我们的应用接口代码并编译到 WebAssembly 了。

编译和使用

生成 JS 和 WASM 文件

dist/vidy-standalone.js:
    emcc transcoder/web.c transcoder/process.c \
                lib/ffmpeg/libavformat/libavformat.a \
                lib/ffmpeg/libavcodec/libavcodec.a \
                lib/ffmpeg/libswscale/libswscale.a \
                lib/ffmpeg/libavutil/libavutil.a \
                -s TOTAL_MEMORY=33554432 \
                -s MODULARIZE=1 \
                -O1 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' -s ALLOW_MEMORY_GROWTH=1  \
                -Ilib/ffmpeg \
                --post-js transcoder/js/post.js \
                -o dist/vidy-standalone.js

使用 emcc 编译:

  • 存放暴露给浏览器的相关接口的 web.c
  • 存放通用的 ffmpeg 方法调用的 process.c
  • 以及之前生成个几个静态库文件 .a

其中一些参数说明一下: - -s MODULARIZE=1 让 emcc 生成模块工厂函数(而且还是 UMD 格式的),留待之后调用。否则默认情况下生成的 JS 会立刻执行,而且还会污染其所在全局环境(例如添加一个 self.Module 对象)

一起生成目标文件 dist/vidy-standalone.js,由于传递了 -s WASM=1,还会生成同名的 dist/vidy-standalone.wasm 文件。JS 是一个一千来行的胶水代码,负责 WASM 模块的初始化和调用适配。WASM 文件大概 4.7M。

查看 emcc文档关于 -s 的全部可选 setting

使用方法

简单的例子

import Module from '../dist/vidy-standalone'

let vidyModule

fetch('//path/to/dist/vidy-standalone.wasm')
    .then(res => res.arrayBuffer())
    .then((arrayBuffer) => {
        vidyModule = Module({
            wasmBinary: arrayBuffer
        })
        decodeVideoFrameImage('some.mp4', 1.2)
    })

function decodeVideoFrameImage(videoPath, timeStamp) {
    fetch(videoPath).then(res => res.arrayBuffer())
        .then((videoBuffer) => {
            const imageResult = vidyModule.getImage(this.result, parseFloat(timeStamp))
            // ...
        })
}

稍微复杂点的例子

为了将模块更好地整合到前端工程中,有必要考虑在使用 webpack 的情况下如何引入。

参考 GoogleChromeLabs/squoosh 项目中的一些经验,首先看下 webpack 配置。webpack 团队在 v4 以后做了很多努力,想要让 WASM 模块的引入和使用与 js 文件一样方便,但实际实用中有很多边边角角奇怪的问题和报错,而且处理一个好几兆的 wasm 文件拖慢 webpack 冷启动许多,我们可以用一下配置让 webpack 不去读取 WASM 文件。使用 file-loader 也可以简单地配置带哈希的文件名,比起在项目中硬编码 WASM 文件路径,少去一些缓存问题。

// webpack config
    rules: [
    ...
      {
        test: /\.wasm$/,
        // This is needed to make webpack NOT process wasm files.
        type: 'javascript/auto',
        loader: 'file-loader',
        options: {
          name: '[name].[hash:5].[ext]',
        },
      },
    ...
    ]

跟上面简单例子里效果相似的写法可以变成这样:

import Module from '../dist/vidy-standalone'
import vidyWasmUrl from '../dist/vidy-standalone.wasm' // 会被 file-loader 处理成一个静态文件的 url

const vidyModule = Module({
    locateFile(url) {
        // Redirect the request for the wasm binary to whatever webpack gave us.
        if (url.endsWith('.wasm')) return vidyWasmUrl;
        return url;
    },
})

emcc 生成的胶水代码里,默认请求的 WASM 文件路径是 vidy-standalone.wasm,但看看 emcc 这一部分实现 知道,如果给模块工厂函数 Module 传递了 locateFile 函数,就可以改写其内部会去请求的 WASM 文件路径。使用模块工厂函数的话,也不用自己去调用 fetch 了。

一些具体实现的代码

首先看看 web.c 里暴露出的方法签名:

EMSCRIPTEN_KEEPALIVE MyImageData *seek_video_to(uint8_t *buff, const int buff_length, float time_stamp)

buffer 数组头指针 buff,buffer 长度 buff_length,以及用单精度浮点数表示的需要提取图像的时间。返回数据为我们自定义的结构。

[JS] 将视频数据写入 WASM 线性内存

post.js 里,添加的一部分代码。

  • 根据 C 的方法签名,使用 emscripten 的胶水代码工具函数 Module.cwrap 包装一个 JS 的调用方法
  • 给 emscripten 模块加上了 Module.getImage 方法,供外部调用
let seek_video_to = null

Module.onRuntimeInitialized = function () {
  seek_video_to = Module.cwrap('seek_video_to', 'number', ['number', 'number', 'number']);
};

Module.getImage = function(buffer, timeStamp) {
  if (!seek_video_to) {
    return { errcode: 1 }
  }
  let ptr = 0;
  let offset = 0;
  try {
    const before = Date.now()
    let data_arr = new Uint8Array(buffer);
    offset = Module._malloc(data_arr.length);
    Module.HEAP8.set(data_arr, offset);
    ptr = seek_video_to(offset, data_arr.length, timeStamp);
    console.log('seek_video_to costs', Date.now() - before)
  } catch (e) {
    throw e;
  }
  ...

[C] 程序头部

首先声明一些方便数据读取的全局变量:

typedef struct
{
    uint8_t *ptr;
    size_t size;
} BufferData;

/**
 * some global variables
 */
BufferData global_buffer_data;

typedef struct {
    uint32_t width;
    uint32_t height;
    uint8_t *data;
} MyImageData;

全局变量 global_buffer_data 留作存放原始视频数据的结构,它所在的内存区域会被 JS 直接写入。

[C] avcodec 解析视频文件

我们需要让 ffmpeg 能够从内存(而不是文件)中读取视频数据。

...
  unsigned char *avio_ctx_buffer = NULL;
  // 对于普通的mp4文件,这个size只要1MB就够了,但是对于mov/m4v需要和buff一样大
  size_t avio_ctx_buffer_size = buff_length;

  global_buffer_data.ptr = buff;         /* will be grown as needed by the realloc above */
  global_buffer_data.size = buff_length; /* no data at this point */

  AVFormatContext *pFormatCtx = avformat_alloc_context();

  uint8_t *avio_ctx_buffer = (uint8_t *)av_malloc(avio_ctx_buffer_size);

  /* 读内存数据 */
  AVIOContext *avio_ctx = avio_alloc_context(avio_ctx_buffer, avio_ctx_buffer_size, 0, NULL, read_packet, NULL, NULL);

  pFormatCtx->pb = avio_ctx;
  pFormatCtx->flags = AVFMT_FLAG_CUSTOM_IO;
...

新建 AVIOContext *avio_ctx,指定目标 buffer 指针,目标 buffer 大小,以及我们提供的读取数据的 read_packet 函数,该 iocontext 需要读下一段数据时, read_packet 函数就将 global_buffer_data 中指定大小的数据写入目标 *buf 位置

int read_packet(void *opaque, uint8_t *buf, int buf_size)
{
    buf_size = FFMIN(buf_size, global_buffer_data.size);

    /* copy internal buffer data to buf */
    memcpy(buf, global_buffer_data.ptr, buf_size);
    global_buffer_data.ptr += buf_size;
    global_buffer_data.size -= buf_size;

    return buf_size;
}

[C] 获取图片rgb数据

这里内容太多,主要涉及 FFMpeg 的接口和视频编解码的知识,准备另写一篇。

[C] 将图像等数据写入内存

当拿到包含 RGB 格式图像数据的 AVFrame *pFrameRGB 后,是时候将其中的颜色信息取出,转化为线性存储的,利于 JS 中 Canvas 元素使用的数据格式。

uint8_t *get_image_frame_buffer(AVFrame *pFrame, AVCodecContext *pCodecCtx)
{
    int width = pCodecCtx->width;
    int height = pCodecCtx->height;

    int buffer_size = height * width * 3;

    uint8_t *buffer = (uint8_t *)malloc(buffer_size);

    // Write pixel data
    for (int y = 0; y < height; y++)
    {
        memcpy(buffer + y * pFrame->linesize[0], pFrame->data[0] + y * pFrame->linesize[0], width * 3);
    }
    return buffer;
}

此函数返回的 buffer 指针指向的内存区域,会按照 rgbrgb... 的顺序存储图像颜色数据。每个像素需要 3 个存储单元,所以整个的 buffer_size 会是 height * width * 3

接下来我们回到 JS 端。

[JS] 根据指针读出数据, 构建 ImageData

WASM 返回的只是一个内存偏移量,此时我们手上有整个 WASM 实例的内存区域,得想办法把有用的数据读取出来。

首先我们知道 MyImageData 结构体宽和高都是用 uint32_t,紧接着存放颜色信息的数组单元类型为 uint8_t

Emscripten 的胶水代码有提供 HEAPU(8/16/32/64) 几种步长的 dataviewer,可以按照以下方法读出数字和颜色数组。

// ...
  let heap32Start = ptr / 4
  let width = Module.HEAPU32[heap32Start]
  let height = Module.HEAPU32[heap32Start + 1],
    imgBufferPtr = Module.HEAPU32[heap32Start + 2],
    imageBuffer = Module.HEAP.subarray(imgBufferPtr, imgBufferPtr + width * height * 3)

  let imageInfo = { width, height, imageDataArr: imageBuffer }
  let imageData = imageInfoToImageData(imageInfo)
// ...

function imageInfoToImageData(imageInfo: VidyImageInfo) {
  const { width, height, imageDataArr } = imageInfo
  const imageData = new ImageData(width, height)
  // 目前只返回 RGB24 格式的数据, 不处理透明度
  let k = 0
  for (let i = 0; i < imageDataArr.length; i++) {
    if (i && i % 3 === 0) {
      imageData.data[k++] = 255
    }
    imageData.data[k++] = imageDataArr[i]
  }
  imageData.data[k] = 255
  return imageData
}

绘制到 canvas 上就很简单了

canvas.width = width
canvas.height = height
let ctx = canvas.getContext('2d')
ctx.drawImage(imageData, 0, 0)

总结

跟着别人的文章思路,小小修改,跑通了一个 demo,大致熟悉一下 C 项目使用 Emscripten 转化为前端可用模块的方案。

不是很熟悉 C 语言,同时在 JS 和 C 端手动管理内存虽然对于入门者来说很容易操作,但稍显繁琐。

Emscripten 多用于翻译现有的 C/C++ 库代码,对于 Web API 和前端生态的支持,明显没有隔壁 Mozilla 的 Rust 社区积极。不过音视频技术实现,的确是 C 的传统强项领域,若想少造轮子,还是要好好学习的。

参考

https://zhuanlan.zhihu.com/p/40786748