Skip to content

Fix inverted AVIF quality on imagick, and quality being ignored by base64() on gd and vips - #332

Merged
freekmurze merged 7 commits into
mainfrom
fix/avif-quality-inverted-imagick
Aug 18, 2026
Merged

Fix inverted AVIF quality on imagick, and quality being ignored by base64() on gd and vips#332
freekmurze merged 7 commits into
mainfrom
fix/avif-quality-inverted-imagick

Conversation

@NickBevers

@NickBevers NickBevers commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Started out as the AVIF quality inversion on the Imagick driver, and grew to cover two related quality bugs found while adding test coverage.

1. AVIF quality is inverted on the imagick driver

Asking for quality 20 gives you a quality 80 image, and asking for 80 gives you a quality 20 one.

Measured on main, encoding the test JPEG to AVIF:

call file size what you actually get
quality(20) 4467 bytes the quality 80 image
quality(80) 2287 bytes the quality 20 image

Exactly swapped.

Why

Imagick reads the compression quality from two different setters depending on the output format:

  • setImageCompressionQuality() — read by JPEG and WebP
  • setCompressionQuality() — read by PNG and AVIF

The driver set the second one to 100 - $quality, with a // For PNGs comment:

$this->image->setImageCompressionQuality($quality);
$this->image->setCompressionQuality(100 - $quality); // For PNGs

The inversion is correct for PNG. PNG is lossless, so its "quality" is really a zlib compression level where a higher number means a smaller file — inverting keeps the library's "higher quality = bigger file" contract. Verified: every PNG quality value produces byte-identical pixels, and raw Q=0 → 36182 bytes vs Q=90 → 35654 bytes.

AVIF reads that same setter but treats it as a normal quality, so it silently inherited PNG's inversion. JPEG and WebP were never affected, because they read the other setter.

Fix

Store the requested quality and only invert it for PNG, resolved right before the image is written, once the target format is known. Applied on both save() and base64(), since each resolves its own format — removing either call flips PNG back the wrong way.

2. GdDriver::base64() ignored the quality for webp and avif

It called \imagewebp($this->image, null) and \imageavif($this->image, null) with no quality argument, and skipped the imagepalettetotruecolor() call that save() does. On main, GD base64('webp') returns 10792 bytes at both quality 10 and 90.

3. VipsDriver::base64() ignored the quality entirely

It called writeToBuffer() with no options at all, while save() builds a Q / compression save property. On main, vips base64('webp') returns 10204 bytes and base64('jpeg') 27680 bytes at both quality 10 and 90. The property-building logic is now shared between the two paths.

Verification

Every format, both write paths, imagick driver:

format q10 q50 q90
jpg 7332 15665 28673
avif 2072 2819 7014 ✅ (was inverted)
webp 4202 6404 11224
png 35667 37283 42790 ✅ (inversion preserved)

After the fix, quality(20) on AVIF produces a file byte-identical to raw Imagick at Q=20.

All three bugs were confirmed against the pre-fix code before being fixed, and the new tests were checked against that same pre-fix code to make sure they actually fail without these changes — the avif inversion and the dropped quality each fail the suite there.

Tests and CI

The existing quality test only covered jpg and png through save(), which is why all of this went unnoticed. This PR adds a regression test for the AVIF inversion, and base64() coverage across drivers and formats.

avifIsSupported() previously returned false whenever GITHUB_ACTIONS was set, so no AVIF test had ever run on CI. It also hard-coded false for the vips driver, which does support AVIF. It now probes a driver by actually encoding a small image — reporting the format as available is not sufficient, since a driver can be built against a libheif without an AVIF encoder and only fail at encode time.

The workflow now installs libheif1 + libheif-plugin-aomenc and prints the detected AVIF support per driver. On the runners this reports gd imageavif(): yes and imagick AVIF formats: AVIF, so the avif tests now genuinely run in CI.

One caveat worth recording: the imagick build on the runners returns identical webp bytes for quality 10 and 90, which is outside this package's control. The per-format base64 test therefore only asserts that quality is never applied in reverse, which is what the avif bug did. A separate test asserts strictly on jpeg — the one format every driver reliably varies — so a driver dropping the quality entirely still gets caught.

PHPStan

Also fixed, since it would otherwise keep this PR red. It was failing on a stale baseline count, not on a new error: the baseline records 4 occurrences of the GdImage|false assignment, but the exif orientation handling has grown to 7 imagerotate() calls since it was written. This has been failing on main on every run since at least March 2026.

Imagick reads the compression quality from two different setters
depending on the output format. JPEG and WebP use the per-image
setter, while PNG and AVIF read the global one.

That global setter was unconditionally inverted with a "For PNGs"
comment. PNG needs it, because there the value is a compression
level where a higher number means a smaller file. AVIF reads the
same setter but treats it as a regular quality, so it silently
inherited PNG's inversion: quality(20) produced a quality 80 image
and quality(80) produced a quality 20 one.

The quality is now stored and only inverted for PNG, right before
the image is written, when the target format is known.
The added webp and avif cases were failing on CI. The avif ones are
skipped there anyway, and the webp output of the ImageMagick build on
the runners does not change between quality 10 and 50.

The base64 test now only covers the imagick driver, since the gd and
vips drivers do not pass the quality on to every format when encoding
to base64. Those are separate, pre-existing bugs.
@freekmurze
freekmurze marked this pull request as ready for review August 18, 2026 07:31
NickBevers and others added 2 commits August 18, 2026 09:38
GdDriver::base64() called imagewebp() and imageavif() without a quality
argument, and did not convert a palette image to true color first, so
webp and avif output ignored the requested quality. Its save() already
did all of this.

VipsDriver::base64() called writeToBuffer() without any options, so it
ignored the quality for every format. The logic that builds those save
properties is now shared with save().

The avif capability check no longer bails out on CI. It probes the
driver by actually encoding an image, since a driver can report avif
as available while being built without an encoder for it. That also
lets the vips driver run the avif tests, which it never did before.
@NickBevers NickBevers changed the title Fix inverted quality for AVIF images on the Imagick driver Fix inverted AVIF quality on imagick, and quality being ignored by base64() on gd and vips Aug 18, 2026
The imagick build on the runners returns the same webp bytes for
quality 10 and 90, which is not something this package controls. The
per-format base64 test now only asserts that the quality is never
applied in reverse, which is what the avif bug did.

To keep the base64 fixes covered, a separate test asserts strictly on
jpeg, the one format every driver does vary. Verified against the
pre-fix code: without the fixes in this branch, the avif inversion and
the dropped quality both still fail the suite.
The baseline records 4 occurrences of the GdImage|false assignment,
but the exif orientation handling has grown to 7 imagerotate() calls
since, so the analysis failed on the count mismatch rather than on any
new error. This has been failing on main since at least March 2026.
The workflow only watched phpstan.neon.dist, so a commit that touches
just the baseline never triggered the analysis. That is also why the
stale count in the baseline could go unnoticed for months.
@NickBevers
NickBevers requested a review from freekmurze August 18, 2026 08:06
@freekmurze
freekmurze merged commit f6f2c78 into main Aug 18, 2026
10 checks passed
@freekmurze

Copy link
Copy Markdown
Member

Thanks!

@freekmurze
freekmurze deleted the fix/avif-quality-inverted-imagick branch August 18, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants