] * : unoptimised (default) | all * * [--mime=] * : Comma-separated MIME types. Default: image/jpeg,image/png * * [--limit=] * : Maximum attachments to process. 0 = no limit. * * [--batch=] * : Batch size for memory hygiene. Default: from settings (10). * * [--pause=] * : Sleep between batches. Default: from settings (1). * * [--dry-run] * : Report counts only. * * [--force] * : Re-optimise even if already processed. * * [--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[].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 ); } }