Most online GIF tools ask you to upload the file. For a quick crop or a frame export, that is a strange trade: the file is often small, the task is simple, and it may contain something you would rather not send to a stranger's server.
I built gifkit as a browser-only alternative. The compression and cropping parts are straightforward. Frame extraction was not. This post is about why.
GIF is a compositing format, not a frame sequence
It is tempting to treat a GIF as a list of images. Real GIFs do not work that way.
A GIF has a logical screen (the canvas), and each frame is a rectangle placed somewhere on that canvas. A frame can be smaller than the canvas, can have an offset, and can use transparency. The previous frame is not necessarily replaced: the graphic control extension carries a disposal method that tells the decoder what to do with the area before drawing the next frame.
The four disposal values matter:
-
0— unspecified -
1— do not dispose: leave the frame in place -
2— restore to background: clear the frame's rectangle -
3— restore to previous: put back what was there before the frame
Add interlacing (four passes over the rows), local versus global color tables, and frames that only contain the changed pixels, and a naive "decode each frame as an image" approach starts producing garbage on real files.
A streaming compositor
The extractor keeps one canvas for the logical screen and processes frames in order. For each frame:
- Apply the previous frame's disposal to the canvas.
- Draw the current frame's pixels at its offset, skipping transparent pixels.
- Snapshot the canvas as a PNG.
- Save only what the next frame needs (the previous canvas for disposal 3, not every decoded frame).
In pseudocode:
for (const frame of frames) {
applyDisposal(canvas, previousFrame);
drawFrame(canvas, frame); // respects offset + transparency
const png = await canvas.toBlob('image/png');
emit(png);
previousFrame = frame;
}
The important part is what is not kept: the decoded pixels of every frame. A 25 MB GIF can expand to hundreds of megabytes if you decode everything at once. Streaming keeps memory bounded by the canvas plus the current frame.
The ZIP is the second memory trap
"Download all frames as a ZIP" is the feature that breaks the streaming model: a ZIP needs every PNG, and every PNG needs memory.
The approach I used is a store-mode ZIP with a hard budget. Frames are appended as they are produced; if the retained PNGs cross the budget, the ZIP is disabled and the page offers single frames instead, re-encoding each one on demand. The user gets a clear failure mode instead of a tab that dies.
Guards around the decoder do the same job: a maximum input size, a maximum canvas dimension, and a maximum frame count. Unsupported or truncated files are refused rather than half-decoded.
Compression is the easy part (until you promise a size)
The compressor reduces the palette and searches for a setting that meets a target such as Discord's 256 KB. It is lossy, and it does not resize. Both facts are on the page, because the honest failure mode — "the smallest size I could reach" — is more useful than a promise the tool cannot keep.
What I learned
- Correctness before features: disposal and transparency are the whole problem; a fast decoder that ignores them is useless.
- Memory is a product constraint, not an implementation detail: the ZIP budget and the input guards are part of the UX.
- Say what the tool cannot do. "Lossy, no resize, common GIF structures only" prevents more support requests than any FAQ.
The result is gifkit: five small browser-based tools — compress, crop, speed, frame extraction, typing text — with no signup and no upload.
If you work with GIFs in docs, Discord/Slack, or design handoffs, I would like to know where it fails. That is the most useful feedback I can get.
Top comments (0)