Cropping a GIF should not make it bigger. Ours did, and not by a little: a
288-frame Foucault pendulum went from 1074 KB in to 4881 KB out, and a 70-frame
gun turret went 116 KB to 1232 KB. That is 455% and 1062%. The same thing
happened with no crop at all, which is the detail that gives the cause away.
If a pure re-encode inflates the file, the encoder is not losing to the
compressor. It is throwing away something the source already had.
What it was throwing away
GIF stores an animation as a sequence of image blocks, and each block carries a
disposal method and its own local palette. Nothing requires a block to cover the
whole canvas or to be a complete picture. A well-optimised animated GIF writes
frame 0 in full and then writes every later frame as only what changed,
leaving the rest transparent and setting disposal to "leave the previous frame
in place". For the pendulum, most of the canvas is a static rig and a dark
background; the swinging bob is a few percent of the pixels. The source spent
its bytes on the bob.
Our encoder wrote every frame as a full-canvas, fully opaque keyframe. Each
frame was individually correct and the animation played correctly, so nothing
looked wrong. It just re-paid the cost of the static background 287 more times
than it needed to.
The fix
For an opaque source, frames after the first are written as the previous frame
plus the changed pixels. Unchanged pixels get the transparent palette index,
and the frame's disposal is set to 1, "do not dispose". The decoder composites
it over what is already on the canvas.
function changedPixels(cur, prev) {
const count = cur.length / 4;
const mask = new Uint8Array(count);
let changed = 0;
for (let p = 0, i = 0; p < count; p++, i += 4) {
if (cur[i] !== prev[i] || cur[i + 1] !== prev[i + 1] || cur[i + 2] !== prev[i + 2]) {
mask[p] = 1;
changed++;
}
}
return { mask, changed };
}
RGB only, no alpha compare: this path runs on opaque sources, where every alpha
is already 255. Sources that carry real transparency keep the old keyframe path,
because the transparent index is then already spoken for.
One constraint shaped the implementation. We use gifenc, which
hard-codes the image descriptor to x=0, y=0, so a frame cannot be written as a
sub-rectangle at an offset the way most GIF encoders do it. Every frame stays
full-canvas, and the saving comes from LZW collapsing the long runs of the
repeated transparent index instead. That reaches the same redundancy. It just
spends a little more CPU to get there.
The heuristic that did not work
The obvious optimisation is a threshold: if more than some fraction of the
frame changed, a delta is not worth it, so write a keyframe. We implemented it
and then deleted it, because the measurements did not cooperate.
| frame-to-frame change | result vs. keyframes |
|---|---|
| 13% of pixels | 74% of the size |
| 26% of pixels | 112% of the size |
The file that changed twice as much was the one that should have used deltas,
and the file that changed less was the one that should not have. What decides it
is not how many pixels moved but how scattered the movement is, because LZW
pays for runs, not for pixels. A count of changed pixels cannot see that, and
neither can any threshold built on one.
So the encoder stops guessing. For each file it takes three sample frames,
encodes each one both ways, and keeps whichever mode won. Both candidates are
appended after the identical frame 0, so the header, the global palette and the
loop block cancel out and what is compared is that frame's cost alone.
The trap worth knowing about
The changed pixels have to be packed into a buffer of their own before being
quantized, and that buffer must be a fresh allocation, never a subarray view:
const out = new Uint8ClampedArray(changed * 4);
gifenc's quantize() and applyPalette() both do new Uint32Array(rgba.buffer).
On a view, .buffer is the whole underlying buffer, not the slice. Both
functions would read far past the region you meant to hand them and build the
palette from the wrong pixels, silently, with no error and a plausible-looking
result. This is a general hazard with any typed-array API that reaches for
.buffer, and it is worth checking for before you spend an afternoon on a
palette that is subtly wrong.
Results
Ten animated GIFs from Wikimedia Commons, including the pendulum and the turret
above, a Muybridge race horse, a cicada molting, lunar libration, a universal
joint and a constant-velocity joint.
- Output is 15% to 100% of the previous encoder's, median 90%. Zero files got bigger.
- The two worst cases are transformed: turret 1232 KB to 184 KB, pendulum 4881 KB to 1292 KB.
- All ten are now smaller than the source they came from, in a 17% to 79% range.
- Colour error is equal or better on all ten. Frame count, dimensions, per-frame delay, loop flag and transparency are all preserved.
The median matters as much as the wins: half of these files barely moved,
because half of them do not have a large static region to exploit. This is a
fix for a failure mode, not a general compression gain, and it is worth being
precise about which one you are shipping.
Checking it against something that is not us
A round-trip through our own decoder only proves the encoder and the decoder
agree with each other. So the test builds a synthetic animation of under 255
colours, encodes it, and decodes the result with Pillow, which implements
GIF disposal independently of anything we wrote. All 12 frames matched
pixel-for-pixel, and the delays matched too.
If you write an encoder for a format with compositing semantics, find a second
implementation and make it the judge. Disposal methods are exactly the kind of
thing two codebases can agree to get wrong together.
What it cost
Encoding takes longer, because deciding the mode means encoding three frames
twice. Worst measured case was +140 ms on a 22-frame file. On large files, where
the absolute time is what you would notice, it stayed within +7%.
The encoder runs in the browser, in the GIF tools on
Image Machine — the measurements above come from
the GIF cropper, where the
inflation showed up first. Nothing is uploaded; the whole pipeline is client
side, which is also why the encoder's own efficiency is the only lever there is.
Top comments (0)