高级前端进阶:我是如何把 C/C 代码跑在浏览器上的?

高级前端进阶:我是如何把 C/C 代码跑在浏览器上的?

首页角色扮演代号GC更新时间:2024-07-26

最近组长交给我一个任务,让我尝试一下将知名视频转码库 ffmpeg (使用 C 编写)跑在浏览器里面,我当时就懵了,还能这么玩?调研了一番,发现有个叫 WebAssembly 的东西可以干这么件事情,于是就有了这篇文章。

如何编译 FFmpeg 到 WebAssembly?

在第二个例子中我们成功编译了已经存在的 C 模块到 WebAssembly,但是有很多项目在编译前依赖 autoconfig/automake 等库来生成系统特定的代码,而 Emscripten 提供了 emconfigure 和 emmake 来封装这些命令,并注入合适的参数来抹平那些有前置依赖的项目,接下来我们通过实际编译 ffmpeg 来讲解如何处理这种依赖 autoconfig/automake 等库来生成特定的代码。

经过实践发现 ffmpeg 的编译依赖于特定的 ffmpeg 版本、Emscripten 版本、操作系统环境等,所以以下的 ffmpeg 的编译都是限制在特定的条件下进行的,主要是为之后通用的 ffmpeg 的编译提供一种思路和调试方法。

编译步骤

使用 Emscripten 编译大部分复杂的 C/C 库时,主要需要三个步骤:

  1. 使用 emconfigure 运行项目的 configure 文件将 C/C 代码编译器从 gcc/g 换成 emcc/em
  2. 通过 emmake make 来构建 C/C 项目,生成 wasm 对象的 .o 文件
  3. 为了生成特定形式的输出,手动调用 emcc 来编译特定的文件
安装特定依赖

为了验证 ffmpeg 的验证,我们需要依赖特定的版本,下面详细讲解依赖的各种文件版本。

首先安装 1.39.18 版本的 Emscripten 编译器,进入之前我们 Clone 到本地的 emsdk 项目运行如下命令:

./emsdk install 1.39.18 ./emsdk activate 1.39.18 source ./emsdk_env.sh

通过在命令行中输入如下命令验证是否切换成功:

emcc -v # 输出 1.39.18

在 emsdk 同级下载分支为 n4.3.1 的 ffmpeg 代码:

git clone --depth 1 --branch n4.3.1 https://github.com/FFmpeg/FFmpeg 使用 emconfigure 处理 configure 文件

通过如下脚本来处理 configure 文件:

export CFLAGS="-s USE_PTHREADS -O3" export LDFLAGS="$CFLAGS -s INITIAL_MEMORY=33554432" emconfigure ./configure \ --target-os=none \ # 设置为 none 来去除特定操作系统的一些依赖 --arch=x86_32 \ # 选中架构为 x86_32 --enable-cross-compile \ # 处理跨平台操作 --disable-x86asm \ # 关闭 x86asm --disable-inline-asm \ # 关闭内联的 asm --disable-stripping \ # 关闭处理 strip 的功能,避免误删一些内容 --disable-programs \ # 加速编译 --disable-doc \ # 添加一些 flag 输出 --extra-cflags="$CFLAGS" \ --extra-cxxflags="$CFLAGS" \ --extra-ldflags="$LDFLAGS" \ --nm="llvm-nm" \ # 使用 llvm 的编译器 --ar=emar \ --ranlib=emranlib \ --cc=emcc \ # 将 gcc 替换为 emcc --cxx=em \ # 将 g 替换为 em --objcc=emcc \ --dep-cc=emcc

上述脚本主要做了如下几件事:

使用 emmake make 来构建依赖

通过上述步骤,就处理好了配置文件,接下来需要通过 emmake 来构建实际的依赖,通过在命令行中运行如下命令:

# 构建最终的 ffmpeg.wasm 文件 emmake make -j4

通过上述的编译,会生成如下四个文件:

前两个都是 JS 文件,第三个为 wasm 模块,第四个是处理 worker 中运行相关逻辑的函数,上述生成的文件的理想形式应该为三个,为了达成这种自定义的编译,有必要自定义使用 emcc 命令来进行处理。

使用 emcc 个性化编译

在 FFmpeg 目录下创建 wasm 文件夹,用于放置构建之后的文件,然后自定义编译文件输出如下:

mkdir -p wasm/dist emcc \ -I. -I./fftools \ -Llibavcodec -Llibavdevice -Llibavfilter -Llibavformat -Llibavresample -Llibavutil -Llibpostproc -Llibswscale -Llibswresample \ -Qunused-arguments \ -o wasm/dist/ffmpeg-core.js fftools/ffmpeg_opt.c fftools/ffmpeg_filter.c fftools/ffmpeg_hw.c fftools/cmdutils.c fftools/ffmpeg.c \ -lavdevice -lavfilter -lavformat -lavcodec -lswresample -lswscale -lavutil -lm \ -O3 \ -s USE_SDL=2 \ # 使用 SDL2 -s USE_PTHREADS=1 \ -s PROXY_TO_PTHREAD=1 \ # 将 main 函数与浏览器/UI主线程分离 -s INVOKE_RUN=0 \ # 执行 C 函数时不首先执行 main 函数 -s EXPORTED_FUNCTIONS="[_main, _proxy_main]" \ -s EXTRA_EXPORTED_RUNTIME_METHODS="[FS, cwrap, setValue, writeAsciiToMemory]" \ -s INITIAL_MEMORY=33554432

上述的脚本主要有如下几点改进:

  1. -s PROXY_TO_PTHREAD=1 在编译时设置了 pthread 时,使得程序具备响应式特效
  2. -o wasm/dist/ffmpeg-core.js 则将原 ffmpeg js 文件的输出重命名为 ffmpeg-core.js ,对应的输出 ffmpeg-core.wasm 和 ffmpeg-core.worker.js
  3. -s EXPORTED_FUNCTIONS="[_main, _proxy_main]" 导出 ffmpeg 对应的 C 文件里的 main 函数,proxy_main 则是通过设置 PROXY_TO_PTHREAD代理 main 函数用于外部使用
  4. -s EXTRA_EXPORTED_RUNTIME_METHODS="[FS, cwrap, setValue, writeAsciiToMemory]" 则是导出一些帮助函数,用于导出 C 函数、处理文件系统、指针的操作

通过上述编译命令最终输出下面三个文件:

使用编译完成的 ffmpeg wasm 模块

在 wasm 目录下创建 ffmpeg.js 文件,在其中写入如下代码:

const Module = require('./dist/ffmpeg-core.js'); Module.onRuntimeInitialized = () => { const ffmpeg = Module.cwrap('proxy_main', 'number', ['number', 'number']); };

然后通过如下命令运行上述代码:

node --experimental-wasm-threads --experimental-wasm-bulk-memory ffmpeg.js

上述代码解释如下:

第一部分很简单,因为 Emscripten 提供了一个辅助函数 writeAsciiToMemory 来完成这一工作:

const str = "FFmpeg.wasm"; const buf = Module._malloc(str.length 1); // 额外分配一个字节的空间来存放 0 表示字符串的结束 Module.writeAsciiToMemory(str, buf);

第二部分有一点困难,我们需要创建 C 中的 32 位整数的指针数组,可以借助 setValue 来帮助我们创建这个数组:

const ptrs = [123, 3455]; const buf = Module._malloc(ptrs.length * Uint32Array.BYTES_PER_ELEMENT); ptrs.forEach((p, idx) => { Module.setValue(buf (Uint32Array.BYTES_PER_ELEMENT * idx), p, 'i32'); });

将上述的代码合并起来,我们就可以获取一个能与 ffmpeg 交互的程序:

