Gif 截取首帧

最佳实践2022-01-16

背景

商家上传 Gif 作为商品图,过程中转为 WebP 格式已优化了约 60% 的体积,但仍接近 2M 的图片对于网站加载过程中用户体验无疑是一场灾难。因此从商家侧上传 Gif 时进行截取首帧作为动图的封面图,在网站加载中预显示,优化用户体验。

探索

Gif 解帧网上也有多种方案,若限定于浏览器端环境的话,可选的其实也就不多。

GIFrame

业界一些类似于 GIFrame 的库可在浏览器端进行解帧,同时无需解析整个 Gif 图片数据,可快速拿到首帧数据(100+ms)。但解析器工作主要运行在 JS 主线程,JS Heap 约 20+m,挺耗性能的任务。

HTMLImageElement + HTMLCanvasElement

一种相对直接简单粗暴而兼容性可靠的方式,例如


_15
const reader = new FileReader()
_15
reader.readAsDataURL(event.target.files[0])
_15
await new Promise((resolve) => (reader.onload = resolve))
_15
const image = new Image()
_15
await new Promise(resolve => {
_15
image.src = reader.result
_15
image.onload = resolve
_15
})
_15
const canvas = document.getElementById('canvas')
_15
const ctx = canvas.getContext('2d')
_15
canvas.width = image.width
_15
canvas.height = image.height
_15
ctx.drawImage(image, 0, 0, image.width, image.height)
_15
const base64 = canvas.toDataURL('image/png', 1.0)
_15
console.log(base64)

与上一种方式对比,避免了 Gif JS 解析器在主线程的性能消耗,但测试对比发现拿到帧的数据的明显会慢些(800+ms),怀疑 toDataURL 同步转换格式、转换字符串等逻辑影响,所以改为


_17
const reader = new FileReader()
_17
reader.readAsDataURL(event.target.files[0])
_17
await new Promise((resolve) => (reader.onload = resolve))
_17
const image = new Image()
_17
await new Promise(resolve => {
_17
image.src = reader.result
_17
image.onload = resolve
_17
})
_17
const canvas = document.getElementById('canvas')
_17
const ctx = canvas.getContext('2d')
_17
canvas.width = image.width
_17
canvas.height = image.height
_17
ctx.drawImage(image, 0, 0, image.width, image.height)
_17
const blob = await new Promise((resolve) => canvas.toBlob((blob) => {
_17
resolve(blob);
_17
}))
_17
document.getElementById('img').src = URL.createObjectURL(blob)

消耗的时间仍差别不大,后续调试发现最长的耗时在于 image.onload 解析 base64 部分(700+ms),再改为


_17
const reader = new FileReader()
_17
reader.readAsArrayBuffer(event.target.files[0])
_17
await new Promise((resolve) => (reader.onload = resolve))
_17
const image = new Image()
_17
await new Promise(resolve => {
_17
image.src = URL.createObjectURL(new Blob([reader.result]));
_17
image.onload = resolve
_17
})
_17
const canvas = document.getElementById('canvas')
_17
const ctx = canvas.getContext('2d')
_17
canvas.width = image.width
_17
canvas.height = image.height
_17
ctx.drawImage(image, 0, 0, image.width, image.height)
_17
const blob = await new Promise((resolve) => canvas.toBlob((blob) => {
_17
resolve(blob);
_17
}))
_17
document.getElementById('img').src = URL.createObjectURL(blob)

多次测试平均整体耗时 50+ms,image.onload 解析 Blob 仅花费了 10+ms,这部分方案梳理发布到 NPM 库 — Gifff。另外这种方式确实能成功导出一帧,但是否首帧?这个问题就留给各位思考了🤔

Playground

WebWorker + OffscreenCanvas + ImageBitmap

尝试一下 WebWorker 生态环境,例如


_10
// worker.js
_10
const imageBitmap = await createImageBitmap(blob)
_10
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height)
_10
const ctx = canvas.getContext('2d')
_10
ctx.drawImage(imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height)
_10
const blob = await canvas.convertToBlob({ type: 'image/png' })
_10
self.postMessage(blob)

但查了一下 caniuse,兼容性的问题算是无解了。

WebAssembly + Rust

先查了 caniuse,主流 PC 端 Chrome、Safari 版本 WebAssembly 能被支持,是个不错的开始。

按照之前的逻辑编译为 Rust 代码,例如


_25
#[wasm_bindgen]
_25
pub fn wasm_memory() -> JsValue {
_25
wasm_bindgen::memory()
_25
}
_25
_25
#[wasm_bindgen]
_25
pub fn alloc(size: usize) -> *mut u8 {
_25
let mut buf = Vec::with_capacity(size);
_25
let ptr = buf.as_mut_ptr();
_25
mem::forget(buf);
_25
ptr
_25
}
_25
_25
#[wasm_bindgen]
_25
pub async fn parse(ptr: *mut u8, size: usize) -> Result<JsValue, JsValue> {
_25
let bytes = unsafe { Vec::from_raw_parts(ptr, size, size) };
_25
let image = match image::load_from_memory_with_format(&bytes, ImageFormat::Gif) {
_25
Ok(image) => image,
_25
Err(err) => return Err(JsValue::from(Error::new(&format!("{:?}", err)))),
_25
};
_25
let mut u8: Vec<u8> = Vec::new();
_25
image.write_to(&mut u8, ImageOutputFormat::Png);
_25
let base64 = format!("{},{}", "data:image/png;base64", base64::encode(u8));
_25
Ok(JsValue::from_str(base64.as_str()))
_25
}


_10
const reader = new FileReader()
_10
reader.readAsArrayBuffer(file)
_10
const buf = await new Promise(resolve => reader.onload = () => {
_10
resolve(reader.result as ArrayBuffer)
_10
})
_10
const size = buf.byteLength
_10
const ptr = alloc(size)
_10
const imgArray = new Uint8Array(wasm_memory().buffer, ptr, size)
_10
imgArray.set(new Uint8Array(buf))
_10
const base64 = await parse(ptr, size)

多次测试平均整体耗时 160+ms,这部分方案梳理发布到 NPM 库 — Gifff

Playground