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

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