视频转GIF通常放在服务端用FFmpeg做。我们试了另一种做法:视频不上传,直接在浏览器里抽帧、编码。这样做不需要转码服务器,但体积和速度都要自己把控。下面是抽帧和编码的实现、踩过的坑,以及同一段视频跑10组参数得到的体积数据。
抽帧:用seek,不用播放录制
抽帧有两条路:让视频播放,边播边截;或者逐帧设置currentTime,等seeked事件后画到canvas上。我们用后者。它不受播放卡顿影响,每帧的时间点是确定的,而且内存只和帧数有关,和视频长度无关。
先算出要取哪些时间点。这一步写成纯函数,页面可以用它实时显示帧数,测试也好写:
const step = 1 / fps;
const wanted = Math.max(1, Math.floor((end - start) * fps + 1e-6));
const count = Math.min(wanted, maxFrames);
const last = Math.max(0, duration - 0.001);
const times = Array.from({
length: count }, (_, i) =>
Math.min(start + i * step, last));
- 超过帧数上限(我们设的是300帧)时,截掉后面的范围,而不是隔帧抽取。抽帧会让每帧的延迟和实际时间对不上,动作速度就变了。
- 最后一个时间点要比
duration略小。seek到视频末尾,有的浏览器取不到画面。
currentTime没有变化时,有的浏览器不会触发seeked,第一帧恰好在0秒时最容易遇到。所以只有时间点确实变了才等事件,第一帧在0秒就改等loadeddata:
if (Math.abs(video.currentTime - target) > 0.001) {
const seeked = waitFor(video, 'seeked');
video.currentTime = target;
await seeked;
} else if (i === 0 && video.readyState < 2) {
await waitFor(video, 'loadeddata');
}
ctx.drawImage(video, 0, 0, width, height);
等事件时一定要加超时。解码出问题时事件可能一直不来,页面就会一直卡在进度条上。
编码:gif.js的三个特点
编码用的是gif.js,它在Web Worker里跑,不占主线程。我们的配置是2个Worker,quality为10。
- 每帧单独算调色板。默认不用全局调色板,每帧各用NeuQuant算一张最多256色的局部颜色表。
quality是采样间隔,数字越小越细,也越慢。 - 每帧都完整存储。编码器不做帧间差分,相邻两帧再像,也是整帧写入。所以体积基本跟帧数成正比。
- 延迟只能精确到10毫秒。GIF格式的延迟单位是百分之一秒。8帧每秒本该是125毫秒一帧,四舍五入后变成130毫秒,40帧就从5秒变成5.2秒;15帧每秒是70毫秒一帧;12帧每秒是80毫秒一帧。短片段看不出来,长片段的时长会有偏差。
宽度只缩不放,放大不会增加细节,只会增加体积:
const scale = Math.min(1, maxWidth / Math.max(1, videoWidth));
实测:10组参数的GIF体积
测试素材是同一段5秒视频,原始尺寸864×496。像素总量等于帧数乘每帧像素数,最后一列是平均每个像素占多少字节,按1MB等于100万字节计算;体积只精确到0.1MB,所以这一列是约数。
| 时间范围 | 帧率 | 输出尺寸 | 帧数 | 像素总量 | GIF体积 | 字节/像素 |
|---|---|---|---|---|---|---|
| 0到5秒 | 8 | 360×207 | 40 | 298万 | 2.0MB | 0.67 |
| 0到5秒 | 5 | 360×207 | 25 | 186万 | 1.3MB | 0.70 |
| 0到5秒 | 12 | 360×207 | 60 | 447万 | 3.1MB | 0.69 |
| 0到5秒 | 15 | 360×207 | 75 | 559万 | 3.8MB | 0.68 |
| 0到5秒 | 8 | 240×138 | 40 | 132万 | 1.0MB | 0.75 |
| 0到5秒 | 8 | 480×276 | 40 | 530万 | 3.3MB | 0.62 |
| 0到5秒 | 8 | 640×367 | 40 | 940万 | 5.1MB | 0.54 |
| 0到5秒 | 15 | 640×367 | 75 | 1762万 | 9.6MB | 0.54 |
| 0到2秒 | 8 | 360×207 | 16 | 119万 | 0.8MB | 0.67 |
| 0到3秒 | 5 | 240×138 | 15 | 50万 | 0.4MB | 0.81 |
体积和帧数基本是线性关系。360宽度下,从25帧到75帧,字节/像素一直在0.67到0.70之间。这和编码器每帧完整存储的特点一致。所以减时长、减帧率的效果可以直接按比例估算:同样的帧率和宽度,5秒截到2秒,帧数从40降到16,体积从2.0MB降到0.8MB。
宽度的效果比像素比例小。8帧、5秒的条件下,宽度从360提到640,像素总量是原来的3.15倍,体积只是2.55倍。分辨率越高,相邻像素越接近,LZW压缩的效率就越高。反过来,缩小宽度省下的体积也比像素比例少一些。
所以压缩时先截时长,它不损失画质;再降帧率,多数片段8帧已经够用;最后才减宽度。要压到2MB以内,5帧、240像素、只留3秒以内,这三项任选一项就够;要压到1MB以内,留2到3秒,或者降到240像素。
以上字节/像素只代表这一段实拍素材。录屏、动画这类颜色成片的画面,压缩效率会高得多,同样的体积可以多给帧率和宽度。
解码失败的处理
浏览器能不能解码,取决于容器和编码,前端判断不了,只能试。我们的做法是先创建一个video元素,等loadedmetadata,拿不到时长和宽高就按不支持处理,并提示用户重新导出。
最常见的是iPhone录的HEVC(H.265)视频。这类MOV在Safari里能解码,Chrome和Edge要看系统有没有装解码器。不支持时,在手机的设置 → 相机 → 格式里选「兼容性最佳」,重新录制导出即可。
还能继续压的地方
gif.js输出的文件还有压缩空间。常见的做法是编码后再做一次帧间优化,只保存相邻两帧有变化的区域;或者改用全局调色板,省下每帧颜色表的开销,但颜色变化大的片段画质会下降。画面越静止,帧间优化的效果越明显。