序列幀精靈圖方案(含摳圖與打包))
做網(wǎng)頁(yè)動(dòng)畫的時(shí)候我經(jīng)常遇到一個(gè)挺煩的環(huán)節(jié)手里有一段綠幕視頻素材想把它變成網(wǎng)頁(yè)里能逐幀控制的序列幀動(dòng)畫就得先抽幀、再摳圖、最后再拼成一張 sprite sheet。以前我都是 FFmpeg 抽幀PS 一張張摳再用 TexturePacker 打包三四個(gè)工具來回切尤其素材一多就特別痛苦。后來我索性寫了一個(gè)純?yōu)g覽器的小工具上傳視頻、設(shè)置抽幀率和摳圖參數(shù)8 秒的綠幕視頻直接處理完導(dǎo)出效果立刻能看到。這篇文章就把這套方案完整拆一遍包括視頻轉(zhuǎn)序列幀的抽幀細(xì)節(jié)、瀏覽器里的綠幕摳圖算法以及怎么把這些透明序列幀拼成一張能直接用的 sprite sheet。適合前端開發(fā)者、游戲美術(shù)和所有想自動(dòng)化處理視頻素材的人參考。1. 項(xiàng)目思路為什么在瀏覽器里完成“抽幀 摳圖 拼圖”一條龍1.1 傳統(tǒng)工作流和瀏覽器工作流的差別先說說過去處理綠幕視頻成序列幀動(dòng)畫的常規(guī)流程。第一步是抽幀F(xiàn)Fmpeg 一條命令就能把 8 秒素材按指定幀率導(dǎo)出幾十張 PNG第二步是摳圖把 PNG 挨個(gè)丟進(jìn) PS或者用 GIMP 的插件批量處理綠幕素材還好遇到背景復(fù)雜的素材還要手動(dòng)調(diào)整邊緣第三步是拼圖摳完的透明 PNG 再丟進(jìn) TexturePacker、SpriteSmith 這類打包工具最后導(dǎo)出 sprite sheet 和元數(shù)據(jù)。這個(gè)流程看著清晰但有幾個(gè)實(shí)際痛點(diǎn)。第一環(huán)境依賴重FFmpeg 要裝、PS 要買、打包工具要裝換個(gè)電腦全部重來第二中間產(chǎn)物多抽出來的 PNG、摳完的 PNG、打包后的圖硬盤空間直線上升第三參數(shù)調(diào)整成本高每改一次抽幀率或扣圖閾值都得重新跑一遍完整流程很難實(shí)時(shí)預(yù)覽效果。而瀏覽器方案把這些全部壓縮到一步。打開頁(yè)面、上傳視頻、設(shè)置參數(shù)就能看到抽幀和摳圖結(jié)果所有中間狀態(tài)都可以實(shí)時(shí)調(diào)整。它不需要安裝任何軟件只要是新一點(diǎn)的 Chrome、Edge 或者 Firefox 就行跨平臺(tái)、可分享甚至可以直接封裝成一個(gè)團(tuán)隊(duì)內(nèi)部使用的在線工具。這也是我做這個(gè)項(xiàng)目最核心的動(dòng)機(jī)把重復(fù)勞動(dòng)變成參數(shù)調(diào)節(jié)。1.2 技術(shù)選型canvas 2D 還是 WebCodecs要不要上機(jī)器學(xué)習(xí)瀏覽器端處理視頻和圖像方案其實(shí)不少我先說結(jié)論這個(gè)項(xiàng)目最穩(wěn)的組合是“video 元素 canvas 2D 像素級(jí)色度鍵摳圖”機(jī)器學(xué)習(xí)模型作為可選項(xiàng)。用 video 元素加載視頻再用 canvas 的 drawImage 把當(dāng)前幀畫到畫布上是兼容性最好的抽幀方案只要瀏覽器能正常播放這個(gè)視頻就能抽幀不需要考慮編碼格式問題。WebCodecs 的 VideoFrame 性能更猛能直接操作壓縮后的視頻幀適合高分辨率批量處理但它需要 Chrome 94 以上的版本Firefox 和 Safari 對(duì)它的支持還不完整。對(duì) 8 秒短視頻這種量級(jí)canvas 2D 完全夠用沒必要引入額外的兼容性風(fēng)險(xiǎn)。摳圖這塊綠幕素材我優(yōu)先用色度鍵算法而不是一上來就跑神經(jīng)網(wǎng)絡(luò)。原因很簡(jiǎn)單綠幕背景的顏色是已知的用像素顏色距離判斷前景背景又快又準(zhǔn)單幀 720p 的像素循環(huán)也就幾十毫秒而且閾值可以直接拖動(dòng)滑塊實(shí)時(shí)看效果。機(jī)器學(xué)習(xí)摳圖模型像 rmbg-2.0確實(shí)能處理沒有綠幕的復(fù)雜背景但模型文件動(dòng)輒上百 MB瀏覽器里還要加載 onnxruntime-web推理一次也得幾百毫秒8 秒視頻幾十幀跑下來等待時(shí)間會(huì)明顯拉長(zhǎng)。所以我的設(shè)計(jì)是綠幕素材走色度鍵非綠幕素材再啟用 rmbg-2.0兩個(gè)方案共存互不干擾。1.3 8 秒視頻的實(shí)際賬抽幀率、分辨率、最終 spritesheet 體積怎么算動(dòng)手之前先把預(yù)算算清楚不然做到一半發(fā)現(xiàn)瀏覽器內(nèi)存爆了就麻煩了。假設(shè)素材是 1280×720、25fps、時(shí)長(zhǎng) 8 秒總共 200 幀原始畫面。如果按 25fps 全部抽出來200 張 720p 的 PNG光是中間產(chǎn)物就好幾十 MB堆內(nèi)存也緊張。實(shí)際做序列幀動(dòng)畫不需要原始幀率動(dòng)畫表現(xiàn)和文件體積要平衡。抽幀率一般取 8fps 到 12fps動(dòng)作很快的取 15fps動(dòng)作緩慢的 5fps 也夠。按 10fps 算8 秒視頻會(huì)得到 81 幀左右這個(gè)數(shù)量對(duì)精靈圖非常友好。再看拼圖尺寸。如果不裁剪、不縮放81 幀 1280×720 的圖硬拼假設(shè)排成 9 列 9 行最終 sprite sheet 是 11520×6480超過瀏覽器單張 canvas 的尺寸上限導(dǎo)出也會(huì)失敗。所以實(shí)際項(xiàng)目里要么把每幀縮放到 640×360要么做透明區(qū)域裁剪幀大小降到 200×300 左右81 幀排成 9 列 9 行整體變成 1800×2700一張普通 PNG 就裝下了。這些數(shù)字決定了后面的代碼怎么設(shè)計(jì)。2. 瀏覽器端視頻抽幀從 video 元素到 ImageData 的完整鏈路2.1 視頻加載和跨域問題的處理第一步是讓用戶把視頻弄進(jìn)瀏覽器。最簡(jiǎn)單的方式是input typefile選擇一個(gè)本地文件拿到 File 對(duì)象后通過 URL.createObjectURL 生成一個(gè)臨時(shí)地址賦值給 video 元素。這里有個(gè)很關(guān)鍵的細(xì)節(jié)如果用完不回收URL.createObjectURL 生成的對(duì)象 URL 會(huì)一直占用內(nèi)存尤其處理大視頻時(shí)頁(yè)面越用越卡。正確做法是視頻加載完成后監(jiān)聽 URL.revokeObjectURL或者干脆在頁(yè)面卸載時(shí)統(tǒng)一回收。如果視頻素材放在 CDN 或者對(duì)象存儲(chǔ)上需要通過 URL 直接加載那就要注意跨域問題。video 元素跨域播放本身沒毛病但一旦 canvas 要把跨域視頻畫上去瀏覽器會(huì)直接污染畫布后面調(diào)用 toDataURL 或 toBlob 導(dǎo)出時(shí)會(huì)拋 SecurityError。解決方法是在 video 標(biāo)簽上設(shè)置 crossOriginanonymous同時(shí)服務(wù)端要返回 Access-Control-Allow-Origin 響應(yīng)頭。凡是遇到“canvas 被污染”的報(bào)錯(cuò)優(yōu)先檢查這兩點(diǎn)。2.2 抽幀的核心邏輯seeked 事件為什么比 setTimeout 更靠譜抽幀的基本操作很簡(jiǎn)單改變 video.currentTime等瀏覽器跳到對(duì)應(yīng)時(shí)間點(diǎn)后用 canvas.drawImage(video, 0, 0, width, height) 把畫面畫下來。但這里有個(gè)新手最容易踩的坑直接給 currentTime 賦值后立刻執(zhí)行 drawImage得到的很可能是一張黑圖或者上一幀的畫面因?yàn)橐曨l跳轉(zhuǎn)是異步的。正確做法是給 video 綁定 seeked 事件跳轉(zhuǎn)完成后事件觸發(fā)再執(zhí)行畫圖操作。如果要在循環(huán)里逐幀抽取最好封裝一個(gè) 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); } }這里還有兩個(gè)細(xì)節(jié)。第一最后的時(shí)間點(diǎn)要減去一個(gè)很小的值否則可能超出視頻邊界觸發(fā) error 事件第二如果視頻沒有預(yù)加載完成直接設(shè)置 currentTime 會(huì)失敗所以要確保 video.readyState 至少是 1或者在 loadedmetadata 之后再開始抽幀。移動(dòng)端還有一個(gè)習(xí)慣性問題video 必須設(shè)置 muted 和 playsinline 屬性否則在部分瀏覽器里根本不會(huì)自動(dòng)播放也就無法跳幀。2.3 減少內(nèi)存分配的三個(gè)優(yōu)化手段抽幀過程中最容易出現(xiàn)的問題是每抽一幀就新建一個(gè) canvas 或者反復(fù)調(diào)用 getImageData導(dǎo)致內(nèi)存暴漲、GC 頻繁頁(yè)面動(dòng)畫都卡了。我實(shí)際驗(yàn)證下來有三個(gè)方面值得注意。第一個(gè)手段是 canvas 實(shí)例復(fù)用。抽幾百幀只要一個(gè)離屏 canvas 就夠了不需要每幀 new 一個(gè)。把目標(biāo)寬高設(shè)好后循環(huán)里反復(fù) drawImage性能會(huì)好很多。第二個(gè)手段是留意 ImageData 的內(nèi)存占用。一張 1920×1080 的 ImageDataRGBA 四個(gè)通道算下來是 1920×1080×4約 8.3MB如果同時(shí)保留十幾幀在內(nèi)存里頁(yè)面馬上飆到上百 MB。操作完的 ImageData 沒有引用后會(huì)被 GC 回收所以要避免把每一幀都存進(jìn)數(shù)組里應(yīng)該處理完一幀就立刻繪制到最終的 sprite sheet canvas 上或者直接轉(zhuǎn)成 Blob 存下來。第三個(gè)手段是優(yōu)先用 toBlob 而不是 toDataURL。toDataURL 返回的是 base64 字符串體積比二進(jìn)制 Blob 大三分之一而且字符串也占內(nèi)存導(dǎo)出時(shí)用 toBlob 加上 URL.createObjectURL 生成下載鏈接內(nèi)存壓力小很多。3. 綠幕摳圖算法色度鍵怎么調(diào)才不傷邊緣3.1 為什么“綠色通道大于某值就是背景”不夠用綠幕摳圖最直覺的思路是看像素的 G 通道值綠色多就是背景。這種算法在光照均勻的素材上勉強(qiáng)能用一旦背景有陰影、反光或者人物衣服帶綠色就會(huì)把前景誤殺一大片。原因是 RGB 三個(gè)通道高度關(guān)聯(lián)單看 G 通道無法區(qū)分“亮綠色背景”和“偏綠的前景像素”。更穩(wěn)的做法是把像素從 RGB 轉(zhuǎn)到 YCbCr 空間Y 是亮度Cb 和 Cr 是藍(lán)色差、紅色差。綠幕背景的色度中心是固定的G255、R0、B0 時(shí)算出來 Cb 約 43.5Cr 約 21.2。我們只需要計(jì)算當(dāng)前像素的 Cb、Cr 值到這個(gè)色度中心的距離距離遠(yuǎn)的就是前景距離近的就是背景。由于把亮度單獨(dú)拆開了陰影造成的亮度變化對(duì)色度距離影響很小邊緣和陰影區(qū)域的識(shí)別比直接比較 RGB 準(zhǔn)確得多。轉(zhuǎn)換公式可以用 BT.601 標(biāo)準(zhǔn)代碼里這么寫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)如果素材的綠幕偏亮或者偏暗可以自動(dòng)采樣畫面角落幾個(gè)像素的顏色作為動(dòng)態(tài)基準(zhǔn)比寫死數(shù)值更通用。3.2 雙閾值軟邊緣和去綠邊算法直接把綠色像素的 alpha 設(shè)為 0前景 alpha 設(shè)為 255會(huì)導(dǎo)致邊緣出現(xiàn)明顯的鋸齒和硬邊。真實(shí)綠幕素材里人物頭發(fā)、衣服邊緣通?;旌狭吮尘熬G和前景色這些像素的色度距離介于純背景和純前景之間。處理方式是用雙閾值設(shè)置 inner 和 outer 兩個(gè)距離值小于 inner 的完全透明大于 outer 的完全不透明中間的部分按距離線性插值得到半透明 alpha。這樣邊緣是漸變的半透明過渡看起來自然得多。去綠邊是另一個(gè)關(guān)鍵步驟。邊緣像素即使 alpha 處理好了RGB 通道里依然殘留一部分綠色光暈導(dǎo)致圖片邊緣看起來發(fā)綠。原理是綠色溢界G 通道明顯高于 R 和 B。摳圖后對(duì)這些邊緣像素做一次 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 的色度距離為單位綠幕均勻時(shí) inner 取 40 到 80outer 取 90 到 140 是比較安全的范圍。inner 太小會(huì)導(dǎo)致背景殘留inner 太大又會(huì)讓薄紗、頭發(fā)絲被完全摳掉。實(shí)操時(shí)把這兩個(gè)參數(shù)做成滑塊實(shí)時(shí)預(yù)覽當(dāng)前幀的效果找到特定素材的最佳組合。這類直覺參數(shù)用可視化調(diào)參遠(yuǎn)比寫死數(shù)值高效。3.3 機(jī)器學(xué)習(xí)摳圖的接入方式onnxruntime-web 跑 rmbg-2.0不是所有素材都有綠幕。如果遇到背景復(fù)雜的真人出鏡視頻色度鍵沒法用這時(shí)候我才會(huì)切換到 rmbg-2.0。這個(gè)模型專門做人像摳圖輸入一張圖輸出黑白 alpha 蒙版配合 onnxruntime-web 可以完整跑在瀏覽器里。接入邏輯分四步加載模型、預(yù)處理輸入、推理、生成帶 alpha 的輸出圖。加載使用 onnxruntime-web優(yōu)先嘗試 WebGPU 后端不支持的話回退到 WebAssemblyimport * as ort from onnxruntime-web; let session; async function loadModel() { session await ort.InferenceSession.create(./rmbg-2.0.onnx, { executionProviders: [webgpu, wasm], }); }預(yù)處理要做三件事把圖像縮放到 1024×1024、歸一化到 0 到 1 范圍、調(diào)整成模型的 NCHW 輸入格式。推理后得到的是 1×1×1024×1024 的灰度蒙版把它等比縮放回原圖尺寸寫入原圖的 alpha 通道即可。模型推理單幀耗時(shí)和硬件強(qiáng)相關(guān)我實(shí)測(cè)在普通筆記本上跑到 300 到 800 毫秒一幀批量處理時(shí)要顯示進(jìn)度條否則用戶會(huì)以為瀏覽器卡死了。另外模型文件如果放在 CDN同樣要注意跨域請(qǐng)求頭否則 fetch 加載會(huì)失敗。4. sprite sheet 打包從透明序列幀到一張可用的精靈圖4.1 網(wǎng)格排布、padding 和透明裁剪抽幀和摳圖完成后得到一長(zhǎng)串透明 PNG 幀下一步就是拼 sprite sheet。最簡(jiǎn)單的排布方式是固定網(wǎng)格確定每行放多少幀然后按順序從左到右、從上到下排列。如果所有幀的尺寸完全一致這種方式最緊湊也最容易理解。但實(shí)際素材經(jīng)常有差別。動(dòng)作變化時(shí)人物在畫面里的包圍盒會(huì)變整幀大小一致的方案會(huì)浪費(fèi)大量透明區(qū)域。所以更專業(yè)的做法是先對(duì)每一幀做透明邊緣裁剪算出非透明區(qū)域的左上角坐標(biāo)和寬高只把有效區(qū)域排進(jìn) sprite sheet然后在 JSON 里記錄每個(gè)幀在原始畫布中的偏移量。這樣既能顯著減小最終文件體積也不會(huì)丟位置信息。還有一個(gè)不能漏的細(xì)節(jié)是 padding。幀與幀之間如果不留間隙有些渲染引擎或 CSS 動(dòng)畫在縮放或者做紋理過濾時(shí)會(huì)采樣到相鄰幀的像素邊緣出現(xiàn)鬼影。通用做法是每幀周圍留 1 到 2 像素透明 padding打包時(shí)把實(shí)際繪制區(qū)域往內(nèi)縮 1 像素既能避免采樣滲色又不會(huì)讓圖變大太多。我自己做項(xiàng)目時(shí)默認(rèn)用 2 像素穩(wěn)一點(diǎn)。4.2 變尺寸裝箱簡(jiǎn)單高效的 shelf 算法當(dāng)幀尺寸不一致時(shí)固定網(wǎng)格會(huì)浪費(fèi)空間這時(shí)需要裝箱算法。算法有很多種從最簡(jiǎn)單的矩形排布到 MaxRects復(fù)雜度遞增。對(duì)于幾十幀這個(gè)規(guī)模shelf 算法足夠高效且實(shí)現(xiàn)簡(jiǎn)單它的思路是把所有幀按高度從大到小排序然后一層一層地往右下擺在一行里從左往右放放不下了就開新的一行。代碼如下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 會(huì)爆。一般限制在 2048 或 4096 以內(nèi)超出就換行最終高度也隨之上漲。裝箱完成后用記錄的 x、y、w、h 把每個(gè)幀的像素繪制到總畫布上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); }這里有個(gè)地方要提醒putImageData 是直接覆蓋像素不會(huì)做透明混合。如果你的幀是已經(jīng)處理好的透明圖這是正確的因?yàn)椴恍枰魏位旌喜僮鳌5绻闾崆鞍褞嫷竭^別的 canvas 上或者 source 本身帶半透明背景就需要注意混合結(jié)果是否符合預(yù)期。4.3 JSON 元數(shù)據(jù)與瀏覽器內(nèi)預(yù)覽拼完圖只是完成了一半另一半是告訴使用者每個(gè)子圖在大圖中的什么位置。導(dǎo)出 JSON 時(shí)我建議至少包含這些字段文件名、x、y、w、h、原始幀的偏移量 sourceX、sourceY、原始寬高 sourceW、sourceH。完整的 JSON 示例長(zhǎng)這樣{ 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 } ] }拿到這份數(shù)據(jù)前端做動(dòng)畫就非常靈活。既可以用 canvas 按幀繪制也可以按 CSS background-position 做逐幀動(dòng)畫游戲引擎里也能直接導(dǎo)入到精靈表驅(qū)動(dòng)。瀏覽器內(nèi)預(yù)覽是個(gè)很有用的功能。我通常在頁(yè)面右側(cè)放一個(gè)預(yù)覽區(qū)域選擇某一幀時(shí)直接把該幀在 sprite sheet 中的數(shù)據(jù)裁剪出來顯示同時(shí)提供一個(gè)自動(dòng)播放按鈕像跑馬燈一樣按順序切換幀方便確認(rèn)摳圖邊緣是否自然、動(dòng)畫是否流暢。這一步不做的話導(dǎo)出后才發(fā)現(xiàn)邊緣有問題又得重新調(diào)參數(shù)。5. 完整實(shí)操流程、參數(shù)調(diào)優(yōu)與問題排查5.1 頁(yè)面操作流程從上傳視頻到導(dǎo)出一氣呵成這套工具的實(shí)際操作流程我按使用習(xí)慣順序列出來。第一步上傳視頻支持本地文件和遠(yuǎn)程 URL 兩種方式上傳后自動(dòng)加載并播放方便確認(rèn)素材內(nèi)容。第二步設(shè)置抽幀率默認(rèn) 10fps頁(yè)面會(huì)同步顯示預(yù)計(jì)抽幀數(shù)量和輸出幀尺寸。第三步選擇摳圖模式綠幕素材選色度鍵非綠幕素材選 rmbg-2.0調(diào)整 inner、outer、despill 滑塊預(yù)覽當(dāng)前幀效果。第四步設(shè)置打包參數(shù)列數(shù)或最大寬高、padding、是否透明裁剪頁(yè)面會(huì)實(shí)時(shí)計(jì)算最終 sprite sheet 的尺寸。第五步點(diǎn)擊導(dǎo)出生成圖片和 JSON一鍵打包成 zip 下載。這里面最值得說的經(jīng)驗(yàn)是預(yù)覽一定要用實(shí)時(shí)數(shù)據(jù)。我一開始是先調(diào)完所有參數(shù)再生成結(jié)果每次調(diào)整都要等好幾秒體驗(yàn)很差。改成實(shí)時(shí)預(yù)覽后滑塊拖動(dòng)過程中直接在當(dāng)前幀上應(yīng)用色度鍵算法肉眼看到效果滿意了再批量處理整個(gè)工具的可用性完全不一樣。5.2 參數(shù)調(diào)優(yōu)建議抽幀率、閾值、輸出尺寸怎么配合抽幀率的選擇依賴素材的動(dòng)作速度。人物正常說話、手勢(shì)變化這類素材10fps 足夠了再高也不會(huì)讓動(dòng)畫更順滑只會(huì)讓文件變大??焖賱?dòng)作比如揮拳、跳躍用 15fps 到 20fps否則會(huì)出現(xiàn)明顯跳幀感。8 秒素材對(duì)應(yīng)關(guān)系可以參考這個(gè)表抽幀率8秒總幀數(shù)適用場(chǎng)景文件體積5fps41緩慢位移、呼吸動(dòng)畫小10fps81正常說話、手勢(shì)中15fps121快速動(dòng)作較大20fps161劇烈動(dòng)作、人物翻轉(zhuǎn)大摳圖閾值方面色度鍵模式調(diào) inner 和 outer 的經(jīng)驗(yàn)是先調(diào) outer 到背景完全不透為止也就是綠色被徹底摳干凈然后再調(diào) inner 到前景細(xì)節(jié)完整。如果發(fā)現(xiàn)邊緣有綠邊優(yōu)先加大 despill 強(qiáng)度其次再微調(diào)兩個(gè)閾值。輸出尺寸方面如果 sprite sheet 超過 4096 像素建議先縮小原始幀再到 640×360 或 480×270因?yàn)橛螒蛞婧蜑g覽器對(duì)超過 4096 的貼圖支持不好而且移動(dòng)端紋理內(nèi)存也很吃緊。5.3 常見問題速查表我在實(shí)際項(xiàng)目中踩過的坑現(xiàn)象原因解決辦法導(dǎo)出圖片時(shí)拋 SecurityErrorcanvas 被跨域視頻污染video 加 crossOriginanonymous服務(wù)端配 CORS 響應(yīng)頭抽幀后全是黑圖或第一幀重復(fù)沒有等 seeked 事件就 drawImage用 Promise 封裝 seek 跳轉(zhuǎn)等事件觸發(fā)后再畫幀瀏覽器內(nèi)存占用持續(xù)升高URL.createObjectURL 未釋放或 ImageData 堆積逐幀處理及時(shí) revokeObjectURL避免長(zhǎng)數(shù)組保存幀數(shù)據(jù)摳圖邊緣有一條明顯綠邊色度鍵閾值外沿殘留綠溢開啟 despill 邊緣處理把 G 通道壓到接近 R/B 最大值邊緣鋸齒嚴(yán)重只用單閾值做顏色判斷改雙閾值軟邊緣半透明像素參與 alpha 漸變r(jià)mbg-2.0 模型加載失敗模型文件跨域或被攔截檢查存儲(chǔ)桶 CORS 配置確認(rèn)模型路徑可訪問WebGPU 后端初始化失敗瀏覽器不支持或不穩(wěn)定executionProviders 設(shè)置回退 wasm速度略慢但穩(wěn)定導(dǎo)出 PNG 透明區(qū)域變黑使用了不支持透明的格式或在畫布上先填充了背景色確認(rèn)導(dǎo)出為 PNG繪制前不要填充黑色背景最終 sprite sheet 單張超過 4096未做裁剪或縮放開啟透明裁剪或把輸出分辨率降低其中內(nèi)存占用這個(gè)問題是最容易被忽略的尤其用 Edge 或 Chrome 長(zhǎng)時(shí)間測(cè)試時(shí)幾十幀處理下來地址欄旁邊經(jīng)常顯示“此頁(yè)面正在影響性能”。性能面板里能明顯看到腳本阻塞主線程。所以我在工具里對(duì)批量操作做了分片處理每處理 10 幀就讓出一次主線程用 setTimeout 或者 requestAnimationFrame 讓瀏覽器喘口氣UI 就不會(huì)假死了。這個(gè)改動(dòng)雖然簡(jiǎn)單但對(duì)長(zhǎng)時(shí)間跑任務(wù)的體驗(yàn)提升非常大。最后再說一個(gè)很少有人注意的細(xì)節(jié)拼完的 sprite sheet 在導(dǎo)出前最好再用 putImageData 讀取一遍檢查 alpha 通道。因?yàn)橛行g覽器在 canvas 某些配置下會(huì)低估半透明像素的數(shù)據(jù)導(dǎo)出的 PNG 和預(yù)覽時(shí)不一致。確認(rèn)無誤后再執(zhí)行 toBlob 導(dǎo)出然后生成下載鏈接。整個(gè)過程我在不同瀏覽器測(cè)下來Chrome 和 Edge 表現(xiàn)最好Firefox 對(duì)某些視頻編碼的 seek 支持稍弱Safari 需要額外處理 HEVC 素材但基本功能和導(dǎo)出流程都能跑通。