前端的 FFmpeg? 可能还没准备好

9,641 阅读7分钟

引言

在前端执行FFmpeg? 听起来很酷吧, 基于Webassembly技术,将FFmpeg编译成wasm就能在前端运行, 各种视频合成,分割,转码之类之前只能交给服务端的任务还不是手到拈来?而且在客户端执行, 还能节省一笔服务器费用, 简直真香预警了呀。

领导问我意见的时候,我心里就是这么想的😁

然后就很惨的掉坑里了...🥹

编译FFmpeg

FFmpeg是C写的,我们需要使用emscripten来将其编译成Webassembly, 但是呢这个编译过程看的我是相当抓瞎,如果你跟我一样菜,也可以使用ffmpeg.wasm这个项目编译好的。

spectre, sharedArrayBuffer和ffmpeg.wasm的故事

在ffmpeg.wasm的README里可以看到, ffmpeg.wasm需要在支持sharedArrayBuffer的浏览器下运行, 而这个SharedArrayBuffer又必须在页面是cross-origin-isolation时才能使用("SharedArrayBuffer" | Can I use...),那么什么是cross-origin-isolation呢? 这又说到了COOP/COEP...

我捋了一下,时间线大概是这样的:

  1. sharedArrayBuffer(以下简称SAB)用于JS在多线程(Web Worker)之间共享内存,wasm使用SAB开启多线程支持,速度提升效果棒棒的。

  2. 2017年7月,chrome 60版本内引入SAB, 一切都很好,直到6个月后

  3. 2018年1月,在一些CPU上发现了一个漏洞:spectre(幽灵)
    这是一种旁路攻击的方式,简单来说就是代码利用CPU分支预测的特性,通过获取高精度的CPU时间差来间接的获取本来不应该读取到的内存数据。

    在浏览器端,代码可以通过img/script/iframe的方式引入任意域的资源,之前在配合CORP/CORS, 以及禁止某些跨域资源通过脚本访问(跨域的图片可以显示,但不能通过canvas api操作)的措施基本是没毛病的,但在有了幽灵漏洞之后,恶意代码可以读取隔离的内存中的数据,一切就变得不安全起来。

    浏览器厂商降低了类似performance.now的计时精度来组织恶意代码获取高精度CPU时间。但通过SAB依然可以制作一个高精度的计时器, 基本原理就是在一个woker线程不停的递增SAB的值,在另外一个线程在任意计算代码的前后分别读取这个SAB的值。取差值来作为时间的增量,浏览器没有办法很好的区分,所以只能先禁用SAB。

  4. 2018年7月,chrome 68版本提出Cross-Origin Read Blocking(CORB), 重新开启了SAB
    CORB是一个新的web平台安全特性,用于帮助减少类似幽灵漏洞的旁路攻击的威胁。

    简单来说就是在恶意代码通过img/script试图跨域获取敏感数据时,CORB将按照一定规则阻止这样的跨域请求, 用空resp来替代真实请求的响应。

  5. 随后,web标准人员一起提出了Cross-origin isolation的概念, 并引入了两个新跨域策略:COOP/COEP
    通过在html响应头上设置
    Cross-Origin-Embedder-Policy: require-corp
    Cross-Origin-Opener-Policy: same-origin
    其中, COEP禁止加载任何没有显示设置CORP/CORS的跨域资源, COOP则是限制当前文档打开的跨域窗口,如果设置为'same-origin',则对打开的窗口执行隔离,同时禁止两个窗口相互通信。

    只有满足了这两个条件, 也即相当于声明"我放弃将其他源的内容, 在无需他们选择的情况下引入的能力(I hereby relinquish my ability to bring other-origin content into this process without their opt-in)"页面也就开启了Cross-origin isolation, 浏览器才会提供给你SAB以及高精度的performance.now等API,

    2020年7月, ff 79版本率先启用了基于这两个限制下的SAB, chrome也在92中应用了这一规则

  6. 上面的COOP会破坏OAuth/三方支付的集成, chrome因此提供了注册来源实验的暂行方案, 可以暂缓设置COOP/COEP, 同时开启SAB,这一方式会在chrome 103版本后失效。

  7. 2022年5月, chrome将暂行方案延期至chrome 106版本。

  8. 2022年8月, chrome再次将暂行方案延期至chrome 109版本。

追溯这个历史还挺有意思的, 时间拉回到现在, ffmpeg.wasm要开启多线程模式的话就需要上面说的SAB的支持, 在目前这个时间点(2022年10月), 要开启SAB有两个选择:

  1. 设置COOP/COEP, 让页面成为Cross-origin isolation
  2. 仅适用于Chrome/Edge with Chromium, 注册origin trial,在109版本之前可以开启SAB,参考Chrome Platform Status (chromestatus.com)也就是到2023年1月10号之前可用, 后续是否会继续延期就不得而知了。

由此可见, 当前比较稳妥的是不使用SAB, 也就是单线程版的ffmpeg.wasm😂

