Skip to content

56. 现代 web 视频处理:FFmpeg.wasm #57

@funfish

Description

@funfish

FFmpeg.wasm 介绍

FFmpeg.wasm 是一个基于 WebAssembly 技术的 FFmpeg 版本,可以在浏览器中运行 FFmpeg 命令行工具,实现视频和音频的转换、处理等功能。其架构示意图如下:

ffmpeg-architecture.jpg

ffmpeg.worker 会主动加载 WebAssembly 代码(ffmpeg-core),并初始化。其中多媒体任务在 worker 中执行。

如果采用多线程版本,ffmpeg.worker 会创建多个 worker 线程,每个线程都会加载 WebAssembly 代码(ffmpeg-core),并初始化。

简单使用:

import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";

const ffmpeg = new FFmpeg();

async function initFFmpeg() {
  ffmpeg.on("log", ({ message }) => {
    // 打印日志
    console.log(message);
  });
  const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
  await ffmpeg.load({
    coreURL: await toBlobURL(`${baseUrl}/ffmpeg-core.js`, "text/javascript"),
    wasmURL: await toBlobURL(`${baseUrl}/ffmpeg-core.wasm`, "application/wasm"),
  });
}

function transcode(rawInputFile: File) {
  const outputName = `${Date.now()}.mp4`;
  const targetFile = await fetchFile(rawInputFile);
  await ffmpeg.writeFile(rawInputFile.name, targetFile);
  await ffmpeg.exec(["-i", rawInputFile.name, "-preset", "fast", outputName]);

  const data = await ffmpeg.readFile(outputName);
  const blob = new Blob([data.buffer], { type: "video/mp4" });

  const transFile = new File([blob], outputName, {
    lastModified: Date.now(),
    type: "video/mp4",
  });

  // const url = URL.createObjectURL(transFile);
  return transFile;
}

上面代码先加载 cdn 上的 ffmpeg-core.jsffmpeg-core.wasm。转换过程里面,读取 rawInputFile 文件转换为适合在 ffmpeg.wasm 环境中使用的格式,再写入虚拟文件系统,然后通过 ffmpeg.exec 执行转换指令。整体流程和架构图里面的过程是一致的。

下图可以看到,执行转换的时候,worker 线程一直在处理

ffmpeg-worker.jpg

需要注意的是:
ffmpeg.wasm 体积太大ffmpeg.wasm 打包到 dist 后的资源增加不多,但是引入后会主动引入多个文件,其中最大的是 core 包,其体积非常大,有 24.5M,官方放在 cdn 上的资源已经通过 Brotli 压缩算法,也要 8.5M,网速比较快也要七八秒,需要增加 loading 交互过程,提高用户体验。

多线程处理

官方有提供过一个数据, 多线程模式比单线程快上一倍,而实际操作中,由于环境的不同,多线程模式甚至可以达到单线程的 4 倍以上。

多线程使用方法:

const baseURL = 'https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm';

await ffmpeg.load({
  coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
  wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
  workerURL: await toBlobURL(`${baseURL}/ffmpeg-core.worker.js`, 'text/javascript'),
});

包切换成 @ffmpeg/core-mt,并增加 workerURL 就可以了,其他代码不用改动。然而多线程的使用虽然可以将执行速度提高 n 倍。只是有几个注意事项:

SharedArrayBuffer is not defined

worker 之间通信是通过 postMessage 方法,该方法在通信的时候,会将消息进行结构化克隆 (structured cloning)。如果采用正常的 ArrayBuffer,ArrayBuffer 为可转移的对象(Transferable object),其在传输的时候,数据会从上下文转移到通信的上下文,导致原始数据不可以用,比如下面:

const uInt8Array = new Uint8Array(1024 * 1024 * 8).map((v, i) => i);
console.log(uInt8Array.byteLength); // 8388608
t
worker.postMessage(uInt8Array, [uInt8Array.buffer]);
console.log(uInt8Array.byteLength); // 0

采用 postMessage 的消息,需要结构化克隆,比如 FormData 就不行,而结构化克隆仅支持两种方式,一种是可序列化对象,另外一种则是可转移的对象。而在 ffmpeg 的多线程方式里面,需要维持数据的一致性,需要保持内存共享,而不是数据转移,所以采用的是 SharedArrayBuffer;

采用 SharedArrayBuffer,需要页面 Header 添加

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

确保文档只能从相同的源加载资源,或显式标记为可从另一个源加载的资源。只是这就要求页面不能有其他域名的资源,包括 js/css/png 等各种 cdn 资源都会有问题,或者在这些资源上加如 cross-origin-resource-policy:cross-origin

对于使用 vite 开发的同学,则需要在 vite.config.ts 里面添加插件:

import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    {
      name: "configure-response-headers",
      configureServer: (server) => {
        server.middlewares.use((_req, res, next) => {
          if (_req.url?.includes('ffmpeg')) {
            // 配置需要设置 COEP 和 COOP 的页面
            res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
            res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
          }
          next();
        });
      },
    },
  ],
});

chrome 多线程使用

采用多线程方案,在 chrome 里面还需要注意,其线程数问题。如果直接使用,会出现 ffmpeg 在执行第一帧之后就卡住的情况,同时也不报错,导致无法排查。这里 可以发现 wasm 采用的线程数量过大,使得其使用的内存超过 chrome 的限制,导致无法执行。不过无法判断 wasm 是否是通过 navigator.hardwareConcurrency (用户计算机的处理器数)来确定线程数量。

通过添加线程控制,比如 -threads 4,则可以顺利执行多线程指令。由此推断可以结合 navigator.hardwareConcurrency 作为上限,取一个较小线程数就可以安全执行了。比如作者的电脑 navigator.hardwareConcurrency = 10,当线程数设置为 7、8 都和出现异常,提示 OOM,发生中断,而 6 以及 6 以下的时候,则正常。替换资源来反复测试,可以发现最大线程数和操作的输入资源有关系,资源越少,可以用的线程数越多,不过基本到 -threads 4 就能正常运行,也和对应指令有关系。

兼容和异常处理

FFmpeg 是C/C++编写的,为什么可以在浏览器中运行呢?因为WebAssembly,让很多传统语言的工具也能在浏览器中运行。兼容性还是不错的。

ffmpeg-jianrong.jpg

只是 FFmpeg.wasm 运行对浏览器端的性能要求比较高,多线程下 chrome 浏览器 CPU 消耗可以达到 400% 以上,同时运行时间较长,会存在不可预估问题,比如前面提到的多线程执行停顿、虚拟文件系统操作异常、或者 OOM 等问题。

ffmpeg-chrome.jpg

异常处理

除了通用的 try...catch方式以外ffmpeg.exec 执行是会返回状态码的,可以看下该方法定义:

export declare class FFmpeg {
  /**
   * Execute ffmpeg command.
   *
   * @returns `0` if no error, `!= 0` if timeout (1) or error.
   * @category FFmpeg
   */
  exec: (args: string[], timeout?: number, { signal }?: FFMessageOptions) => Promise<number>;
}

可以发现,返回 0 意味着执行正常,返回 -1,则是超时或者异常。

针对执行卡顿问题,可以采用 Promise.race 方法,可以采用超时不执行,就是返回的方式。同时对于 exec,其支持 AbortController,可以在超时的时候通过控制器停止 FFmpeg 运行,代码如下:

const controller = new AbortController();
const signal = controller.signal;
const timeout = 1000 * 60 * 5;
const result  = await Promise.race([transcode(rawInputFile, signal), new Promise(
  (res) => setTimeout(() => {
    res(void 0);
    console.log(`${timeout / 1000}秒限时,转换失败超时`);
    controller.abort();
  }, timeout)
)]);


function transcode(rawInputFile: File, signal: AbortSignal) {
  // ...
  const returnCode = await ffmpeg.exec(["-i", rawInputFile.name, "-preset", "fast", outputName], undefined, { signal }) 
  // returnCode 0 表示成功
}

当然还有另外一种方式,通过 ffmpeg.exec,第二个参数 timeout 传入超时毫秒数,也是个控制超时的方式,不过没有 AbortController 灵活。

性能

在2019年的一份分析报告里面,可以看到基准测试的数据:wasm 的性能基本是原生的 1.5 倍到 2.5 倍之间

ffmpeg-benchmark.jpg

这里则简单对比一下 ffmpeg.wasm 的性能,是不是也是在 1.5 倍到 2.5 倍之间。采用比较耗时间的视频压缩,指令为 ffmpeg -i input -threads 4 -preset veryfast output。对比下面四种

  1. chrome wasm 多线程
  2. chrome wasm 单线程
  3. 本地运行 FFmpeg 多线程
  4. 服务器运行 FFmpeg 多线程

ffmpeg-compress-compare.jpg

采用chrome多线程方式, -thread 4 执行,可以看到输出日志为:
frame= 690 fps= 12 q=31.0 size= 6400kB time=00:00:13.86 bitrate=3781.0kbits/s speed=0.235x,最终压缩为 13.2MB。

chrome 单线程方式,输出日志为 frame= 607 fps=3.9 q=31.0 size= 5888kB time=00:00:11.94 bitrate=4037.5kbits/s speed=0.0769x,最终压缩为 13.2MB,大小和多线程模式一致。

本地运行 FFmpeg 的方式,日志如下:frame= 439 fps=107 q=31.0 size= 4352kB time=00:00:09.04 bitrate=3941.5kbits/s speed= 2.2x,最终压缩为 13.9MB。

服务器运行 FFmpeg 的方式,日志如下: frame= 1496 fps= 23 q=31.0 size= 12800kB time=00:00:30.16 bitrate=3476.1kbits/s speed=0.467x

通过最终数据以及运行时的日志都可以发现,多线程要快于单线程,而本地的方式则要比浏览器多线程要好更多,浏览器的方式可能是 wasm 转换带来的限制,也可能是浏览器本身约束。

测试用的服务器,其性能可以发现也是要远低于本地,不过本地采用的是 M1 pro 笔记本,cpu 本身自然是远远好于服务器的,而 chrome 本身也是运行在 M1 pro 上,如果是比较差的机型,chrome 的表现自然也是比较差。

由于 M1 pro 性能是比较好的,用了惠普i5笔记本测试,本地耗时和服务器类似,chrome 的多线程耗时也在 10 倍左右。

什么时候用

ffmpeg 是一个非常消耗 cpu 资源的服务,在 web 端,让用户等待两三分钟,才能执行完,体验是很糟糕,尤其是会遇到 cpu 卡等问题,影响正常使用。尤其是遇到多人并发的的时候,一个压缩就要耗时 1min,虽然可以采用异步,让用户等待的方式,但是在服务端采用 ffmpeg 并不是一个最佳办法,一分钟处理一次任务,一天下来最多也才 1440 个任务,实在无法满足正常需求。

上面提到 web 端执行弊端是等待时间较久,cpu 消耗大可能导致浏览器的卡顿,而服务端又会遇到并发瓶颈问题。于是一个比较通用的方式是采用客户端来执行本地的 ffmpeg,而不是通过 wasm 版本,这种可以满足有个性化需求,甚至打造一个类似剪映的编辑器。

其实还有个方向,ffmpeg 并不是都是耗时特别高的操作,有些操作比如

  1. 格式转换:当使用 -c copy 选项进行容器格式转换而无需重新编码时,操作会非常快。
  2. 视频裁剪:使用 -ss(开始时间)和 -t(持续时间)选项来裁剪视频片段时,如果视频已经被解码到内存中,这个操作可以很快完成。
  3. 音频提取:从视频中提取音频流(例如,使用 -vn 忽略视频并复制音频流)通常很快,尤其是当音频编码不需要重新编码时。

还有一些其他的比如视频缩放等等,这些耗时少的,完全可以拿出来正常使用。这里用同样的视频,对比了音频提取操作的耗时:

ffmpeg-audio-compare.jpg

可以看到对于音频提取操作,不管是 web 端还是本地,都在 3s 以内,多线程和单线程类似,cpu 消耗基本也在 100%。于是这个时候 web 端的优势就体现出来,对于几百MB的视频,无需上传到服务器,就可以在线处理。

其他

ffmpeg 的用途实在非常多,比如字幕添加、滤镜等,其中过滤器 filter 功能就非常强大,包含裁剪、修改分辨率、视频合并等等功能。视频片段组合就写过很复杂的组合

[
  "[0:v]trim=start=0:end=0.8,setpts=PTS-STARTPTS[fhoqo]",
  "[1:v]trim=start=0:end=3.12,setpts=PTS-STARTPTS[06nfv]",
  "[2:v]trim=start=0:end=4.731837,setpts=PTS-STARTPTS[so2ht]",
  "[fhoqo:v][06nfv:v][so2ht:v]concat=n=3:v=1:a=0[fsnvy]",
  "[fsnvy:v][3:a]concat=n=2:v=1:a=0[7xx7d]",
  "[4:v]trim=start=0:end=0.76,setpts=PTS-STARTPTS[t77ug]",
  "[5:v]trim=start=0:end=2.2,setpts=PTS-STARTPTS[1jhq5]",
  "[6:v]trim=start=0:end=4.731837,setpts=PTS-STARTPTS[k2uk9]",
  "[t77ug:v][1jhq5:v][k2uk9:v]concat=n=3:v=1:a=0[mfpb7]",
  "[mfpb7:v][7:a]concat=n=2:v=1:a=0[1vb2p]",
  "[8:v]trim=start=0:end=2.2,setpts=PTS-STARTPTS[bffby]",
  "[9:v]trim=start=0:end=4.731837,setpts=PTS-STARTPTS[dfgs5]",
  "[bffby:v][dfgs5:v]concat=n=2:v=1:a=0[4farr]",
  "[4farr:v][10:a]concat=n=2:v=1:a=0[fn5xh]",
  "[7xx7d][1vb2p][fn5xh]concat=n=3:v=1:a=0[outputV]",
]

ffmpeg还有可以用于画面转场过度,使用 editly 就可以搞定,本质还是使用 gl-transitions,是基于 GLSL ES 的 Fragment Shader 来实现的。

ffmpeg 应用场景非常丰富,可以多探索。

另外 wasm 的内存限制在 chrome 83 版本后,已经从 2GB 提升到 4GB, 4gb-wasm-memory,考虑兼容性,最大还是限制 2GB。

参考

  1. Multithreading is not working on any Chromium-based browser
  2. Up to 4GB of memory in WebAssembly

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions