If you have ever built a sprite tool or dropped an "optimized" GIF into a custom game importer, you have met The Haunted Sprite Sheet:
Then, right after you fix the GIF decoder, someone drops in an AI-generated character sheet with a solid lime-green background, uneven gutters, and a sword detached 4 pixels from the character's hand. Or they scale up a 32×32 pixel slime and accidentally introduce 900 muddy anti-aliased gray pixels.
When I built GIF to Sprite Sheet — a zero-upload browser toolkit for converting, cutting, packing, and upscaling sprite sheets — almost all the real work came down to four surprisingly small algorithms on raw ImageData arrays.
Here is every trap we hit, and the exact TypeScript that fixed it.
A lot of naive GIF-to-sheet scripts parse the binary stream with gifuct-js and draw each frame's patch straight onto its own cell.
That breaks immediately on real-world GIFs, because encoders save bytes in two sneaky ways:
i might only be a 14×9 rectangle at (x: 22, y: 8) where the character blinked.disposalType): 0 / 1 (i + 1 paints right over them.2 (dims rectangle back to transparent before frame 3 (
Ignore disposalType, and 1 gives you floating eyeballs while 2 gives you stacked legs. To output clean game-engine cells like this 96×96 sheet:
…you maintain a running virtual screen buffer and snapshot out of it on every tick:
const full = createCanvas(gifWidth, gifHeight);
const fctx = full.getContext("2d", { willReadFrequently: true })!;
const patch = createCanvas(1, 1);
const pctx = patch.getContext("2d")!;
let restoreSnapshot: ImageData | null = null;
const frames: HTMLCanvasElement[] = [];
for (const f of rawFrames) {
// Disposal 3: snapshot the screen BEFORE this frame touches it
if (f.disposalType === 3) {
restoreSnapshot = fctx.getImageData(0, 0, gifWidth, gifHeight);
}
// Paint this frame's dirty rectangle onto the running screen
patch.width = f.dims.width;
patch.height = f.dims.height;
pctx.putImageData(
new ImageData(new Uint8ClampedArray(f.patch), f.dims.width, f.dims.height),
0,
0
);
fctx.drawImage(patch, f.dims.left, f.dims.top);
// Copy the composited full frame into our output list
const out = createCanvas(gifWidth, gifHeight);
out.getContext("2d")!.drawImage(full, 0, 0);
frames.push(out);
// Honour the frame's cleanup rule before the next tick
if (f.disposalType === 2) {
fctx.clearRect(f.dims.left, f.dims.top, f.dims.width, f.dims.height);
} else if (f.disposalType === 3 && restoreSnapshot) {
fctx.putImageData(restoreSnapshot, 0, 0);
}
}
Bonus 1990s browser quirk: Any GIF frame delay <= 10ms (0 or 1 hundredth of a second in the binary header) is silently clamped to 100ms (10 fps) by Chrome, Safari, and Firefox. Old GeoCities banner ads left the delay byte at 0, so browsers invented a floor. If your exporter trusts f.delay === 0 literally, your sprite sheet plays 10× faster in Unity or Godot than it ever did in Chrome!
Clean pixel-art sheets sit on a strict 32×32 or 64×64 grid. Real-world sheets downloaded from itch.io forums — or generated by AI models — almost never do. They have a solid #00ff00 or #14121f backdrop, drifted spacing, and detached sword slashes.
In the sprite sheet cutter, two passes turn a messy sheet into jitter-free frames:
If you simply delete every pixel matching the top-left corner color (kr, kg, kb), a character with black boots and black outlines on a dark sheet gets Swiss-cheesed.
Instead, seed a DFS stack only from the 4 outer edges of the image:
const stack = new Int32Array(W * H);
const seen = new Uint8Array(W * H);
let top = 0;
const push = (p: number) => {
if (!seen[p] && (alpha(p) <= 8 || colorMatches(p, keyRgb, tolerance))) {
seen[p] = 1;
stack[top++] = p;
}
};
// Seed ONLY from top/bottom rows and left/right columns
for (let x = 0; x < W; x++) { push(x); push((H - 1) * W + x); }
for (let y = 0; y < H; y++) { push(y * W); push(y * W + W - 1); }
while (top > 0) {
const p = stack[--top];
setAlphaZero(p);
// push 4-neighbors...
}
The backdrop melts away right up to the outer silhouette, while identical colors inside the character stay untouched.
Once the backdrop is transparent:
alpha > 8 to find every pixel island.join pixels (e.g. 4px) of each other W × H anchored at bottom-center. EPX in 15 Lines)
Game devs constantly need a 32×32 sprite enlarged to 256×256 for Steam capsules, UI portraits, or high-DPI canvases. Bilinear filtering blurs it into vaseline. Plain Nearest Neighbor keeps razor-sharp squares:
Nearest Neighbor is usually what you want inside a retro engine. But what if you want rounded diagonals without inventing blurry anti-aliased colors?
Enter EPX (Eric's Pixel Expansion) — written by Eric Johnston at LucasArts around 1992 when porting SCUMM engine classics to higher-res color Macs (later famous as Scale2x):
For every pixel P with cardinal neighbors A (up), B (right), C (left), D (down), P expands into a 2×2 block, rounding off corners wherever two adjacent neighbors agree:
function epx2x(src: Uint32Array, w: number, h: number): Uint32Array {
const W = w * 2;
const out = new Uint32Array(W * h * 2);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const p = src[y * w + x];
const a = y > 0 ? src[(y - 1) * w + x] : p;
const d = y < h - 1 ? src[(y + 1) * w + x] : p;
const c = x > 0 ? src[y * w + x - 1] : p;
const b = x < w - 1 ? src[y * w + x + 1] : p;
const o = y * 2 * W + x * 2;
out[o] = c === a && c !== d && a !== b ? a : p;
out[o + 1] = a === b && a !== c && b !== d ? b : p;
out[o + W] = d === c && d !== b && c !== a ? c : p;
out[o + W + 1] = b === d && b !== a && d !== c ? d : p;
}
}
return out;
}
Look closely at the assignments: zero new colors are ever synthesized. Every output pixel is copied verbatim from src via Uint32Array integer comparisons! And if you actually want painterly vector-like curves, you can step up to Hyllian's xBR:
(Production gotcha: people often upload "pixel art" from Twitter/Discord that was already enlarged 4× with Nearest Neighbor, so each visible "pixel" is really a 4×4 block. Run EPX or xBR on pre-upscaled art and only the microscopic corners of each 4×4 block round off! In the pixel art upscaler, we scan k = 32..2 first to detect if every k×k block is identical, and warn you what native scale the sprite is actually hiding at.)
canvas.toBlob() Chokes on 200-Frame ZIP Exports
One last browser trap: when someone slices a 180-frame animation and clicks Frames ZIP, calling await canvas.toBlob() inside a for loop crawls in Chromium because toBlob schedules async GPU readbacks and full PNG compression one at a time.
Instead, grab ctx.getImageData(0, 0, w, h).data.buffer, encode each PNG synchronously with UPNG.encode([buffer], w, h, 0), and bundle all the Uint8Array s with fflate's zipSync at level: 0 (PNGs are already Deflate-compressed — re-compressing them inside a ZIP burns CPU for ~0.3% savings). Yield every 8 frames with setTimeout(r, 0) so the UI never drops a frame, and 180 sprites zip up in well under a second.
All five tools (GIF → sheet, sheet → GIF, cutter, frame packer, and pixel-art upscaler) run 100% locally in your browser at giftospritesheet.com — including direct .tres SpriteFrames export for Godot 4, TexturePacker JSON, VRChat 1024×1024 emoji presets, and Sparrow v2 XML.
If you've ever built a custom asset pipeline for a 2D game, what's the most cursed sprite sheet an artist (or AI) ever handed your importer?