Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gif-transcode

Decode an animated GIF, edit it, and write it back out without throwing away the interframe redundancy the source already had.

npm install gif-transcode

Pure 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.

The problem it exists for

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.

Usage

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.

What is exported

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).

What the differencing actually buys, measured

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.

Implementation notes worth knowing

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.

Loading it from a CDN

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.

Where it came from

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.

About

Decode, edit and re-encode animated GIFs without inflating them. Interframe differencing keeps a re-encode smaller than the source instead of several times larger. Runs in Node and the browser.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages