教程[18] 使用 ffmpeg.js 在浏览器中将 mp4 转成 gif

开篇废话

这篇文章还是对 ugoira.huggy.moe 加功能后留下的笔记。
起因是因为这个破站 mp4 转 gif 功能在服务端太占资源了,CPU + 硬盘非常满,就有了尝试在浏览器里面跑 ffmpeg 糟蹋用户资源的想法。

WebAssembly(以下简称 Wasm) 技术到目前已经算很成熟了,在 Can I use 可以得知: Wasm 在 Chrome 57+ / Firefox 52+ / WebKit 11+ 被正式支持,经过几年的沉淀,用户浏览器升级得差不多了,问题也应该都修得差不多了(其中甚至有相关函数会被幽灵漏洞影响),特别是听到叔叔的站甚至软解 HEVC / AV1 来省流的新闻后,我觉得我也可以用了!

内容多倾向于编译部分,使用还是很简单的(毕竟都封装好了只要调用下)
记念下人生第一次写(改)Makefile

注意本文讲的是 ffmpeg.js 而不是 ffmpeg.wasm

找现成的库

首先是在 Github 找有没有现成的库,太菜了不会自己用 Emscripten 重新编译调用,一开始找的就是上文提到的 ffmpeg.wasm 实践了一波发现还是有很多问题,最终放弃:

  • 二进制文件太大
  • 提供的编译方法花了一星期没有编译成功过
  • 使用了 worker 需要在用 SharedArrayBuffer 要强制开启跨域隔离策略 (这点非常要命,跨域被强制限定死了,不单是 Google Adsense 拒绝加载,而且需要所有引用的跨域资源都加头,感觉十分不划算)

此部分还是看个人取舍,如果你的玩具可以接受这些的话那也可以用 ffmpeg.wasm 项目。
于是我放弃了 ffmpeg.wasm 于是最后还是用了 ffmpeg.js 来安排。

使用

这部分教程参考 readme.md 可能更好点,很简单的调用,这里不作重点介绍。

这里不想用多线程,没有使用 worker 版本,需要多判断很多,好麻烦的样子。
传入的文件需要用 Uint8Array 编码,部分代码参考:

const mp4Filename = '1.mp4' // 这里文件名就没什么必要了,大概就这样

if (mp4_u8 instanceof Blob) {// 如果是 blob 对象
    mp4_u8 = new Uint8Array(await mp4_u8.arrayBuffer())
}
let r0 = ffmpeg({
    MEMFS: [{ name: 'mp4Filename', data: mp4_u8 }],
    arguments: ['-y', '-i', mp4Filename, '233.gif'],
})
console.log(r0) // 会输出成品文件[]

不过官方提供的两个成品文件缺少对应的 codec 并不足以完成 mp4 转成 gif 的操作:

Unable to find a suitable output format

console error 1

所以接下来要自己编译带 xxx codec 的 ffmpeg

编译自己的 ffmpeg.js

请注意版权,带 mp4 的 ffmpeg 为 GPL2 协议授权,带 webm 的为 LGPL2 协议授权,请在修改完后开源!
GPL2 为 X264 污染

建议先参考官方教程 以及 ffmpeg.js#build-instructions 提供的教程,这个部分更多的是我踩坑记录,也就是错误的时候要怎么修改参数,具体编译过程不会介绍得太详细。

编译原始成品

首先我们要能通过 ffmpeg.js 能编译出可用的成品才能再去修改其中的编译参数,最简易编译得出成品的办法就是按照 ffmpeg.js#build-instructions 提供的 docker 环境里面编译,当然我使用 podman 也一样能跑。

git clone https://github.com/Kagami/ffmpeg.js.git --recurse-submodules
docker run --rm -it -v /path/to/ffmpeg.js:/mnt -w /opt kagamihi/ffmpeg.js
# cp -a /mnt/{.git,build,Makefile} . && source /root/emsdk/emsdk_env.sh && make && cp ffmpeg*.js /mnt

然后就有成品了
建议拿线程撕裂者来编译 手上 Ryzen5 2400G 从零开始编译要4分钟左右。

编译参数

这里我们要简单知道下 ffmpeg 是怎么组合的:
ffmpeg 支持自由组合 编码器(encoder/decoder)、合成器(demuxer)、过滤器(filter) 从而减少二进制文件大小(依赖)
支持的列表可以在下面搜索到参数:

然后在 ffmpeg.js/Makefile 里面还是能较为流畅地修改编译参数的。
基本思路就是,先添加依赖到能用后,再精简到不能再精简为止。
不要想尝试一步到位,天下没有改一次就成功的 Makefile

不一定需要直接编译成 wasm 版的,可以手动换成 make 取代 emcc cli 先编译 && 测试通过后再编译成 Wasm 版本,这样测试的压力也许会小点。
ffmpeg.js 也自带测试 /test,不过要根据自己的情况改下参数才能更好用(使用 Node.js)

切换 ffmpeg 版本

在 ffmpeg.js 里面还是能切换 ffmpeg 版本的,我试了 4.x 5.x 都可以正常编译:

cd build/ffmpeg-mp4
git checkout n4.4

就是 git 换个分支(tag),不再过多介绍。

palettegen

我们首先看下要用到的 ffmpeg 命令:

