imazen / imazen/zenwebp

VP8L encoder emits a valid-but-wrong stream (84% pixels corrupt) — shipping in 0.4.4 at default settings

Open
#72 2 comments 0 reactions 1 assignee View on GitHub

@lilith is already working on this.

Since Jul 15, 2026.

Dominant language
Rust
Stars
15
Forks
1
PR merge metrics
No merged PRs in 30d

Description

Published zenwebp 0.4.4 can silently emit a corrupt VP8L stream — at default settings. The stream is well-formed, so decoders accept it and return garbage pixels rather than an error. Fixed on main but not yet published, so every crates.io user is currently exposed.

Filing because the fix is unreleased and there is no regression gate on the trigger, so it can silently come back.

Impact

vp8l-m4-ql25 on a 512x320 rendition: 138,316 of 163,840 pixels wrong (84.42%), max |delta| 255, wrong from pixel (0,0) across all 320 rows. Whole-image garbage, not an edge/stride artifact.

Not ql25-specific and not method-specific — observed at ql100 and at default quality 75 with m6:

rendition cells manifestation
o_8148.scale512x320 m4-ql25, m6-ql25 silent corruption
o_3014.scale512x424 m4-ql25, m6-ql25 BitStreamError
o_8113.scale896x560 m4-ql25, m6-ql25 BitStreamError
o_8341.scale384x512 m4-ql25, m6-ql25 BitStreamError
o_9231.scale597x896 m4-ql100, m6-ql100 BitStreamError
o_8384.scale192x256 m6, default ql75 BitStreamError

Trigger is a content x clustering lottery, not a dimension or a dial: for o_8148.scale512x320 a full 7x21 grid (q0-q100 step 5) fails at exactly m4/m5/m6 @ q=25 and nowhere else, and 1-pixel crops (511x320, 512x319) flip it to BitStreamError.

Mechanism

Phase 5 allocates one final histogram per active cluster, including clusters the remap stranded with zero mapped tiles, so the encoder writes Huffman trees for an unreferenced trailing cluster. Decoders size the group list from the entropy image's max symbol (max+1), so the extra trees get parsed as pixel data and the rest of the bitstream shifts.

Published v0.4.4:src/encoder/vp8l/meta_huffman.rs:883:

// Phase 5: Rebuild final histograms from remapped tiles
let mut final_histos: Vec<Histogram> = active_indices
    .iter()                                  // one slot per ACTIVE cluster...
    .map(|_| Histogram::new(cache_bits))     // ...even with zero tiles mapped to it
    .collect();

main fixes this in build_final_histograms (src/encoder/vp8l/meta_huffman.rs:904) by compacting to referenced clusters with dense renumbering. Introduced 3626d22 (2026-02-03), fixed 6930c53 (2026-07-13, squashed into the m0 low-effort commit), noted in CHANGELOG.md under [Unreleased]. Bisect confirms 6930c53 first-good, parent dd871b2 bad. Full 147-cell grid on main: 147/147 bit-exact.

The decoder is innocent

Three decoders — old zenwebp, current zenwebp, and libwebp — reproduce the identical 138,316-pixel corruption, and libwebp decodes main's output bit-exactly. So this is an encoder emitting a valid stream that encodes the wrong pixels; efddb6c (the VP8L decoder refill fix) is not involved.

Two things worth doing

  1. Publish the fix. 0.4.4 is the current crates.io release and can silently corrupt lossless output at default settings. Lossless users have no signal that anything went wrong.
  2. Add a regression gate. Nothing currently covers the trigger: tests/lossless_fast_tier.rs (added with the fix) only tests method(0); tests/lossless_roundtrip.rs pins quality 75. examples/sweep_validate.rs has the zero-tolerance gate but runs 3 CID22 images and never saw this content. A gate over m4/m5/m6 x the ql axis on content known to strand a cluster would have caught it.

Provenance

Found while rolling up a 5.74M-encode zenmetrics sweep for the coefficient viewer: exactly 2 of 40,464 lossless rows scored below 100 (ssim2 -1613.79, zensim -56.66). The parquet understates it — cells that hard-failed wrote no row at all, so the 2 visible rows are the ones that corrupted quietly rather than the full damage.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.