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

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

高级前端进阶:我是如何把 C/C++ 代码跑在浏览器上的?_第1张图片

什么是 WebAssembly?

  • 一种新型的代码,可以运行在 Web 浏览器,提供一些新特性并主要专注于高性能
  • 主要不是用于写,而是 C/C++、C#、Rust 等语言编译的目标,所以你即使不知道如何编写 WebAssembly 代码也能利用它的优势
  • 其他语言编写的代码也能以近似于原生速度运行,客户端 App 也能在 Web 上运行
  • 在浏览器或 Node.js 中可以导入 WebAssembly 模块,JS 框架能够使用 WebAssembly 来获得巨大的性能优势和新的特性的同时在功能上易于使用

WebAssembly 的目标

  1. 快、高效、便利 – 通过利用一些通用的硬件能力,能够跨平台以近乎于原生的速度执行
  2. 可读、可调试 – WebAssembly 是一种低层次的汇编语言,但是它也有一种人类可读的文本格式,使得人们可编写代码、查看代码、可调试代码。
  3. 确保安全 – WebAssembly 明确运行在安全、沙箱的执行环境,类似其他 Web 的代码,它会强制开启同源和一些权限策略。
  4. 不破坏现有的 Web – WebAssembly 被设计与其他 Web 技术兼容运行,并且保持向后兼容性。

WebAssembly 如何与 Web 兼容的?

Web 平台可以看做有两个部分:

  1. 一个虚拟机(VM)用于运行 Web 应用代码,例如 JS 引擎运行 JS 代码
  2. 一系列 Web API,Web 应用可以调用这些 API 来控制 Web 浏览器/设备 的功能,来做某些事情(DOM、CSSOM、WebGL、IndexedDB、Web Audio API 等)

长期以来,VM 只能加载 JS 运行,JS 可能足够满足我们的需求,但如今我们却遇到了各种性能问题,如 3D 游戏、VR/AR、计算机视觉、图片/视频编辑、以及其他需要原生性能的领域。

同时,下载、解析和编译大体积的 JS 应用是很困难的,在一些资源更加受限的平台上,如移动设备等,则会更加放到这种性能瓶颈。

WebAssembly 是一种与 JavaScript 不同的语言,它不是为了替代 JS 而生的,而是被设计为与 JS 互为补充并能协作,使得 Web 开发者能够重复利用两种语言的优点:

  1. JS 是高层次的语言,灵活且极具表现力,动态类型、不需要编译步骤,并且有强大的生态,非常易于编写 Web 应用。
  2. WebAssembly 是一种低层次、类汇编的语言,使用一种紧凑的二级制格式,能够以近乎原生的性能运行,并提供了低层次的内存模型,是 C++、Rust 等语言的编译目标,使得这类语言编写的代码能够在 Web 上运行(需要注意的是,WebAssembly 将在未来提供垃圾回收的内存模型等高层次的目标)

随着 WebAssembly 的出现,上述提到的 VM 现在可以加载两种类型的代码执行:JavaScript 和 WebAssembly。

JavaScript 和 WebAssembly 可以互操作,实际上一份 WebAssembly 代码被称为一个模块,而 WebAssembly 的模块与 ES2015 的模块在具有很多共同的特性。

WebAssembly 的关键概念

为了理解 WebAssembly 是如何在 Web 运行的,需要了解几个关键概念:

  1. Module:通过浏览器编译成为可执行机器码的 WebAssembly 二进制文件,Module 是无状态的,类似 Blob,能够在 Window 和 Worker 之间通过 postMessage 共享,一个 Module 声明了类似 ES2015 模块类似的 import 和 export。
  2. Memory:一个可调整大小的 ArrayBuffer,其中包含由 WebAssembly 的低层次内存访问指令读取和写入的线性字节数组。
  3. Table:一个可调整大小的类型化引用数组(如函数),然而处于安全和可移植性的原因,不能作为原始字节存储在内存中
  4. Instance:一个包含它在运行时用到的所有状态,包含 Memory、Table、以及一系列导入值的 Module,一个 Instance 类似一个 ES2015 的模块,它被加载到具有特定导入集的特定全局变量中

WebAssembly 的 JavaScript API 提供给开发者创建 Module、Memory、Table 和 Instance 的能力,给定一个 WebAssembly 的 Instance,JS 代码可以同步的调用它的 exports – 被作为普通的 JavaScript 函数导出。任意 JavaScript 函数可以被 WebAssembly 代码同步的调用,通过将 JavaScript 函数作为 imports 传给 WebAssembly Instance。

因为 JavaScript 能够完全控制 WebAssembly 代码的下载、编译和运行,所以 JavaScript 开发者可以认为 WebAssembly 只是 JavaScript 的一个新特性 – 可以高效的生成高性能的函数。

在未来, WebAssembly 模块可以以 ES2015 的模块加载形式加载,如

保存上述内容,重新刷新浏览器可以看到如下结果:

高级前端进阶:我是如何把 C/C++ 代码跑在浏览器上的?_第6张图片

当我们点击上图中的按钮时,可以获得如下结果:

高级前端进阶:我是如何把 C/C++ 代码跑在浏览器上的?_第7张图片

高级前端进阶:我是如何把 C/C++ 代码跑在浏览器上的?_第8张图片

首先会收到一个 alert 提示,然后在输出里面打印了 MyFunction Called 内容,表示 myFunction 调用了,打开控制台也可以看到如下打印结果:

高级前端进阶:我是如何把 C/C++ 代码跑在浏览器上的?_第9张图片

上述例子展示了可以在 JavaScript 中通过 ccall 来调用 C 代码中导出的函数。

如何编译已经存在的 C 模块到 WebAssembly?

一个 WebAssembly 的核心使用场景就是将重复利用已经存在的 C 生态系统中的库,并将它们编译到 Web 平台上使用而不用重新实现一套代码。

这些 C 库通常依赖 C 的标准库,操作系统,文件系统或者其他依赖,Emscripten 提供绝大部分上述依赖的特性,尽管还是存在一些限制。

让我们将 C 库的 WebP 编码器编译到 wasm 来了解如何编译已经存在的 C 模块,WebP codec 的源码是用 C 实现的,能够在 Github 上找到它,同时可以了解到它的一些 API 文档。

首先 Clone WebP 编码器的源码到本地,和 emsdkWebAssembly 目录同级:

git clone https://github.com/webmproject/libwebp

为了快速上手,我们可以先导出 encode.h 头文件里面的 WebPGetEncoderVersion 函数给到 JavaScript 使用,首先在 WebAssembly 文件夹下创建 webp.c 文件并加入如下:

#include "emscripten.h"

#include "src/webp/encode.h"



EMSCRIPTEN_KEEPALIVE

int version() {

  return WebPGetEncoderVersion();

}

上述的例子可以很快速的检验是否正确编译了 libwebp 的源码并能成功使用其函数,因为上述函数无需各种复杂的传参和数据结构即可成功执行。

为了编译上述函数,我们首先得告诉编译器如何找到 libwebp 库的头文件,通过在编译时加上标志 I ,然后指定 libwep 头文件的地址来告诉编译器地址,并将编译器所需要的所有 libwebp 里面的 C 文件都传给它。但有时候一个个列举 C 文件非常的繁琐,所以一种有效的策略就是将所有的 C 文件都传给编译器,然后依赖编译器自身去过滤掉那些不必要的文件,上述描述的操作可以通过在命令行编写如下命令实现:

emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \

 -I libwebp \

 WebAssembly/webp.c \

 libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

注意:上述的传参策略并不对在所有 C 项目都生效,有很多项目在编译前依赖 autoconfig/automake 等库来生成系统特定的代码,而 Emscripten 提供了 emconfigureemmake 来封装这些命令,并注入合适的参数来抹平那些有前置依赖的项目。

运行上述命令之后,会产出一份 a.out.js 胶水代码,和 a.out.wasm 文件,然后你需要在 a.out.js 文件输出的目录下创建一份 HTML 文件,并在其中添加如下代码




上述代码中,我们首先导入编译器编译输出的 a.out.js 胶水代码,然后在 WebAssembly 的模块初始化好了之后,通过 cwrap 函数导出 C 函数 version 使用,通过运行和之前类似的 npx serve . 命令,然后打开浏览器可以看到如下效果:

高级前端进阶:我是如何把 C/C++ 代码跑在浏览器上的?_第10张图片

libwebp 通过十六进制的 0xabc 的 abc 来表示当前版本 a.b.c ,例如 v0.6.1,则会被编码成十六进制 0x000601 ,对应的十进制为 1537。而这里为十进制 66049,转成 16 进制则为 0x010201 ,表示当前版本为 v1.2.1。

在 JavaScript 中获取图片并放入 wasm 中运行

刚刚通过调用编码器的 WebPGetEncoderVersion 方法来获取版本号来证实了已经成功编译了 libwebp 库到 wasm,然后可以在 JavaScript 使用它,接下来我们将了解更加复杂的操作,如何使用 libwebp 的编码 API 来转换图片格式。

libwebp 的 encoding API 需要接收一个关于 RGB、RGBA、BGR 或 BGRA 的字节数组,所以首先要回答的问题是,如何将图片放入 wasm 运行?幸运的是,Canvas API 有一个 CanvasRenderingContext2D.getImageData 方法,能够返回一个 Uint8ClampedArray ,这个数组包含 RGBA 格式的图片数据。

首先我们需要在 JavaScript 中编写加载图片的函数,将其写到上一步创建的 HTML 文件里:




现在剩下的操作则是如何将图片数据从 JavaScript 复制到 wasm,为了达成这个目的,需要在先前的 webp.c 函数里面暴露额外的方法:

  • 一个为 wasm 里面的图片分配内存的方法
  • 一个释放内存的方法

修改 webp.c 如下:

#include  // 此头文件导入用于分配内存的 malloc 方法和释放内存的 free 方法



EMSCRIPTEN_KEEPALIVE

uint8_t* create_buffer(int width, int height) {

  return malloc(width * height * 4 * sizeof(uint8_t));

}



EMSCRIPTEN_KEEPALIVE

void destroy_buffer(uint8_t* p) {

  free(p);

}

create_buffer 为 RGBA 的图片分配内存,RGBA 图片一个像素包含 4 个字节,所以代码中需要添加 4 * sizeof(uint8_t)malloc 函数返回的指针指向所分配内存的第一块内存单元地址,当这个指针返回给 JavaScript 使用时,会被当做一个简单的数字处理。当通过 cwrap 函数获取暴露给 JavaScript 的对应 C 函数时,可以使用这个指针数字找到复制图片数据的内存开始位置。

我们在 HTML 文件中添加额外的代码如下:




可以看到上述代码除了导入之前添加的 create_bufferdestroy_buffer 外,还有很多用于编码文件等方面的函数,我们将在后续讲解,除此之外,代码首先加载了一份 image.jpg 的图片,然后调用 C 函数为此图片数据分配内存,并相应的拿到返回的指针传给 WebAssembly 的 Module.HEAP8 ,在内存开始位置 p,写入图片的数据,最后会释放分配的内存。

编码图片

现在图片数据已经加载进 wasm 的内存中,可以调用 libwebp 的 encoder 方法来完成编码过程了,通过查阅 WebP 的文档,发现可以使用 WebPEncodeRGBA 函数来完成工作。这个函数接收一个指向图片数据的指针以及它的尺寸,以及一个区间在 0-100 的可选的质量参数。在编码的过程中,WebPEncodeRGBA 会分配一块用于输出数据的内存,我们需要在编码完成之后调用 WebPFree 来释放这块内存。

我们打开 webp.c 文件,添加如下处理编码的代码:

int result[2];

EMSCRIPTEN_KEEPALIVE

void encode(uint8_t* img_in, int width, int height, float quality) {

  uint8_t* img_out;

  size_t size;



  size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);



  result[0] = (int)img_out;

  result[1] = size;

}



EMSCRIPTEN_KEEPALIVE

void free_result(uint8_t* result) {

  WebPFree(result);

}



EMSCRIPTEN_KEEPALIVE

int get_result_pointer() {

  return result[0];

}



EMSCRIPTEN_KEEPALIVE

int get_result_size() {

  return result[1];

}

上述 WebPEncodeRGBA 函数执行的结果为分配一块输出数据的内存以及返回内存的大小。因为 C 函数无法使用数组作为返回值(除非我们需要进行动态内存分配),所以我们使用一个全局静态数组来获取返回的结果,这可能不是很规范的 C 代码写法,同时它要求 wasm 指针为 32 比特长,但是为了简单起见我们可以暂时容忍这种做法。

现在 C 侧的相关逻辑已经编写完毕,可以在 JavaScript 侧调用编码函数,获取图片数据的指针和图片所占用的内存大小,将这份数据保存到 JavaScript 自己的内存中,然后释放 wasm 在处理图片时所分配的内存,让我们打开 HTML 文件完成上述描述的逻辑:




在上述代码中我们通过 loadImage 函数加载了一张本地的 image.jpg 图片,你需要事先准备一张图片放置在 emcc 编译器输出的目录下,也就是我们的 HTML 文件目录下使用。

注意:new Uint8Array(someBuffer) 将会在同样的内存块上创建一个新视图,而 new Uint8Array(someTypedArray) 只会复制 someTypedArray 的数据。

当你的图片比较大时,因为 wasm 不能扩充可以容纳 inputoutput 图片数据的内存,你可能会遇到如下报错:

但是我们例子中使用的图片比较小,所以只需要单纯的在编译时加上一个过滤参数 -s ALLOW_MEMORY_GROWTH=1 忽略这个报错信息即可:

emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \

    -I libwebp \

    test-dir/webp.c \

    libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c \

    -s ALLOW_MEMORY_GROWTH=1

再次运行上述命令,得到添加了编码函数的 wasm 代码和对应的 JavaScript 胶水代码,这样当我们打开 HTML 文件时,它已经能够将一份 JPG 文件编码成 WebP 的格式,为了近一步证实这个观点,我们可以将图片展示到 Web 界面上,通过修改 HTML 文件,添加如下代码:


然后刷新浏览器,应该可以看到WebP图片展示到 Web 端,通过将这个文件下载到本地,可以看到其格式转成了 WebP:

高级前端进阶:我是如何把 C/C++ 代码跑在浏览器上的?_第11张图片

通过上述的流程我们成功编译了现有的 libwebp C 库到 wasm 使用,并将 JPG 图片转成了 WebP 格式并展示在 Web 界面上,通过 wasm 来处理计算密集型的转码操作可以大大提高网页的性能,这也是 WebAssembly 带来的主要优势之一。

如何编译 FFmpeg 到 WebAssembly?

  • emconfigure is to replace the compiler from gcc to emcc (or g++ to em++):编译 C 项目
  • Make generates wasm object files:生成 wasm 对象 .o 文件

在第二个例子中我们成功编译了已经存在的 C 模块到 WebAssembly,但是有很多项目在编译前依赖 autoconfig/automake 等库来生成系统特定的代码,而 Emscripten 提供了 emconfigureemmake 来封装这些命令,并注入合适的参数来抹平那些有前置依赖的项目,接下来我们通过实际编译 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 

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

  • USE_PTHREADS 开启 pthreads 支持
  • -O3 表示在编译时优化代码体积,一般可以从 30MB 压缩到 15MB
  • INITIAL_MEMORY 设置为 33554432 (32MB),主要是 Emscripten 可能占用 19MB,所以设置更大的内存容量来避免在编译过程中可分配的内存不足的问题
  • 实际使用 emconfigure 来配置 configure 文件,替换 gcc 编译器为 emcc ,以及设置一些必要的操作来处理可能遇到的编译 BUG,最终生成用于编译构建的配置文件

使用 emmake make 来构建依赖

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

