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,71 @@
<?php
/**
* Tracks per-attachment optimisation history.
*
* Stored as a single postmeta key `_h4b_img_optim` containing:
* [
* 'version' => '0.1.0',
* 'processed_at' => 'ISO-8601',
* 'tool_chain' => ['imagick','cjpeg','jpegoptim',...],
* 'sizes' => [
* 'full' => ['before'=>123, 'after'=>78, 'webp'=>45, 'avif'=>30, 'percent'=>37],
* '800x524' => [...],
* ],
* 'backup_path' => 'wp-content/h4b-img-originals/2026/02/...',
* 'errors' => [],
* ]
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class Attachment_Meta {
public const META_KEY = '_h4b_img_optim';
public static function get( int $attachment_id ): array {
$meta = get_post_meta( $attachment_id, self::META_KEY, true );
return is_array( $meta ) ? $meta : [];
}
public static function set( int $attachment_id, array $meta ): void {
update_post_meta( $attachment_id, self::META_KEY, $meta );
}
public static function is_processed( int $attachment_id ): bool {
$meta = self::get( $attachment_id );
return ! empty( $meta['processed_at'] );
}
public static function record_size( int $attachment_id, string $size_key, array $stats ): void {
$meta = self::get( $attachment_id );
$meta['sizes'] = $meta['sizes'] ?? [];
$meta['sizes'][ $size_key ] = $stats;
$meta['version'] = H4B_IMG_OPTIM_VERSION;
$meta['processed_at'] = current_time( 'c' );
self::set( $attachment_id, $meta );
}
public static function mark_avif_pending( int $attachment_id, string $size_key ): void {
$meta = self::get( $attachment_id );
$meta['avif_pending'] = $meta['avif_pending'] ?? [];
$meta['avif_pending'][ $size_key ] = current_time( 'c' );
self::set( $attachment_id, $meta );
}
public static function clear_avif_pending( int $attachment_id, string $size_key ): void {
$meta = self::get( $attachment_id );
if ( isset( $meta['avif_pending'][ $size_key ] ) ) {
unset( $meta['avif_pending'][ $size_key ] );
if ( empty( $meta['avif_pending'] ) ) {
unset( $meta['avif_pending'] );
}
self::set( $attachment_id, $meta );
}
}
}

283
includes/class-cli-bulk.php Normal file
View File

@@ -0,0 +1,283 @@
<?php
/**
* wp h4b-img bulk — walk all image attachments and optimise unprocessed ones.
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class CLI_Bulk {
/**
* Optimise all image attachments.
*
* ## OPTIONS
*
* [--status=<status>]
* : unoptimised (default) | all
*
* [--mime=<mime>]
* : Comma-separated MIME types. Default: image/jpeg,image/png
*
* [--limit=<n>]
* : Maximum attachments to process. 0 = no limit.
*
* [--batch=<n>]
* : Batch size for memory hygiene. Default: from settings (10).
*
* [--pause=<seconds>]
* : Sleep between batches. Default: from settings (1).
*
* [--dry-run]
* : Report counts only.
*
* [--force]
* : Re-optimise even if already processed.
*
* [--id=<id>]
* : Process a single attachment (alias for `optimise --id=X`).
*
* ## EXAMPLES
* wp h4b-img bulk --dry-run
* wp h4b-img bulk --status=unoptimised
* wp h4b-img bulk --status=all --force --limit=50
*/
public function __invoke( $args, $assoc ): void {
$status = $assoc['status'] ?? 'unoptimised';
$mime = isset( $assoc['mime'] )
? array_map( 'trim', explode( ',', $assoc['mime'] ) )
: [ 'image/jpeg', 'image/png' ];
$limit = (int) ( $assoc['limit'] ?? 0 );
$batch = (int) ( $assoc['batch'] ?? Settings::get( 'bulk_batch_size', 10 ) );
$pause = (int) ( $assoc['pause'] ?? Settings::get( 'bulk_pause_seconds', 1 ) );
$dry_run = ! empty( $assoc['dry-run'] );
$force = ! empty( $assoc['force'] );
$ids = self::find_candidates( $status, $mime, $limit );
$total = count( $ids );
\WP_CLI::log( sprintf(
'Found %d attachment(s) matching status=%s mime=%s',
$total, $status, implode( ',', $mime )
) );
if ( $dry_run ) {
\WP_CLI::success( 'Dry run only — no changes made.' );
return;
}
if ( $total === 0 ) {
\WP_CLI::success( 'Nothing to do.' );
return;
}
$progress = \WP_CLI\Utils\make_progress_bar( 'Optimising', $total );
$summary = [
'attachments' => 0,
'sizes' => 0,
'skipped' => 0,
'errors' => 0,
'bytes_before' => 0,
'bytes_after' => 0,
];
$processed_since_pause = 0;
foreach ( $ids as $id ) {
$result = self::process_attachment( (int) $id, $force );
$summary['attachments']++;
$summary['sizes'] += $result['sizes_processed'];
$summary['skipped'] += $result['sizes_skipped'];
$summary['errors'] += $result['errors'];
$summary['bytes_before'] += $result['bytes_before'];
$summary['bytes_after'] += $result['bytes_after'];
$progress->tick();
$processed_since_pause++;
if ( $processed_since_pause >= $batch ) {
if ( $pause > 0 ) {
sleep( $pause );
}
$processed_since_pause = 0;
// Free memory accumulated by Imagick + WP postmeta cache
wp_cache_flush_runtime();
}
}
$progress->finish();
$saved = $summary['bytes_before'] - $summary['bytes_after'];
$pct = $summary['bytes_before'] > 0
? round( $saved / $summary['bytes_before'] * 100, 1 )
: 0;
\WP_CLI::success( sprintf(
"Bulk done.\n Attachments: %d\n Sizes processed: %d\n Sizes skipped (small): %d\n Errors: %d\n Bytes before: %s\n Bytes after: %s\n Saved: %s (%s%%)",
$summary['attachments'],
$summary['sizes'],
$summary['skipped'],
$summary['errors'],
size_format( $summary['bytes_before'], 2 ),
size_format( $summary['bytes_after'], 2 ),
size_format( $saved, 2 ),
$pct
) );
}
/**
* Find attachment IDs to process.
*
* @return int[]
*/
private static function find_candidates( string $status, array $mime, int $limit ): array {
global $wpdb;
$mime_placeholders = implode( ',', array_fill( 0, count( $mime ), '%s' ) );
$base_query = "SELECT p.ID FROM {$wpdb->posts} p
WHERE p.post_type = 'attachment'
AND p.post_mime_type IN ($mime_placeholders)";
if ( $status === 'unoptimised' ) {
// LEFT JOIN postmeta; pick rows where our meta is missing or status pending
$base_query = "SELECT p.ID FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm
ON pm.post_id = p.ID AND pm.meta_key = %s
WHERE p.post_type = 'attachment'
AND p.post_mime_type IN ($mime_placeholders)
AND pm.meta_id IS NULL";
$query_args = array_merge( [ Attachment_Meta::META_KEY ], $mime );
} else {
$query_args = $mime;
}
$base_query .= ' ORDER BY p.ID ASC';
if ( $limit > 0 ) {
$base_query .= ' LIMIT ' . (int) $limit;
}
$sql = $wpdb->prepare( $base_query, ...$query_args );
return array_map( 'intval', $wpdb->get_col( $sql ) );
}
/**
* Process one attachment.
*
* @return array{sizes_processed:int, sizes_skipped:int, errors:int, bytes_before:int, bytes_after:int}
*/
private static function process_attachment( int $id, bool $force ): array {
$summary = [
'sizes_processed' => 0,
'sizes_skipped' => 0,
'errors' => 0,
'bytes_before' => 0,
'bytes_after' => 0,
];
if ( ! $force && Attachment_Meta::is_processed( $id ) ) {
$summary['sizes_skipped']++;
return $summary;
}
$metadata = wp_get_attachment_metadata( $id );
$relative = get_post_meta( $id, '_wp_attached_file', true );
if ( ! $metadata || ! $relative ) {
$summary['errors']++;
return $summary;
}
$uploads = wp_get_upload_dir();
$basedir = trailingslashit( $uploads['basedir'] );
$full = $basedir . $relative;
if ( ! is_readable( $full ) ) {
$summary['errors']++;
return $summary;
}
// Process full + every size
$paths = [ 'full' => $full ];
$dir = dirname( $full );
foreach ( ( $metadata['sizes'] ?? [] ) as $size_key => $size_data ) {
if ( empty( $size_data['file'] ) ) {
continue;
}
$paths[ (string) $size_key ] = trailingslashit( $dir ) . $size_data['file'];
}
foreach ( $paths as $size_key => $path ) {
if ( ! is_readable( $path ) ) {
$summary['errors']++;
continue;
}
$before = filesize( $path );
$summary['bytes_before'] += $before;
$opt = Optimizer::optimise( $path );
if ( $opt['status'] === 'done' ) {
$summary['sizes_processed']++;
} elseif ( $opt['status'] === 'skipped' ) {
$summary['sizes_skipped']++;
} else {
$summary['errors']++;
}
// Siblings
$webp_size = null;
$avif_status = 'skipped';
$avif_size = null;
if ( in_array( $opt['status'], [ 'done', 'skipped' ], true ) ) {
if ( Settings::get( 'generate_webp', true ) ) {
$w = Format_Generator::make_webp( $path );
if ( $w['status'] === 'done' ) {
$webp_size = $w['size'];
}
}
if ( Settings::get( 'generate_avif', true ) ) {
$a = Format_Generator::make_avif( $path );
$avif_status = $a['status'];
if ( $a['status'] === 'done' ) {
$avif_size = $a['size'];
} elseif ( $a['status'] === 'queued' ) {
Attachment_Meta::mark_avif_pending( $id, (string) $size_key );
}
}
}
clearstatcache( true, $path );
$after = filesize( $path );
$summary['bytes_after'] += $after;
Attachment_Meta::record_size( $id, (string) $size_key, [
'status' => $opt['status'],
'before' => $before,
'after' => $after,
'percent' => $opt['percent'] ?? 0,
'icc_preserved' => $opt['icc_preserved'] ?? false,
'tool_chain' => $opt['tool_chain'] ?? [],
'webp' => $webp_size,
'avif' => $avif_size,
'avif_status' => $avif_status,
'backup' => $opt['backup_path'] ?? null,
'error' => $opt['error'] ?? null,
] );
// Update WP-core's filesize record so admin "media used" stats stay accurate
self::refresh_metadata_filesize( $id, (string) $size_key, $after );
}
return $summary;
}
/**
* Update _wp_attachment_metadata.sizes[<key>].filesize so WP admin stays accurate.
*/
private static function refresh_metadata_filesize( int $id, string $size_key, int $bytes ): void {
$meta = wp_get_attachment_metadata( $id );
if ( ! is_array( $meta ) ) {
return;
}
if ( $size_key === 'full' ) {
$meta['filesize'] = $bytes;
} elseif ( isset( $meta['sizes'][ $size_key ] ) ) {
$meta['sizes'][ $size_key ]['filesize'] = $bytes;
}
wp_update_attachment_metadata( $id, $meta );
}
}

View File

@@ -0,0 +1,299 @@
<?php
/**
* wp h4b-img rescue — regenerate Smush-mangled JPGs from their .webp twins.
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
use Imagick;
use ImagickException;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class CLI_Rescue {
/**
* Find or fix JPGs damaged by Smush Ultra's grey-wash bug.
*
* ## OPTIONS
*
* [--scan]
* : Just scan; report counts. Default if neither --scan nor --apply given.
*
* [--apply]
* : Actually regenerate broken JPGs from their .webp siblings.
*
* [--min-severity=<float>]
* : Only rescue files with severity >= this. Default 10.0
*
* [--limit=<n>]
* : Maximum files to process. 0 = unlimited.
*
* [--csv=<path>]
* : Write per-file scan results to CSV.
*
* [--manifest=<path>]
* : Write JSON manifest of rescue actions.
*
* [--backup-dir=<path>]
* : Where to put pre-rescue backups. Default: wp-content/h4b-img-originals/_rescue_<timestamp>/
*
* ## EXAMPLES
* wp h4b-img rescue --scan
* wp h4b-img rescue --apply --min-severity=20 --limit=10
* wp h4b-img rescue --apply --csv=/tmp/scan.csv --manifest=/tmp/manifest.json
*/
public function __invoke( $args, $assoc ): void {
$apply = ! empty( $assoc['apply'] );
$min_sev = (float) ( $assoc['min-severity'] ?? 10.0 );
$limit = (int) ( $assoc['limit'] ?? 0 );
$csv_path = $assoc['csv'] ?? null;
$manifest_path = $assoc['manifest'] ?? null;
$backup_dir = $assoc['backup-dir'] ?? null;
$uploads = wp_get_upload_dir();
$basedir = trailingslashit( $uploads['basedir'] );
// Smush's WebP siblings live in wp-content/smush-webp/, NOT inside uploads/.
// Mirror tree: uploads/2026/02/foo.jpg → smush-webp/2026/02/foo.jpg.webp
$webp_root = trailingslashit( WP_CONTENT_DIR ) . 'smush-webp/';
if ( ! is_dir( $webp_root ) ) {
\WP_CLI::error( "WebP root not found: $webp_root — rescue requires Smush's smush-webp/ directory to read clean WebP twins from." );
}
// Scan phase
\WP_CLI::log( "Scanning $basedir for .jpg files…" );
$jpgs = self::find_jpgs( $basedir );
\WP_CLI::log( sprintf( 'Found %d JPG files.', count( $jpgs ) ) );
$progress = \WP_CLI\Utils\make_progress_bar( 'Analysing', count( $jpgs ) );
$results = [];
$broken_list = [];
foreach ( $jpgs as $jpg ) {
$rel = substr( $jpg, strlen( $basedir ) );
$webp = $webp_root . $rel . '.webp';
// Also try the symmetric layout where webp is alongside the jpg
if ( ! is_readable( $webp ) ) {
$webp_alt = $jpg . '.webp';
if ( is_readable( $webp_alt ) ) {
$webp = $webp_alt;
}
}
$res = Rescue_Detector::analyse( $jpg, $webp );
$res['path'] = $jpg;
$res['rel_path'] = $rel;
$res['has_webp'] = is_readable( $webp );
$results[] = $res;
if ( $res['classification'] === 'broken' && $res['severity'] >= $min_sev ) {
$broken_list[] = $res;
}
$progress->tick();
}
$progress->finish();
// Sort broken by severity desc
usort( $broken_list, fn( $a, $b ) => $b['severity'] <=> $a['severity'] );
if ( $limit > 0 ) {
$broken_list = array_slice( $broken_list, 0, $limit );
}
$by_class = [];
foreach ( $results as $r ) {
$by_class[ $r['classification'] ] = ( $by_class[ $r['classification'] ] ?? 0 ) + 1;
}
\WP_CLI::log( '' );
\WP_CLI::log( 'Classification summary:' );
foreach ( $by_class as $k => $v ) {
\WP_CLI::log( sprintf( ' %-35s %5d', $k, $v ) );
}
\WP_CLI::log( sprintf( ' → %d to rescue at severity >= %.1f', count( $broken_list ), $min_sev ) );
// CSV
if ( $csv_path ) {
$fh = fopen( $csv_path, 'w' );
fputcsv( $fh, [ 'path', 'classification', 'severity', 'delta_mean', 'jpg_stddev', 'webp_mean', 'jpg_mean', 'webp_white_pixels', 'has_webp', 'error' ] );
foreach ( $results as $r ) {
fputcsv( $fh, [
$r['rel_path'],
$r['classification'],
$r['severity'],
$r['delta_mean'],
$r['jpg_stddev'],
$r['webp_mean'],
$r['jpg_mean'],
$r['webp_white_pixels'],
$r['has_webp'] ? 1 : 0,
$r['error'],
] );
}
fclose( $fh );
\WP_CLI::log( "Scan CSV written: $csv_path" );
}
if ( ! $apply ) {
\WP_CLI::success( 'Scan complete. Use --apply to actually rescue.' );
return;
}
if ( empty( $broken_list ) ) {
\WP_CLI::success( 'Nothing to rescue.' );
return;
}
// Rescue phase
$timestamp = date( 'Ymd_His' );
$backup_dir = $backup_dir
?: ( $basedir . Optimizer::ORIGINALS_DIRNAME . '/_rescue_' . $timestamp );
wp_mkdir_p( $backup_dir );
\WP_CLI::log( "Backup dir: $backup_dir" );
$progress = \WP_CLI\Utils\make_progress_bar( 'Rescuing', count( $broken_list ) );
$actions = [];
$done = 0;
$errored = 0;
foreach ( $broken_list as $r ) {
$jpg = $r['path'];
// Re-resolve the WebP using the same fallback logic
$webp = $webp_root . $r['rel_path'] . '.webp';
if ( ! is_readable( $webp ) ) {
$webp_alt = $jpg . '.webp';
if ( is_readable( $webp_alt ) ) {
$webp = $webp_alt;
}
}
$backup = trailingslashit( $backup_dir ) . $r['rel_path'];
$res = self::rescue_one( $jpg, $webp, $backup );
$res['path'] = $jpg;
$res['severity'] = $r['severity'];
$actions[] = $res;
if ( $res['status'] === 'done' ) {
$done++;
} else {
$errored++;
}
$progress->tick();
}
$progress->finish();
if ( $manifest_path ) {
file_put_contents( $manifest_path, wp_json_encode( [
'timestamp' => $timestamp,
'backup_dir' => $backup_dir,
'min_severity' => $min_sev,
'total' => count( $actions ),
'done' => $done,
'errored' => $errored,
'actions' => $actions,
], JSON_PRETTY_PRINT ) );
\WP_CLI::log( "Manifest: $manifest_path" );
}
\WP_CLI::success( sprintf(
'Rescued %d / %d (errors: %d). Backups in %s',
$done, count( $broken_list ), $errored, $backup_dir
) );
}
/**
* @return string[]
*/
private static function find_jpgs( string $root ): array {
$out = [];
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS )
);
foreach ( $it as $entry ) {
/** @var \SplFileInfo $entry */
if ( ! $entry->isFile() ) {
continue;
}
$path = $entry->getPathname();
// Skip our own working directories
if ( strpos( $path, '/' . Optimizer::ORIGINALS_DIRNAME . '/' ) !== false
|| strpos( $path, '/smush-webp/' ) !== false ) {
continue;
}
$ext = strtolower( $entry->getExtension() );
if ( $ext === 'jpg' || $ext === 'jpeg' ) {
$out[] = $path;
}
}
return $out;
}
/**
* Rescue a single broken JPG by re-encoding from its clean WebP twin.
*
* @return array{status:string, new_size:int, original_size:int, error:?string, backup_path:string}
*/
private static function rescue_one( string $jpg, string $webp, string $backup_path ): array {
$res = [
'status' => 'pending',
'new_size' => 0,
'original_size' => is_readable( $jpg ) ? filesize( $jpg ) : 0,
'error' => null,
'backup_path' => $backup_path,
];
if ( ! is_readable( $webp ) ) {
$res['status'] = 'error';
$res['error'] = 'webp_not_readable';
return $res;
}
// Backup first
wp_mkdir_p( dirname( $backup_path ) );
if ( ! @copy( $jpg, $backup_path ) ) {
$res['status'] = 'error';
$res['error'] = 'backup_failed';
return $res;
}
if ( filesize( $backup_path ) !== $res['original_size'] ) {
$res['status'] = 'error';
$res['error'] = 'backup_size_mismatch';
return $res;
}
// Decode WebP → re-encode as JPEG q=90, 4:4:4, sRGB, ICC preserved
try {
$img = new Imagick( $webp );
$img->setImageFormat( 'jpeg' );
$img->setImageCompressionQuality( 90 );
$img->setSamplingFactors( [ '1x1', '1x1', '1x1' ] ); // 4:4:4 — no chroma loss
$img->setInterlaceScheme( Imagick::INTERLACE_NO );
$img->setImageColorspace( Imagick::COLORSPACE_SRGB );
// Ensure an ICC profile is attached
ICC_Profile::preserve_or_inject( $img );
// Atomic write
$tmp = $jpg . '.h4b.tmp';
$img->writeImage( $tmp );
$img->clear();
$stat = stat( $jpg );
if ( $stat !== false ) {
@chown( $tmp, $stat['uid'] );
@chgrp( $tmp, $stat['gid'] );
@chmod( $tmp, $stat['mode'] & 0777 );
}
rename( $tmp, $jpg );
} catch ( ImagickException $e ) {
$res['status'] = 'error';
$res['error'] = 'reencode_failed: ' . $e->getMessage();
// Best-effort restore from backup
@copy( $backup_path, $jpg );
return $res;
}
clearstatcache( true, $jpg );
$res['new_size'] = filesize( $jpg );
$res['status'] = 'done';
return $res;
}
}

180
includes/class-cli.php Normal file
View File

@@ -0,0 +1,180 @@
<?php
/**
* WP-CLI command: wp h4b-img <sub>
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class CLI {
public static function register(): void {
\WP_CLI::add_command( 'h4b-img', __CLASS__ );
\WP_CLI::add_command( 'h4b-img bulk', CLI_Bulk::class );
\WP_CLI::add_command( 'h4b-img rescue', CLI_Rescue::class );
}
/**
* Show tool detection + plugin status.
*
* ## EXAMPLES
* wp h4b-img status
*/
public function status( $args, $assoc ): void {
$tools = Tools::detect();
\WP_CLI::log( 'h4b-image-optim v' . H4B_IMG_OPTIM_VERSION );
\WP_CLI::log( '' );
\WP_CLI::log( 'External tools:' );
foreach ( $tools as $name => $path ) {
$mark = $path ? '✓' : '✗';
\WP_CLI::log( sprintf( ' %s %-12s %s', $mark, $name, $path ?: '(not found)' ) );
}
$missing = Tools::missing_required();
if ( ! empty( $missing ) ) {
\WP_CLI::warning( 'Missing required tools: ' . implode( ', ', $missing ) );
}
\WP_CLI::log( '' );
\WP_CLI::log( 'Settings:' );
foreach ( Settings::all() as $k => $v ) {
if ( is_array( $v ) ) {
$v = '[' . implode( ',', $v ) . ']';
} elseif ( is_bool( $v ) ) {
$v = $v ? 'true' : 'false';
}
\WP_CLI::log( sprintf( ' %-28s %s', $k, $v ) );
}
\WP_CLI::log( '' );
\WP_CLI::log( 'Originals dir: ' . Optimizer::originals_root() );
\WP_CLI::log( ' exists: ' . ( is_dir( Optimizer::originals_root() ) ? 'yes' : 'no' ) );
}
/**
* Optimise a single attachment by ID.
*
* ## OPTIONS
*
* --id=<id>
* : Attachment post ID.
*
* [--dry-run]
* : Report what would happen without writing.
*
* ## EXAMPLES
* wp h4b-img optimise --id=12345
* wp h4b-img optimise --id=12345 --dry-run
*/
public function optimise( $args, $assoc ): void {
$id = (int) ( $assoc['id'] ?? 0 );
if ( $id <= 0 ) {
\WP_CLI::error( '--id required' );
}
$dry_run = ! empty( $assoc['dry-run'] );
$metadata = wp_get_attachment_metadata( $id );
if ( ! $metadata ) {
\WP_CLI::error( "No metadata for attachment $id" );
}
$uploads = wp_get_upload_dir();
$basedir = trailingslashit( $uploads['basedir'] );
$relative = get_post_meta( $id, '_wp_attached_file', true );
if ( ! $relative ) {
\WP_CLI::error( "Attachment $id has no _wp_attached_file" );
}
$full_path = $basedir . $relative;
if ( ! is_readable( $full_path ) ) {
\WP_CLI::error( "Not readable: $full_path" );
}
\WP_CLI::log( "Attachment $id: $relative" );
\WP_CLI::log( ' full path: ' . $full_path );
$summary = [
'full' => self::run_one( $id, 'full', $full_path, $dry_run ),
];
$dir = dirname( $full_path );
foreach ( ( $metadata['sizes'] ?? [] ) as $size_key => $size_data ) {
if ( empty( $size_data['file'] ) ) {
continue;
}
$path = trailingslashit( $dir ) . $size_data['file'];
if ( ! is_readable( $path ) ) {
continue;
}
$summary[ $size_key ] = self::run_one( $id, (string) $size_key, $path, $dry_run );
}
// Tabular summary
$rows = [];
foreach ( $summary as $size => $r ) {
$rows[] = [
'size' => $size,
'status' => $r['status'] ?? '',
'before' => $r['before'] ?? 0,
'after' => $r['after'] ?? 0,
'pct' => $r['percent'] ?? 0,
'icc' => ! empty( $r['icc_preserved'] ) ? '✓' : '',
'webp' => $r['webp_size'] ?? '',
'avif' => $r['avif_status'] ?? '',
'error' => $r['error'] ?? '',
];
}
\WP_CLI\Utils\format_items( 'table', $rows, [ 'size', 'status', 'before', 'after', 'pct', 'icc', 'webp', 'avif', 'error' ] );
}
private static function run_one( int $id, string $size_key, string $path, bool $dry_run ): array {
if ( $dry_run ) {
return [
'status' => 'dry-run',
'before' => filesize( $path ),
'after' => filesize( $path ),
'percent' => 0,
];
}
$opt = Optimizer::optimise( $path );
$webp_stats = [ 'status' => 'skipped' ];
$avif_stats = [ 'status' => 'skipped' ];
$generate_siblings = in_array( $opt['status'], [ 'done', 'skipped' ], true );
if ( $generate_siblings && Settings::get( 'generate_webp', true ) ) {
$webp_stats = Format_Generator::make_webp( $path );
}
if ( $generate_siblings && Settings::get( 'generate_avif', true ) ) {
$avif_stats = Format_Generator::make_avif( $path );
}
$record = [
'status' => $opt['status'],
'before' => $opt['before'],
'after' => $opt['after'],
'percent' => $opt['percent'],
'icc_preserved' => $opt['icc_preserved'] ?? false,
'tool_chain' => $opt['tool_chain'] ?? [],
'webp' => $webp_stats['status'] === 'done' ? $webp_stats['size'] : null,
'avif' => $avif_stats['status'] === 'done' ? $avif_stats['size'] : null,
'avif_status' => $avif_stats['status'],
'backup' => $opt['backup_path'] ?? null,
'error' => $opt['error'] ?? null,
];
Attachment_Meta::record_size( $id, $size_key, $record );
return [
'status' => $opt['status'],
'before' => $opt['before'],
'after' => $opt['after'],
'percent' => $opt['percent'],
'icc_preserved' => $opt['icc_preserved'] ?? false,
'webp_size' => $webp_stats['status'] === 'done' ? $webp_stats['size'] : '',
'avif_status' => $avif_stats['status'],
'error' => $opt['error'] ?? null,
];
}
}

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;
}
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* ICC profile handling — the critical fix for the Smush grey-wash bug.
*
* If an image has an ICC profile, KEEP IT. Stripping ICC + applying YCbCr
* subsampling is what produced the grey-wash on rds.ink art.
*
* If an image has NO profile, the safest default is to embed a small sRGB v4
* profile so browsers don't fall back to vendor-specific assumptions.
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
use Imagick;
use ImagickException;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class ICC_Profile {
/**
* Path to the bundled sRGB v4 ICC profile.
* If missing, we silently skip injection (the file's pixels will just
* be interpreted as implicit sRGB by the browser).
*/
public static function srgb_profile_path(): string {
return H4B_IMG_OPTIM_DIR . 'assets/sRGB_v4_ICC_preference.icc';
}
/**
* Inspect an Imagick image and ensure an ICC profile is present.
* Does NOT modify the colour space — only attaches a profile.
*
* @return array{result:bool, source:string} result is true if a profile
* is present/added. source is one of: 'original','injected','none'.
*/
public static function preserve_or_inject( Imagick $img ): array {
try {
$profiles = $img->getImageProfiles( 'icc', true );
} catch ( ImagickException $e ) {
$profiles = [];
}
if ( ! empty( $profiles ) ) {
// Source has an ICC profile — leave it alone. This is the
// common case for camera JPEGs and properly-saved Photoshop exports.
return [ 'result' => true, 'source' => 'original' ];
}
// No profile. Try to inject sRGB v4 fallback.
$profile_path = self::srgb_profile_path();
if ( ! is_readable( $profile_path ) ) {
return [ 'result' => false, 'source' => 'none' ];
}
try {
$img->setImageColorspace( Imagick::COLORSPACE_SRGB );
$img->profileImage( 'icc', (string) file_get_contents( $profile_path ) );
return [ 'result' => true, 'source' => 'injected' ];
} catch ( ImagickException $e ) {
return [ 'result' => false, 'source' => 'none' ];
}
}
}

View File

@@ -0,0 +1,353 @@
<?php
/**
* Core optimisation pipeline.
*
* For each image:
* 1. Backup the original to wp-content/h4b-img-originals/<rel-path>
* 2. Optionally rotate based on EXIF Orientation
* 3. Re-encode with ICC preserved and 4:4:4 chroma (the bug fix)
* 4. Pass through jpegoptim/pngquant for lossless tail
* 5. Queue WebP + AVIF sibling generation (Format_Generator)
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
use Imagick;
use ImagickException;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class Optimizer {
public const ORIGINALS_DIRNAME = 'h4b-img-originals';
public static function ensure_dirs(): void {
$root = self::originals_root();
if ( ! is_dir( $root ) ) {
wp_mkdir_p( $root );
}
// Prevent any web-side access; originals are operations-only.
$ht = $root . '/.htaccess';
if ( ! file_exists( $ht ) ) {
file_put_contents( $ht, "Order allow,deny\nDeny from all\n" );
}
}
public static function originals_root(): string {
$uploads = wp_get_upload_dir();
return trailingslashit( $uploads['basedir'] ) . self::ORIGINALS_DIRNAME;
}
/**
* Optimise one file in place.
*
* @param string $path Absolute path to a JPEG or PNG file under uploads/.
* @param array $opts Override settings (jpeg_quality, etc).
* @return array{
* status:string,
* path:string,
* mime:string,
* before:int,
* after:int,
* percent:float,
* icc_preserved:bool,
* backup_path:?string,
* tool_chain:array<string>,
* error:?string
* }
*/
public static function optimise( string $path, array $opts = [] ): array {
$result = [
'status' => 'pending',
'path' => $path,
'mime' => '',
'before' => 0,
'after' => 0,
'percent' => 0.0,
'icc_preserved' => false,
'backup_path' => null,
'tool_chain' => [],
'error' => null,
];
if ( ! is_readable( $path ) || ! is_writable( $path ) ) {
$result['status'] = 'error';
$result['error'] = 'not_readable_or_writable';
return $result;
}
$settings = array_replace( Settings::all(), $opts );
$result['before'] = filesize( $path );
$mime = self::detect_mime( $path );
$result['mime'] = $mime;
if ( ! in_array( $mime, [ 'image/jpeg', 'image/png' ], true ) ) {
$result['status'] = 'skipped';
$result['error'] = 'unsupported_mime:' . $mime;
return $result;
}
// Don't re-encode small thumbnails. JPEG q=85 + ICC profile on a tiny
// file INCREASES size (2.5KB profile is huge relative to <20KB images).
// At those dimensions humans can't see chroma artefacts anyway, so the
// Smush-bug fix isn't relevant. WebP/AVIF siblings still get made.
// 20KB threshold matches WordPress's typical thumbnail/medium sizes.
$threshold = (int) ( $settings['min_optimise_bytes'] ?? 20480 );
if ( $result['before'] < $threshold ) {
$result['status'] = 'skipped';
$result['error'] = 'too_small_keep_as_is';
$result['after'] = $result['before'];
return $result;
}
// 1. Backup the original (atomic copy, only once per file)
if ( ! empty( $settings['backup_originals'] ) ) {
$backup = self::backup( $path );
if ( $backup === null ) {
$result['status'] = 'error';
$result['error'] = 'backup_failed';
return $result;
}
$result['backup_path'] = $backup;
}
// 2. Re-encode via Imagick (handles ICC + orientation cleanly)
try {
$reencoded = self::reencode( $path, $mime, $settings );
} catch ( \Throwable $e ) {
$result['status'] = 'error';
$result['error'] = 'reencode_failed: ' . $e->getMessage();
return $result;
}
$result['icc_preserved'] = $reencoded['icc_preserved'];
$result['tool_chain'][] = 'imagick';
// 3. Lossless tail pass to squeeze the last few percent
if ( $mime === 'image/jpeg' && Tools::path( 'jpegoptim' ) ) {
self::jpegoptim_pass( $path, $settings );
$result['tool_chain'][] = 'jpegoptim';
}
if ( $mime === 'image/png' && Tools::path( 'pngquant' ) ) {
self::pngquant_pass( $path, $settings );
$result['tool_chain'][] = 'pngquant';
}
clearstatcache( true, $path );
$result['after'] = filesize( $path );
$result['percent'] = $result['before'] > 0
? round( ( $result['before'] - $result['after'] ) / $result['before'] * 100, 2 )
: 0.0;
$result['status'] = 'done';
return $result;
}
/**
* Re-encode through Imagick to enforce: ICC preserved, 4:4:4 chroma,
* EXIF orientation applied, sRGB colorspace.
*
* @return array{icc_preserved:bool}
*/
private static function reencode( string $path, string $mime, array $settings ): array {
$img = new Imagick( $path );
// Apply EXIF orientation so the raw pixels are upright, then strip orientation tag.
try {
$orient = $img->getImageOrientation();
switch ( $orient ) {
case Imagick::ORIENTATION_BOTTOMRIGHT:
$img->rotateImage( 'transparent', 180 ); break;
case Imagick::ORIENTATION_RIGHTTOP:
$img->rotateImage( 'transparent', 90 ); break;
case Imagick::ORIENTATION_LEFTBOTTOM:
$img->rotateImage( 'transparent', -90 ); break;
}
if ( $orient !== Imagick::ORIENTATION_TOPLEFT ) {
$img->setImageOrientation( Imagick::ORIENTATION_TOPLEFT );
}
} catch ( ImagickException $e ) {
// Some images have no orientation tag — that's fine.
}
// Preserve ICC profile — the critical fix
$icc = ICC_Profile::preserve_or_inject( $img );
$icc_ok = $icc['result'];
$had_source_profile = $icc['source'] === 'original';
// Strip EXIF privacy fields but KEEP ICC profile.
// Imagick stripImage() removes everything including ICC, so we don't call it.
// Instead we remove specific profiles selectively.
if ( ! empty( $settings['strip_gps_exif'] ) ) {
try { $img->removeImageProfile( 'exif' ); } catch ( ImagickException $e ) {}
// Note: this drops ALL exif including camera. For art use-case we
// might want to re-add ICC. ICC is already attached above.
}
if ( $mime === 'image/jpeg' ) {
$quality = (int) ( $settings['jpeg_quality'] ?? 85 );
// For small thumbnails, the grey-wash bug doesn't matter (browser
// renders them too small to see ringing). Use the standard 4:2:0
// subsampling to keep file size reasonable. For larger images use
// 4:4:4 (the bug-fix subsampling). Threshold: 400x400 = 160000 px.
$width = $img->getImageWidth();
$height = $img->getImageHeight();
$is_small = ( $width * $height ) < 160000;
$subsamp = $is_small
? '4:2:0'
: ( $settings['jpeg_chroma_subsampling'] ?? '4:4:4' );
$img->setImageCompressionQuality( $quality );
$img->setSamplingFactors( self::sampling_factors( $subsamp ) );
$img->setInterlaceScheme( Imagick::INTERLACE_NO );
$img->setImageColorspace( Imagick::COLORSPACE_SRGB );
// Skip ICC injection on tiny files — the profile costs 2.5KB which
// dwarfs the actual image bytes for thumbnails under 20KB
if ( $is_small && empty( $had_source_profile ) ) {
try { $img->removeImageProfile( 'icc' ); } catch ( ImagickException $e ) {}
}
} elseif ( $mime === 'image/png' ) {
// Lossless PNG via Imagick — actual compression happens in pngquant pass.
$img->setImageCompressionQuality( 95 );
}
// Write back to the same path atomically
$tmp = $path . '.h4b.tmp';
if ( $mime === 'image/jpeg' ) {
$img->writeImage( $tmp );
} else {
$img->writeImage( $tmp );
}
$img->clear();
$img->destroy();
// Preserve original ownership and mtime
$stat = stat( $path );
if ( $stat !== false ) {
@chown( $tmp, $stat['uid'] );
@chgrp( $tmp, $stat['gid'] );
@chmod( $tmp, $stat['mode'] & 0777 );
}
rename( $tmp, $path );
return [ 'icc_preserved' => $icc_ok ];
}
private static function sampling_factors( string $mode ): array {
switch ( $mode ) {
case '4:2:0': return [ '2x2', '1x1', '1x1' ];
case '4:2:2': return [ '2x1', '1x1', '1x1' ];
case '4:4:4':
default: return [ '1x1', '1x1', '1x1' ];
}
}
private static function jpegoptim_pass( string $path, array $settings ): void {
$bin = Tools::path( 'jpegoptim' );
if ( ! $bin ) {
return;
}
// --strip-none keeps EXIF + ICC intact. -P preserves file timestamps.
// -o overwrites only if smaller. -q quiet.
$cmd = sprintf(
'%s --strip-none -P -o -q -m%d %s 2>&1',
escapeshellcmd( $bin ),
(int) ( $settings['jpeg_quality'] ?? 85 ),
escapeshellarg( $path )
);
@shell_exec( $cmd );
}
private static function pngquant_pass( string $path, array $settings ): void {
$bin = Tools::path( 'pngquant' );
if ( ! $bin ) {
return;
}
$min = (int) ( $settings['png_quality_min'] ?? 70 );
$max = (int) ( $settings['png_quality_max'] ?? 90 );
$cmd = sprintf(
'%s --force --skip-if-larger --speed 3 --quality=%d-%d --output %s -- %s 2>&1',
escapeshellcmd( $bin ),
$min,
$max,
escapeshellarg( $path . '.pngq.tmp' ),
escapeshellarg( $path )
);
@shell_exec( $cmd );
if ( is_readable( $path . '.pngq.tmp' ) && filesize( $path . '.pngq.tmp' ) > 0 ) {
$stat = stat( $path );
if ( $stat !== false ) {
@chown( $path . '.pngq.tmp', $stat['uid'] );
@chgrp( $path . '.pngq.tmp', $stat['gid'] );
@chmod( $path . '.pngq.tmp', $stat['mode'] & 0777 );
}
rename( $path . '.pngq.tmp', $path );
}
}
private static function detect_mime( string $path ): string {
$info = @getimagesize( $path );
return $info['mime'] ?? '';
}
/**
* Copy the original to wp-content/h4b-img-originals/<relative>, preserving mtime.
* Skips if a backup already exists (don't overwrite the *original* with a re-processed file).
*/
private static function backup( string $path ): ?string {
$uploads = wp_get_upload_dir();
$basedir = trailingslashit( $uploads['basedir'] );
if ( strpos( $path, $basedir ) !== 0 ) {
return null; // outside uploads/, refuse
}
$rel = substr( $path, strlen( $basedir ) );
$dest = trailingslashit( self::originals_root() ) . $rel;
if ( file_exists( $dest ) ) {
return $dest; // already backed up — don't overwrite
}
wp_mkdir_p( dirname( $dest ) );
if ( ! copy( $path, $dest ) ) {
return null;
}
// Preserve mtime so prune_originals can age files reliably
@touch( $dest, filemtime( $path ) );
return $dest;
}
/**
* Cron callback: delete originals older than settings.backup_prune_days.
*/
public static function prune_originals(): void {
$days = (int) Settings::get( 'backup_prune_days', 90 );
if ( $days <= 0 ) {
return;
}
$cutoff = time() - $days * DAY_IN_SECONDS;
$root = self::originals_root();
if ( ! is_dir( $root ) ) {
return;
}
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ),
\RecursiveIteratorIterator::CHILD_FIRST
);
$deleted = 0;
foreach ( $it as $entry ) {
/** @var \SplFileInfo $entry */
if ( $entry->isFile() && $entry->getMTime() < $cutoff ) {
@unlink( $entry->getPathname() );
$deleted++;
} elseif ( $entry->isDir() ) {
@rmdir( $entry->getPathname() ); // succeeds only if empty
}
}
do_action( 'h4b_img_originals_pruned', $deleted, $days );
}
}

80
includes/class-plugin.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
/**
* Plugin loader. Wires hooks; defers heavy work until needed.
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
require_once H4B_IMG_OPTIM_DIR . 'includes/class-tools.php';
require_once H4B_IMG_OPTIM_DIR . 'includes/class-settings.php';
require_once H4B_IMG_OPTIM_DIR . 'includes/class-icc-profile.php';
require_once H4B_IMG_OPTIM_DIR . 'includes/class-attachment-meta.php';
require_once H4B_IMG_OPTIM_DIR . 'includes/class-optimizer.php';
require_once H4B_IMG_OPTIM_DIR . 'includes/class-format-generator.php';
require_once H4B_IMG_OPTIM_DIR . 'includes/class-uploader-hook.php';
require_once H4B_IMG_OPTIM_DIR . 'includes/class-rescue-detector.php';
require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli.php';
require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-bulk.php';
require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-rescue.php';
final class Plugin {
private static ?self $instance = null;
public static function instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
public function boot(): void {
// Settings load first — everything else reads them.
Settings::register();
// Activation / deactivation
register_activation_hook( H4B_IMG_OPTIM_FILE, [ $this, 'on_activate' ] );
register_deactivation_hook( H4B_IMG_OPTIM_FILE, [ $this, 'on_deactivate' ] );
// Upload pipeline
Uploader_Hook::register();
// Background AVIF queue (WP-Cron)
Format_Generator::register_cron();
// Backup pruning cron
add_action( 'h4b_img_prune_originals', [ Optimizer::class, 'prune_originals' ] );
add_action( 'init', [ $this, 'maybe_schedule_cron' ] );
// WP-CLI
if ( defined( 'WP_CLI' ) && \WP_CLI ) {
CLI::register();
}
}
public function on_activate(): void {
Settings::install_defaults();
if ( ! wp_next_scheduled( 'h4b_img_prune_originals' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'h4b_img_prune_originals' );
}
// Make sure the originals dir + .htaccess exist
Optimizer::ensure_dirs();
}
public function on_deactivate(): void {
wp_clear_scheduled_hook( 'h4b_img_prune_originals' );
wp_clear_scheduled_hook( 'h4b_img_generate_avif' );
}
public function maybe_schedule_cron(): void {
if ( ! wp_next_scheduled( 'h4b_img_prune_originals' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'h4b_img_prune_originals' );
}
}
}

View File

@@ -0,0 +1,161 @@
<?php
/**
* PHP port of the Python grey-wash detector used to find Smush-mangled JPGs.
*
* The detection strategy:
* 1. Find pixels in the .webp twin where all three RGB channels are
* >= WEBP_WHITE_RGB_MIN (i.e. should-be-white regions).
* 2. Sample those same pixel coordinates in the JPG.
* 3. If the JPG average there is significantly darker AND the stddev is
* high, classify as `broken` (the Smush grey-wash bug fingerprint).
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
use Imagick;
use ImagickPixel;
use ImagickException;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class Rescue_Detector {
// Calibrated thresholds (validated against rds.ink + dayboro + tawnytrails)
public const WEBP_WHITE_RGB_MIN = 248;
public const JPG_DARKER_THAN_WEBP = 10;
public const JPG_WHITE_STDDEV_BROKEN = 4.0;
public const MIN_WHITE_PIXELS = 100;
/**
* @return array{
* classification:string,
* severity:float,
* delta_mean:float,
* jpg_stddev:float,
* webp_mean:float,
* jpg_mean:float,
* webp_white_pixels:int,
* error:?string
* }
*/
public static function analyse( string $jpg_path, string $webp_path ): array {
$result = [
'classification' => 'unknown',
'severity' => 0.0,
'delta_mean' => 0.0,
'jpg_stddev' => 0.0,
'webp_mean' => 0.0,
'jpg_mean' => 0.0,
'webp_white_pixels' => 0,
'error' => null,
];
if ( ! is_readable( $jpg_path ) ) {
$result['classification'] = 'error';
$result['error'] = 'jpg_not_readable';
return $result;
}
if ( ! is_readable( $webp_path ) ) {
$result['classification'] = 'no_webp';
return $result;
}
try {
$jpg = new Imagick( $jpg_path );
$webp = new Imagick( $webp_path );
} catch ( ImagickException $e ) {
$result['classification'] = 'error';
$result['error'] = 'imagick_open: ' . $e->getMessage();
return $result;
}
// Resize webp to match jpg if needed (handles minor dimension drift)
$jw = $jpg->getImageWidth();
$jh = $jpg->getImageHeight();
if ( $webp->getImageWidth() !== $jw || $webp->getImageHeight() !== $jh ) {
$webp->resizeImage( $jw, $jh, Imagick::FILTER_LANCZOS, 1.0 );
}
// Pull raw pixel data as RGB bytes
try {
$jpg_bytes = $jpg->exportImagePixels( 0, 0, $jw, $jh, 'RGB', Imagick::PIXEL_CHAR );
$webp_bytes = $webp->exportImagePixels( 0, 0, $jw, $jh, 'RGB', Imagick::PIXEL_CHAR );
} catch ( ImagickException $e ) {
$jpg->clear(); $webp->clear();
$result['classification'] = 'error';
$result['error'] = 'pixel_export: ' . $e->getMessage();
return $result;
}
$jpg->clear();
$webp->clear();
$n_pixels = $jw * $jh;
$threshold = self::WEBP_WHITE_RGB_MIN;
$webp_white_count = 0;
$jpg_sum = 0.0;
$webp_sum = 0.0;
$jpg_sum_sq = 0.0;
// Iterate; PHP arrays from exportImagePixels are 0-indexed integers
for ( $i = 0; $i < $n_pixels; $i++ ) {
$base = $i * 3;
$wr = $webp_bytes[ $base ];
$wg = $webp_bytes[ $base + 1 ];
$wb = $webp_bytes[ $base + 2 ];
if ( $wr < $threshold || $wg < $threshold || $wb < $threshold ) {
continue;
}
$jr = $jpg_bytes[ $base ];
$jg = $jpg_bytes[ $base + 1 ];
$jb = $jpg_bytes[ $base + 2 ];
$jpg_mean_pixel = ( $jr + $jg + $jb ) / 3.0;
$webp_mean_pixel = ( $wr + $wg + $wb ) / 3.0;
$webp_white_count++;
$jpg_sum += $jpg_mean_pixel;
$webp_sum += $webp_mean_pixel;
$jpg_sum_sq += $jpg_mean_pixel * $jpg_mean_pixel;
}
$result['webp_white_pixels'] = $webp_white_count;
if ( $webp_white_count < self::MIN_WHITE_PIXELS ) {
$result['classification'] = 'no_white_area';
return $result;
}
$jpg_mean = $jpg_sum / $webp_white_count;
$webp_mean = $webp_sum / $webp_white_count;
$variance = ( $jpg_sum_sq / $webp_white_count ) - ( $jpg_mean * $jpg_mean );
$jpg_stddev = $variance > 0 ? sqrt( $variance ) : 0.0;
$result['jpg_mean'] = $jpg_mean;
$result['webp_mean'] = $webp_mean;
$result['delta_mean'] = $webp_mean - $jpg_mean;
$result['jpg_stddev'] = $jpg_stddev;
$is_darker = $result['delta_mean'] > self::JPG_DARKER_THAN_WEBP;
$is_mottled = $jpg_stddev > self::JPG_WHITE_STDDEV_BROKEN;
if ( $is_darker && $is_mottled ) {
$result['classification'] = 'broken';
$result['severity'] = $result['delta_mean']
* ( $jpg_stddev / self::JPG_WHITE_STDDEV_BROKEN );
} elseif ( $is_darker ) {
$result['classification'] = 'uniformly_darker';
$result['severity'] = $result['delta_mean'];
} elseif ( $is_mottled ) {
$result['classification'] = 'mottled_but_correct_brightness';
$result['severity'] = $jpg_stddev;
} else {
$result['classification'] = 'clean';
}
return $result;
}
}