const Module = require('./dist/ffmpeg-core'); Module.onRuntimeInitialized = () => { const ffmpeg = Module.cwrap('proxy_main', 'number', ['number', 'number']); const args = ['ffmpeg', '-hide_banner']; const argsPtr = Module._malloc(args.length * Uint32Array.BYTES_PER_ELEMENT); args.forEach((s, idx) => { const buf = Module._malloc(s.length 1); Module.writeAsciiToMemory(s, buf); Module.setValue(argsPtr (Uint32Array.BYTES_PER_ELEMENT * idx), buf, 'i32'); }) ffmpeg(args.length, argsPtr); };

然后通过同样的命令运行程序:

node --experimental-wasm-threads --experimental-wasm-bulk-memory ffmpeg.js

上述运行的结果如下:

可以看到我们成功编译并运行了 ffmpeg 。

处理 Emscripten 文件系统

Emscripten 内建了一个虚拟的文件系统来支持 C 中标准的文件读取和写入,所以我们需要将音频文件传给 ffmpeg.wasm 时先写入到文件系统中。

可以戳此查看更多关于文件系统 API[1]

为了完成上述的任务,只需要使用到 FS 模块的两个函数 FS.writeFile() 和 FS.readFile() ,对于从文件系统中读取和写入的所有数据都要求是 JavaScript 中的 Uint8Array 类型,所以在消费数据之前有必要约定数据类型。

我们将通过 fs.readFileSync() 方法读取名为 flame.avi 的视频文件,然后使用 FS.writeFile() 将其写入到 Emscripten 文件系统。

const fs = require('fs'); const Module = require('./dist/ffmpeg-core'); Module.onRuntimeInitialized = () => { const data = Uint8Array.from(fs.readFileSync('./flame.avi')); Module.FS.writeFile('flame.avi', data); const ffmpeg = Module.cwrap('proxy_main', 'number', ['number', 'number']); const args = ['ffmpeg', '-hide_banner']; const argsPtr = Module._malloc(args.length * Uint32Array.BYTES_PER_ELEMENT); args.forEach((s, idx) => { const buf = Module._malloc(s.length 1); Module.writeAsciiToMemory(s, buf); Module.setValue(argsPtr (Uint32Array.BYTES_PER_ELEMENT * idx), buf, 'i32'); }) ffmpeg(args.length, argsPtr); }; 使用 ffmpeg.wasm 编译视频

现在我们已经可以将视频文件保存到 Emscripten 文件系统了,接下来就是实际使用编译好的 ffmepg 来进行视频的转码了。

我们修改代码如下:

const fs = require('fs'); const Module = require('./dist/ffmpeg-core'); Module.onRuntimeInitialized = () => { const data = Uint8Array.from(fs.readFileSync('./flame.avi')); Module.FS.writeFile('flame.avi', data); const ffmpeg = Module.cwrap('proxy_main', 'number', ['number', 'number']); const args = ['ffmpeg', '-hide_banner', '-report', '-i', 'flame.avi', 'flame.mp4']; const argsPtr = Module._malloc(args.length * Uint32Array.BYTES_PER_ELEMENT); args.forEach((s, idx) => { const buf = Module._malloc(s.length 1); Module.writeAsciiToMemory(s, buf); Module.setValue(argsPtr (Uint32Array.BYTES_PER_ELEMENT * idx), buf, 'i32'); }); ffmpeg(args.length, argsPtr); const timer = setInterval(() => { const logFileName = Module.FS.readdir('.').find(name => name.endsWith('.log')); if (typeof logFileName !== 'undefined') { const log = String.fromCharCode.apply(null, Module.FS.readFile(logFileName)); if (log.includes("frames successfully decoded")) { clearInterval(timer); const output = Module.FS.readFile('flame.mp4'); fs.writeFileSync('flame.mp4', output); } } }, 500); };

在上述代码中,我们添加了一个定时器,因为 ffmpeg 转码视频的过程是异步的,所以我们需要不断的去读取 Emscripten 文件系统中是否有转码好的文件标志,当拿到文件标志且不为 undefined,我们就使用 Module.FS.readFile() 方法从 Emscripten 文件系统中读取转码好的视频文件,然后通过 fs.writeFileSync() 将视频写入到本地文件系统。最终我们会收到如下结果:

在浏览器中使用 ffmpeg 转码视频并播放

在上一步中,我们成功在 Node 端使用了编译好的 ffmpeg 完成从了 avi 格式到 mp4 格式的转码,接下来我们将在浏览器中使用 ffmpeg 转码视频,并在浏览器中播放。

之前我们编译的 ffmpeg 虽然可以将 avi 格式转码到 mp4 ,但是 mp4 的文件无法直接在浏览器中播放,因为不支持这种编码,所以我们需要使用 libx264 编码器来将 mp4 文件编码成浏览器可播放的编码格式。

首先在 WebAssembly 目录下下载 x264 的编码器源码:

curl -OL https://download.videolan.org/pub/videolan/x264/snapshots/x264-snapshot-20170226-2245-stable.tar.bz2 ttar xvfj x264-snapshot-20170226-2245-stable.tar.bz2

然后进入 x264 的文件夹,可以创建一个 build.sh 文件,并加入如下内容:

#!/bin/bash -x ROOT=$PWD BUILD_DIR=$ROOT/build cd $ROOT/x264-snapshot-20170226-2245-stable ARGS=( --prefix=$BUILD_DIR --host=i686-gnu # use i686 gnu --enable-static # enable building static library --disable-cli # disable cli tools --disable-asm # disable asm optimization --extra-cflags="-s USE_PTHREADS=1" # pass this flags for using pthreads ) emconfigure ./configure "${ARGS[@]}" emmake make install-lib-static -j4 cd -

注意需要在 WebAssembly 目录下运行如下命令来构建 x264:

bash x264-snapshot-20170226-2245-stable/build-x264.sh

安装了 x264 编码器之后,就可以在 ffmpeg 的编译脚本中加入打开 x264 的开关,这一次我们在 ffmpeg 文件夹下创建 Bash 脚本用于构建,创建 configure.sh 如下:

#!/bin/bash -x emcc -v ROOT=$PWD BUILD_DIR=$ROOT/build cd $ROOT/ffmpeg-4.3.2-3 CFLAGS="-s USE_PTHREADS -I$BUILD_DIR/include" LDFLAGS="$CFLAGS -L$BUILD_DIR/lib -s INITIAL_MEMORY=33554432" # 33554432 bytes = 32 MB CONFIG_ARGS=( --target-os=none # use none to prevent any os specific configurations --arch=x86_32 # use x86_32 to achieve minimal architectural optimization --enable-cross-compile # enable cross compile --disable-x86asm # disable x86 asm --disable-inline-asm # disable inline asm --disable-stripping --disable-programs # disable programs build (incl. ffplay, ffprobe & ffmpeg) --disable-doc # disable doc --enable-gpl ## required by x264 --enable-libx264 ## enable x264 --extra-cflags="$CFLAGS" --extra-cxxflags="$CFLAGS" --extra-ldflags="$LDFLAGS" --nm="llvm-nm" --ar=emar --ranlib=emranlib --cc=emcc --cxx=em --objcc=emcc --dep-cc=emcc ) emconfigure ./configure "${CONFIG_ARGS[@]}" # build ffmpeg.wasm emmake make -j4 cd -

然后创建用于自定义输出构建文件的脚本文件 build-ffmpeg.sh :

