在瀏覽器放大動畫 GIF,以及意料之外的黑色光暈
Upscaling animated GIFs in the browser, and the black fringe I didn't see coming
開發者以 WebGPU 引擎建立瀏覽器動畫 GIF 放大工具,並修正透明像素造成的黑色邊緣光暈。工具以 TypeScript 解碼 GIF,逐幀處理以控制記憶體用量,並為透明像素填入鄰近不透明像素的顏色。像素藝術模式則以 2x 至 8x 的整數倍最近鄰放大,保留原有像素色彩與硬邊。
I run a small site of video tools, and one of them is a video upscaler that runs on WebGPU. Last week I built a GIF upscaler on top of the same engine. I expected a short job: decode the frames, push each one through the model, write a new GIF.
The model part really was short, because I reused it as is. Everything around the model took the time.
I wrote my own GIF decoder
I looked at ImageDecoder first. It has two problems for this job. Not every browser ships it, and I wanted the pixel art mode to work everywhere. Browsers also clamp very short frame delays when they decode a GIF, and I wanted the output to keep the source timing exactly, loop count included.
So I wrote a small decoder in TypeScript. It walks the block structure once to collect frame offsets, delays and palettes. Then a generator composites one frame at a time and yields a copy of the canvas. Memory stays flat that way: one or two full canvases live at once, whether the GIF has 20 frames or 400.
Disposal methods are the fiddly part. Each frame tells the player what to do before drawing the next one: keep the canvas, clear its rectangle, or restore what was there before. If you get it wrong, old frames leave trails. I copied what browsers actually do. For disposal 2 that means clearing to transparent, not painting the background color the spec mentions.
The black fringe
Sprites on a transparent background come out of a naive upscale with a dark outline around every edge.
A transparent pixel in a GIF still stores an RGB value, and that value is almost always black. The upscaler has no idea the pixel is invisible, so it blends the black into the edge pixels next to it. You get a halo.
Before a frame goes into the model, I now fill the RGB of every transparent pixel with the color of its opaque neighbors. A few rounds of averaging spread the color outward, and anything still unreached gets the mean opaque color. Alpha stays untouched. The model then blends the sprite's own colors into the edge, and the halo goes away.
Alpha gets its own path, since GIF transparency is 1-bit. I upscale it with a plain bilinear sample and cut it at 50%. Edges come out as smooth diagonals instead of big square steps, and every pixel is still fully on or fully off, which is all a GIF can store.
Pixel art wants the opposite of AI
AI upscaling works well on memes, photos and anime clips (there's an optional line pass for anime). On pixel art it does damage. It smooths every hard edge the artist placed on purpose.
So the tool has a second mode: nearest-neighbor at whole-number scales, from 2x up to 8x. Each source pixel becomes an exact NxN block of the same color. Writing whole RGBA pixels through a Uint32Array and copying finished rows keeps it fast:
for (let y = 0; y < h; y++) {
const rowStart = y * scale * ow;
for (let x = 0; x < w; x++) {
const v = src32[y * w + x];
const base = rowStart + x * scale;
for (let k = 0; k < scale; k++) out32[base + k] = v;
}
for (let r = 1; r < scale; r++) {
out32.copyWithin(rowStart + r * ow, rowStart, rowStart + ow);
}
}
One palette per frame
The encoder sits on gifenc. Every frame gets its own color table. A single shared palette would make a smaller file, but upscaling invents new in-between colors in every frame, and squeezing all of them into one palette causes visible banding.
When a frame has 256 colors or fewer (255 if it needs a transparent slot), I keep those colors exactly and skip quantization. Pixel art mode always lands here, so the output uses the same colors as the source, bit for bit. Frames with more colors go through the quantizer, which samples only opaque pixels so transparent areas don't waste palette slots.
GIFs get big fast
GIF compresses photo-like frames badly. A 4K animated GIF easily runs to hundreds of megabytes, and most apps refuse to open it. I cap output at about 2048x2048 pixels per frame, which covers 1080p and 1440p. There's also a cap on total pixels across all frames so the encode fits in what a browser tab can hold, and the scale buttons that would go past either cap simply don't show up.
Even under the cap, an upscaled GIF is heavy. So the result panel has a button that sends the file straight to the GIF compressor I built a few weeks ago.
If you want to try it, it's here: https://squishyfile.com/gif-upscaler
I'd like to hear about GIFs that break it, especially weird disposal setups or files with a local palette on every frame.
來源:DEV Community · dev.to