搞 2D 游戏角色动画或者网页动效的时候,最烦人的一个环节就是把视频素材转成引擎能用的序列帧。前阵子我拿到一条 8 秒的绿幕舞蹈视频,需要在浏览器里直接抽帧、抠图、拼成一张 sprite sheet,整个流程不装 FFmpeg、不碰 Photoshop,最后还真就全程在浏览器里跑通了。今天把这条流水线完整拆开讲一遍,包括每一步的代码、参数选择、坑和排查思路,给后面做类似需求的朋友当个参考。
这条流程说白了就是四个动作:从视频里按固定时间间隔把画面一帧帧截出来,然后把每帧的绿色背景去掉变成透明图,最后把这些透明图按网格排列拼到一张大图上,顺便导出一份描述每个小图位置的 JSON。做完之后的产物,可以直接丢给 Unity、Godot、Cocos 或者纯前端 Canvas 动画去用。
1. 先理清需求:一条 8 秒视频,到底要产出什么
1.1 从“一条视频”到“一张雪碧图”,中间发生了什么
视频本质是连续播放的静态图像,sprite sheet(也叫雪碧图、序列帧图集)则是把这些静态图像按规律排在一张大图上。游戏引擎和前端动画运行时拿到这张大图后,按网格坐标把每一格裁出来按帧播放,就还原成视频里的动作效果了。
相比直接播放视频,sprite sheet 在游戏场景里有几个实打实的好处:一是图片资源比视频解码更可控,不需要依赖浏览器或引擎的视频解码器;二是方便做每帧的独立变换,比如在代码里让某一帧放大、旋转、镜像,视频就做不了这么灵活;三是美术和程序的分工更清晰,美术出图集,程序挂动画组件,互不干扰。
但代价也很明显:8 秒视频按 30fps 抽帧就是 240 张图,按 25fps 是 200 张,帧率越高、视频越长,最终 sprite sheet 的体积会爆炸。所以流程第一步的“抽帧节奏”直接决定后面的运算量和资源体量。
1.2 为什么我选择浏览器而不是 FFmpeg + PS 那套
传统路线一般是:FFmpeg 抽帧 → GIMP/PS 逐帧抠图 → 手动拼图或写脚本拼。这个流程能吃,但有几个让我很不爽的地方。
首先是环境依赖。FFmpeg 虽然强大,但要在目标机器上安装、配路径,美术同事拿到项目还得跟着配一遍环境。其次是中间产物太多,240 帧 PNG 是 240 个文件,抠图之后又是 240 个透明 PNG,项目仓库瞬间多出几百个文件,管理起来非常难受。
浏览器路线把全流程收敛在一个 HTML 文件里,双击打开就能用,视频文件全程本地读入、本地处理、本地导出,不存在文件上传的隐私问题。对不熟悉命令行的美术同事尤其友好。性能上看着好像不如本地命令行工具,但 8 秒、30fps、1080p 这个量级,纯 JavaScript 逐像素处理完全跑得动,瓶颈反而在算法写得够不够聪明。
1.3 整个流水线的四个步骤
我实际落地的时候把流程拆成了四段:
- 视频抽帧:读取本地视频,按设定帧率把每一帧画面截成 canvas 位图数据;
- 绿幕抠图:对每帧位图做色度键控(chroma key),把绿色背景变成透明通道;
- 序列帧拼图:把所有透明帧按网格拼接到一张大画布上;
- 导出资源:输出 sprite sheet 图片 + 一份记录帧位置、帧尺寸、播放顺序的 JSON。
每一步的产物边界清晰,调试的时候也能单独看中间结果,不用一次性把整条链路跑完才看到效果。
2. 视频抽帧:把连续时间切成离散画面
2.1 浏览器读本地视频文件,基础准备
抽帧第一步是让浏览器拿到视频文件并且能解码播放。这里我用的是File API + URL.createObjectURL + HTMLVideoElement的组合,这套方案兼容性最好,不需要处理任何二进制协议。
const input = document.getElementById('videoFile'); const video = document.createElement('video'); video.muted = true; // 必须静音,否则部分浏览器会拦自动播放 video.playsInline = true; input.addEventListener('change', async (e) => { const file = e.target.files[0]; const url = URL.createObjectURL(file); video.src = url; await new Promise((resolve) => { video.onloadedmetadata = resolve; }); console.log('视频时长:', video.duration, '秒'); console.log('视频分辨率:', video.videoWidth, 'x', video.videoHeight); });这里有几个容易踩的点。video.muted = true不是随便写的:浏览器自动播放策略下,带声音的视频在用户没有明确交互时会被阻止播放,但静音视频可以正常解码和 seek。await new Promise(...)是为了等视频元数据加载完成,否则video.duration和videoWidth都拿不到。
另一个细节是URL.createObjectURL产生的对象 URL 用完后要revokeObjectURL,否则会一直占内存。但这个项目里视频文件会在整个处理期间反复使用,所以我是等全部处理完才 revoke 的。
2.2 抽帧节奏怎么定:8 秒视频该抽多少帧
抽帧间隔取决于最终动画要用的帧率。对游戏角色动画来说,30fps 是常见选择,8 秒视频就是 240 帧;如果是 Web 前端里的装饰性动效,15fps 甚至 12fps 也够用,还能大幅减小资源体积。
在代码里我一般把帧率暴露成可调参数,默认给 30,因为 30 帧动画的流畅度基本能覆盖大部分 2D 动作场景。
帧数定了之后,内存占用也要估算。一帧 1920x1080 的 RGBA 图像素是 1920 x 1080 x 4 ≈ 8.3MB,240 帧全部存到内存里就是 2GB 上下,浏览器直接爆掉。所以这里有个非常重要的设计决定:我不把所有帧的ImageData都常驻内存,而是每抽完一帧就立刻抠图,抠完就画到最终 sprite sheet 大画布上,最后只保留大画布一块内存。整个处理过程的内存峰值大概是大画布一份 + 当前帧两三份,量级可控很多。
2.3 seek 抽帧的异步坑与稳定写法
video 元素的currentTime是异步 seek 的,设置之后不会立刻生效,也没有同步接口能马上取到那一帧画面。网上很多简单示例直接循环设置currentTime再立刻drawImage,十有八九会抽到黑帧或重复帧。
我的稳定写法是每次 seek 都等待seeked事件,再取画面:
function grabFrame(time) { return new Promise((resolve) => { const onSeeked = () => { video.removeEventListener('seeked', onSeeked); ctx.drawImage(video, 0, 0, width, height); resolve(ctx.getImageData(0, 0, width, height)); // 实际工程里,拿到 ImageData 后马上交给抠图函数处理 }; video.addEventListener('seeked', onSeeked); video.currentTime = time; }); } async function extractFrames(totalFrames, fps) { const frames = []; for (let i = 0; i < totalFrames; i++) { const t = i / fps; const frameData = await grabFrame(t); frames.push(frameData); // 注意:这里真实项目不 push,直接走抠图 } }真实工程里我是不会把 240 帧ImageData全部 push 到数组里的,上面只是为了展示抽帧主流程。另外还有一个隐藏问题:浏览器对currentTime的精度不是无限高,部分浏览器实测在 24fps 时相邻帧的间隔会有 1~2 帧误差,尤其在视频编码是 VFR(可变帧率)时更明显。
针对 VFR 视频,我后来加了一个补偿策略:不依赖每次 seek 都严格落在目标时间点,而是抽完帧后用video.currentTime读回实际时间戳,存进每帧的元数据里,这样即使个别帧偏移了,后续拼 JSON 时也能记录真实播放时间。
3. 绿幕抠图:核心是“颜色距离”而不是“擦除”
3.1 chroma key 原理通俗版
绿幕抠图,专业叫 chroma keying,原理一句话:把画面中颜色接近绿色的像素变成透明。这和 PS 魔棒工具的思路类似,但在代码里实现时,判断“接近绿色”不是简单比较g > 200 && r < 100,而是计算每个像素颜色和目标绿色在色彩空间里的距离。
我们基于像素的 RGB 值,算它与目标绿(0, 255, 0)的欧氏距离。距离越近,越可能是背景;距离越远,越可能是前景。然后设一个“完全透明阈值”和“完全不透明阈值”,中间地带做渐变,这样边缘不会出现一刀切的锯齿和硬边。
顺带说一句,如果视频背景不是绿幕而是任意真实背景,这种色度键就不适用了,得换 AI 语义分割模型来做,比如那些人物抠图模型。但 AI 抠图在浏览器里跑模型、调阈值、处理边缘,成本和不确定性都比绿幕色度键高不少。所以能用绿幕解决的,我绝不先上模型。
3.2 一份能用的像素抠图代码
下面是我用的核心函数,输入ImageData,原地修改它的 alpha 通道:
function chromaKey(imageData, options = {}) { const data = imageData.data; const { keyR = 0, keyG = 255, keyB = 0, // 默认绿幕 thresholdLow = 80, // 完全透明阈值 thresholdHigh = 160, // 完全不透明阈值 } = options; for (let i = 0; i < data.length; i += 4) { const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; // 计算与目标绿幕的颜色距离 const dr = r - keyR; const dg = g - keyG; const db = b - keyB; const dist = Math.sqrt(dr * dr + dg * dg + db * db); let alpha = 255; if (dist <= thresholdLow) { alpha = 0; // 肯定属于背景 } else if (dist >= thresholdHigh) { alpha = 255; // 肯定属于前景 } else { // 过渡带线性插值,边缘柔和 alpha = Math.round(255 * (dist - thresholdLow) / (thresholdHigh - thresholdLow)); } data[i + 3] = alpha; // 这里还可以顺手做边缘去绿,见 3.3 } }直接改ImageData.data里的 alpha 通道即可,RGBA 每个像素 4 个字节,步长 4。
两个阈值参数thresholdLow和thresholdHigh是抠图质量的关键。阈值太低会扣不掉背景的暗部(绿幕打光不均的地方),阈值太高会把主体边缘的绿色信息一起扣掉,看起来是“穿洞”或者“毛边”。我这个 8 秒视频受灯光影响,绿幕左下角和右上角亮度不一致,最后还是把thresholdLow降到 60、thresholdHigh提到 180,才在暗部和亮部之间找到平衡。
3.3 边缘质量与绿边处理
只做 alpha 抠图,放大看人物边缘会有一圈淡淡的绿色描边。这是因为前景边缘像素本身混合了绿幕的颜色,RGB 里残留了绿色分量,即使 alpha 过渡平滑了,绿色依然在视觉效果上扎眼。
处理绿边最实用的办法是“去溢色”(despill):对 alpha 处于过渡带、或者颜色中绿色分量明显偏高的边缘像素,降低它的绿色通道值,甚至往绿色分量的方向做一点反相。
// 在 chromaKey 循环里,alpha 处于过渡区时追加: const spillFactor = (255 - alpha) / 255; // 越透明,去绿越强 data[i + 1] = Math.max(0, data[i + 1] - Math.round(spillFactor * 80));这个强度要给个合理范围,去得太过边缘会发紫发灰。我在实际调试时,会把强度参数暴露成滑块,边看预览边调。预览方式也很简单:处理完一帧后直接putImageData到一个带棋盘格的 canvas 上,就能看出边缘残留情况。
如果对边缘质量要求更高,可以换到 YUV 或 HSV 色彩空间里算色度距离。这类颜色空间把亮度和色度分离了,绿色判断不容易受打光不均匀影响。公式会复杂一点,但对“绿幕有亮暗变化”这类常见问题有本质改善。
3.4 性能优化路线:从 getImageData 到 WebGL
240 帧、每帧 1080p,纯for循环逐像素运算的耗时实测在普通笔记本上大概是 10~20 秒,能接受,但不是最优。如果视频更长,或者要做成工具给美术批量用,性能就得优化。
我的优化路线有三档。第一档是把处理逻辑搬进 Web Worker,避免长时间占用 UI 线程导致页面假死。第二档是用Uint8ClampedArray这种 TypedArray 替换普通数组读写,减少类型转换开销。第三档是把色度键算法改成 WebGL 片段着色器,一个像素一个 fragment,GPU 并行算 240 帧,耗时能压到一两秒以内。
WebGL 方案核心是把原视频帧传到着色器的纹理里,然后在 fragment shader 里做同样的颜色距离判断。这里我不展开全部 GLSL 代码,但思路就是:gl_FragColor.a由距离计算得出,边缘过渡用smoothstep。性能提升非常明显,适合做成可复用工具。
4. 拼合 sprite sheet:布局、元数据与导出
4.1 网格布局怎么定
抽帧抠图完成之后,240 张透明帧要拼到一张大图里。最省事的布局是均匀网格:先决定列数cols,行数根据总帧数自适应。
const totalFrames = 240; const cols = Math.ceil(Math.sqrt(totalFrames)); // 16 列 const rows = Math.ceil(totalFrames / cols); // 15 行这里我故意用了接近正方形的方式,方便大多数引擎直接按cols切格子。格子总数是cols * rows,可能比实际帧数多几个空位,比如这里 16x15=240 刚好填满,但如果是 200 帧就会多出整整一排空白。我的处理是:多出来的格子不管它,保持透明即可,引擎只按 JSON 里的帧数来播。
拼接挂前还要检查 canvas 最大尺寸。浏览器对 canvas 尺寸有上限,超出后 canvas 会变成空白或直接报错。经验值:Chrome 桌面最大大约是 32767px 边长,Safari 在部分版本会限制到 4096px 或 16384px。如果 240 帧 1080p 全尺寸拼图明显超限,就得先降采样,把每帧缩到适合的尺寸再拼。
降采样参考:如果最终动画控制在 512x512 的显示区域,每帧降到 512 宽就够了;如果游戏里需要近景特写,保留 1024 宽,视觉上也看不太出劣化。我这次按照游戏内角色显示高度大约 360px 的需求,把每帧统一缩放到了 512 宽。
4.2 把 240 帧画到一张大画布上
拼图的核心操作是反复drawImage— 把每个剪辑后的帧画到最终大画布的正确位置。但浏览器 canvas 的drawImage只能接受画布或图片元素作为源,不能直接接受ImageData,所以每帧抠完图后先写到一个临时 canvas 上,再作为源画到 sheet 上。
function buildSpriteSheet(frames, frameW, frameH) { const cols = Math.ceil(Math.sqrt(frames.length)); const rows = Math.ceil(frames.length / cols); const sheet = document.createElement('canvas'); sheet.width = cols * frameW; sheet.height = rows * frameH; const sctx = sheet.getContext('2d'); frames.forEach((frameData, index) => { const tempCanvas = document.createElement('canvas'); tempCanvas.width = frameW; tempCanvas.height = frameH; const tempCtx = tempCanvas.getContext('2d'); tempCtx.putImageData(frameData, 0, 0); const col = index % cols; const row = Math.floor(index / cols); sctx.drawImage(tempCanvas, col * frameW, row * frameH); }); return sheet; }这里有个细节容易被忽略:先把抠图后的ImageData写入临时 canvas,再drawImage到大画布,能得到带透明通道的正确合成结果;如果直接在最终大画布上putImageData,它会把像素原样铺上去,不经过 alpha 混合,但对我们这种“每个格子都独立、互不重叠”的场景,视觉结果是一样的,只是putImageData不受 canvas 变换和缩放影响,性能也更低一些。所以我一般用drawImage而不是putImageData来拼图。
4.3 导出 PNG 和配套 JSON
拼完大画布,导出图片我用canvas.toBlob,格式必须选 PNG,因为 JPEG 不支持透明通道,一导出绿边和安全区就全变成黑色或白色底了。
sheet.toBlob((blob) => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'sprite_sheet.png'; a.click(); URL.revokeObjectURL(a.href); }, 'image/png');同时导出配套 JSON。JSON 里记录帧序列、每帧在 sheet 中的坐标、宽高、源视频时长和帧率。这样引擎端读取时,就不需要自己去猜网格尺寸了。
{ "name": "dance_8s", "frameWidth": 512, "frameHeight": 512, "cols": 16, "rows": 15, "fps": 30, "duration": 8, "frames": [ { "index": 0, "x": 0, "y": 0, "time": 0.0 }, { "index": 1, "x": 512, "y": 0, "time": 0.033 }, { "index": 2, "x": 1024, "y": 0, "time": 0.067 } ] }这些元数据对动画播放很关键,尤其是time字段,兼容 VFR 视频时能记录每一帧的真实时间点,引擎可以直接按绝对时间而非平均帧间隔来播。
5. 实操中遇到的坑和解决记录
5.1 常见问题速查表
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 抽帧全是黑帧 | 没有等待seeked就drawImage | 用事件或 Promise 等待 seek 完成,再取画面 |
| 抽出的帧偶尔重复 | 视频是 VFR,时间戳不均匀 | 抽完读取video.currentTime记录真实时间 |
| 人物边缘有明显绿边 | 无色溢处理或阈值过紧 | 增加 despill 逻辑,调高thresholdHigh |
| 边缘出现锯齿/破洞 | thresholdLow过高 | 降低thresholdLow,让过渡带更宽 |
| 浏览器内存暴涨 | 所有帧全部常驻内存 | 改成处理一帧就拼进 sheet,释放中间位图 |
| canvas 导出后是全空白 | canvas 尺寸超过浏览器上限 | 检测尺寸并降采样,或分行分块导出 |
| Safari 里视频不播 | 缺少playsInline或带声播放受限 | 设置muted+playsInline,让视频静音播放 |
5.2 三个让我印象深刻的排查过程
第一个是 VFR 视频的抽帧偏移。同一段视频,本地播放器看每个动作都正常,但抽帧后拼出的动画偶尔会卡一下,像是动作“跳”了一小段。排查后发现视频源是手机录屏导出,编码是可变帧率,实际帧间隔在 30~60fps 之间波动。我的修复方法是每抽一帧都回读真实时间,写进 JSON。播放端如果支持time字段,就按真实时间播;如果是普通引擎只看平均帧率,那只能先把源视频转成固定帧率再处理。
第二个是抽帧时发现有几帧动画里人物边缘在闪。放大看是把背景绿色和人物衣服上相近的绿色一起扣掉了,导致胸前的图案像穿了洞。这个光调阈值解决不了,因为衣服局部的 RGB 和绿幕在色度空间里真的很接近。我的处理是:绿幕打光尽量均匀,穿帮镜头里主体不要穿绿色系服装。后来问美术,他们其实也早知道这个规则,只是素材是现成视频,没法重新拍,最后只能通过把thresholdLow调小、让背景残留一点绿再手工修关键帧来妥协。
第三个是 canvas 尺寸超限。我能自由选择每帧尺寸,但如果直接按 1920x1080 拼 240 帧,最终尺寸会超过 Chrome 的 canvas 上限,导致导出 PNG 是空白的。排查时最诡异的是浏览器不报错,只是toBlob拿到一个全白(或全透明)的 blob,一开始以为是导出代码写错了。后来用二分法测试,发现大画布宽高超过某个值后sheet.toBlob就返回空内容。解决方式是限制单图最大 8192x8192,超了就动态降采样。这个值结合 Safari 的 4096 限制也做了检测,跨平台稳一点。
最后再分享一个实际经验:这个流程最花时间的不是写代码,而是调阈值参数。我在界面上加了一个“预览关键帧”功能,直接在浏览器里显示抽帧、抠图后的透明底棋盘格效果,把thresholdLow、thresholdHigh、despill强度做成滑块,实时候调整。参数调满意了再跑全量 240 帧,几十秒出结果,比盲调代码重新跑一遍刷屏效率高多了。如果你后面也想做类似的浏览器视频处理工具,建议先把“参数可视化调优”这个功能排进第一版。