ROOT=$PWD BUILD_DIR=$ROOT/build cd ffmpeg-4.3.2-3 ARGS=( -I. -I./fftools -I$BUILD_DIR/include -Llibavcodec -Llibavdevice -Llibavfilter -Llibavformat -Llibavresample -Llibavutil -Llibpostproc -Llibswscale -Llibswresample -L$BUILD_DIR/lib -Qunused-arguments # 这一行加入 -lpostproc 和 -lx264,添加加入 x264 的编译 -o wasm/dist/ffmpeg-core.js fftools/ffmpeg_opt.c fftools/ffmpeg_filter.c fftools/ffmpeg_hw.c fftools/cmdutils.c fftools/ffmpeg.c -lavdevice -lavfilter -lavformat -lavcodec -lswresample -lswscale -lavutil -lpostproc -lm -lx264 -pthread -O3 # Optimize code with performance first -s USE_SDL=2 # use SDL2 -s USE_PTHREADS=1 # enable pthreads support -s PROXY_TO_PTHREAD=1 # detach main() from browser/UI main thread -s INVOKE_RUN=0 # not to run the main() in the beginning -s EXPORTED_FUNCTIONS="[_main, _proxy_main]" # export main and proxy_main funcs -s EXTRA_EXPORTED_RUNTIME_METHODS="[FS, cwrap, setValue, writeAsciiToMemory]" # export preamble funcs -s INITIAL_MEMORY=268435456 # 268435456 bytes = 268435456 MB ) emcc "${ARGS[@]}" cd - 实际使用 ffmpeg 转码

我们将创建一个 Web 网页,然后提供一个上传视频文件的按钮,以及播放上传的视频文件。尽管无法直接在 Web 端播放 avi 格式的视频文件,但是我们可以通过 ffmpeg 转码之后播放。

在 ffmpeg 目录下的 wasm 文件夹下创建 index.html 文件,然后添加如下内容:

<html> <head> <style> html, body { margin: 0; width: 100%; height: 100% } body { display: flex; flex-direction: column; align-items: center; } </style> </head> <body> <h3>上传视频文件,然后转码到 mp4 (x264) 进行播放!</h3> <video id="output-video" controls></video><br/> <input type="file" id="uploader"> <p id="message">ffmpeg 脚本需要等待 5S 左右加载完成</p> <script type="text/javascript"> const readFromBlobOrFile = (blob) => ( new Promise((resolve, reject) => { const fileReader = new FileReader(); fileReader.onload = () => { resolve(fileReader.result); }; fileReader.onerror = ({ target: { error: { code } } }) => { reject(Error(`File could not be read! Code=${code}`)); }; fileReader.readAsArrayBuffer(blob); }) ); const message = document.getElementById('message'); const transcode = async ({ target: { files } }) => { const { name } = files[0]; message.innerHTML = '将文件写入到 Emscripten 文件系统'; const data = await readFromBlobOrFile(files[0]); Module.FS.writeFile(name, new Uint8Array(data)); const ffmpeg = Module.cwrap('proxy_main', 'number', ['number', 'number']); const args = ['ffmpeg', '-hide_banner', '-nostdin', '-report', '-i', name, 'out.mp4']; const argsPtr = Module._malloc(args.length * Uint32Array.BYTES_PER_ELEMENT); args.forEach((s, idx) => { const buf = Module._malloc(s.length 1); Module.writeAsciiToMemory(s, buf); Module.setValue(argsPtr (Uint32Array.BYTES_PER_ELEMENT * idx), buf, 'i32'); }); message.innerHTML = '开始转码'; ffmpeg(args.length, argsPtr); const timer = setInterval(() => { const logFileName = Module.FS.readdir('.').find(name => name.endsWith('.log')); if (typeof logFileName !== 'undefined') { const log = String.fromCharCode.apply(null, Module.FS.readFile(logFileName)); if (log.includes("frames successfully decoded")) { clearInterval(timer); message.innerHTML = '完成转码'; const out = Module.FS.readFile('out.mp4'); const video = document.getElementById('output-video'); video.src = URL.createObjectURL(new Blob([out.buffer], { type: 'video/mp4' })); } } }, 500); }; document.getElementById('uploader').addEventListener('change', transcode); </script> <script type="text/javascript" src="./dist/ffmpeg-core.js"></script> </body> </html>

打开上述网页运行,我们可以看到如下效果:

恭喜你!成功编译 ffmpeg 并在 Web 端使用。

参考参考资料

[1]

文件系统 API: https://emscripten.org/docs/api_reference/Filesystem-API.html

❤️/ 感谢支持 /

以上便是本次分享的全部内容,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 三连哦~

欢迎关注公众号 程序员巴士,来自字节、虾皮、招银的三端兄弟,分享编程经验、技术干货与职业规划,助你少走弯路进大厂。

查看全文
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved