分享到: 分享到QQ  分享到Twitter

作者: BigLoser    访问次数: 232 创建时间: 2023-04-12 20:06:43 更新时间: 2024-04-30 00:22:34

01 项目背景

 

在视频编辑器里常见这样的功能,在用户上传完视频后抽取关键帧 ,提供给用户以便快捷选取封面,如下图:

图片

在本文中,我们将探讨一种使用 FFmpeg 和 WebAssembly(Wasm)的 Web 端视频截帧方案,以解决传统的基于 canvas 的截帧方案所存在的问题。通过采用这种新方法,我们可以克服 video 标签的限制,实现更高效、更灵活的视频截帧功能。

首先,我们需要了解一下传统的 Web 截帧方案的局限性。虽然该方案在处理一些常见的视频格式(如 MP4、WebM 和 OGG)时表现良好,但其存在以下缺陷:

  • 类型有限:video 标签支持的视频格式十分有限,无法处理一些其他常见的视频格式,如 FLV、MKV 和 AVI 等。

  • DOM 依赖:该方案依赖于 DOM,只能在主线程中完成。这意味着在处理大量截帧任务时,可能会对页面性能产生负面影响。

  • 抽帧策略局限:传统方案无法精确控制抽帧策只能传递时间交给浏览器,设置 currentTime 时会解码寻找最接近的帧,而非关键帧。

为解决上述问题,选取 FFmpeg+Wasm 的方案,通过自定义编译 FFmpeg,在 web-worker 里执行 rgb24 格式数据到 ImageData 的运算,再传递结果给主线程,实现。

 

02 Wasm 核心原理

 

2.1 Wasm 是什么

 

用官网的话说,WebAssembly (缩写为 Wasm) 是一种用于基于堆栈的虚拟机的二进制指令格式。

WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.  

--- https://webassembly.org/

Wasm 可以看作一种容器技术,它定义了一种独立的、可移植的虚拟机,可以在各种平台上执行,类比于 docker,但更为轻量。WebAssembly 于 2017 年粉墨登场,2019 年 12 月正式认证为 Web 标准之一并被推荐,拥有高性能、跨平台、安全性、多语言高可移植等优势。

业界有很多 Wasm 虚拟机的实现,包含解释器,单层 / 多层 AOT、JIT 模式。

图片

 

2.2 chrome 如何运行 Wasm

 

浏览器内置 JIT 引擎,V8 使用了分层编译模式(Tiered)来编译和优化 WASM 代码。分层编译模式包括两个主要的编译器:

  1. 基线编译器(Baseline compiler) Liftoff 编译器

  2. 优化编译器(Optimizing compiler) TurboFun 编译器

 

2.2.1 Liftoff 编译器

 

当 WASM 代码首次加载时,V8 使用 Liftoff 编译器进行快速编译。Liftoff 是一个线性时间编译器,它可以在极短的时间内为每个 WASM 指令生成机器代码。这意味着,它可以尽快地生成可执行代码,从而缩短代码加载时间。

然而,Liftoff 编译器的优化空间有限。它采用一种简单的一对一映射策略,将 WASM 指令独立地转换为机器代码,而不进行任何高级优化。这使得生成的代码性能较低。

 

2.2.2 TurboFan 编译器

 

对于那些被频繁调用的热函数(Hot Functions),V8 会使用 TurboFan 编译器进行优化编译。TurboFan 是一个更高级的编译器,能够执行各种复杂的优化技术,如内联缓存(Inline Caching)、死代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)和常量折叠(Constant Folding)等,从而显著提高代码的运行效率。

V8 会监控 WASM 函数的调用频率。一旦一个函数达到特定的阈值,它就会被认为是 Hot,并在后台线程中触发重新编译。在优化编译完成后,新生成的 TurboFan 代码会替换原有的 Liftoff 代码。之后对该函数的任何新调用都将使用 TurboFan 生成的新的优化代码,而不是 Liftoff 代码。

 

2.2.3 流式编译与代码缓存

 

V8 引擎支持流式编译(Streaming Compilation),这意味着 WASM 代码可以在下载的同时进行编译。这大大缩短了从加载到可执行的总时间。流式编译在基线编译阶段(Liftoff 编译器)尤为重要,因为它可以确保 WASM 代码在最短的时间内变得可运行。

为了进一步提高性能和加载速度,V8 引擎支持代码缓存(Code Caching)机制。代码缓存可以将编译后的 WASM 代码存储在缓存中,以便在将来需要时直接从缓存中加载,而无需重新编译。这大大缩短了页面加载时间,提高了用户体验。目前 WebAssembly 缓存仅针对流式 API 调用, compileStreaming 和 instantiateStreaming 这两个 API,使用流式 API 拥有更好的性能。对于缓存的工作原理:

  1. 当 TurboFan 完成编译后,如果.wasm 资源足够大(128 kb),Chrome 会将编译后的代码写入 WebAssembly 代码缓存。

  2. 当.wasm 第二次请求资源时(hot run),Chrome.wasm 从资源缓存中加载资源,同时查询代码缓存。如果缓存命中,编译后的 module bytes 将发送到渲染器进程并传递给 V8,V8 将其进行反序列化,与编译相比,反序列化速度更快,占用的 CPU 更少。

  3. 如果.wasm 资源发生了变化或是 V8 发生了变化,缓存会失效,缓存的本地代码会从缓存中清除,编译会像步骤 1 一样继续进行。

 
2.2.6 编译管道(Compilation Pipeline)

 

图片

△频效果 V8 编译 Wasm 的流程图

V8 编译 WASM 代码的整个过程可以概括为以下几个步骤:

  1. 解码(Decoding):首先,将 WASM 模块解码为二进制可执行代码,并验证其是否符合 WASM 标准。

  2. 基线编译(Baseline Compilation):接下来,使用 Liftoff 编译器进行快速编译。这一阶段生成的代码性能较低,但编译速度快。流式编译在这个阶段发挥作用,使得代码在下载过程中就能进行编译。

  3. 热点分析(Hotspot Analysis):V8 引擎会持续监控 WASM 函数的调用频率,以识别 Hot Function。

  4. 优化编译(Optimizing Compilation):对于被标记为热门函数的代码,使用 TurboFan 编译器进行优化编译。编译完成后,优化后的代码会替换原有的 Liftoff 代码。这一过程称为分层升级(Tier-up)。

  5. 执行(Execution):在优化编译完成后,代码将在 V8 引擎中运行。

对比 V8 执行 js 的流程,省去了Parser生成 ast,Ignition生成字节码的的过程,因此有更高的性能和执行效率。

 

03 FFmpeg 的介绍

 

FFmpeg 作为一个开源的强大的音视频处理工具,实现视频和音频的录制、转换、编辑等多种功能。FFmpeg 包含了众多的编码库和工具,可以处理各种格式的音视频文件,例如 MPEG、AVI、FLV、WMV、MP4 等等。

FFmpeg 最初是由 Fabrice Bellard 于 2000 年创立的,现在它是由一个庞大的社区维护的开源软件项目。FFmpeg 支持各种操作系统,包括 Windows、macOS、Linux 等,也支持各种硬件平台,例如 x86、ARM 等。

FFmpeg 的功能非常强大,可以进行很多复杂的音视频处理操作,例如视频转码、视频合并、音频剪辑、音频混合等等。FFmpeg 支持众多编码格式和协议,包括 H.264、HEVC、VP9、AAC、MP3 等等。同时,它还可以进行流媒体的处理,例如将视频流推送到 RTMP 服务器、从 RTSP 服务器拉取视频流等等。

 

04 截帧策略的制定

 

4.1 I、B、P 帧是什么

 

这个概念来源于视频编码,为描述视频压缩编码中的帧类型。

I 帧(Intra-coded frame),也叫关键帧(keyframe),它是视频序列中的一种独立帧,也就是说,它不需要参考其它帧进行解码。I 帧通常用来作为视频序列的参考点,后续的 B 帧和 P 帧都会参考它进行编码。I 帧通常具有较高的压缩比和较大的文件大小,但是它也提供了最高的图像质量。

P 帧(Predictive-coded frame) 是通过对前面的 I 帧或 P 帧进行运动预测得到的帧,也就是说,P 帧需要参考前面的一个或多个帧进行解码。P 帧通常比 I 帧小一些,但是它的压缩比比 I 帧高。

B 帧(Bidirectionally-predictive-coded frame) 是通过对前面和后面的帧进行运动预测得到的帧,也就是说,B 帧需要参考前面和后面的帧进行解码。B 帧通常比 P 帧更小,因为它可以更充分地利用前后两个参考帧之间的冗余信息进行编码。

因此,视频编码中通常会使用一种叫做 “三合一” 编码的方式,即将一个 I 帧和它前面的若干个 P 帧以及后面的若干个 B 帧组成一个 GOP(Group of Pictures)。这样的编码方式既可以提高编码的效率,也可以提供高质量的图像。

图片

△I、B、P 帧关系示例图

 

4.2 关键帧生成策略

 

视频编辑器抽帧的目的是为用户提供有效的封面图选取,因此我们希望抽出来包含较大信息量质量较高的图作为抽帧产物,从上面的介绍可知,一般情况下关键帧是包含信息量较大的帧,因此理想状态是只产出关键帧。

按照需求场景,我们需要对每个视频提取 12 张图片。若使用 canvas 抽帧方案,就意味着这 12 张图片只能根据时间间隔进行抽取,无法使用视频本身的关键帧信息,图片可能是关键帧,也可能是 BP 帧。非关键帧的图片往往质量较差不适合作为封面图。且浏览器也需要基于 I 帧进行逐帧的解码,这会耗费较长的时间。因此我们决定借助 FFmpeg 库的能力,生成关键帧。

为什么不直接使用 FFmpeg 的命令生成关键帧呢,一个视频具体有多少张关键帧这是不一定的,可能多于 12 张也可能少于 12 张,因此只用 FFmpeg 的命令生成关键帧一把梭生成全部关键帧这是不够的。

对于少于 12 张关键帧的视频,采取补齐的策略,在两关键帧之间,以 2s 为时间间隔进行补齐。如果两帧间隔时间不足 2s 间隔分配,那就按照两关键帧间隔时间 / 在此间隔需要补的帧数,计算出需要补齐的帧的所在时间。

FFmpeg 在获取关键帧是很快的,因为关键帧的时间信息是可以直接从视频里获取到的,可以直接调用av_seek_frame 跳到关键帧位置,然后解一帧即可,对于指定时间的非关键帧的寻找,需要跳到最近的关键帧,再一帧帧的解包寻找,知道寻找的指定的时间,进行输出。

对于超出 12 帧关键帧的视频,按照相等的间隔进行选取,比如有 24 张,那么选取 0、2、...23 索引的帧为输出帧。

其他的优化点,第一帧一定是 I 帧,因此在第一时间读取第一帧并返回,让用户瞬间看到一帧,减少视觉等待时间,其他帧每确定一帧是符合输出帧就立即输出,用户看到的是一帧帧输出的,而不是等到全部抽帧任务完成再输出。

图片

△百家号 wasm 抽帧效果图

 

05 定义编译 FFmpeg

 

5.1 环境准备

 

Emscripten、LLVM、Clang 都可以将 c、cpp 代码编译成 Wasm,我们使用 Emscripten 编译。Emscripten 会帮你生成胶水代码 (.js 文件) 和 Wasm 文件。

首先下载 emsdk,执行以下命令配置并激活已安装的 Emscripten。

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
  git pull
  ./emsdk install latest
  ./emsdk activate latest
   source ./emsdk_env.sh

最后 source 环境变量,配置 Emscripten 各个组件的 PATH 等环境变量。

 

5.2 编译 FFmpeg

 

为了产出能在以在浏览器中运行的 WebAssembly 版本的 FFmpeg,我们禁用了大部分针对特定平台或体系结构的优化,以便生成尽可能兼容的 WebAssembly 代码。

使用 Emscripten 的emconfigure命令运行 FFmpeg 的configure脚本,传入自定义参数以便完成兼容。下面是自定义参数:

CFLAGS="-s USE_PTHREADS"
LDFLAGS="$CFLAGS -s INITIAL_MEMORY=33554432" # 33554432 bytes = 32 MB
CONFIG_ARGS=(
  --prefix=$WEB_CAPTURE_PATH/lib2/ffmpeg-emcc \
  --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 stripping
  --disable-programs      # disable programs build (incl. ffplay, ffprobe & ffmpeg)
  --disable-doc           # disable doc
  --extra-cflags="$CFLAGS"
  --extra-cxxflags="$CFLAGS"
  --extra-ldflags="$LDFLAGS"
  --nm="llvm-nm-12"
  --ar=emar
  --ranlib=emranlib
  --cc=emcc
  --cxx=em++
  --objcc=emcc
  --dep-cc=emcc
)
cd $FFMPEG_PATH
emconfigure ./configure "${CONFIG_ARGS[@]}"

PS:上面我们允许了 C++ 使用 pthread,但因为在浏览器使用 pthread 多线程需要SharedArrayBuffer 允许多个 Web Workers 或 WebAssembly 线程访问和操作相同的内存区域,而SharedArrayBuffer的兼容性较差,并且要求 https,因此我们在接下来产出 wasm 时禁用 pthread。

