Files
h4b-image-optim/includes/class-cli-siblings.php
Henk c54eccc2d5 fix: postmeta tracking on synchronous AVIF + add reconcile-meta (v0.2.2)
Two improvements after v0.2.1 deploy revealed the avif_status bug
wasn't fully fixed:

Fix:
  Format_Generator::make_avif() now calls record_avif_outcome() at the
  end of the synchronous path. Previously only the cron path recorded
  outcomes, so wp h4b-img generate-missing-siblings (synchronous) left
  4067 stale 'queued' rows even though it successfully generated 603
  AVIFs on disk. process_avif_job() simplified to a thin wrapper
  around make_avif(avif_async=false).

Added:
  wp h4b-img reconcile-meta — walks _h4b_img_optim postmeta, checks
  for .webp / .avif files on disk, and updates avif_status / webp size
  fields to match reality. One-shot reconciliation for stale records
  left by earlier plugin versions. --dry-run supported.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 14:59:18 +10:00

301 lines
9.3 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']
) );
}
/**
* 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;
}
}