npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

gif-transcode

v1.0.0

Published

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.

Readme

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/[email protected]",
    "gifenc": "https://cdn.jsdelivr.net/npm/[email protected]/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.