feat: initial v0.1.0 MVP

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>
This commit is contained in:
Henk
2026-05-19 13:41:03 +10:00
commit 7e1c86f215
19 changed files with 2498 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
<?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;
}
}