ffmpeg.wasm也提供了单线程版本use-single-thread-version, 但单线程版的一个可能的问题就是性能不高

单线程ffmpeg.wasm转换视频为GIF

使用vite起了一个测试项目,让我们来尝试在前端用ffmpeg.wasm将视频转换成gif并下载吧。

async function convert(){
    const mod = await import('@ffmpeg/ffmpeg');
    const ffmpeg = mod.createFFmpeg({
        // 打开log
        log: true,
        mainName: 'main',
        //使用单线程版
        corePath: 'https://unpkg.com/@ffmpeg/core-st@0.11.1/dist/ffmpeg-core.js',
    });
    
    // 读取视频文件
    ffmpeg.FS('writeFile', 'test.mp4', await mod.fetchFile(video_src));
    // 运行ffmpeg转换gif的命令, 将test.mp4的0-3s转换为Gif
    await ffmpeg.run(
        '-i',
        'test.mp4',
        '-t',
        '3',
        '-ss',
        '0',
        '-vf',
        `fps=24,scale=1080:-1:flags=lanczos`,
        '-f',
        'gif',
        'out.gif'
    );
    // 读取刚才转换的gif
    const data = ffmpeg.FS('readFile', 'out.gif');
    // 转换成ObjectURL
    const url = URL.createObjectURL(new Blob([data.buffer], { type: 'image/gif' }));
    // 触发下载
    const link = document.createElement('a');
    link.href = url;
    link.download = 'out.gif';
    link.click();
    
    // 退出, 释放资源
    ffmpeg.exit();
}

还是挺简单的,命令跟本地执行时是一样的。

使用web worker

上面的例子里直接在主线程执行了转码任务,但这种高cpu占用的计算任务最好是放在web worker里去做,避免阻塞主线程渲染任务,接下来我们来看看如何在web worker内使用ffmpeg.wasm:

首先是主线程代码:

async function convert(){
    const worker = new Worker('/static/path/to/worker.js', { type: 'classic' });
    // 得到视频文件的array buffer
    const buffer = await fetch(video_src).then(resp => resp.arrayBuffer());
    // 传递给worker, 使用Transferable对象传输
    worker.postMessage({ op: 'gifTask', buffer, name: 'test.mp4' }, [buffer]);
    // worker处理完毕, 返回array buffer
    const resultBuffer = await new Promise((resolve, reject) => {
        worker.onmessage = e => {
            if(e.data.op === 'gifTaskComplete'){
                resolve(e.data.buffer);
            }
        }
        worker.onerror = reject;
    });
    
    // 关闭线程
    worker.terminate();
    
    // 下载
    const url = URL.createObjectURL(new Blob([resultBuffer], { type: "image/gif" }));
    const link = document.createElement("a");
    link.href = url;
    link.download = "out.gif";
    link.click();
}

然后是web worker线程代码, 这里ffmpeg.wasm有些问题(Running single threaded FFMPEG in a web worker · Issue #337),因此我使用了一个fork版本(nxtexe/ffmpeg.wasm):

// 文件来自于上面说的fork版本
importScripts('./ffmpeg.min.js');
const ffmpeg = self.FFmpeg.createFFmpeg({
  log: true,
  mainName: 'main',
  corePath: 'https://unpkg.com/@ffmpeg/core-st@0.11.1/dist/ffmpeg-core.js',
});

// 监听postMessage
addEventListener('message', async e => {
  if (e.data.op && e.data.op === 'gifTask' && e.data.buffer) {
    // 和上面的非worker版本执行一样的逻辑, 这里就不重复了。
    // ...
    // 读取刚才转换的gif
    const data = ffmpeg.FS('readFile', 'out.gif');
    // 发回主线程
    self.postMessage({ op: 'gifTaskComplete', buffer: data.buffer }, [data.buffer]);
  }
});

这样一个demo大致就实现了, 来让我们看看转换速度怎么样...

lQLPJxa-TzsY1CbM280GO7D2Y4JKpJugMwM43pAZgDYA_1595_219.png

在我的笔记本上fps = 2...几乎等于不可用了.

你好李焕英.gif

本来我以为的性能不够是转个4-5s的视频需要10-20s的那种...现在看来怕不是要400-500s哦🤦‍♂️

后记

ffmpeg.wasm上挺多关于性能的issue的,作者在后续计划中也打算通过SIMD指令来提高性能(Next steps for ffmpeg.wasm · Issue #413), 但至少目前来说, 我觉得ffmpeg在前端还无法进入生产环境使用:

  1. 开启SAB会获得相当的性能提升,要么需要设置COOP/COEP, 这会导致OAuth/支付功能受影响,要么在Chrome下注册试用源,只能用于Chrome不说,到2023年1月就失效了,明显也不是长久之计。
  2. 不开启SAB就只能使用单线程的ffmpeg.wasm,性能实再堪忧,基于SIMD的优化也还在路上,不知道具体能提高多少。