做网页动画的时候,我经常遇到一个挺烦的环节:手里有一段绿幕视频素材,想把它变成网页里能逐帧控制的序列帧动画,就得先抽帧、再抠图、最后再拼成一张 sprite sheet。以前我都是 FFmpeg 抽帧,PS 一张张抠,再用 TexturePacker 打包,三四个工具来回切,尤其素材一多就特别痛苦。后来我索性写了一个纯浏览器的小工具,上传视频、设置抽帧率和抠图参数,8 秒的绿幕视频直接处理完,导出效果立刻能看到。这篇文章就把这套方案完整拆一遍,包括视频转序列帧的抽帧细节、浏览器里的绿幕抠图算法,以及怎么把这些透明序列帧拼成一张能直接用的 sprite sheet。适合前端开发者、游戏美术和所有想自动化处理视频素材的人参考。
1. 项目思路:为什么在浏览器里完成“抽帧 + 抠图 + 拼图”一条龙
1.1 传统工作流和浏览器工作流的差别
先说说过去处理绿幕视频成序列帧动画的常规流程。第一步是抽帧,FFmpeg 一条命令就能把 8 秒素材按指定帧率导出几十张 PNG;第二步是抠图,把 PNG 挨个丢进 PS,或者用 GIMP 的插件批量处理,绿幕素材还好,遇到背景复杂的素材还要手动调整边缘;第三步是拼图,抠完的透明 PNG 再丢进 TexturePacker、SpriteSmith 这类打包工具,最后导出 sprite sheet 和元数据。
这个流程看着清晰,但有几个实际痛点。第一,环境依赖重,FFmpeg 要装、PS 要买、打包工具要装,换个电脑全部重来;第二,中间产物多,抽出来的 PNG、抠完的 PNG、打包后的图,硬盘空间直线上升;第三,参数调整成本高,每改一次抽帧率或扣图阈值,都得重新跑一遍完整流程,很难实时预览效果。
而浏览器方案把这些全部压缩到一步。打开页面、上传视频、设置参数,就能看到抽帧和抠图结果,所有中间状态都可以实时调整。它不需要安装任何软件,只要是新一点的 Chrome、Edge 或者 Firefox 就行,跨平台、可分享,甚至可以直接封装成一个团队内部使用的在线工具。这也是我做这个项目最核心的动机:把重复劳动变成参数调节。
1.2 技术选型:canvas 2D 还是 WebCodecs,要不要上机器学习
浏览器端处理视频和图像,方案其实不少,我先说结论:这个项目最稳的组合是“video 元素 + canvas 2D + 像素级色度键抠图”,机器学习模型作为可选项。
用 video 元素加载视频,再用 canvas 的 drawImage 把当前帧画到画布上,是兼容性最好的抽帧方案,只要浏览器能正常播放这个视频,就能抽帧,不需要考虑编码格式问题。WebCodecs 的 VideoFrame 性能更猛,能直接操作压缩后的视频帧,适合高分辨率批量处理,但它需要 Chrome 94 以上的版本,Firefox 和 Safari 对它的支持还不完整。对 8 秒短视频这种量级,canvas 2D 完全够用,没必要引入额外的兼容性风险。
抠图这块,绿幕素材我优先用色度键算法,而不是一上来就跑神经网络。原因很简单,绿幕背景的颜色是已知的,用像素颜色距离判断前景背景又快又准,单帧 720p 的像素循环也就几十毫秒,而且阈值可以直接拖动滑块实时看效果。机器学习抠图模型像 rmbg-2.0,确实能处理没有绿幕的复杂背景,但模型文件动辄上百 MB,浏览器里还要加载 onnxruntime-web,推理一次也得几百毫秒,8 秒视频几十帧跑下来,等待时间会明显拉长。所以我的设计是:绿幕素材走色度键,非绿幕素材再启用 rmbg-2.0,两个方案共存,互不干扰。
1.3 8 秒视频的实际账:抽帧率、分辨率、最终 spritesheet 体积怎么算
动手之前先把预算算清楚,不然做到一半发现浏览器内存爆了,就麻烦了。
假设素材是 1280×720、25fps、时长 8 秒,总共 200 帧原始画面。如果按 25fps 全部抽出来,200 张 720p 的 PNG,光是中间产物就好几十 MB,堆内存也紧张。实际做序列帧动画,不需要原始帧率,动画表现和文件体积要平衡。抽帧率一般取 8fps 到 12fps,动作很快的取 15fps,动作缓慢的 5fps 也够。按 10fps 算,8 秒视频会得到 81 帧左右,这个数量对精灵图非常友好。
再看拼图尺寸。如果不裁剪、不缩放,81 帧 1280×720 的图硬拼,假设排成 9 列 9 行,最终 sprite sheet 是 11520×6480,超过浏览器单张 canvas 的尺寸上限,导出也会失败。所以实际项目里要么把每帧缩放到 640×360,要么做透明区域裁剪,帧大小降到 200×300 左右,81 帧排成 9 列 9 行,整体变成 1800×2700,一张普通 PNG 就装下了。这些数字决定了后面的代码怎么设计。
2. 浏览器端视频抽帧:从 video 元素到 ImageData 的完整链路
2.1 视频加载和跨域问题的处理
第一步是让用户把视频弄进浏览器。最简单的方式是<input type="file">选择一个本地文件,拿到 File 对象后通过 URL.createObjectURL 生成一个临时地址,赋值给 video 元素。这里有个很关键的细节,如果用完不回收,URL.createObjectURL 生成的对象 URL 会一直占用内存,尤其处理大视频时,页面越用越卡。正确做法是视频加载完成后监听 URL.revokeObjectURL,或者干脆在页面卸载时统一回收。
如果视频素材放在 CDN 或者对象存储上,需要通过 URL 直接加载,那就要注意跨域问题。video 元素跨域播放本身没毛病,但一旦 canvas 要把跨域视频画上去,浏览器会直接污染画布,后面调用 toDataURL 或 toBlob 导出时会抛 SecurityError。解决方法是在 video 标签上设置 crossOrigin="anonymous",同时服务端要返回 Access-Control-Allow-Origin 响应头。凡是遇到“canvas 被污染”的报错,优先检查这两点。
2.2 抽帧的核心逻辑:seeked 事件为什么比 setTimeout 更靠谱
抽帧的基本操作很简单,改变 video.currentTime,等浏览器跳到对应时间点后,用 canvas.drawImage(video, 0, 0, width, height) 把画面画下来。但这里有个新手最容易踩的坑:直接给 currentTime 赋值后立刻执行 drawImage,得到的很可能是一张黑图或者上一帧的画面,因为视频跳转是异步的。
正确做法是给 video 绑定 seeked 事件,跳转完成后事件触发,再执行画图操作。如果要在循环里逐帧抽取,最好封装一个 Promise,让每一帧都等待 seeked 事件后再处理。示例代码如下:
function seekTo(video, time) { return new Promise((resolve) => { const handler = () => { video.removeEventListener('seeked', handler); resolve(); }; video.addEventListener('seeked', handler); video.currentTime = time; }); } async function extractFrames(video, fps, onFrame) { const duration = video.duration; const total = Math.floor(duration * fps); for (let i = 0; i <= total; i++) { const time = Math.min(i / fps, duration - 0.001); await seekTo(video, time); onFrame(video); } }这里还有两个细节。第一,最后的时间点要减去一个很小的值,否则可能超出视频边界触发 error 事件;第二,如果视频没有预加载完成,直接设置 currentTime 会失败,所以要确保 video.readyState 至少是 1,或者在 loadedmetadata 之后再开始抽帧。移动端还有一个习惯性问题,video 必须设置 muted 和 playsinline 属性,否则在部分浏览器里根本不会自动播放,也就无法跳帧。
2.3 减少内存分配的三个优化手段
抽帧过程中最容易出现的问题,是每抽一帧就新建一个 canvas 或者反复调用 getImageData,导致内存暴涨、GC 频繁,页面动画都卡了。我实际验证下来,有三个方面值得注意。
第一个手段是 canvas 实例复用。抽几百帧,只要一个离屏 canvas 就够了,不需要每帧 new 一个。把目标宽高设好后,循环里反复 drawImage,性能会好很多。第二个手段是留意 ImageData 的内存占用。一张 1920×1080 的 ImageData,RGBA 四个通道,算下来是 1920×1080×4,约 8.3MB,如果同时保留十几帧在内存里,页面马上飙到上百 MB。操作完的 ImageData 没有引用后会被 GC 回收,所以要避免把每一帧都存进数组里,应该处理完一帧就立刻绘制到最终的 sprite sheet canvas 上,或者直接转成 Blob 存下来。第三个手段是优先用 toBlob 而不是 toDataURL。toDataURL 返回的是 base64 字符串,体积比二进制 Blob 大三分之一,而且字符串也占内存,导出时用 toBlob 加上 URL.createObjectURL 生成下载链接,内存压力小很多。
3. 绿幕抠图算法:色度键怎么调才不伤边缘
3.1 为什么“绿色通道大于某值就是背景”不够用
绿幕抠图最直觉的思路是看像素的 G 通道值,绿色多就是背景。这种算法在光照均匀的素材上勉强能用,一旦背景有阴影、反光,或者人物衣服带绿色,就会把前景误杀一大片。原因是 RGB 三个通道高度关联,单看 G 通道无法区分“亮绿色背景”和“偏绿的前景像素”。
更稳的做法是把像素从 RGB 转到 YCbCr 空间,Y 是亮度,Cb 和 Cr 是蓝色差、红色差。绿幕背景的色度中心是固定的,G=255、R=0、B=0 时算出来 Cb 约 43.5,Cr 约 21.2。我们只需要计算当前像素的 Cb、Cr 值到这个色度中心的距离,距离远的就是前景,距离近的就是背景。由于把亮度单独拆开了,阴影造成的亮度变化对色度距离影响很小,边缘和阴影区域的识别比直接比较 RGB 准确得多。
转换公式可以用 BT.601 标准,代码里这么写:
function rgbToCbCr(r, g, b) { const cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128; const cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128; return { cb, cr }; }绿色背景中心大约在 (43.5, 21.2),如果素材的绿幕偏亮或者偏暗,可以自动采样画面角落几个像素的颜色作为动态基准,比写死数值更通用。
3.2 双阈值软边缘和去绿边算法
直接把绿色像素的 alpha 设为 0,前景 alpha 设为 255,会导致边缘出现明显的锯齿和硬边。真实绿幕素材里,人物头发、衣服边缘通常混合了背景绿和前景色,这些像素的色度距离介于纯背景和纯前景之间。处理方式是用双阈值,设置 inner 和 outer 两个距离值:小于 inner 的完全透明,大于 outer 的完全不透明,中间的部分按距离线性插值得到半透明 alpha。这样边缘是渐变的半透明过渡,看起来自然得多。
去绿边是另一个关键步骤。边缘像素即使 alpha 处理好了,RGB 通道里依然残留一部分绿色光晕,导致图片边缘看起来发绿。原理是绿色溢界,G 通道明显高于 R 和 B。抠图后对这些边缘像素做一次 despill 处理,把多余的绿色压掉,让 G 值接近 R 和 B 的最大值。完整代码如下:
function chromaKeyImage(imageData, options) { const data = imageData.data; const { inner = 60, outer = 120, despill = 0.8 } = options; const greenCb = 43.5; const greenCr = 21.2; for (let i = 0; i < data.length; i += 4) { const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; const cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128; const cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128; const dist = Math.sqrt((cb - greenCb) ** 2 + (cr - greenCr) ** 2); let alpha = 1; if (dist < inner) { alpha = 0; } else if (dist < outer) { alpha = (dist - inner) / (outer - inner); } data[i + 3] = Math.min(data[i + 3], Math.round(alpha * 255)); const edgeAlpha = data[i + 3] / 255; if (edgeAlpha > 0 && edgeAlpha < 1 && despill > 0) { const maxRB = Math.max(r, b); if (g > maxRB) { data[i + 1] = Math.round(g - (g - maxRB) * despill); } } } }inner 和 outer 的取值直接影响抠图效果。以 0 到 255 的色度距离为单位,绿幕均匀时 inner 取 40 到 80,outer 取 90 到 140 是比较安全的范围。inner 太小会导致背景残留,inner 太大又会让薄纱、头发丝被完全抠掉。实操时把这两个参数做成滑块,实时预览当前帧的效果,找到特定素材的最佳组合。这类直觉参数用可视化调参远比写死数值高效。
3.3 机器学习抠图的接入方式:onnxruntime-web 跑 rmbg-2.0
不是所有素材都有绿幕。如果遇到背景复杂的真人出镜视频,色度键没法用,这时候我才会切换到 rmbg-2.0。这个模型专门做人像抠图,输入一张图,输出黑白 alpha 蒙版,配合 onnxruntime-web 可以完整跑在浏览器里。
接入逻辑分四步:加载模型、预处理输入、推理、生成带 alpha 的输出图。加载使用 onnxruntime-web,优先尝试 WebGPU 后端,不支持的话回退到 WebAssembly:
import * as ort from 'onnxruntime-web'; let session; async function loadModel() { session = await ort.InferenceSession.create('./rmbg-2.0.onnx', { executionProviders: ['webgpu', 'wasm'], }); }预处理要做三件事:把图像缩放到 1024×1024、归一化到 0 到 1 范围、调整成模型的 NCHW 输入格式。推理后得到的是 1×1×1024×1024 的灰度蒙版,把它等比缩放回原图尺寸,写入原图的 alpha 通道即可。模型推理单帧耗时和硬件强相关,我实测在普通笔记本上跑到 300 到 800 毫秒一帧,批量处理时要显示进度条,否则用户会以为浏览器卡死了。另外,模型文件如果放在 CDN,同样要注意跨域请求头,否则 fetch 加载会失败。
4. sprite sheet 打包:从透明序列帧到一张可用的精灵图
4.1 网格排布、padding 和透明裁剪
抽帧和抠图完成后,得到一长串透明 PNG 帧,下一步就是拼 sprite sheet。最简单的排布方式是固定网格:确定每行放多少帧,然后按顺序从左到右、从上到下排列。如果所有帧的尺寸完全一致,这种方式最紧凑也最容易理解。
但实际素材经常有差别。动作变化时,人物在画面里的包围盒会变,整帧大小一致的方案会浪费大量透明区域。所以更专业的做法是先对每一帧做透明边缘裁剪,算出非透明区域的左上角坐标和宽高,只把有效区域排进 sprite sheet,然后在 JSON 里记录每个帧在原始画布中的偏移量。这样既能显著减小最终文件体积,也不会丢位置信息。
还有一个不能漏的细节是 padding。帧与帧之间如果不留间隙,有些渲染引擎或 CSS 动画在缩放或者做纹理过滤时,会采样到相邻帧的像素,边缘出现鬼影。通用做法是每帧周围留 1 到 2 像素透明 padding,打包时把实际绘制区域往内缩 1 像素,既能避免采样渗色,又不会让图变大太多。我自己做项目时默认用 2 像素,稳一点。
4.2 变尺寸装箱:简单高效的 shelf 算法
当帧尺寸不一致时,固定网格会浪费空间,这时需要装箱算法。算法有很多种,从最简单的矩形排布到 MaxRects,复杂度递增。对于几十帧这个规模,shelf 算法足够高效且实现简单,它的思路是把所有帧按高度从大到小排序,然后一层一层地往右下摆:在一行里从左往右放,放不下了就开新的一行。代码如下:
function shelfPack(frames, sheetMaxWidth, padding) { const sorted = [...frames].sort((a, b) => b.trimH - a.trimH); const packed = []; let x = padding; let y = padding; let shelfHeight = 0; let sheetWidth = 0; let sheetHeight = padding; for (const f of sorted) { const w = f.trimW + padding * 2; const h = f.trimH + padding * 2; if (x > padding && x + w > sheetMaxWidth) { y += shelfHeight; x = padding; shelfHeight = 0; } f.x = x; f.y = y; x += w; shelfHeight = Math.max(shelfHeight, h); sheetWidth = Math.max(sheetWidth, x); sheetHeight = Math.max(sheetHeight, y + h); packed.push(f); } return { packed, sheetWidth, sheetHeight }; }注意 sheetMaxWidth 不能无限大,否则浏览器 canvas 会爆。一般限制在 2048 或 4096 以内,超出就换行,最终高度也随之上涨。装箱完成后,用记录的 x、y、w、h 把每个帧的像素绘制到总画布上:
const sheetCanvas = document.createElement('canvas'); sheetCanvas.width = sheetWidth; sheetCanvas.height = sheetHeight; const ctx = sheetCanvas.getContext('2d'); for (const f of frames) { ctx.putImageData(f.imageData, f.x + padding, f.y + padding); }这里有个地方要提醒,putImageData 是直接覆盖像素,不会做透明混合。如果你的帧是已经处理好的透明图,这是正确的,因为不需要任何混合操作。但如果你提前把帧画到过别的 canvas 上,或者 source 本身带半透明背景,就需要注意混合结果是否符合预期。
4.3 JSON 元数据与浏览器内预览
拼完图只是完成了一半,另一半是告诉使用者,每个子图在大图中的什么位置。导出 JSON 时,我建议至少包含这些字段:文件名、x、y、w、h、原始帧的偏移量 sourceX、sourceY、原始宽高 sourceW、sourceH。完整的 JSON 示例长这样:
{ "meta": { "image": "sprite.png", "size": { "w": 2048, "h": 2048 }, "padding": 2 }, "frames": [ { "name": "frame_0000", "x": 2, "y": 2, "w": 200, "h": 300, "sourceX": 10, "sourceY": 20, "sourceW": 1280, "sourceH": 720 } ] }拿到这份数据,前端做动画就非常灵活。既可以用 canvas 按帧绘制,也可以按 CSS background-position 做逐帧动画,游戏引擎里也能直接导入到精灵表驱动。
浏览器内预览是个很有用的功能。我通常在页面右侧放一个预览区域,选择某一帧时,直接把该帧在 sprite sheet 中的数据裁剪出来显示,同时提供一个自动播放按钮,像跑马灯一样按顺序切换帧,方便确认抠图边缘是否自然、动画是否流畅。这一步不做的话,导出后才发现边缘有问题,又得重新调参数。
5. 完整实操流程、参数调优与问题排查
5.1 页面操作流程:从上传视频到导出一气呵成
这套工具的实际操作流程,我按使用习惯顺序列出来。第一步,上传视频,支持本地文件和远程 URL 两种方式,上传后自动加载并播放,方便确认素材内容。第二步,设置抽帧率,默认 10fps,页面会同步显示预计抽帧数量和输出帧尺寸。第三步,选择抠图模式,绿幕素材选色度键,非绿幕素材选 rmbg-2.0,调整 inner、outer、despill 滑块,预览当前帧效果。第四步,设置打包参数,列数或最大宽高、padding、是否透明裁剪,页面会实时计算最终 sprite sheet 的尺寸。第五步,点击导出,生成图片和 JSON,一键打包成 zip 下载。
这里面最值得说的经验是,预览一定要用实时数据。我一开始是先调完所有参数再生成,结果每次调整都要等好几秒,体验很差。改成实时预览后,滑块拖动过程中直接在当前帧上应用色度键算法,肉眼看到效果满意了再批量处理,整个工具的可用性完全不一样。
5.2 参数调优建议:抽帧率、阈值、输出尺寸怎么配合
抽帧率的选择依赖素材的动作速度。人物正常说话、手势变化这类素材,10fps 足够了,再高也不会让动画更顺滑,只会让文件变大。快速动作比如挥拳、跳跃,用 15fps 到 20fps,否则会出现明显跳帧感。8 秒素材对应关系可以参考这个表:
| 抽帧率 | 8秒总帧数 | 适用场景 | 文件体积 |
|---|---|---|---|
| 5fps | 41 | 缓慢位移、呼吸动画 | 小 |
| 10fps | 81 | 正常说话、手势 | 中 |
| 15fps | 121 | 快速动作 | 较大 |
| 20fps | 161 | 剧烈动作、人物翻转 | 大 |
抠图阈值方面,色度键模式调 inner 和 outer 的经验是:先调 outer 到背景完全不透为止,也就是绿色被彻底抠干净,然后再调 inner 到前景细节完整。如果发现边缘有绿边,优先加大 despill 强度,其次再微调两个阈值。输出尺寸方面,如果 sprite sheet 超过 4096 像素,建议先缩小原始帧再到 640×360 或 480×270,因为游戏引擎和浏览器对超过 4096 的贴图支持不好,而且移动端纹理内存也很吃紧。
5.3 常见问题速查表:我在实际项目中踩过的坑
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 导出图片时抛 SecurityError | canvas 被跨域视频污染 | video 加 crossOrigin="anonymous",服务端配 CORS 响应头 |
| 抽帧后全是黑图或第一帧重复 | 没有等 seeked 事件就 drawImage | 用 Promise 封装 seek 跳转,等事件触发后再画帧 |
| 浏览器内存占用持续升高 | URL.createObjectURL 未释放,或 ImageData 堆积 | 逐帧处理,及时 revokeObjectURL,避免长数组保存帧数据 |
| 抠图边缘有一条明显绿边 | 色度键阈值外沿残留绿溢 | 开启 despill 边缘处理,把 G 通道压到接近 R/B 最大值 |
| 边缘锯齿严重 | 只用单阈值做颜色判断 | 改双阈值软边缘,半透明像素参与 alpha 渐变 |
| rmbg-2.0 模型加载失败 | 模型文件跨域或被拦截 | 检查存储桶 CORS 配置,确认模型路径可访问 |
| WebGPU 后端初始化失败 | 浏览器不支持或不稳定 | executionProviders 设置回退 wasm,速度略慢但稳定 |
| 导出 PNG 透明区域变黑 | 使用了不支持透明的格式或在画布上先填充了背景色 | 确认导出为 PNG,绘制前不要填充黑色背景 |
| 最终 sprite sheet 单张超过 4096 | 未做裁剪或缩放 | 开启透明裁剪,或把输出分辨率降低 |
其中内存占用这个问题是最容易被忽略的,尤其用 Edge 或 Chrome 长时间测试时,几十帧处理下来地址栏旁边经常显示“此页面正在影响性能”。性能面板里能明显看到脚本阻塞主线程。所以我在工具里对批量操作做了分片处理:每处理 10 帧就让出一次主线程,用 setTimeout 或者 requestAnimationFrame 让浏览器喘口气,UI 就不会假死了。这个改动虽然简单,但对长时间跑任务的体验提升非常大。
最后再说一个很少有人注意的细节:拼完的 sprite sheet 在导出前,最好再用 putImageData 读取一遍检查 alpha 通道。因为有些浏览器在 canvas 某些配置下,会低估半透明像素的数据,导出的 PNG 和预览时不一致。确认无误后,再执行 toBlob 导出,然后生成下载链接。整个过程我在不同浏览器测下来,Chrome 和 Edge 表现最好,Firefox 对某些视频编码的 seek 支持稍弱,Safari 需要额外处理 HEVC 素材,但基本功能和导出流程都能跑通。