View File

@@ -0,0 +1,89 @@
<?php
/**
* Settings — single option, schema in defaults().
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class Settings {
public const OPTION_KEY = 'h4b_image_optim_settings';
public static function register(): void {
// No admin UI in v0.1 — settings read-only via WP-CLI for now.
}
public static function defaults(): array {
return [
// Quality
'jpeg_quality' => 85,
'png_quality_min' => 70,
'png_quality_max' => 90,
'jpeg_chroma_subsampling' => '4:4:4', // preserve edges; the fix for the Smush bug
'preserve_icc_profile' => true, // CRITICAL — the bug fix
'strip_gps_exif' => true,
'strip_camera_exif' => false, // keep camera info for art provenance
// Format generation
'generate_webp' => true,
'generate_avif' => true, // design decision 1: on by default everywhere
'webp_quality' => 80,
'avif_quality' => 65, // ≈ JPEG q=85 visually
'avif_speed' => 6, // 0-10; 6 is balanced
'avif_async' => true, // background WP-Cron so upload UI is responsive
// Behaviour
'optimise_on_upload' => true,
'backup_originals' => true, // design decision 4: always
'backup_prune_days' => 90, // 0 = keep forever
'resize_max_width' => 2560,
'resize_max_height' => 2560,
'skip_already_processed' => true,
'min_optimise_bytes' => 20480, // skip files < 20KB (thumbnails)
// Serving
'rewrite_content_images' => true, // Picture tag rewriting
'use_htaccess_fallback' => true, // .htaccess content negotiation
// Bulk
'bulk_batch_size' => 10,
'bulk_pause_seconds' => 1,
// Exclusions
'exclude_mime_types' => [ 'image/svg+xml', 'image/x-icon', 'image/gif' ],
'exclude_paths_regex' => '',
];
}
public static function install_defaults(): void {
$existing = get_option( self::OPTION_KEY );
if ( false === $existing ) {
add_option( self::OPTION_KEY, self::defaults(), '', false );
return;
}
// Merge new keys without clobbering user changes
$merged = array_replace( self::defaults(), is_array( $existing ) ? $existing : [] );
update_option( self::OPTION_KEY, $merged, false );
}
public static function get( string $key, mixed $default = null ): mixed {
$settings = get_option( self::OPTION_KEY, self::defaults() );
return $settings[ $key ] ?? $default;
}
public static function set( string $key, mixed $value ): void {
$settings = get_option( self::OPTION_KEY, self::defaults() );
$settings[ $key ] = $value;
update_option( self::OPTION_KEY, $settings, false );
}
public static function all(): array {
return get_option( self::OPTION_KEY, self::defaults() );
}
}

68
includes/class-tools.php Normal file
View File

@@ -0,0 +1,68 @@
<?php
/**
* Detects + caches paths to the external image binaries.
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class Tools {
private const REQUIRED = [ 'cwebp', 'cjpeg', 'djpeg', 'jpegtran', 'jpegoptim' ];
private const OPTIONAL = [ 'avifenc', 'avifdec', 'pngquant', 'optipng' ];
/**
* @return array<string,string|null> Map of tool name → absolute path or null.
*/
public static function detect(): array {
static $cache = null;
if ( null !== $cache ) {
return $cache;
}
$cache = [];
foreach ( array_merge( self::REQUIRED, self::OPTIONAL ) as $bin ) {
$cache[ $bin ] = self::which( $bin );
}
return $cache;
}
public static function path( string $bin ): ?string {
$tools = self::detect();
return $tools[ $bin ] ?? null;
}
public static function missing_required(): array {
$tools = self::detect();
$missing = [];
foreach ( self::REQUIRED as $bin ) {
if ( empty( $tools[ $bin ] ) ) {
$missing[] = $bin;
}
}
return $missing;
}
public static function has_avif(): bool {
return ! empty( self::path( 'avifenc' ) );
}
private static function which( string $bin ): ?string {
// Common locations first, then fall back to PATH lookup.
foreach ( [ '/usr/bin/', '/usr/local/bin/', '/opt/local/bin/' ] as $prefix ) {
$candidate = $prefix . $bin;
if ( is_executable( $candidate ) ) {
return $candidate;
}
}
if ( ! function_exists( 'shell_exec' ) ) {
return null;
}
$out = trim( (string) @shell_exec( 'command -v ' . escapeshellarg( $bin ) . ' 2>/dev/null' ) );
return $out !== '' && is_executable( $out ) ? $out : null;
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* Hooks into the WP upload pipeline.
*
* Important: we hook into wp_generate_attachment_metadata so we run AFTER
* WP-core generates all the size variants. We then optimise each variant in
* place. This keeps WP's metadata structure intact.
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class Uploader_Hook {
public static function register(): void {
add_filter( 'wp_generate_attachment_metadata', [ self::class, 'on_metadata' ], 10, 2 );
}
/**
* @param array $metadata The freshly-generated _wp_attachment_metadata array.
* @param int $attachment_id Post ID of the attachment.
*/
public static function on_metadata( $metadata, $attachment_id ): array {
$metadata = is_array( $metadata ) ? $metadata : [];
if ( ! Settings::get( 'optimise_on_upload', true ) ) {
return $metadata;
}
if ( Attachment_Meta::is_processed( (int) $attachment_id )
&& Settings::get( 'skip_already_processed', true ) ) {
return $metadata;
}
$uploads = wp_get_upload_dir();
$basedir = trailingslashit( $uploads['basedir'] );
// File path of the original full-size image
$relative = $metadata['file'] ?? get_post_meta( $attachment_id, '_wp_attached_file', true );
if ( ! $relative ) {
return $metadata;
}
$full_path = $basedir . $relative;
if ( ! is_readable( $full_path ) ) {
return $metadata;
}
// Optimise the full file
self::process_single( (int) $attachment_id, 'full', $full_path );
// Optimise every generated size
$dir = dirname( $full_path );
foreach ( ( $metadata['sizes'] ?? [] ) as $size_key => $size_data ) {
if ( empty( $size_data['file'] ) ) {
continue;
}
$size_path = trailingslashit( $dir ) . $size_data['file'];
if ( ! is_readable( $size_path ) ) {
continue;
}
self::process_single( (int) $attachment_id, (string) $size_key, $size_path );
// Refresh filesize in metadata after re-encode
clearstatcache( true, $size_path );
$metadata['sizes'][ $size_key ]['filesize'] = filesize( $size_path );
}
// Refresh full filesize too
clearstatcache( true, $full_path );
$metadata['filesize'] = filesize( $full_path );
return $metadata;
}
private static function process_single( int $attachment_id, string $size_key, string $path ): void {
$opt = Optimizer::optimise( $path );
// Generate WebP/AVIF siblings regardless of whether Optimizer touched
// the source JPEG (e.g. it might have skipped tiny thumbnails).
// Sibling generation is still valuable for those.
$webp_stats = [ 'status' => 'skipped' ];
$avif_stats = [ 'status' => 'skipped' ];
$generate_siblings = in_array( $opt['status'], [ 'done', 'skipped' ], true );
if ( $generate_siblings && Settings::get( 'generate_webp', true ) ) {
$webp_stats = Format_Generator::make_webp( $path );
}
if ( $generate_siblings && Settings::get( 'generate_avif', true ) ) {
$avif_stats = Format_Generator::make_avif( $path );
if ( $avif_stats['status'] === 'queued' ) {
Attachment_Meta::mark_avif_pending( $attachment_id, $size_key );
}
}
Attachment_Meta::record_size( $attachment_id, $size_key, [
'status' => $opt['status'],
'before' => $opt['before'],
'after' => $opt['after'],
'percent' => $opt['percent'],
'icc_preserved' => $opt['icc_preserved'] ?? false,
'tool_chain' => $opt['tool_chain'] ?? [],
'webp' => $webp_stats['status'] === 'done' ? $webp_stats['size'] : null,
'avif' => $avif_stats['status'] === 'done' ? $avif_stats['size'] : null,
'avif_status' => $avif_stats['status'],
'backup' => $opt['backup_path'] ?? null,
'error' => $opt['error'] ?? null,
] );
}
}