] * : Max attachments to process. 0 = unlimited. * * [--webp-only] * : Generate only WebP siblings. * * [--avif-only] * : Generate only AVIF siblings. * * [--min-size=] * : Skip source files smaller than this. Default: 20480 (20 KB). * * [--include-orphans] * : Also process disk files that aren't in wp_attachment_metadata. * Default: false (only registered sizes). * * ## EXAMPLES * wp h4b-img generate-missing-siblings --dry-run * wp h4b-img generate-missing-siblings --avif-only * wp h4b-img generate-missing-siblings --limit=50 */ public function __invoke( $args, $assoc ): void { $dry_run = ! empty( $assoc['dry-run'] ); $webp_only = ! empty( $assoc['webp-only'] ); $avif_only = ! empty( $assoc['avif-only'] ); $limit = (int) ( $assoc['limit'] ?? 0 ); $min_size = (int) ( $assoc['min-size'] ?? 20480 ); $include_orph = ! empty( $assoc['include-orphans'] ); if ( $webp_only && $avif_only ) { \WP_CLI::error( 'Pick one of --webp-only / --avif-only, not both.' ); } $do_webp = ! $avif_only; $do_avif = ! $webp_only; global $wpdb; $uploads = wp_get_upload_dir(); $basedir = trailingslashit( $uploads['basedir'] ); // Collect all registered source paths $registered_paths = self::collect_registered_paths(); \WP_CLI::log( sprintf( 'Registered image paths: %d', count( $registered_paths ) ) ); // Optionally also walk disk for orphans if ( $include_orph ) { $disk_paths = self::collect_disk_paths( $basedir ); $orphans = array_diff_key( $disk_paths, $registered_paths ); \WP_CLI::log( sprintf( ' + including %d orphan disk files', count( $orphans ) ) ); $registered_paths = $registered_paths + $orphans; } // Filter by --min-size and existing siblings $todo = []; // [ ['path'=>abs, 'webp'=>bool, 'avif'=>bool], … ] foreach ( $registered_paths as $rel => $_ ) { $abs = $basedir . $rel; if ( ! is_readable( $abs ) ) { continue; } if ( filesize( $abs ) < $min_size ) { continue; } $need_webp = $do_webp && ! file_exists( $abs . '.webp' ); $need_avif = $do_avif && Tools::has_avif() && ! file_exists( $abs . '.avif' ); if ( $need_webp || $need_avif ) { $todo[] = [ 'path' => $abs, 'webp' => $need_webp, 'avif' => $need_avif ]; if ( $limit > 0 && count( $todo ) >= $limit ) { break; } } } \WP_CLI::log( sprintf( 'Need siblings: %d files', count( $todo ) ) ); if ( $dry_run ) { \WP_CLI::success( 'Dry-run only.' ); return; } if ( empty( $todo ) ) { \WP_CLI::success( 'Nothing to do.' ); return; } $progress = \WP_CLI\Utils\make_progress_bar( 'Generating', count( $todo ) ); $summary = [ 'webp_done' => 0, 'webp_failed' => 0, 'avif_done' => 0, 'avif_failed' => 0 ]; foreach ( $todo as $item ) { if ( $item['webp'] ) { $r = Format_Generator::make_webp( $item['path'] ); $key = $r['status'] === 'done' ? 'webp_done' : 'webp_failed'; $summary[ $key ]++; } if ( $item['avif'] ) { // Run synchronously here — bypass the cron queue path $settings = Settings::all(); $settings['avif_async'] = false; $r = Format_Generator::make_avif( $item['path'], $settings ); $key = $r['status'] === 'done' ? 'avif_done' : 'avif_failed'; $summary[ $key ]++; } $progress->tick(); } $progress->finish(); \WP_CLI::success( sprintf( "Done.\n WebP generated: %d\n WebP failed: %d\n AVIF generated: %d\n AVIF failed: %d", $summary['webp_done'], $summary['webp_failed'], $summary['avif_done'], $summary['avif_failed'] ) ); } /** * Reconcile attachment postmeta with what's actually on disk. * * For each `_h4b_img_optim` size entry, if avif_status is 'queued' or 'never_generated' * but the .avif file exists on disk, update to 'done' with the actual byte size. * Similarly for webp. * * Use this after manual generate-missing-siblings runs OR to fix the stale * 'queued' values from a v0.2.0 era bulk run. * * ## OPTIONS * * [--dry-run] * : Report counts only. * * ## EXAMPLES * wp h4b-img reconcile-meta --dry-run * wp h4b-img reconcile-meta */ public function reconcile_meta( $args, $assoc ): void { $dry_run = ! empty( $assoc['dry-run'] ); global $wpdb; $basedir = trailingslashit( wp_get_upload_dir()['basedir'] ); $rows = $wpdb->get_results( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key='_h4b_img_optim'" ); \WP_CLI::log( sprintf( 'Scanning %d attachments…', count( $rows ) ) ); $updates = [ 'avif_done_marked' => 0, 'avif_already_done' => 0, 'avif_still_missing' => 0, 'webp_done_marked' => 0, ]; foreach ( $rows as $r ) { $meta = @unserialize( $r->meta_value, [ 'allowed_classes' => false ] ); if ( ! is_array( $meta ) || empty( $meta['sizes'] ) ) { continue; } $rel_parent = get_post_meta( $r->post_id, '_wp_attached_file', true ); if ( ! $rel_parent ) { continue; } $parent_dir = dirname( $rel_parent ); $attached_meta = wp_get_attachment_metadata( $r->post_id ); $changed = false; foreach ( $meta['sizes'] as $size_key => &$entry ) { // Resolve the file path for this size key if ( $size_key === 'full' ) { $file_rel = $rel_parent; } elseif ( is_array( $attached_meta ) && ! empty( $attached_meta['sizes'][ $size_key ]['file'] ) ) { $f = $attached_meta['sizes'][ $size_key ]['file']; $file_rel = ( $parent_dir === '.' ) ? $f : "$parent_dir/$f"; } else { continue; } $src = $basedir . $file_rel; $avif = $src . '.avif'; $webp = $src . '.webp'; // AVIF if ( is_readable( $avif ) ) { if ( ( $entry['avif_status'] ?? '' ) !== 'done' ) { $entry['avif'] = filesize( $avif ); $entry['avif_status'] = 'done'; $updates['avif_done_marked']++; $changed = true; } else { $updates['avif_already_done']++; } } elseif ( ( $entry['avif_status'] ?? '' ) === 'queued' ) { $updates['avif_still_missing']++; } // WebP — only mark if currently null/missing if ( is_readable( $webp ) && empty( $entry['webp'] ) ) { $entry['webp'] = filesize( $webp ); $updates['webp_done_marked']++; $changed = true; } } unset( $entry ); if ( $changed && ! $dry_run ) { Attachment_Meta::set( (int) $r->post_id, $meta ); } } \WP_CLI::success( sprintf( "Reconciliation %s.\n avif_status 'queued' → 'done' on disk: %d\n avif already correctly 'done': %d\n avif still missing (queue stale): %d\n webp filesize backfilled in meta: %d", $dry_run ? 'dry-run' : 'done', $updates['avif_done_marked'], $updates['avif_already_done'], $updates['avif_still_missing'], $updates['webp_done_marked'] ) ); } /** * Build the set of all JPG/PNG paths registered in wp_attachment_metadata. * Returns rel-path => true. */ private static function collect_registered_paths(): array { global $wpdb; $paths = []; $rows = $wpdb->get_results( "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key='_wp_attachment_metadata'" ); foreach ( $rows as $r ) { $m = @unserialize( $r->meta_value, [ 'allowed_classes' => false ] ); if ( ! is_array( $m ) ) { continue; } if ( ! empty( $m['file'] ) ) { $paths[ $m['file'] ] = true; $parent_dir = dirname( $m['file'] ); if ( ! empty( $m['sizes'] ) ) { foreach ( $m['sizes'] as $sz ) { if ( ! empty( $sz['file'] ) ) { $rel = ( $parent_dir === '.' ) ? $sz['file'] : $parent_dir . '/' . $sz['file']; $paths[ $rel ] = true; } } } } } return $paths; } /** * Walk the disk and return all .jpg/.jpeg/.png paths under $basedir, * excluding our own working dirs. * * Returns rel-path => true. */ private static function collect_disk_paths( string $basedir ): array { $paths = []; $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $basedir, \FilesystemIterator::SKIP_DOTS ) ); foreach ( $it as $entry ) { if ( ! $entry->isFile() ) { continue; } $p = $entry->getPathname(); if ( strpos( $p, '/' . Optimizer::ORIGINALS_DIRNAME . '/' ) !== false ) { continue; } $ext = strtolower( $entry->getExtension() ); if ( ! in_array( $ext, [ 'jpg', 'jpeg', 'png' ], true ) ) { continue; } $paths[ substr( $p, strlen( $basedir ) ) ] = true; } return $paths; } }