ffmpeg -y -i '1.mp4' -vf "fps=24,scale=iw*min(1\\,min(${width}/iw\\,${height}/ih)):-2:flags=lanczos,palettegen" palette-1.png

可以看到 -vf 里面有许多参数,这里的 -vf 就是 video filter 的意思,就是要使用把过滤器参数传进去。

那么我们需要以下过滤器:

  • fps
  • scale
  • lanczos
  • palettegen

(基本上都能在 ffmpeg-filters 找到)

于是编译参数就是

COMMON_FILTERS = aresample scale crop overlay hstack vstack palettegen fps lanczos paletteuse

了,经过测试 lanczos 应该是自带的,这里不需要再加了加了也没用

实际测试还是会出错误,没有图片输出,接下文 png 部分

重新编译需要 make clean 一下,清理掉编译缓存。

png

这次我们将 loglevel 调到 verbose 看看用 palettegen filter 生成 .png 用了些什么库:

ffmpeg -y -i '1.mp4' -vf "palettegen" palette-1.png -loglevel verbose

ffmpeg png verbose

我们发现 .png 也是要编码器的,最后测试还需要参数 --enable-parser=png 才能正常生成图片,这部分没有找到相关资料介绍,所以也不知道 parser 部分代表什么。

当然 .jpg 也同理,自行参考(我就不测试了):
ffmpeg jpg verbose

zlib

这部分是精简思路

在去掉 mp3lame 依赖后,zlib 库(libz.so)感觉变得没有地方引用了,需要手动产生 libz.so

如果要 mp4 那么 mp3 依赖也是必不可少的,如果尝试精简掉 mp3,那么会直接无法读取 mp4 文件,不过我们可以少编译 libmp3lame 来减少大小。

而 png 等需要 zlib 库,所以也不能放弃 zlib 的支持 当然是测试过后才知道这件事

于是我照着抄了个 Makefile-zlib 出来

zlib:
	cd build/zlib && \
	emconfigure ./configure \
		--prefix="$$(pwd)/dist" \
		&& \
	emmake make -j && \
	emmake make install

然后在编译的时候把这个库引入,参考 Makefile#124-125

如果后面 make 显示 dup symbol 那么还需要执行一遍 make clean 就可以(因为之前编译 libmp3lame 已经自带了 zlib 所以这部分被缓存了,需要清除编译时候的中间件才行)
console output OOM

OOM

在浏览器以坑爹 Safari 为主运行的时候可能会出现 OOM (Out Of Memory)情况,我们需要在 emcc 参数里面手动限制下内存占用。
console output OOM
我这里配了最大 256M 内存暂时解决(感觉也够用了,所以没有再去折腾)

参考 Makefile#140-141

动态载入

最后编译出了 gzip 后 1.5M 大小的文件,不过还是需要动态载入一下,仅在需要使用的时候加载此部分 JS 这里我用了最简单的办法来动态载入:

if (!pageCache.ffmpeg) {
    // 这里最好再加个 toast 弹窗一下
    pageCache.ffmpeg = (await import('@ugoira/ffmpeg.js/ffmpeg-mp4')).default
}
let r0 = pageCache.ffmpeg({})

这样弄了个最简单的变量来存放 ffmpeg.js ,当然也可以通过先进点的 Cache 或者 Service Workers 来安排,这里就偷懒用这个方法了。

放心,都支持 Wasm 了,基本也可以直接使用 await 了,思维要放开

浏览器兼容性

这里我建议还是留下 fallback 机制,如果检测到不兼容的浏览器(比如浏览器禁用了 Wasm)情况下要能提醒用户开启,以及留下服务端渲染的 API 来。

有些浏览器的增强隐私保护机制会直接禁用掉 Wasm,参考 clip-1#edge-浏览器有些页面无法正常加载

伪代码参考:

export let supportWasm = typeof WebAssembly === 'object' && ((
    // When user = Android and AndroidVersion >= 9, use wasm
    // Android version < 9 performance is poor
    (navigator.userAgent.includes('Android') && getAndroidVersion() >= 9) ||
    // When user = iOS and iOSVersion >= 12, use wasm
    // iOS version < 12 performance is poor
    ((navigator.userAgent.includes('iPhone') || navigator.userAgent.includes('iPad') || navigator.userAgent.includes('iPod')) && getiOSVersion() >= 12)
    // bypass Windows
) || !isMobile)

在这里,我首先判断 WebAssembly 是否存在,然后系统版本会不会太旧了(能上 Android 9+ / iOS 12+ 的性能应该都还 ok)最后判断是否有条件使用上本地 ffmpeg 如果不支持那么就继续使用原来的方案,由服务端来渲染 gif 然后再下载。

后记

在这里表示佩服能维护 && 提交给这种大项目的贡献者,我这样的新手就连编译都前前后后花了几星期时间才基本理清套路,如果不是对这些熟悉点的话连编译都不成功 >_<

虽然文章里面各种方法是轻描淡写走过的,不过想必还是有人连引入包都卡住了,在表示真的没法写这部分,大家的环境都各不相同,这里也只能粗略介绍一下了,希望你能成功地与 webpack (或者其它脚手架)斗智斗勇,成功引用依赖并且调用~




参考资料

堆 栈 溢 出:


gist: