wtfgif 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BENCHMARKS.md CHANGED
@@ -1,185 +1,53 @@
1
1
  # Benchmarks
2
2
 
3
- Performance claims are only meaningful when the backend, cache state, fixtures,
4
- and correctness checks are explicit.
3
+ wtfgif benchmarks encode and decode separately against the same public
4
+ `GifWriter` and `GifReader` APIs from `omggif`.
5
5
 
6
- ## MakeEmoji pixel-perfect timing export
6
+ WebAssembly initialization and warmup happen before timed samples, matching an
7
+ app that initializes wtfgif during page load. Reader construction and GIF
8
+ parsing remain inside every timed decode sample.
7
9
 
8
- This reproduces MakeEmoji's current export after its preview frames have been
9
- decoded: each RGBA frame is quantized with MakeEmoji's `image-q` settings and
10
- written with `omggif`. It compares that path with a one-shot wtfgif timing edit
11
- and with a GIF compiled while the upload loads.
10
+ ## Encode
12
11
 
13
12
  ```bash
14
- BENCH_ITERATIONS=5 BENCH_WARMUP_ITERATIONS=2 \
15
- npm run bench:makeemoji-retime
13
+ BENCH_ITERATIONS=300 npm run bench:encode
16
14
  ```
17
15
 
18
- Five measured samples per implementation and fixture, with two warmups:
16
+ The default encode benchmark is a normal 256-color animation: 12 full 128×128
17
+ indexed frames, a global palette, typed output, and `compression: "fast"`.
19
18
 
20
- | Fixture | One-shot parse + retime | Page-load compiled retime |
21
- | --- | ---: | ---: |
22
- | 18d | 1,110.2x | 4,225.9x |
23
- | Clap | 321.6x | 951.5x |
24
- | Homer | 9,418.9x | 29,954.7x |
25
- | Chipmunk | 6,706.7x | 27,473.8x |
26
- | GIGACHAD | 5,386.7x | 12,998.1x |
27
- | NODDERS | 1,414.5x | 3,158.2x |
28
- | Proud | 5,005.4x | 32,190.9x |
29
- | catJAM | 3,953.6x | 6,151.6x |
30
- | excuseme | 4,922.4x | 10,105.7x |
31
- | party_blob | 7,268.6x | 16,010.2x |
32
- | partyparrot | 600.4x | 1,643.9x |
33
- | tenor | 26,241.3x | 72,672.1x |
34
- | **Geometric mean** | **3,338.5x** | **9,469.6x** |
35
-
36
- Every wtfgif output had the requested delays and exact composited RGBA pixels.
37
- The unit suite separately verifies that original compressed frame data is
38
- retained. MakeEmoji's current quantize-and-reencode output was pixel-perfect on
39
- only 2 of 12 fixtures.
40
-
41
- This result comes from doing less work, not from a faster quantizer: timing
42
- changes do not require decoding or recompressing the image stream.
43
-
44
- The page-load compiled column is the intended web path and the >100x contract.
45
- Cold one-shot results on tiny GIFs are more sensitive to JavaScript startup,
46
- JIT, and microsecond timer noise; a separate three-sample filtered run observed
47
- a 59.3x one-shot outlier while the compiled edit remained 782.5x.
48
-
49
- The same benchmark exercises strict reverse and MakeEmoji-style boomerang on
50
- fixtures whose full-canvas/disposal structure proves that frame order is
51
- independent:
52
-
53
- | Operation | Slowest accepted fixture | Geometric mean |
54
- | --- | ---: | ---: |
55
- | Reverse | 5,329.5x | 18,054.3x |
56
- | Boomerang | 6,866.7x | 21,720.1x |
57
-
58
- The structural path compares each output frame with its mapped source frame,
59
- including exact composited RGBA and delay values. Fixtures that cannot be
60
- proven independent are rejected and excluded, rather than silently rewritten.
61
-
62
- ## Experimental cold pixel-perfect reencoding
63
-
64
- The current research checkpoint starts a fresh process for each sample and
65
- reencodes every GIF image descriptor with the experimental native addon. The
66
- timer excludes the decoded-pixel verification that follows each sample. Every
67
- output and source GIF is then decoded and compared; any pixel mismatch aborts
68
- the run.
69
-
70
- Command:
71
-
72
- ```bash
73
- BENCH_ITERATIONS=31 BENCH_REENCODE_ONLY=1 npm run bench
74
- ```
75
-
76
- 31 fresh-process samples per implementation and fixture:
77
-
78
- | Fixture | wtfgif faster than omggif |
19
+ | Implementation | Median |
79
20
  | --- | ---: |
80
- | 18d | 142.65x |
81
- | Clap | 100.97x |
82
- | Homer | 191.85x |
83
- | Chip | 200.35x |
84
- | GIG | 180.09x |
85
- | NOD | 111.37x |
86
- | Proud | 229.79x |
87
- | cat | 180.53x |
88
- | excuse | 117.74x |
89
- | blob | 126.32x |
90
- | parrot | 149.29x |
91
- | tenor | 214.14x |
92
- | Geometric mean | 156.69x |
21
+ | omggif | 15.187 ms |
22
+ | wtfgif | 0.149 ms |
23
+ | **Speedup** | **102.18x** |
93
24
 
94
- These are experimental-addon results on the development machine, not a promise
95
- for the portable npm package or other hardware. The 1.0.0 npm artifact ships
96
- JavaScript and WebAssembly; it does not ship a prebuilt native addon.
25
+ The benchmark decodes both outputs with omggif and requires exact RGBA equality
26
+ before timing. The omggif and balanced wtfgif outputs are 163,797 bytes. Fast
27
+ wtfgif output is 224,001 bytes, or 1.37x larger.
97
28
 
98
- ## Page-load-prepared portable pixel-perfect remux
29
+ Use `BENCH_COMPRESSION=balanced` to measure normal LZW compression, or
30
+ `BENCH_COLOR_COUNTS=4,16,256` to run non-default palette sizes.
99
31
 
100
- This is the web product benchmark. Wasm initialization and generic hot-path
101
- preparation happen before the clock, as they would during page load. Each timed
102
- sample is still the first real GIF processed in a fresh process. Preparation
103
- uses unrelated generated GIFs and cannot cache a fixture's input or output.
104
-
105
- wtfgif validates and rewrites the GIF container while preserving original LZW
106
- frame payloads. omggif decodes and freshly recompresses the frames. The
107
- externally tested contract is the same—an owned GIF with identical decoded
108
- pixels—but the algorithms intentionally differ.
32
+ ## Decode
109
33
 
110
34
  ```bash
111
- BENCH_ITERATIONS=31 BENCH_REENCODE_ONLY=1 \
112
- WTFGIF_BENCH_BACKEND=wasm \
113
- WTFGIF_PREPARE_WASM_AT_PAGE_LOAD=1 \
114
- WTFGIF_REENCODE_MODE=remux npm run bench
35
+ BENCH_GIF_FILTER=GIGACHAD BENCH_ITERATIONS=100 npm run bench:decode
36
+ BENCH_GIF_FILTER=tenor BENCH_ITERATIONS=50 npm run bench:decode
115
37
  ```
116
38
 
117
- | Fixture | wtfgif faster than omggif |
118
- | --- | ---: |
119
- | 18d | 738.64x |
120
- | Clap | 309.84x |
121
- | Homer | 983.53x |
122
- | Chip | 768.77x |
123
- | GIG | 607.23x |
124
- | NOD | 512.23x |
125
- | Proud | 681.13x |
126
- | cat | 519.37x |
127
- | excuse | 480.06x |
128
- | blob | 955.88x |
129
- | parrot | 244.09x |
130
- | tenor | 1,238.09x |
131
- | Geometric mean | 609.73x |
132
-
133
- Every fixture clears 100x. The slowest is partyparrot at 244.09x.
134
-
135
- The excluded initialization plus generic preparation cost was measured
136
- separately across 31 fresh processes on the same machine: 8.64 ms median,
137
- 9.86 ms p95, and 9.91 ms maximum.
138
-
139
- ## Cold portable Rust/WebAssembly reencoding
140
-
141
- The same pixel-perfect Rust reencoder now runs in browsers, Cloudflare Workers,
142
- Vercel Edge, and Node through WebAssembly.
143
-
144
- ```bash
145
- BENCH_ITERATIONS=31 BENCH_REENCODE_ONLY=1 \
146
- WTFGIF_BENCH_BACKEND=wasm npm run bench
147
- ```
148
-
149
- | Fixture | wtfgif faster than omggif |
150
- | --- | ---: |
151
- | 18d | 8.19x |
152
- | Clap | 1.23x |
153
- | Homer | 14.68x |
154
- | Chip | 17.70x |
155
- | GIG | 15.44x |
156
- | NOD | 8.60x |
157
- | Proud | 20.00x |
158
- | cat | 14.42x |
159
- | excuse | 9.60x |
160
- | blob | 9.74x |
161
- | parrot | 3.38x |
162
- | tenor | 16.00x |
163
- | Geometric mean | 9.45x |
164
-
165
- These are first-call numbers in 31 fresh Node processes per implementation and
166
- fixture. They include Wasm's first-call cost and use no warmup. Browser and edge
167
- support is separately exercised by real-browser, Cloudflare workerd, and Vercel
168
- Edge VM smoke tests.
169
-
170
- Page-load preparation improves fresh LZW recompression, but it does not remove
171
- the actual LZW decode and encode work. Profiling found decoding responsible for
172
- roughly 57–85% of its hot runtime and literal reencoding for roughly 15–43%.
173
- The remux API avoids both stages by preserving the original compressed payload.
39
+ | Fixture | Shape | omggif | wtfgif | Speedup |
40
+ | --- | ---: | ---: | ---: | ---: |
41
+ | GIGACHAD | 198 × 128×128 | 28.977 ms | 16.802 ms | **1.72x** |
42
+ | tenor | 16 × 498×498 | 33.967 ms | 7.642 ms | **4.44x** |
174
43
 
175
- ## Correctness gates
44
+ The decoder reuses one caller-owned RGBA buffer and invokes
45
+ `decodeAndBlitFrameRGBA()` for every frame, exactly like normal omggif usage.
46
+ Before timing, the complete final RGBA buffer must match omggif byte-for-byte.
176
47
 
177
- - Source and reencoded decoded pixels must match exactly for every timed sample.
178
- - Source and remuxed decoded pixels must match exactly for every timed sample.
179
- - A randomized parity stress run covered 200 generated GIFs across 4, 16, 64,
180
- 128, and 256 colors, global palettes, and identical local palettes.
181
- - Unit tests compare reader and writer behavior with omggif across the fixture
182
- corpus.
48
+ ## Reproducing results
183
49
 
184
- Record new results here only with the exact command, sample count, environment,
185
- backend, and correctness gate.
50
+ Times are medians from the reported sample count on the development Mac with
51
+ Node.js 22. Microbenchmarks vary with hardware, power state, Node version, and
52
+ background load. Speedup ratios are more useful than raw milliseconds, but
53
+ neither should be treated as a universal result.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to wtfgif are documented here.
4
4
 
5
+ ## 1.3.0 - 2026-07-19
6
+
7
+ - Add separately optimized, pixel-perfect `GifWriter` encode and `GifReader`
8
+ decode paths for normal typed-buffer usage.
9
+ - Add opt-in `compression: "fast"` encoding. The included 256-color,
10
+ 12-frame benchmark measures 102.18x faster than omggif with exact RGBA
11
+ parity; its output is 1.37x larger.
12
+ - Route substantial ordinary `decodeAndBlitFrameRGBA()` and
13
+ `decodeAndBlitFrameBGRA()` calls through reusable Rust/WebAssembly scratch
14
+ memory while preserving omggif's caller-buffer and transparency behavior.
15
+ - Measure encode and decode independently with public drop-in APIs and
16
+ mandatory pixel-parity checks.
17
+ - Simplify the README and benchmark documentation around initialization,
18
+ encoding, decoding, measured speedups, and the fast encoder's size tradeoff.
19
+
5
20
  ## 1.2.0 - 2026-07-19
6
21
 
7
22
  - Add `compileGif()`, an immutable compiled-GIF representation that retains
package/README.md CHANGED
@@ -1,166 +1,92 @@
1
1
  # wtfgif
2
2
 
3
- An `omggif`-compatible GIF reader and writer, plus pixel-perfect fast paths
4
- that avoid rebuilding pixels when the requested edit does not require it.
5
-
6
- **[Run the browser benchmark →](https://mpopv.github.io/wtfgif/)**
3
+ A faster, TypeScript-ready replacement for
4
+ [`omggif`](https://github.com/deanm/omggif).
7
5
 
8
6
  ```bash
9
7
  npm install wtfgif
10
8
  ```
11
9
 
12
- ## Replace omggif
13
-
14
- For the core `GifReader` and `GifWriter` API, change the import:
15
-
16
- ```diff
17
- - import { GifReader, GifWriter } from "omggif";
18
- + import { GifReader, GifWriter } from "wtfgif";
19
- ```
20
-
21
- CommonJS and TypeScript are supported:
22
-
23
- ```js
24
- const { GifReader, GifWriter } = require("wtfgif");
25
- ```
26
-
27
- This is the compatibility path. It accepts the same indexed pixels, palettes,
28
- frame rectangles, delays, transparency, and disposal options used by omggif.
29
- It does not promise a 100x speedup for arbitrary new pixels.
10
+ ## Initialize once
30
11
 
31
- ## The 100x fast paths
32
-
33
- wtfgif is fastest when pixels are already valid GIF data and the edit can be
34
- expressed as a container change:
35
-
36
- | Operation | Fixtures | Slowest speedup | Correctness |
37
- | --- | ---: | ---: | --- |
38
- | Compiled timing edit | 12/12 | **951.5x** | Exact pixels, delays, and original LZW payloads |
39
- | Prepared Wasm remux | 12/12 | **244.09x** | Exact decoded animation |
40
- | Strict frame reverse | 2/12 eligible | **5,329.5x** | Exact mapped pixels and delays |
41
- | Strict boomerang | 2/12 eligible | **6,866.7x** | Exact mapped pixels and delays |
42
-
43
- These are not claims that every wtfgif call is 100x faster. They are results
44
- for the named operations across the accepted benchmark fixtures. Timing,
45
- reverse, and boomerang are compared with MakeEmoji's `image-q` + `omggif`
46
- export; remux is compared with `omggif` decode + fresh encode. See
47
- [BENCHMARKS.md](BENCHMARKS.md) for commands, fixtures, sample counts, cold
48
- results, and correctness gates.
49
-
50
- ## Edit timing without touching pixels
51
-
52
- Compile once when a GIF is loaded:
12
+ Load WebAssembly during page startup, before encoding or decoding:
53
13
 
54
14
  ```ts
55
- import { compileGif } from "wtfgif";
15
+ import { initializeWasmGlobally } from "wtfgif";
56
16
 
57
- const gif = compileGif(inputGif);
17
+ await initializeWasmGlobally();
58
18
  ```
59
19
 
60
- Export new frame delays later. GIF delays are measured in centiseconds:
61
-
62
- ```ts
63
- const outputGif = gif
64
- .withDelays(Uint16Array.of(4, 8, 12, 8))
65
- .toUint8Array();
66
- ```
20
+ Node.js loads the bundled WebAssembly automatically. Browsers, Web Workers,
21
+ Cloudflare Workers, and Vercel Edge should call the initializer once.
67
22
 
68
- `compileGif()` retains the original GIF structure and compressed image data.
69
- `withDelays()` changes only timing metadata, inserting a graphics control
70
- extension when one is missing. It never decodes, quantizes, or recompresses a
71
- frame.
23
+ ## Decode
72
24
 
73
- For a one-shot timing edit:
25
+ Use the same API as `omggif`:
74
26
 
75
27
  ```ts
76
- import { retimeGifPixelPerfect } from "wtfgif";
77
-
78
- const outputGif = retimeGifPixelPerfect(inputGif, delaysInCentiseconds);
79
- ```
80
-
81
- These timing APIs are synchronous JavaScript. They need no Wasm initialization
82
- and run in Node, browsers, Web Workers, Cloudflare Workers, and Vercel Edge.
28
+ import { GifReader } from "wtfgif";
83
29
 
84
- ## Reverse or boomerang proven-independent frames
30
+ const reader = new GifReader(gifBytes);
31
+ const rgba = new Uint8ClampedArray(reader.width * reader.height * 4);
85
32
 
86
- ```ts
87
- const gif = compileGif(inputGif);
88
-
89
- if (gif.canReorderFrames) {
90
- const reversed = gif.reverseFrames();
91
- const boomerang = gif.boomerangFrames();
92
- // A, B, C becomes A, B, C, C, B, A
33
+ for (let frame = 0; frame < reader.numFrames(); frame += 1) {
34
+ reader.decodeAndBlitFrameRGBA(frame, rgba);
93
35
  }
94
36
  ```
95
37
 
96
- This path reorders complete compressed frame records. It deliberately rejects
97
- GIFs whose appearance can depend on the preceding frame, including unsafe
98
- partial-frame composition or metadata anchored between frames. A rejection is
99
- a request to use a real decode-and-render pipeline, not a malformed output.
38
+ `frameInfo()`, `loopCount()`, `decodeAndBlitFrameRGBA()`, and
39
+ `decodeAndBlitFrameBGRA()` are drop-in compatible.
100
40
 
101
- ## Remux with Wasm
41
+ ## Encode
102
42
 
103
- Initialize Wasm once per JavaScript realm during page or worker startup:
43
+ For the fastest path, use a preallocated `Uint8Array`, a normal 256-color
44
+ palette, and `compression: "fast"`:
104
45
 
105
46
  ```ts
106
- import {
107
- initializeWasmGlobally,
108
- remuxGifPixelPerfect,
109
- } from "wtfgif";
110
-
111
- await initializeWasmGlobally();
112
-
113
- const outputGif = remuxGifPixelPerfect(inputGif);
114
- ```
115
-
116
- `remuxGifPixelPerfect()` validates and rewrites the GIF container while
117
- preserving palettes and compressed frame payloads. It guarantees the displayed
118
- animation: dimensions, frames, timing, loop behavior, disposal, transparency,
119
- and decoded pixels. Non-rendering extension metadata such as comments is not
120
- part of its contract.
121
-
122
- Cloudflare Workers and Vercel Edge require static Wasm imports:
47
+ import { GifWriter } from "wtfgif";
48
+
49
+ const output = new Uint8Array(width * height * frameCount * 2 + 4096);
50
+ const writer = new GifWriter(output, width, height, {
51
+ palette, // 256 RGB integers, just like omggif
52
+ loop: 0,
53
+ compression: "fast",
54
+ });
55
+
56
+ for (const indexedPixels of frames) {
57
+ writer.addFrame(0, 0, width, height, indexedPixels, {
58
+ delay: 10,
59
+ disposal: 2,
60
+ });
61
+ }
123
62
 
124
- ```ts
125
- import {
126
- initializeWasmModule,
127
- remuxGifPixelPerfect,
128
- } from "wtfgif";
129
- import init, * as rust from "wtfgif/wasm-web";
130
- import wasm from "wtfgif/wasm-web/wasm";
131
-
132
- await initializeWasmModule({ ...rust, default: init }, wasm);
63
+ const gifBytes = output.subarray(0, writer.end());
133
64
  ```
134
65
 
135
- Initialization is not global across a website: the window and each Worker,
136
- Node process, or edge isolate are separate realms and must initialize their
137
- own module.
66
+ Everything except `compression` is ordinary `omggif` usage. Omit
67
+ `compression: "fast"` for balanced LZW compression and omggif-sized output.
68
+ Fast compression is pixel-perfect and standards-valid, but the benchmark GIF
69
+ is 1.37x larger.
138
70
 
139
- ## What is not 100x
71
+ ## Speed
140
72
 
141
- - `GifReader` and `GifWriter` are drop-in compatibility APIs, not universal
142
- 100x APIs.
143
- - Encoding arbitrary new pixels still requires palette selection and LZW
144
- compression. Wasm initialization cannot make that work disappear.
145
- - `reencodeGifPixelPerfect()` performs fresh LZW encoding and is substantially
146
- slower than remuxing.
147
- - Cold startup is excluded from the prepared-Wasm and compiled-edit results.
148
- The measured Wasm initialization and preparation cost was 8.64 ms median;
149
- one tiny-GIF timing run also produced a 59.3x cold one-shot outlier.
150
- - Reverse and boomerang are fast only when wtfgif can prove that raw frame
151
- reordering preserves the rendered animation.
73
+ After initialization, on the included normal 256-color benchmark:
152
74
 
153
- The speedup comes from recognizing when an edit does not need a pixel pipeline.
154
- Instead of decode quantize recompress, wtfgif validates the structure and
155
- copies already-correct compressed bytes.
75
+ | Operation | omggif | wtfgif | Speedup |
76
+ | --- | ---: | ---: | ---: |
77
+ | Encode 12 × 128×128 frames | 15.187 ms | 0.149 ms | **102.18x** |
78
+ | Decode 198 × 128×128 frames | 28.977 ms | 16.802 ms | **1.72x** |
79
+ | Decode 16 × 498×498 frames | 33.967 ms | 7.642 ms | **4.44x** |
156
80
 
157
- ## Verify
81
+ Every result is checked against omggif for exact RGBA pixel equality before it
82
+ is timed. Decode and encode are measured separately.
158
83
 
159
84
  ```bash
160
- npm run check
161
-
162
- BENCH_ITERATIONS=5 BENCH_WARMUP_ITERATIONS=2 \
163
- npm run bench:makeemoji-retime
85
+ npm run bench:encode
86
+ BENCH_GIF_FILTER=GIGACHAD npm run bench:decode
87
+ BENCH_GIF_FILTER=tenor npm run bench:decode
164
88
  ```
165
89
 
90
+ See [BENCHMARKS.md](BENCHMARKS.md) for the benchmark contract and caveats.
91
+
166
92
  [MIT](LICENSE)
@@ -12,6 +12,7 @@ export declare class GifReader {
12
12
  private stack;
13
13
  private firstByte;
14
14
  private activePreparedFrames;
15
+ private wasmCore;
15
16
  private static decodeBackend;
16
17
  static createPooled(buf: GifBinary): GifReader;
17
18
  static createUnpooled(buf: GifBinary): GifReader;
@@ -43,7 +43,10 @@ export declare class GifWriter {
43
43
  private loopCount;
44
44
  private globalPalette;
45
45
  private background;
46
+ private compression;
46
47
  private globalColorCount;
48
+ private globalColorTableSizeBits;
49
+ private globalMinCodeSize;
47
50
  private previousIndexedFrame;
48
51
  constructor(buf: GifBinary, width: number, height: number, gopts?: GifOptions);
49
52
  addFrame(x: number, y: number, w: number, h: number, indexedPixels: IndexedPixels, opts?: FrameOptions): number;