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:
71
includes/class-attachment-meta.php
Normal file
71
includes/class-attachment-meta.php
Normal 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
283
includes/class-cli-bulk.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
299
includes/class-cli-rescue.php
Normal file
299
includes/class-cli-rescue.php
Normal 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
180
includes/class-cli.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
185
includes/class-format-generator.php
Normal file
185
includes/class-format-generator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
67
includes/class-icc-profile.php
Normal file
67
includes/class-icc-profile.php
Normal 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' ];
|
||||
}
|
||||
}
|
||||
}
|
||||
353
includes/class-optimizer.php
Normal file
353
includes/class-optimizer.php
Normal 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
80
includes/class-plugin.php
Normal 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' );
|
||||
}
|
||||
}
|
||||
}
|
||||
161
includes/class-rescue-detector.php
Normal file
161
includes/class-rescue-detector.php
Normal 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;
|
||||
}
|
||||
}
|
||||
89
includes/class-settings.php
Normal file
89
includes/class-settings.php
Normal 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
68
includes/class-tools.php
Normal 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;
|
||||
}
|
||||
}
|
||||
112
includes/class-uploader-hook.php
Normal file
112
includes/class-uploader-hook.php
Normal 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,
|
||||
] );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user