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>
284 lines
8.1 KiB
PHP
284 lines
8.1 KiB
PHP
<?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 );
|
|
}
|
|
}
|