Skip to main content

gif_writer 0.2.1 copy "gif_writer: ^0.2.1" to clipboard
gif_writer: ^0.2.1 copied to clipboard

A high-performance, low-memory GIF encoder. Streams frames straight to a sink as they arrive, so memory stays flat however long the animation runs.

gif_writer

A high-performance, low-memory GIF encoder for Dart.
Frames are compressed and written out as they arrive, so memory stays flat
however long the animation runs — while running 1.5–1.9× faster than the
alternative and writing a smaller file.

pub package pub points likes MIT licence Dart SDK 3.7+ all platforms no dependencies


At a glance #

Against package:image, the only other GIF encoder for Dart. 60 frames of 256×256, both given pre-palettised input so neither quantises. Median of nine interleaved trials, and every gap below is wider than the run-to-run spread of either encoder:

Throughput and output size against package:image across four workloads

workload gif_writer package:image gif_writer package:image
photo · 32 colours 82.2 Mpx/s 48.1 Mpx/s +71% 1.13 MB 1.21 MB −6.4%
photo · 256 colours 53.7 Mpx/s 35.8 Mpx/s +50% 3.34 MB 3.39 MB −1.3%
noise · 32 colours 56.1 Mpx/s 30.2 Mpx/s +86% 2.91 MB 3.04 MB −4.3%
noise · 256 colours 46.5 Mpx/s 29.2 Mpx/s +60% 5.15 MB 5.19 MB −0.8%
throughput file written

1.5–1.9× faster, and the file is smaller every time. Speed alone would not settle it — an encoder can always go faster by compressing worse — so the output size sits in the table beside it.

The percentages are from one run on one machine, and they move: a second run of the same tool put noise · 32 at +95% and photo · 256 at +46%. What is stable across runs is the direction, the rough size of the gap, and the file sizes, which are byte-identical every time because compression is deterministic. Treat the throughput column as "comfortably faster", not as four significant figures — and run it on your own hardware, which is the only number that describes your hardware.

And the number that is not a percentage:

held in memory, 60 frames held in memory, 1000 frames
gif_writer 0.19 MB 0.19 MB
package:image 5.19 MB ~87 MB

That 0.19 MB is the whole fixed overhead of the indexed path — a 64 kB staging buffer plus the 128 kB LZW string table — and it is the same number as Where the speed comes from below. Neither column counts the caller's own frame buffer, which both encoders need.

Memory held against frames written: flat for gif_writer, linear for package:image

package:image hands over the finished file, so what it holds is the file. This holds a fixed staging buffer. One side scales with the animation; the other does not — and that is the whole reason this package exists.

Reproduce the numbers with dart run tool/compare.dart — it decodes both outputs and checks every pixel against the input before reporting a single timing, and refuses to report at all if either encoder got the picture wrong. Redraw the charts with python tool/charts.py.

The problem #

Every GIF encoder available for Dart builds the finished file in memory and hands it back at the end. That is fine for a six-frame spinner. It is unworkable for anything long:

A thousand 256×256 frames is ~87 MB held at once — measured, not estimated — and that scales with the frame size, so the same recording at 512×512 is four times worse. On a phone, which is where long GIFs actually get made, that is the difference between working and being killed.

Nothing in the format requires it. A GIF is a header, a colour table, self-contained per-frame blocks, and a one-byte trailer — no frame count in the header, no index, nothing to backpatch. It is a naturally streamable container. This package writes it that way.

 frame ─► LZW ─► 255-byte sub-block ─► 64 kB staging buffer ─► your sink
                 (reused)              (reused, flushed per frame)

Peak memory is a function of one frame, never of the animation's length.

Install #

dart pub add gif_writer

Quick start #

import 'package:gif_writer/gif_writer.dart';

final gif = GifWriter.toFile(
  'out.gif',
  width: 256,
  height: 256,
  colors: GifColorTable.packed(<int>[0x000000, 0xFF5500, 0xFFFFFF]),
);

for (final frame in frames) {
  await gif.addIndexedFrame(frame, delay: const Duration(milliseconds: 50));
}
await gif.close();

frame is one byte per pixel — an index into the colour table you supplied. Nothing approximates it: what you put in is what decodes out, byte for byte.

Already holding ordinary pixels? Pass those instead and they are mapped onto the same table, dithered where it cannot hold the exact colour:

await gif.addRgbFrame(rgb);                          // 3 bytes per pixel
await gif.addRgbaFrame(rgba, background: 0xFFFFFF);  // 4, composited first

A pixel that is exactly a table colour still maps to that entry, so palettised content survives this path unchanged. Choosing the table for you — quantisation — is 0.3.0; see Scope.

Recipes #

Pipe a stream of frames

GifWriter is a StreamConsumer<GifFrame>, so frames are consumed as they are produced and never collected into a list. A GifFrame comes in the same three shapes as the add…Frame methods.

await frames.pipe(
  GifWriter.toFile('out.gif', width: w, height: h, colors: colors),
);

// frames yields any mix of:
GifFrame(indices: bytes);                      // one byte per pixel
GifFrame.rgb(rgb);                             // three
GifFrame.rgba(rgba, background: 0xFFFFFF);     // four
Write to any sink — a socket, a buffer, the web

The core imports no dart:io, so it runs anywhere Dart does.

final gif = GifWriter(
  socket,                 // any StreamSink<List<int>>
  width: w,
  height: h,
  colors: colors,
  onFlush: () => socket.flush(),
);
Control looping
GifWriter.toFile(..., repeat: GifRepeat.forever);   // the default
GifWriter.toFile(..., repeat: GifRepeat.once);      // no looping block at all
GifWriter.toFile(..., repeat: GifRepeat.times(3));  // play three times
Trim the memory further
GifWriter(sink, ..., bufferSize: 4 * 1024);

The staging buffer is the package's whole fixed overhead besides the LZW tables. Below about a kilobyte the syscalls cost more than the buffer saves.

If you only ever call addIndexedFrame, that is the whole story — the 96 kB colour cube and the dither's error rows are allocated on the first RGB frame and never before it.

Benchmarks #

Against package:image #

60 frames of 256×256, run with tool/compare.dart. Both encoders are given frames that already carry a palette, so neither quantises — otherwise image would be paying for NeuQuant this package does not implement, and the comparison would say nothing. The tool decodes both outputs and checks them pixel-for-pixel against the input before printing a single number; it refuses to report timings if either is wrong.

The full comparison, and how it is measured, is in At a glance above.

Method #

Both benchmarks report the median of nine trials, not the best of them. An earlier version took best-of-N, and it flattered this package: at 32 colours it showed gif_writer clearly ahead where the median put the two level, which is what sent us looking and turned up the broken hash above. A minimum measures the luckiest moment the machine had; the median is what a caller sees.

The comparison interleaves the two encoders rather than running them in blocks, so a thermal drift or a background process partway through hits both equally. It prints the range beside every median, and says in words when a gap is inside the spread, so a difference too small to be real cannot be read as a result. And it decodes both outputs and checks every pixel before reporting any timing at all — package:image silently quantises a frame that arrives without a palette, and a comparison against an encoder doing extra work would be worthless.

On its own #

120 frames of 256×256 at 32 colours, tool/benchmark.dart:

workload throughput range output sink writes
noise — worst case for LZW 59.7 Mpx/s 54.0 – 61.6 5.82 MB 121
photo — representative content 89.5 Mpx/s 86.6 – 92.2 2.26 MB 121
smooth gradient — best case 142.9 Mpx/s 133.8 – 146.8 0.30 MB 121

Where it started, before any of the tuning below: 12.4 Mpx/s on noise and 36.6 on the gradient.

The test images are generated, not bundled. The convention in this field is to reach for "Lenna", which IEEE retired in 2024 and most venues have dropped over its provenance — and shipping any photograph would add weight to the package and a licence to reason about. What an LZW encoder actually responds to is the statistics of the content: run lengths, and how often the dictionary hits. So tool/sample_image.dart generates the three cases that bracket real content — uniform noise, a gradient, and a photographic image built from smooth shading, hard edges and fine grain. Reproducible exactly, on any machine, with no download.

Where the speed comes from #

Fixed overhead does not grow with the animation. What it is depends on which entry point you use, and an indexed-only caller pays nothing for the RGB machinery — none of it is allocated until the first RGB frame:

path fixed overhead
addIndexedFrame ~192 kB — a 64 kB staging buffer plus a 128 kB LZW string table
addRgbFrame, ordered dither ~288 kB — plus the 96 kB inverse colour cube
addRgbFrame, error diffusion ~288 kB + 12 × width for two rows of error

All of it allocated once for the whole animation rather than per frame.

Open-addressed Int32List string table not a Map<int, int>. Probed once per pixel, the hottest loop here.
A hash every key can address the classic (pixel << 4) ^ prefix from the C implementations is quietly broken below 256 colours: at 32 it never exceeds 4095, so most of the table is unreachable and the load factor is 1.0. Mixing the key properly took noise from 27.2 to 59.7 Mpx/s.
A power-of-two table with an odd step odd is coprime to a power of two, so the probe still reaches every slot — what a prime table buys, without a division per pixel. The prime it replaced measured 86.8 Mpx/s on a gradient against 142.9.
Sized at a 0.25 load factor the classic 5003 slots puts 4096 codes at 0.82 and probes several times per pixel. 16384 is the top of the curve: 8192 measured 51.6 Mpx/s on noise, 16384 59.7, and 32768 gains nothing for twice the memory.
Batched sink writes passing every 255-byte sub-block straight through cost 24,365 sink calls for a 5.8 MB animation. Batching makes it 121.
Sub-blocks written in place the length byte is reserved and patched afterwards, so compressed bytes are never staged in a scratch array and copied — that copy is a memcpy of the entire output.
No per-pixel range check at 256 colours where no byte can be out of range. Elsewhere the check ORs the bytes together and only walks precisely if that suggests trouble — 2.6% rather than a second pass.

Three changes that looked obviously right and measured worse, kept here so nobody repeats them: a power-of-two hash with linear probing ran 36× slower, because GIF keys cluster hard and linear probing turns clustering into long walks; enlarging the hash past 16384 costs more in clearing between frames than it saves in probing; and a prime table with key % size, which is correct and was the implementation for a while, pays a division on every pixel — most painfully where runs are long and the first probe already succeeds.

API #

GifWriter(sink, …) Writes to any StreamSink<List<int>>.
GifWriter.toFile(path, …) Convenience for dart:io, with back-pressure wired to IOSink.flush.
addIndexedFrame(indices, delay:) One byte per pixel. Validated, then compressed straight out.
addRgbFrame(rgb, delay:) Three bytes per pixel, mapped onto your table with the writer's dither.
addRgbaFrame(rgba, background:, delay:) Four bytes per pixel, composited over background first.
GifDither.blueNoise / .bayer4 / .bayer8 / .floydSteinberg / .atkinson / .none How in-between colours are resolved.
GifFrame.rgb(…) / .rgba(…, background:) The same three shapes, for the stream form.
addStream(stream) / pipe Consume a Stream<GifFrame>.
close() Writes the trailer and closes the sink.
GifColorTable.packed([0xRRGGBB, …]) Up to 256 colours.
GifColorTable.rgb([r, g, b, …]) The same, as raw bytes.
GifRepeat.forever / .once / .times(n) Looping.

Back-pressure matters #

addIndexedFrame awaits the sink once per frame. Without that the buffering would simply move one layer down — a producer faster than the disk would queue frames inside the sink, using the same memory somewhere much harder to notice. GifWriter.toFile handles this for you; pass onFlush yourself for other sinks.

Dithering, and why the default is not Floyd–Steinberg #

Pass RGB and the writer maps it onto your table, dithering colours the table cannot hold:

final gif = GifWriter.toFile(
  'out.gif',
  width: w,
  height: h,
  colors: colors,
  dither: GifDither.blueNoise,   // the default
);
await gif.addRgbFrame(rgb);                            // 3 bytes per pixel
await gif.addRgbaFrame(rgba, background: 0xFFFFFF);    // 4, composited first

Floyd–Steinberg makes the best-looking single image and is the wrong default for an animation. It carries error from each pixel into the next, so one pixel changing by one level changes every pixel after it. Three consequences: static regions decode differently frame to frame and the result shimmers; the noise defeats the LZW dictionary this package is built around; and no region is ever byte-identical, which rules out frame diffing.

An ordered dither reads its threshold from position alone. The same colour in the same place always resolves the same way, so a one-pixel change affects exactly one pixel — a property with a test on it, not just a claim.

Measured with tool/dither.dart, 27 colours on a photographic frame:

blurred error structure output rate
none 33.37 1226 0.15 MB 65.5 Mpx/s
bayer4 21.10 571 0.27 MB 32.7 Mpx/s
bayer8 21.09 564 0.28 MB 30.9 Mpx/s
blueNoise (default) 21.05 578 0.41 MB 27.6 Mpx/s
floydSteinberg 4.04 84 0.62 MB 18.4 Mpx/s
atkinson 9.72 63 0.57 MB 10.2 Mpx/s

Read that error column carefully. It is RMSE after blurring both images, because per-pixel error measures the opposite of what dithering is for — a dithered pixel is deliberately the wrong colour, the right one on average across its neighbours. On plain per-pixel error none scores best of all six and looks the worst; the tool prints both columns so the divergence is visible rather than hidden.

Blue noise and Bayer score the same there, and that is a limit of the metric rather than a real tie: blurring destroys high-frequency structure, which is precisely where an ordered dither's artefact lives. On a flat mid-tone field, where the only structure is the dither's own:

structure
bayer4, bayer8 4095 — the maximum: a pure periodic grid
floydSteinberg 4095 — its known "worm" patterns on flat areas
atkinson 1870
blueNoise 10

That 400-fold difference is the whole reason blue noise is the default, and it is visible immediately on any gradient. It is not free: it costs about 50% more bytes than Bayer at 27 colours, because unstructured noise is harder to compress than a repeating pattern. If output size matters more than smoothness, bayer4 is the one to reach for; if the target is a single still image, floydSteinberg is markedly more accurate than anything else here.

Delays are hundredths of a second #

That is all the format stores. A Duration is rounded to the nearest hundredth, and most viewers refuse delays below two hundredths, silently substituting ten. A 60 fps GIF is not something GIF can express; this package writes what you ask for rather than pretending otherwise.

Scope #

You bring the colour table. Indexed frames are byte-exact, and RGB frames are mapped onto the table you supplied. What is not here yet is choosing that table for you — see 0.3.0.

version
0.1.0 ✅ Streaming container, LZW, indexed frames, any sink
0.2.0 ✅ RGB and RGBA input, mapped to your table, with five dithers
0.2.1 ✅ Loop-count and dictionary-test fixes; LZW resets without zeroing its table
0.3.0 Octree quantisation — deriving the palette, global or per frame
0.4.0 Transparency, disposal methods, frame diffing

The full picture — open work, structural decisions, and what was ruled out and why — is in ROADMAP.md.

Quantisation is deliberately not first: it is the part with a memory cost of its own, and getting the streaming container and the mapping right matters more than accepting more input formats early.

Testing #

Round trips are verified against package:image — a separate implementation, deliberately. Checking an encoder against its own decoder proves only that the two share a misunderstanding.

The streaming guarantee has tests of its own, and those are verified by tampering: make the writer accumulate, and they must fail. A guard never seen red is not known to guard anything.

Covered: 1×1, single-row and single-column frames, non-multiples of eight, a frame large enough to refill the LZW dictionary, and a full 256-colour table — the sizes where block boundaries and code widths go wrong. Everything runs on the VM and under Chrome.

Licence #

MIT © Saman Sedighi Rad — see LICENSE.

0
likes
0
points
173
downloads

Publisher

unverified uploader

Weekly Downloads

A high-performance, low-memory GIF encoder. Streams frames straight to a sink as they arrive, so memory stays flat however long the animation runs.

Repository (GitHub)
View/report issues

Topics

#gif #image #encoding #animation

License

unknown (license)

More

Packages that depend on gif_writer