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:
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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user