54

wasm + ffmpeg实现前端截取视频帧功能

 5 years ago
source link: https://www.yinchengli.com/2018/07/28/wasm-ffmpeg-get-video-frame/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

有没有那么一种可能,在前端页面处理音视频?例如用户选择一个视频,然后支持他设置视频的任意一帧作为封面,就不用把整一个视频上传到后端处理了。经过笔者的一番摸索,基本实现了这个功能,一个完整的demo: ffmpeg wasm截取视频帧功能

3imai2i.jpg!web

支持mp4/mov/mkv/avi等文件。

基本的思想是这样的:

QzEF7zY.png!web

使用一个file input让用户选择一个视频文件,然后读取为ArrayBuffer,传给ffmpeg.wasm处理,处理完之后,输出rgb数据画到canvas上或者是转成base64当做img标签的src属性就形成图片了。(Canvas可以直接把video dom当作drawImage的对象进而得到视频帧,不过video能播放的格式比较少,本文重点讨论ffmpeg方案的实现,因为ffmpeg还可做其它的事情,这只是一个例子。)

这里有一个问题,为什么要借助ffmpeg呢,而不直接用JS写?因为多媒体处理的C库比较成熟,ffmpeg就是其中一个,还是开源的,而wasm刚好可以把它转化格式,在网页上使用,多媒体处理相关的JS库比较少,自己写一个多路解复用(demux)和解码视频的复杂度可想而知,JS直接编解码也会比较耗时。所以有现成的先用现成的。

第1步是编译(如果你对编译过程不感兴趣的话,可以直接跳到第2步)

1. 编译ffmpeg为wasm版本

我一开始以为难度会很大,后来发现并没有那么大,因为有一个 videoconverter.js 已经转过了(它是一个借助ffmpeg在网页实现音视频转码的),关键在于把一些没用的特性在configure的时候给disable掉,不然编译的时候会报语法错误。这里使用的是 emsdk 转的wasm,emsdk的安装方法在它的 安装教程 已经说得很明白,主要是使用脚本判定系统下载不同编译好的文件。下载好之后就会有几个可执行文件,包括emcc、emc++、emar等命令,emcc是C的编译器,emc++是C++的编译器,而emar是用于把不同的.o库文件打包成一个.a文件的。

先要在 ffmpeg 的官网下载源码。

(1)configure

解压进入目录,然后执行以下命令:

emconfigure ./configure --cc="emcc" --enable-cross-compile --target-os=none --arch=x86_32 --cpu=generic \
    --disable-ffplay --disable-ffprobe --disable-asm --disable-doc --disable-devices --disable-pthreads --disable-w32threads --disable-network \
    --disable-hwaccels --disable-parsers --disable-bsfs --disable-debug --disable-protocols --disable-indevs --disable-outdevs --enable-protocol=file

通常configure的作用是生成Makefile——configure阶段确认一些编译的环境和参数,然后生成编译命令放到Makefile里面。

而前面的emconfigure的主要作用是把编译器指定为emcc,但只是这样是不够的,因为ffmpeg里面有一些子模块,并不能彻底地把所有的编译器都指定为emcc,好在ffmpeg的configure可以通过–cc的参数指定自定义的编译器,在Mac上C编译器一般是使用 /usr/bin/clang,这里指定为emcc。

后面的disable是把一些不支持wasm的特性给禁掉了,例如–disable-asm是把使用汇编代码的部分给禁掉了,因为那些汇编语法emcc不兼容,没有禁掉的话编译会报错语法错误。另外一个–disable-hwaccels是把硬解码禁用了,有些显卡支持直接解码,不需要应用程序解码(软解码),硬解码性能明显会比软解码的高,这个禁了之后,会导致后面使用的时候报了一个warning:

[swscaler @ 0x105c480] No accelerated colorspace conversion found from yuv420p to rgb24.

但是不影响使用。

(执行configure的过程会报一个segment fault,但后续的过程中发现没有影响。)

等待configure命令执行完了,就会生成Makefile和相关的一些配置文件。

(2)make

make是开始编译的阶段,执行以下命令进行编译:

emmake make

在Mac上执行,你会发现最后把多个.o文件组装成.a文件的时候会报错:

AR libavdevice/libavdevice.a  fatal error: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ar: fatal error in /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ranlib

解决这个问题需要把打包的命令从ar改成emar,然后再把一个ranlib的过程去掉就行,修改ffbuild/config.mak文件:

# 修改ar为emar
- AR=ar
+ AR=emar
 
# 去掉ranlib
- RANLIB=ranlib
+ #RANLIB=ranlib

然后再重新make就可以了。

编译完成之后,会在ffmpeg目录生成一个总的ffmpeg文件,在ffmpeg的libavcodec等目录会生成libavcodec.a等文件,这些文件是后面我们要使用的bitcode文件,bitcode是一种已编译程序的中间代码。

(最后在执行 strip -o ffmpeg ffmpeg_g 命令会挂掉,但是不要紧,strip改成cp  ffmpeg_g ffmpeg就好了

2. 使用ffmpeg

ffmpeg主要是由几个lib目录组成的:

  •     libavcodec: 提供编解码功能
  •     libavformat:多路解复用(demux)和多路复用(mux)
  •     libswscale:图像伸缩和像素格式转化

以一个mp4文件为例,mp4是一种容器格式,首先使用libavformat的API把mp4进行多路解复用,得到音视频在这个文件存放的位置等信息,视频一般是使用h264等进行编码的,所以需要再使用libavcodec进行解码得到图像的yuv格式,最后再借助libswscale转成rgb格式。

这里有两个使用ffmpeg的方式,第一种是直接把第一步得到的ffmpeg文件编译成wasm:

# 需要拷贝一个.bc后缀,因为emcc是根据后缀区分文件格式的
cp ffmpeg_g ffmpeg.bc
emcc ffmpeg.bc -o ffmpeg.html

然后就会生成一个ffmpeg.js和ffpmeg.wasm,ffmpeg.js是用来加载和编译wasm文件以及提供一个全局的Module对象用来操控wasm里面ffmpeg API的功能的。有了这个之后,在JS里面通过Module调用ffmpeg的API。

但是我感觉这个方式比较麻烦,JS的数据类型和C的数据类型差异比较多,在JS里面频繁地调C的API,需要让数据传来传去比较麻烦,因为要实现一个截取功能要调很多ffmpeg的API。

所以我用的是第二种方式,先写C代码,在C里面把功能实现了,最后再暴露一个接口给JS使用,这样JS和WASM只需要通过一个接口API进行通信就好了,不用像第一种方式一样频繁地调用。

所以问题就转化成两步:

第一步是使用C语言写一个ffmpeg保存视频帧图像的功能

第二步是编译成wasm和js进行数据的交互

第一步的实现主要参考了一个ffmpeg的教程: ffmpeg tutorial 。里面的代码都是现成的直接拷过来就好,有一些小问题是他用的ffmpeg版本稍老,部分API的参数需要修改一下。代码已上传到github,可见: cfile/simple.c

使用方法已在readme里面进行介绍,通过以下命令编译成一个可执行文件simple:

gcc simple.c -lavutil -lavformat -lavcodec `pkg-config --libs --cflags libavutil` `pkg-config --libs --cflags libavformat` `pkg-config --libs --cflags libavcodec` `pkg-config --libs --cflags libswscale` -o simple

然后使用的时候传一个视频文件的位置就可以了:

./simple mountain.mp4

就会在当前目录生成一张pcm格式的图片。

这个simple.c是调用的ffmpeg自动读取硬盘文件的api,需要改成从内存读取文件内容,即我们自己读到内存的buffer然后传给ffmpeg,后面才能把数据传输改成从JS的buffer获取,这个的实现可见: simple-from-memory.c . 具体的C代码这里就不分析了,就是调调API,相对来说还是比较简单,就是要知道怎么用,ffmpeg网上的开发文档相对较少。

这样第一步就算完成了,接着第二步,把数据的输入改成从JS获取,输出改成返回给JS.

3. js和wasm的交互

wasm版的具体实现是在 web.c (还有一个proccess.c是把simple.c的一些功能拆了出去),在web.c里面有一个暴露给JS调用的函数,姑且起名叫setFile,这个setFile就是给JS调的:

EMSCRIPTEN_KEEPALIVE // 这个宏表示这个函数要作为导出的函数
ImageData *setFile(uint8_t *buff, const int buffLength, int timestamp) {
    // process ...
    return result;
}

需要传递三个参数:

  • buff:原始的视频数据(通过JS的ArrayBuffer传进来)
  • buffLength:视频buff的总大小(单位字节)
  • timestamp:是希望截取第几秒的视频帧

最后处理完了返回一个ImageData的数据结构:

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

里面有三个字段:图片的宽高和rgb数据。

写好这些C文件后进行编译:

emcc web.c process.c ../lib/libavformat.bc ../lib/libavcodec.bc ../lib/libswscale.bc ../lib/libswresample.bc ../lib/libavutil.bc \
    -Os -s WASM=1 -o index.html -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' -s ALLOW_MEMORY_GROWTH=1 -s TOTAL_MEMORY=16777216

使用第1步编译生成的那些libavcode.bc等文件,这些文件有依赖顺序,前后不能颠倒,被依赖的要放在后面。这里面有些参数说明一下:

-o index.html 表示导出hmtl文件,同时会导出 index.jsindex.wasm ,主要使用这两个,生成的index.html是没用的;

-s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"] 表示要导出ccall和cwrap这两个函数,这两个函数的功能是为了调用上面C里面写的setFile函数;

-s TOTAL_MEMORY=16777216 表示wasm总内存大小为约16MB,这个也是默认值,这个需要是64的倍数;

-s ALLOW_MEMORY_GROWTH=1 当内存超出总大小时自动扩容。

编译好之后写一个main.html,加入input[type=file]等控件,并引入上面生成的index.js,它会去加载index.wasm,并提供一个全局的Module对象操控wasm的API,包括上面在编译的时候指定导出的函数,如下代码所示:

<!DOCType html>
<html>
<head>
    <meta charset="utf-8">
    <title>ffmpeg wasm截取视频帧功能</title>
</head>
<body>
<form>
    <p>请选择一个视频(本地操作不会上传)</p>
    <input type="file" required name="file">
    <label>时间(秒)</label><input type="number" step="1" value="0" required name="time">
    <input type="submit" value="获取图像" style="font-size:16px;">
</form>
<!--这个canvas用来画导出的图像-->
<canvas width="600" height="400" id="canvas"></canvas>
<!--引入index.js-->
<script src="index.js"></script>
<script>
<script>
!function() {
   let setFile = null;
   // WASM下载并解析完毕
   Module.onRuntimeInitialized = function () {
        console.log('WASM initialized done!');
        // 导出的核心处理函数
        setFile = Module.cwrap('setFile', 'number',
                      ['number', 'number', 'number']);
   };
}();
</script>

需要在wasm下载并解析完成之后才能开始操作,它提供了一个onRuntimeInitialized的回调。

为了能够使用C文件里面导出的函数,可以使用Module.cwrap,第一个参数是函数名,第二个参数是返回类型,由于返回的是一个指针地址,这里是一个32位的数字,所以用js的number类型,第三个参数是传参类型。

接着读取input的文件内容到放到一个buffer里面:

let form = document.querySelector('form');
// 监听onchange事件
form.file.onchange = function () {
    if (!setFile) {
        console.warn('WASM未加载解析完毕,请稍候');
        return;
    }
    let fileReader = new FileReader();
    fileReader.onload = function () {
        // 得到文件的原始二进制数据ArrayBuffer
        // 并放在buffer的Unit8Array里面
        let buffer = new Uint8Array(this.result);
        // ...
    };
    // 读取文件
    fileReader.readAsArrayBuffer(form.file.files[0]);
};

读取得到的buffer放在了一个Uint8Array,它是一个数组,数组里面每个元素都是unit8类型的即无符号8位整型,就是一个字节的0101的数字大小。

接下来的关键问题是:怎么把这个buffer传给wasm的setFile函数?这个需要理解wasm的内存堆模型。

4. wasm的内存堆模型

上面在编译的时候指定的wasm使用的总内存大小,内存里面的内容可以通过Module.buffer和Module.HEAP8查看:

IFzeqeB.png!web

这个东西就是JS和WASM数据交互的关键,在JS里面把数据放到这个HEAP8的数组里面,然后告诉WASM数据的指针地址在哪里和占用的内存大小,即在这个HEAP8数组的index和占用长度,反过来WASM想要返回数据给JS也是被放到这个HEA8里面,然后返回指针地址和和长度。

但是我们不能随便指定一个位置,需要用它提供的API进行分配和扩容。在JS里面通过Module._molloc或者Module.dynamicMalloc申请内存,如下代码所示:

// 得到文件的原始二进制数据,放在buffer里面
let buffer = new Uint8Array(this.result);
// 在HEAP里面申请一块指定大小的内存空间
// 返回起始指针地址
let offset = Module._malloc(buffer.length);
// 填充数据
Module.HEAP8.set(buffer, offset); 
// 最后调WASM的函数
let ptr = setFile(offset, buffer.length, +form.time.value * 1000);

调用malloc,传需要的内存空间大小,然后会返回分配好的内存起始地址offset,这个offset其实就是HEAP8数组里的index,然后调用Uint8Array的set方法填充数据。接着把这个offset的指针地址传给setFile,并告知内存大小。这样就实现了JS向WASM传数据。

调用setFile之后返回值是一个指针地址,指向一个struct的数据结构:

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

它的前4个字节,用来表示宽度,紧接着的4个字节是高度,后面的是图片的rgb数据的指针,指针的大小也是4个字节,这个省略了数据长度,因为可以通过width * height * 3得到。

所以[ptr, ptr + 4)存的内容是宽度,[ptr + 4, ptr + 8)存的内容是长度,[ptr + 8, ptr + 12)存的内容是指向图像数据的指针,如下代码所示:

let ptr = setFile(offset, buffer.length, +form.time.value * 1000);
let width = Module.HEAPU32[ptr / 4]
    height = Module.HEAPU32[ptr / 4 + 1],
    imgBufferPtr = Module.HEAPU32[ptr / 4 + 2],
    imageBuffer = Module.HEAPU8.subarray(imgBufferPtr, 
                      imgBufferPtr + width * height * 3);

HEAPU32和上面的HEAP8是类似的,只不过它是每个32位就读一个数,由于我们上面都是32位的数字,所以用这个刚刚好,它是4个字节一个单位,而ptr是一个字节一个单位,所以ptr / 4就得到index。这里不用担心不能够被4整除,因为它是64位对齐的。

这样我们就拿到图片的rgb数据内容了,然后用canvas画一下。

5. Canvas画图像

利用Cavans的ImageData类,如下代码所示:

function drawImage(width, height, buffer) {
    let imageData = ctx.createImageData(width, height);
    let k = 0;
    // 把buffer内存放到ImageData
    for (let i = 0; i < buffer.length; i++) {
        // 注意buffer数据是rgb的,而ImageData是rgba的
        if (i && i % 3 === 0) {
            imageData.data[k++] = 255;
        }
        imageData.data[k++] = buffer[i];
    }
    imageData.data[k] = 255;
    memCanvas.width = width;
    memCanvas.height = height;
    canvas.height = canvas.width * height / width;
    memContext.putImageData(imageData, 0, 0, 0, 0, width, height);
    ctx.drawImage(memCanvas, 0, 0, width, height, 0, 0, canvas.width, canvas.height);
}
drawImage(width, height, imageBuffer);

这样基本就完工了,但是还有一个很重要的事情要做,就是把申请的内存给释放,不然反复操作几次之后,网页的内存就飙到一两个G,然后就抛内存不够用异常了,所以在drawImage后之后把申请的内存释放了:

drawImage(width, height, imageBuffer);
// 释放内存
Module._free(offset);
Module._free(ptr);
Module._free(imgBufferPtr);

在C里面写的代码也要释放掉中间过程申请的内存,不然这个内存泄露还是挺厉害的。如果正确free之后,每次执行malloc的地址都是16358200,没有free的话,每次都会重新扩容,返回递增的offset地址。

但是这个东西整体消耗的内存还是比较大。

6. 存在的问题

初始化ffmpeg之后,网页使用的内存就飙到500MB,如果选了一个300MB的文件处理,内存就会飙到1.3GB,因为在调setFile的时候需要malloc一个300MB大小的内存,然后在C代码的setFile执行过程中又会malloc一个300MB大小的context变量,因为要处理mov/m4v格式的话为了获取moov信息需要这么大的,暂时没优化,这几个加起来就超过1GB了,并且WebAssembly.Memory只能grow,不能shrink,即只能往大扩,不能往小缩,扩充后的内存就一直在那里了。而对于普通的mp4文件,context变量只需要1MB,这个可以把内存控制在1GB以内。

第二个问题是生成的wasm的文件比较大,原始有12.6MB,gzip之后还有5MB,如下图所示:

RrMZnmZ.png!web

因为ffmpeg本身比较大,如果能够深入研究源码,然后把一些没用的功能disable掉或者不要include进来应该就可以给它瘦身,或者是只提取有用的代码,这个难度可能略高。

第三个问题是代码的稳健性,除了想办法把内存降下来,还需要考虑一些内存访问越界的问题,因为有时候跑着跑着就抛了这个异常:

Uncaught RuntimeError: memory access out of bounds

虽然存在一些问题,但是起码已经跑起来,可能暂时还不具备部署生产环境的价值,后面可以慢慢优化。

除了本文这个例子外,还可以利用ffmpeg实现其它一些功能,让网页也能够直接处理多媒体。基本上只要ffmpeg能做的,在网页也是能跑,并且wasm的性能要比直接跑JS的高。

Post Views: 22


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK