Replaces Smush Pro's optimisation pipeline without the grey-wash bug. CLI commands working: wp h4b-img status wp h4b-img optimise --id=<n> wp h4b-img bulk wp h4b-img rescue Verified on dev.rds.ink: - ICC profile preservation works (the Smush-bug fix) - Bulk: 20 attachments → 487 KB saved (10.4%), 0 errors - Rescue: end-to-end mechanism verified on WorkingAsOne_horse fixture - WebP synchronous, AVIF queued via WP-Cron - Originals backed up to wp-content/h4b-img-originals/ See CHANGELOG.md for details + ../DESIGN-h4b-image-optim.md for architecture. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
186 lines
5.0 KiB
PHP
186 lines
5.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;
|
|
}
|
|
|
|
return self::encode_avif_now( $source, $settings );
|
|
}
|
|
|
|
/**
|
|
* Cron entry point — encodes one queued path.
|
|
*/
|
|
public static function process_avif_job( string $source ): void {
|
|
if ( ! is_readable( $source ) ) {
|
|
return;
|
|
}
|
|
$settings = Settings::all();
|
|
self::encode_avif_now( $source, $settings );
|
|
}
|
|
|
|
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';
|
|
|
|
$cmd = sprintf(
|
|
'%s --min %d --max %d -s %d %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;
|
|
}
|
|
}
|