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

gifwarp

v0.1.1

Published

The fastest zero-dependency GIF encoder and decoder for JavaScript. Typed-array fast paths, lazy frame decoding, and full canvas composition.

Downloads

299

Readme

gifwarp

The fastest GIF encoder and decoder for JavaScript. Zero dependencies, TypeScript types, ESM and CJS.

  • Decodes faster than gifuct-js and omggif — 1.25–2.7x ahead of omggif, the quickest decoder available until now — and gets you the first frame 20–90x sooner through lazy decoding.
  • Encodes without the usual quantizer tax — a full animation in one call, or frame by frame as they arrive.
  • Composites for you. GIF frames are patches, not pictures. composeFrames applies the disposal rules so you do not have to.
  • Typed arrays everywhere. Pixels, palettes and patches are all ArrayBuffer-backed, so they transfer to a Worker at zero copy cost.
npm install gifwarp

Decoding

import { decodeFrames, composeFrames, readGifInfo } from "gifwarp";

const bytes = new Uint8Array(await file.arrayBuffer());

readGifInfo(bytes);
// { width, height, frameCount, duration, loopCount, isAnimated, hasTransparency }

const frames = decodeFrames(bytes);
// Each frame: { left, top, width, height, delay, disposal, pixels, rgba, colorTable }

const screens = composeFrames(bytes);
// Each frame: full logical-screen RGBA, disposal methods already applied

Painting an animation onto a canvas is the compositor plus a timer:

import { composeFrameIterator, parseGif } from "gifwarp";

const gif = parseGif(bytes);
const context = canvas.getContext("2d")!;

for (const frame of composeFrameIterator(gif)) {
  context.putImageData(new ImageData(frame.rgba, gif.width, gif.height), 0, 0);
  await new Promise((resolve) => setTimeout(resolve, frame.delay || 100));
}

composeFrameIterator decodes lazily, so the first frame is on screen before the rest of the file has been touched. Its rgba is the compositor's own buffer and is overwritten on each step — pass { copy: true } if you need to keep frames around.

To skip the intermediate RGBA entirely and work with palette indices:

for (const frame of decodeFrameIterator(bytes, { rgba: false, reuseBuffers: true })) {
  // frame.pixels is one palette index per pixel, valid until the next iteration
}

Encoding

import { encodeGif } from "gifwarp";

const gif = encodeGif({
  width: 320,
  height: 240,
  frames: [
    { data: rgbaFrame0, delay: 40 },
    { data: rgbaFrame1, delay: 40 },
  ],
});

data is width * height * 4 RGBA bytes for the whole screen — the same layout ctx.getImageData() gives you. The encoder crops each frame to the rectangle that changed and marks untouched pixels transparent, which is where most of the size saving in an animation comes from.

When frames arrive over time, stream them instead so nothing has to be held in memory:

import { GifEncoder } from "gifwarp";

const encoder = new GifEncoder({ width, height, maxColors: 128, dither: "floyd-steinberg" });
for await (const frame of capture()) {
  encoder.addFrame(frame.data, { delay: 40 });
}
const gif = encoder.finish();

Options that matter

| Option | Default | What it does | | --- | --- | --- | | maxColors | 256 | Palette size. Lower is smaller and faster. | | palette | "local" | "global" quantizes once for the whole animation: smaller files, roughly 2x faster, slightly worse color. | | dither | false | "floyd-steinberg", "atkinson" or "ordered". Trades banding for noise; costs size. | | optimize | true | Crop frames to what changed. Ignored when the animation uses transparency. | | loop | 0 | 0 loops forever, -1 writes no loop block. | | fixedPalette | — | Supply your own palette and skip quantization entirely. | | transparent | auto | Whether the animation carries real transparency. See below. |

Transparency

GIF has one fully transparent color and no way to make an already-painted pixel transparent again without a disposal method that rules out cross-frame optimization. That is a property of the format, not of this library, so the choice is made once for the whole animation:

  • encodeGif scans every frame and turns transparency on if any frame needs it.
  • GifEncoder cannot look ahead, so it infers it from the first frame. Pass transparent: true explicitly if a later frame is the one that needs it.

With transparency on, each frame is written full-size with Disposal.RestoreBackground and optimize has no effect.

Benchmarks

Node 22, Linux x64, against [email protected], [email protected] and [email protected]. Median of 15 runs. Reproduce with npm run bench.

Decode, 500x500, 20 frames:

| | time | | | --- | --- | --- | | gifuct-js decompressFrames(…, true) | 135.2 ms | | | omggif decodeAndBlitFrameRGBA | 79.8 ms | 1.7x | | gifwarp composeFrames | 29.7 ms | 4.6x | | gifwarp decodeFrames | 25.8 ms | 5.3x | | gifwarp decodeFrames({ rgba: false }) | 17.1 ms | 7.9x | | gifuct-js, first frame only | 116.6 ms | | | gifwarp decodeFrameIterator, first frame | 1.3 ms | 89x |

omggif is the one to beat — it blits each frame onto a canvas as it decodes, so composeFrames is the closest row, and it is 2.7x faster. It is not quite a like-for-like comparison in omggif's favor: omggif deliberately leaves disposal methods to its caller, so composeFrames is doing strictly more work. On animations that ask for no disposal, where the two are directly comparable, their output agrees byte for byte — asserted in the test suite and on every benchmark run.

gifuct-js times vary by up to 3x between runs because its arrays of arrays keep the collector busy; the median is reported, and the spread is printed next to it.

Memory retained for the decoded animation: 38.9 MB for gifuct-js, 23.9 MB for gifwarp — 1.6x less.

On deliberately incompressible input (400x300 noise, 2.4 MB) the LZW bit loop dominates and the lead narrows to 1.25x over omggif and 1.8x over gifuct-js, with the first frame still arriving 22x sooner.

Encode, 320x240, 8 frames. Quality is the PSNR of the decoded result against the source, so speed can be read against what it cost:

| | time | PSNR | output | | --- | --- | --- | --- | | gifenc, quantizing every frame | 4290 ms | 29.58 dB | 119 KB | | gifenc, one palette reused | 565 ms | 24.28 dB | 112 KB | | gifwarp encodeGif | 169 ms | 29.12 dB | 126 KB | | gifwarp encodeGif({ palette: "global" }) | 91 ms | 28.11 dB | 119 KB |

Read honestly: against gifenc's per-frame quantizer gifwarp is 25x faster for 0.46 dB less quality and 6% more bytes. Against the palette-reuse pattern gifenc's own README recommends, gifwarp is 6x faster and 3.8 dB better. If you want the last half a decibel and do not care what it costs, gifenc's quantizer is still the better one.

Dithering trades measured error for perceptual smoothness — dither: "floyd-steinberg" here scores 26.67 dB and 299 KB. Use it for banding on gradients, not to chase this number.

An image whose colors already fit in the palette is encoded exactly — no quantization error, byte-for-byte the same pixels on the way back out.

When not to use this

If you are decoding in a browser that has ImageDecoder (Chrome, Edge, Safari 17+), the platform decodes GIF frames natively and no JavaScript decoder will beat it:

const decoder = ImageDecoder.isTypeSupported("image/gif")
  ? nativeDecoder(bytes)
  : gifwarpDecoder(bytes);

gifwarp is the right choice as the fallback, on the encode side, in Node, in a Worker, or when you need palette indices and frame metadata that ImageDecoder does not expose.

API

Decoding

  • parseGif(source) — structure only, no pixels decoded. Frames keep byte ranges into the source.
  • decodeFrames(source, options?) — every frame at once.
  • decodeFrameIterator(source, options?) — a generator that decodes on demand. reuseBuffers: true shares one scratch buffer per frame.
  • decodeGif(source, options?) — frames plus file-level metadata.
  • readGifInfo(source) — dimensions, frame count, duration, loop count, without decompressing anything.
  • composeFrames(source) / composeFrameIterator(source, options?) — full-screen RGBA per frame.
  • GifCompositor — the compositor on its own, if you are driving decoding yourself.

source may be a Uint8Array, a Uint8ClampedArray, an ArrayBuffer, or the result of parseGif.

Encoding

  • encodeGif(input) — a whole animation in one call.
  • GifEncoderaddFrame(data, options?), finish(), frameCount.

Building blocks

Exported because they are useful on their own: lzwDecode, lzwEncode, deinterlace, packPalette, applyPalette, quantize, PaletteMapper, remap.

Errors

Anything malformed throws GifError, which carries the byte offset the parser reached. Truncated LZW data does not throw — the frame is decoded as far as it goes, since partial GIFs are common in the wild.

Correctness

The decoder is checked pixel for pixel against gifuct-js on files written by a third-party encoder, including transparent and interlaced ones, and against omggif over the range omggif covers. The encoder's output is checked by decoding it with gifuct-js as well as with our own decoder. LZW round-trips are fuzzed at every legal code size, including streams long enough to force mid-stream dictionary resets.

License

MIT