FFmpeg 包含了很多库,若直接使用 @ffmpeg/ffmpeg @ffmpeg/core 便是全量的库的 wasm 版本。

  1. libavformat:负责多媒体文件和流的格式处理。这个库可以帮助你读取和写入多种音频和视频文件格式,以及网络流。

  2. libavcodec:负责音视频编解码。这个库包含了众多的音频和视频编解码器,可以处理多种格式的音频和视频。

  3. libavutil:提供一些实用功能,例如内存管理、数学运算、时间处理等。这个库被 libavformat 和 libavcodec 等其他库所使用,用于辅助处理各种任务。

  4. libswscale:负责图像的缩放和颜色空间转换。这个库可以帮助你将视频帧从一种像素格式转换为另一种,或者对图像进行缩放。

  5. libswresample:负责音频重采样、混合和格式转换。这个库用于处理音频数据,例如改变采样率、改变声道数等。

  6. libavfilter:负责音视频滤镜处理。这个库提供了一系列音视频滤镜,用于处理音频和视频,例如调整色彩、裁剪、添加水印等。

  7. libavdevice:负责获取和输出设备相关的操作。这个库提供了对各种设备的支持,例如摄像头、麦克风、屏幕捕捉等。

而我们抽帧只需要读取视频文件或流、解码、对产生的像素格式转换以及通用工具函数,也就是 libavformat、libavcodec、libswscale 和 libavutil 这几个库, 在接下来产出 wasm 我们便选取这几个库作为编译的输入文件,可以大幅减少产出的 wasm 资源体积。

图片

 

5.3 编译产出.wasm、.js

 

Emscripten 支持产出多种格式文件,我们这里使用他为我们准备的胶水代码,故生成.wasm 和.js 文件,

使用 emcc 命令编译 cpp 代码,首先通过Clang编译为LLVM 字节码,然后根据不同的目标编译为asm.js或Wasm。由于内部调用Clang,因此emcc支持绝大多数的Clang编译选项,比如-s OPTIONS=VALUE、-O、-g等。除此之外,为了适应 Web 环境,emcc增加了一些特有的选项,如--pre-js <file>、--post-js <file>等。

emcc $WEB_CAPTURE_PATH/src/capture.c $FFMPEG_PATH/lib/libavformat.a $FFMPEG_PATH/lib/libavcodec.a $FFMPEG_PATH/lib/libswscale.a $FFMPEG_PATH/lib/libavutil.a \
    -O0 \
    # 使用workerfs文件系统
    -lworkerfs.js \
    # 讲这个文件内连到胶水js里面 共享上下文
    --pre-js $WEB_CAPTURE_PATH/dist/capture.worker.js \
    # 指定编译入口路径
    -I "$FFMPEG_PATH/include" \
    # 声明编译目标是wasm
    -s WASM=1 \
    -s TOTAL_MEMORY=$TOTAL_MEMORY \
    # 告诉编译器我们希望从编译后的代码中访问哪些内容(如果不使用,内容可能会被删除)
    -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' \
    # 告诉编译器需要塞到Module里的方法
    -s EXPORTED_FUNCTIONS='["_main", "_free", "_captureByMs", "_captureByCount"]' \
    -s ASSERTIONS=0 \
    # 允许wasm的内存增长
    -s ALLOW_MEMORY_GROWTH=1 \
    # 产出路径
    -o $WEB_CAPTURE_PATH/dist/capture.worker.js

Emscripten 提供了四种文件系统,默认是MEMFS(memory fs),其他都需要在编译时候添加进来,-lnodefs.js (NODEFS), -lidbfs.js ( IDBFS ), -lworkerfs.js ( WORKERFS ), or -lproxyfs.js ( PROXYFS )。我们在 worker 中运行 wasm,选取workerfs文件系统,它提供了在 worker 中的 file 和 Blob 对象的只读访问,而不需要将整个数据复制到内存中,可能用于巨大的文件,防止了文件过大导致的浏览器 crash。

生成的 js 里面,Module 是全局 JavaScript 对象,Module 里固有的方法,可以参考文档 Module object documentation ,同时,你也可以通过 --pre-js 往 Module 里添加方法,没有塞入 Module 的方法可以通过EXPORTED_FUNCTIONS添加。

图片

△Module 内方法的定义

 

5.4 Js 和 C 的通信

 

5.4.1 Js 调用 C

 

JavaScript 调用 C 只能使用Number作为参数,因此如果参数是数组、对象等非Number类型,就麻烦了,使用Module._malloc ()分配内存,拿到栈指针地址,将数组拷贝到栈空间,将指针作为参数调用 c 的方法。Emscripten 的cwrap方法可以轻松解决。

crap (函数名,返回值,传入 c 的参数类型数组)

