音视频开发入门,可能绕不开 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