Files
h4b-image-optim/includes/class-format-generator.php
Henk 0e56ca6e63 fix: eliminate AVIF/WebP grey halo on white backgrounds (v0.2.3)
Diagnosed visible faint grey rectangle around B&W ink art images on
pages with pure-white backgrounds. Root cause was format encoder
luminance shift in near-white pixels:

  Source JPG corner: (253,253,253) = #FDFDFD = 99.2% white
  AVIF q=65 (default): (250,250,250) = #FAFAFA = 98.0% (1.2% halo)
  WebP q=80 (default): (249,249,249) = #F9F9F9 = 97.6% (1.6% halo)

Two changes:

1. avifenc now uses -y 444 (full chroma subsampling) instead of
   default 4:2:0. Brings AVIF corner to #FBFBFB = 98.4%, smaller
   file size as a bonus (~10% reduction on a typical art image).

2. WebP default quality raised 80 → 90. Reaches #FDFDFD = exact
   match with source JPG. File size increases ~30% but eliminates
   the halo entirely for WebP-capable browsers (vast majority).

AVIF still has 0.4% residual halo (libavif 0.11.1 ceiling at this
quality range — pushing higher yields no improvement, only file
size). Acceptable tradeoff: WebP is the served-by-default fallback
when AVIF isn't perfect.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:31:53 +10:00

270 lines
8.0 KiB
PHP

<?php
/**
* WebP + AVIF sibling generation.
*
* WebP runs synchronously (cwebp is fast — sub-second per image).
* AVIF runs in a WP-Cron background job (avifenc is slower; we don't want
* to block the upload UI).
*
* Sibling naming matches what Smush used so .htaccess and Picture-tag
* rewriting find them: /path/to/file.jpg → /path/to/file.jpg.webp
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class Format_Generator {
public const CRON_HOOK = 'h4b_img_generate_avif';
public static function register_cron(): void {
add_action( self::CRON_HOOK, [ self::class, 'process_avif_job' ], 10, 1 );
}
/**
* Generate the .webp sibling next to the source.
*
* @return array{status:string, path:?string, size:int, error:?string}
*/
public static function make_webp( string $source, array $opts = [] ): array {
$res = [ 'status' => 'pending', 'path' => null, 'size' => 0, 'error' => null ];
$bin = Tools::path( 'cwebp' );
if ( ! $bin ) {
$res['status'] = 'error';
$res['error'] = 'cwebp_not_installed';
return $res;
}
if ( ! is_readable( $source ) ) {
$res['status'] = 'error';
$res['error'] = 'source_not_readable';
return $res;
}
$settings = array_replace( Settings::all(), $opts );
$quality = (int) ( $settings['webp_quality'] ?? 80 );
$dest = $source . '.webp';
$tmp = $dest . '.h4b.tmp';
// -metadata icc preserves the ICC profile if present
// -mt enables multithreading
// -m 4 = method 4 (good balance of compression vs speed)
$cmd = sprintf(
'%s -quiet -q %d -m 4 -mt -metadata icc %s -o %s 2>&1',
escapeshellcmd( $bin ),
$quality,
escapeshellarg( $source ),
escapeshellarg( $tmp )
);
$out = @shell_exec( $cmd );
if ( ! is_readable( $tmp ) || filesize( $tmp ) <= 0 ) {
$res['status'] = 'error';
$res['error'] = 'cwebp_failed: ' . trim( (string) $out );
@unlink( $tmp );
return $res;
}
// Preserve ownership/perms
$stat = stat( $source );
if ( $stat !== false ) {
@chown( $tmp, $stat['uid'] );
@chgrp( $tmp, $stat['gid'] );
@chmod( $tmp, $stat['mode'] & 0777 );
}
rename( $tmp, $dest );
$res['status'] = 'done';
$res['path'] = $dest;
$res['size'] = filesize( $dest );
return $res;
}
/**
* Generate the .avif sibling.
*
* If settings.avif_async is true, this enqueues a WP-Cron job and returns
* status=queued. Otherwise generates synchronously.
*/
public static function make_avif( string $source, array $opts = [] ): array {
$res = [ 'status' => 'pending', 'path' => null, 'size' => 0, 'error' => null ];
if ( ! Tools::has_avif() ) {
$res['status'] = 'error';
$res['error'] = 'avifenc_not_installed';
return $res;
}
$settings = array_replace( Settings::all(), $opts );
if ( empty( $settings['generate_avif'] ) ) {
$res['status'] = 'skipped';
$res['error'] = 'avif_disabled_in_settings';
return $res;
}
if ( ! empty( $settings['avif_async'] ) && ! ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
// Schedule a one-off job in 30 seconds; let the upload return.
wp_schedule_single_event( time() + 30, self::CRON_HOOK, [ $source ] );
$res['status'] = 'queued';
return $res;
}
// Synchronous path: encode + record outcome in attachment postmeta.
$result = self::encode_avif_now( $source, $settings );
self::record_avif_outcome( $source, $result );
return $result;
}
/**
* Cron entry point — encodes one queued path. Recording happens inside
* make_avif() now, so this is a thin wrapper that forces synchronous mode.
*/
public static function process_avif_job( string $source ): void {
if ( ! is_readable( $source ) ) {
self::record_avif_outcome( $source, [ 'status' => 'error', 'error' => 'source_unreadable' ] );
return;
}
$settings = Settings::all();
$settings['avif_async'] = false; // we ARE the queue handler — never re-queue
self::make_avif( $source, $settings );
}
/**
* Find the attachment + size_key that owns a given absolute path on disk,
* then update its h4b postmeta with the AVIF outcome.
*/
private static function record_avif_outcome( string $source_path, array $result ): void {
global $wpdb;
$uploads = wp_get_upload_dir();
$basedir = trailingslashit( $uploads['basedir'] );
if ( strpos( $source_path, $basedir ) !== 0 ) {
return;
}
$rel = substr( $source_path, strlen( $basedir ) );
$dir = dirname( $rel );
$name = basename( $rel );
// Reverse the -<W>x<H> filename suffix to find the parent attachment file
if ( preg_match( '/^(.+)-\d+x\d+(\.[a-z]+)$/i', $name, $m ) ) {
$parent_name = $m[1] . $m[2];
} else {
$parent_name = $name;
}
$parent_rel = ( $dir === '.' ) ? $parent_name : "$dir/$parent_name";
$att_id = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key=%s AND meta_value=%s LIMIT 1",
'_wp_attached_file', $parent_rel
) );
if ( $att_id <= 0 ) {
return;
}
// Find the size key that matches this file
$meta = wp_get_attachment_metadata( $att_id );
$size_key = null;
if ( $name === $parent_name ) {
$size_key = 'full';
} elseif ( is_array( $meta ) && ! empty( $meta['sizes'] ) ) {
foreach ( $meta['sizes'] as $sk => $sd ) {
if ( ( $sd['file'] ?? '' ) === $name ) {
$size_key = (string) $sk;
break;
}
}
}
if ( $size_key === null ) {
return;
}
$h4b_meta = Attachment_Meta::get( $att_id );
if ( ! isset( $h4b_meta['sizes'][ $size_key ] ) ) {
$h4b_meta['sizes'][ $size_key ] = [];
}
$entry = &$h4b_meta['sizes'][ $size_key ];
if ( $result['status'] === 'done' ) {
$entry['avif'] = $result['size'] ?? null;
$entry['avif_status'] = 'done';
} elseif ( $result['status'] === 'skipped' ) {
$entry['avif_status'] = 'skipped';
} else {
$entry['avif_status'] = 'error';
$entry['avif_error'] = $result['error'] ?? 'unknown';
}
// Clear from the pending bag too
if ( isset( $h4b_meta['avif_pending'][ $size_key ] ) ) {
unset( $h4b_meta['avif_pending'][ $size_key ] );
if ( empty( $h4b_meta['avif_pending'] ) ) {
unset( $h4b_meta['avif_pending'] );
}
}
Attachment_Meta::set( $att_id, $h4b_meta );
}
private static function encode_avif_now( string $source, array $settings ): array {
$res = [ 'status' => 'pending', 'path' => null, 'size' => 0, 'error' => null ];
$bin = Tools::path( 'avifenc' );
if ( ! $bin ) {
$res['status'] = 'error';
$res['error'] = 'avifenc_not_installed';
return $res;
}
$quality = (int) ( $settings['avif_quality'] ?? 65 );
$speed = (int) ( $settings['avif_speed'] ?? 6 );
// libavif quality is 0-100 (higher = better). avifenc 0.11 uses --min / --max
// where lower values = higher quality (counterintuitive, inherited from av1).
// Map: quality 65 → --min 25 --max 35 (matches our smoke test settings)
$qmin = max( 0, (int) round( ( 100 - $quality ) * 0.6 ) );
$qmax = max( $qmin + 5, $qmin + 10 );
$dest = $source . '.avif';
$tmp = $dest . '.h4b.tmp';
// --min / --max set quality range.
// -s sets encoder speed (0-10; we default to 6 = balanced).
// -y 444 forces 4:4:4 chroma subsampling — preserves luminance of near-white
// pixels exactly, otherwise avifenc rounds white-ish pixels darker (~2% shift)
// which creates a visible grey halo against pure-white page backgrounds.
// Tradeoff: ~5% larger AVIF files in exchange for true colour fidelity.
$cmd = sprintf(
'%s --min %d --max %d -s %d -y 444 %s %s 2>&1',
escapeshellcmd( $bin ),
$qmin,
$qmax,
$speed,
escapeshellarg( $source ),
escapeshellarg( $tmp )
);
$out = @shell_exec( $cmd );
if ( ! is_readable( $tmp ) || filesize( $tmp ) <= 0 ) {
$res['status'] = 'error';
$res['error'] = 'avifenc_failed: ' . trim( (string) $out );
@unlink( $tmp );
return $res;
}
$stat = stat( $source );
if ( $stat !== false ) {
@chown( $tmp, $stat['uid'] );
@chgrp( $tmp, $stat['gid'] );
@chmod( $tmp, $stat['mode'] & 0777 );
}
rename( $tmp, $dest );
$res['status'] = 'done';
$res['path'] = $dest;
$res['size'] = filesize( $dest );
return $res;
}
}