Decode an animated GIF, edit it, and write it back out without throwing away the interframe redundancy the source already had.
npm install gif-transcodePure JavaScript. No DOM, no WASM, no worker, so the same code runs in Node and
in the browser. Depends only on gifuct-js (decode) and gifenc (encode).
Try it on your own GIF -- the demo runs the library in your tab and prints three sizes: the source, the re-encode, and what the same re-encode costs without differencing. Nothing is uploaded.
A GIF stores frame 0 in full and then, if it was encoded competently, stores each later frame as only what changed. Re-encode that with a naive encoder and every frame becomes a full opaque keyframe, so the static background gets paid for once per frame. The file does not shrink a little. It multiplies.
Measured before this library had interframe differencing, re-encoding with no edit at all:
| Source | In | Out |
|---|---|---|
| 288-frame Foucault pendulum | 1074 KB | 4881 KB (455%) |
| 70-frame gun turret | 116 KB | 1232 KB (1062%) |
Every one of those output frames was individually correct and the animation played correctly, which is why this survives ordinary correctness testing.
import { decodeGif, encodeGif, cropFrames, resizeFrames } from "gif-transcode";
const decoded = await decodeGif(arrayBuffer);
// decoded.frames: [{ data: Uint8ClampedArray /* full-canvas RGBA */, delay }]
// decoded.width, decoded.height, decoded.loopCount
const cropped = cropFrames(decoded.frames, decoded.width, decoded.height,
{ x: 40, y: 20, width: 200, height: 150 });
const bytes = await encodeGif(cropped, 200, 150, {
loopCount: decoded.loopCount, // preserves "loop exactly 3 times"
});Frames come back coalesced: every frame is a complete width * height * 4
RGBA bitmap, not the partial patch the file stores. Downstream code can treat
each one as an independent image. Differencing is re-applied on the way out.
decodeGif(buffer) |
→ { frames, width, height, loopCount } |
encodeGif(frames, w, h, opts?) |
→ Uint8Array |
coalesceFrames(input) |
patches + disposal → full-canvas frames |
cropFrames resizeFrames rotateFrames flipFrames |
geometry, exact at the frame level |
reverseFrames scaleDelays setUniformDelay dropFrames |
timing |
encodeGif options: loopCount (raw NETSCAPE2.0 count, -1 for play-once),
loop, transparent, maxColors, onProgress(done, total).
Run node test/measure.mjs to reproduce these.
| Fixture | Source | Re-encoded | vs. keyframes only |
|---|---|---|---|
| Photographic, static background, 12 frames | 226.9 KB | 199.2 KB (88%) | 72% |
| Synthetic 2-colour, 12 frames | 3.5 KB | 8.4 KB (239%) | 91% |
Across ten real animated GIFs from Wikimedia Commons the output was 15% to 100% of what the keyframe-only encoder produced, median 90%, with no file getting larger; the two worst cases went 1232 KB → 184 KB and 4881 KB → 1292 KB.
Read the second row before adopting this. It is not a GIF optimiser. On a source that a specialised optimiser already squeezed — few colours, minimal sub-rectangles — a general re-encode still comes out larger, and no amount of differencing changes that. What this library guarantees is that it will not be the thing that multiplies your file. How much you gain depends entirely on how much of each frame stands still: a panning shot, where every pixel changes, has no interframe redundancy to keep and will not benefit at all.
Colour is inherently lossy either way: every frame is requantised to ≤256 colours, so decode → encode → decode is never byte-exact. The manipulations (crop, reverse, delay arithmetic) are exact at the coalesced-frame level.
Full-canvas frames, not sub-rectangles. gifenc hard-codes the image
descriptor to x=0, y=0, so a frame cannot be written at an offset the way most
GIF encoders do it. Unchanged pixels get the transparent palette index and
disposal is set to "leave in place", and LZW collapses the long runs. Same
redundancy, slightly more CPU.
The mode is chosen per file by trying both. A change-ratio threshold was implemented and deleted: a file changing 13% of its pixels per frame came out at 74% while one changing 26% came out at 112%. What decides it is how scattered the movement is, not how much of it there is, and a pixel count cannot see that. So three sample frames are encoded both ways and the smaller wins. Both candidates are appended after the identical frame 0, so the header, the global palette and the loop block cancel out.
Verified against an implementation that is not this one. A round trip through our own decoder only proves the encoder and decoder agree with each other, and two halves of one codebase can be wrong together -- disposal especially, since a frame that only carries changed pixels is meaningless unless the reader composites it the same way the writer assumed. So the output is also read by Pillow, which implements GIF disposal independently:
$ node test/crosscheck.mjs && python test/crosscheck.py
frame 0: differing pixels 0 / 9600 delay 60ms (want 60)
...
frame 11: differing pixels 0 / 9600 delay 170ms (want 170)
RESULT: OK -- an independent decoder reproduces every frame
Twelve differenced frames, every pixel and every delay reproduced. That check
lives in test/crosscheck.mjs + test/crosscheck.py and is deliberately not
part of npm test, so installing this package never requires Python; run it
yourself with the two commands above.
If you use an import map instead of a bundler, point gifenc at its ESM
build. Its package.json sets browser to the CJS bundle, and a CDN that
honours that field hands back a namespace whose only export is a bare
function -- no quantize, no applyPalette. The page loads, looks
completely normal, and fails at the moment you encode. module, unpkg
and jsdelivr all name the ESM build, so:
<script type="importmap">
{
"imports": {
"gifuct-js": "https://esm.sh/gifuct-js@2.1.2",
"gifenc": "https://cdn.jsdelivr.net/npm/gifenc@1.0.3/dist/gifenc.esm.js"
}
}
</script>gifuct-js ships CJS only, so esm.sh is the right source for that one. If you get the gifenc entry wrong this library throws an error naming the fix rather than the symptom.
Extracted from the GIF tools on Image Machine, where the inflation showed up first in the GIF cropper. The long-form writeup is here.
MIT.