引言
在前端执行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...
我捋了一下,时间线大概是这样的:
-
sharedArrayBuffer(以下简称SAB)用于JS在多线程(Web Worker)之间共享内存,wasm使用SAB开启多线程支持,速度提升效果棒棒的。
-
2017年7月,chrome 60版本内引入SAB, 一切都很好,直到6个月后
-
2018年1月,在一些CPU上发现了一个漏洞:spectre(幽灵)
这是一种旁路攻击的方式,简单来说就是代码利用CPU分支预测的特性,通过获取高精度的CPU时间差来间接的获取本来不应该读取到的内存数据。在浏览器端,代码可以通过img/script/iframe的方式引入任意域的资源,之前在配合CORP/CORS, 以及禁止某些跨域资源通过脚本访问(跨域的图片可以显示,但不能通过canvas api操作)的措施基本是没毛病的,但在有了幽灵漏洞之后,恶意代码可以读取隔离的内存中的数据,一切就变得不安全起来。
浏览器厂商降低了类似
performance.now
的计时精度来组织恶意代码获取高精度CPU时间。但通过SAB依然可以制作一个高精度的计时器, 基本原理就是在一个woker线程不停的递增SAB的值,在另外一个线程在任意计算代码的前后分别读取这个SAB的值。取差值来作为时间的增量,浏览器没有办法很好的区分,所以只能先禁用SAB。 -
2018年7月,chrome 68版本提出
Cross-Origin Read Blocking
(CORB), 重新开启了SAB
CORB是一个新的web平台安全特性,用于帮助减少类似幽灵漏洞的旁路攻击的威胁。简单来说就是在恶意代码通过img/script试图跨域获取敏感数据时,CORB将按照一定规则阻止这样的跨域请求, 用空resp来替代真实请求的响应。
-
随后,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中应用了这一规则
-
上面的COOP会破坏OAuth/三方支付的集成, chrome因此提供了注册来源实验的暂行方案, 可以暂缓设置COOP/COEP, 同时开启SAB,这一方式会在chrome 103版本后失效。
-
2022年5月, chrome将暂行方案延期至chrome 106版本。
-
2022年8月, chrome再次将暂行方案延期至chrome 109版本。
追溯这个历史还挺有意思的, 时间拉回到现在, ffmpeg.wasm要开启多线程模式的话就需要上面说的SAB的支持, 在目前这个时间点(2022年10月), 要开启SAB有两个选择:
- 设置COOP/COEP, 让页面成为Cross-origin isolation
- 仅适用于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大致就实现了, 来让我们看看转换速度怎么样...
在我的笔记本上fps = 2...几乎等于不可用了.
本来我以为的性能不够是转个4-5s的视频需要10-20s的那种...现在看来怕不是要400-500s哦🤦♂️
后记
ffmpeg.wasm上挺多关于性能的issue的,作者在后续计划中也打算通过SIMD指令来提高性能(Next steps for ffmpeg.wasm · Issue #413), 但至少目前来说, 我觉得ffmpeg在前端还无法进入生产环境使用:
- 开启SAB会获得相当的性能提升,要么需要设置COOP/COEP, 这会导致OAuth/支付功能受影响,要么在Chrome下注册试用源,只能用于Chrome不说,到2023年1月就失效了,明显也不是长久之计。
- 不开启SAB就只能使用单线程的ffmpeg.wasm,性能实再堪忧,基于SIMD的优化也还在路上,不知道具体能提高多少。