// example ts:captureByMs(info: 'string', path:'string', id:'number'):number
this.cCaptureByMs = Module.cwrap('captureByMs', 'number', ['string', 'string', 'number']);

 

5.4.2 C 调用 Js

 

可以通过emscripten_run_scriptapi 在 c 里调用 js,接受参数是拼接成字符串的要执行的 js 内容,用起来很像 eval。

emscripten_run_script("console.log('hi')");

如果传参是指针,js 的方法里接受到的是 c 的指针地址,在当前版本的 Emscripten 中,指针地址类型为 int32,Wasm 中 js 的内存空间均为ArrayBuffer,Emscripten 提供的访问对象是Module.buffer, 但是 js 中的ArrayBuffer无法直接访问,Emscripten 提供TypedArray对象进行访问。

比如需要传递给 js 是结构体指针,是这样定义的。

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

结构体的内存对齐,所以选取最长的就是uint32_t,uint32_t对应的TypedArray数组是Module.HEAPU32,由于是 4 字节无符号整数,因此 js 拿到的 ptr 需除以 4(既右移 2 位)获得正确的索引。按此类比,8 字节无符号整数就需要右移 3 位。

图片

虽然看起来 c 调用 js 很简单,但你不应该做频繁的调用,这会导致较大的开销抵消掉 Wasm 本身的物理优势。这也是为什么 dom 操作相关的框架不会选用 Wasm 进行优化,Wasm 还无法直接操作 dom,频繁的 js 和 Wasm 的上下文的开销也带来不可忽视的性能缺失,他的目的从不是替代 js, 类比 react,reconciler 部分是可以用 rust/go 重写,社区也有人做过此尝试,但是并没有带来显著性能优势,社区也有用 go/rust 编写 web 应用的框架,比如 (yew),他们为跨端带来更多的可能。

 

5.5 FFmpeg api 介绍

 

对整体抽帧流程使用到的关键 api 做简单的介绍,包含对视频的解码、编码以及处理等操作。

  • av_register_all 注册全部解码器,在使用 FFmpeg 的其他函数之前调用,以确保 Ffmpeg 可以正确地加载和初始化。

  • avformat_open_input 根据路径读取文件,并将其解析为一个AVFormatContext结构体,其中包含了文件的格式信息和媒体流的信息。

  • avformat_find_stream_info 获取视频的媒体信息 类比 ffplay file 获取的信息,包含编码格式、视频长度、fps、分辨率等。

  • avcodec_find_decoder 寻找视频对应的解码器。

  • av_read_frame 大量耗时在解码环节,在解码前,可以通过读取压缩的帧信息,获取关键帧队列,AVPacket 结构体里的 flag 等于 1,标志该帧是关键帧。

  • av_seek_frame 快速定位到某个时间戳的视频帧,在这里使用它定位到关键帧。

  • 基于关键帧进行解包,先调用av_read_frame读取压缩帧,avcodec_send_packet发送压缩包到 FFmpeg 的解码队列(如果成功,则返回 0),avcodec_receive_frame从解码队列里成功取出,判断 pts(位于的时间),符合条件的 frame 信息被存储。

图片

△抽帧的关键代码及解释

 

5.6 编译后产物体积对比

 

自定义编译

图片

使用 npm 包 @ffmpeg/ffmpeg @ffmpeg/core

图片

对比全量引入 24.5M,我们只需要 4M,体积上的收益还是非常明显的。

 

06 总结

 

使用 FFmepg+Wasm 方案进行视频抽帧,通过自定义编译 FFmpeg 减少编译产物的体积;定义关键帧优先策略,第一时间给到用户抽帧结果,尽可能减少用户等待时间。在 Emscripten 工具链的加持下,可以方便地将 C/C++ 代码编译成 Wasm,并配合产出完整的与 web 的交互 js。在速度和体验以及视频兼容性方面都取得了较为明显的收益,请大胆拥抱 WebAssembly 为 web 赋能吧!

目前这套方案已在百家号视频场景落地数月,收益明显。

图片

项目地址:https://github.com/wanwu/cheetah-capture,欢迎 star。

封装好 api 支持按照帧数目和秒数抽取。你也选择自定义编译,通过更改 FFmpeg 的编译参数让他支持更多的视频类型,通过更改 capture.c 文件增加更多 api 能力,期待你来丰富更多场景。

年度最有价值文章

季度最有价值文章

月度最有价值文章

投票统计

是否原创: 0 %

0 % Complete (success)

是否有价值: 0 %

0% Complete

是否有素质: 0 %

0% Complete (warning)

是否合法: 0 %

0% Complete

   群组工具

   外部链接