Two related fixes to the AVIF pipeline: Fix #1 — postmeta tracking Format_Generator::process_avif_job() now updates _h4b_img_optim's avif_status from 'queued' → 'done' (with byte size) or 'error' (with reason) after the cron job runs. Previously the postmeta said 'queued' forever even when AVIF files existed on disk. Fix #2 — root cause of missing AVIFs on rds.ink bulk run wp_schedule_single_event(time()+30, …) coalesces identical args at the same timestamp, so bulk-queueing hundreds of AVIF jobs in the same second silently dropped many. Added wp h4b-img generate-missing-siblings that walks attachment metadata, finds files missing .webp/.avif, and generates them SYNCHRONOUSLY (no queue, no coalescing). Only processes registered sizes by default; --include-orphans flag for disk-walk mode. Verified on prod rds.ink: 1,134 of 1,737 registered images >=20KB have AVIF, 603 are missing. Of 389 orphan files >=20KB missing AVIF, those aren't referenced from any post/postmeta — correctly excluded. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
203 lines
6.1 KiB
PHP
203 lines
6.1 KiB
PHP
<?php
|
|
/**
|
|
* wp h4b-img generate-missing-siblings
|
|
*
|
|
* Walks every attachment that WP knows about (via wp_attachment_metadata),
|
|
* and for each registered size file, creates any missing .webp / .avif
|
|
* sibling synchronously. Does NOT re-encode the source JPG/PNG.
|
|
*
|
|
* Why this exists
|
|
* ===============
|
|
* The `bulk` command queues AVIF jobs via wp_schedule_single_event(time()+30, …).
|
|
* WP-Cron coalesces identical timestamped events, so when bulk enqueues several
|
|
* hundred at the same instant a chunk get dropped. This command does the work
|
|
* inline — no queueing, no coalescing — and only touches files that WordPress
|
|
* actually references.
|
|
*
|
|
* @package H4B\ImageOptim
|
|
*/
|
|
|
|
namespace H4B\ImageOptim;
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
final class CLI_Siblings {
|
|
|
|
/**
|
|
* Generate any missing .webp / .avif siblings for registered attachment sizes.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* [--dry-run]
|
|
* : Just count what would be done.
|
|
*
|
|
* [--limit=<n>]
|
|
* : Max attachments to process. 0 = unlimited.
|
|
*
|
|
* [--webp-only]
|
|
* : Generate only WebP siblings.
|
|
*
|
|
* [--avif-only]
|
|
* : Generate only AVIF siblings.
|
|
*
|
|
* [--min-size=<bytes>]
|
|
* : 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']
|
|
) );
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|