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
Maintainers
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.
composeFramesapplies 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 gifwarpDecoding
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 appliedPainting 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:
encodeGifscans every frame and turns transparency on if any frame needs it.GifEncodercannot look ahead, so it infers it from the first frame. Passtransparent: trueexplicitly 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: trueshares 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.GifEncoder—addFrame(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
