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>
270 lines
8.0 KiB
PHP
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;
|
|
}
|
|
}
|