# 构建最终的 ffmpeg.wasm 文件

emmake make -j4

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

  • ffmpeg
  • ffmpeg_g
  • ffmpeg_g.wasm
  • ffmpeg_g.worker.js

前两个都是 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.wasmffmpeg-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-core.js
  • ffmpeg-core.wasm
  • ffmpeg-core.worker.js

使用编译完成的 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

上述代码解释如下:

  • onRuntimeInitialized 是加载 WebAssembly 模块完成之后执行的逻辑,我们所有相关逻辑需要在这个函数中编写

  • cwrap 则用于导出 C 文件中(fftools/ffmpeg.c )的 proxy_main 使用,函数的签名为 int main(int argc, char **argv) ,其中 int 对应到 JavaScript 就是 number ,而 char **argv 是 C 中的指针,也可以映射到 number

  • 接着处理 ffmpeg 的传参兼容逻辑,对于命令行中运行 ffmpeg -hide_banner ,在我们代码里通过函数调用需要 main(2, ["./ffmpeg", "-hide_banner"]) ,第一个参数很好解决,那么我们如何传递一个字符串数组呢?这个问题可以分解为两个部分:

    • 我们需要将 JavaScript 的字符串转换成 C 中的字符数组
    • 我们需要将 JavaScript 中的数字数组转换为 C 中的指针数组

第一部分很简单,因为 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 。

为了完成上述的任务,只需要使用到 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() 将视频写入到本地文件系统。最终我们会收到如下结果:

高级前端进阶:我是如何把 C/C++ 代码跑在浏览器上的?_第12张图片

在浏览器中使用 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 文件,然后添加如下内容:

                                                                                                                                            

                                                                                                                                            

                                                                                                                                          

                                                                  

                                                                   

    

上传视频文件,然后转码到 mp4 (x264) 进行播放!


ffmpeg 脚本需要等待 5S 左右加载完成

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

高级前端进阶:我是如何把 C/C++ 代码跑在浏览器上的?_第13张图片

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

参考

  • https://www.ruanyifeng.com/blog/2017/09/asmjs_emscripten.html
  • https://pspdfkit.com/blog/2017/webassembly-a-new-hope/
  • https://hacks.mozilla.org/2017/02/what-makes-webassembly-fast/
  • https://www.sitepoint.com/understanding-asm-js/
  • http://www.cmake.org/download/
  • https://developer.mozilla.org/en-US/docs/WebAssembly/existing_C_to_wasm
  • https://research.mozilla.org/webassembly/
  • https://itnext.io/build-ffmpeg-webassembly-version-ffmpeg-js-part-2-compile-with-emscripten-4c581e8c9a16?gi=e525b34f2c21
  • https://dev.to/alfg/ffmpeg-webassembly-2cbl
  • https://gist.github.com/rinthel/f4df3023245dd3e5a27218e8b3d79926
  • https://github.com/Kagami/ffmpeg.js/
  • https://qdmana.com/2021/04/20210401214625324n.html
  • https://github.com/leandromoreira/ffmpeg-libav-tutorial
  • http://ffmpeg.org/doxygen/4.1/examples.html
  • https://github.com/alfg/ffmpeg-webassembly-example
  • https://github.com/alfg/ffprobe-wasm
  • https://gist.github.com/rinthel/f4df3023245dd3e5a27218e8b3d79926#file-ffmpeg-emscripten-build-sh
  • https://emscripten.org/docs/compiling/Building-Projects.html#integrating-with-a-build-system
  • https://itnext.io/build-ffmpeg-webassembly-version-ffmpeg-js-part-2-compile-with-emscripten-4c581e8c9a16
  • https://github.com/mymindstorm/setup-emsdk
  • https://github.com/emscripten-core/emsdk
  • https://github.com/FFmpeg/FFmpeg/blob/n4.3.1/INSTALL.md
  • https://yeasy.gitbook.io/docker_practice/container/run

❤️/ 感谢支持 /

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

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

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

你可能感兴趣的:(前端,webassembly,编译器)