From 868dbe0ff4ee989df1ee3ddd918ad8f8b5e5d2d1 Mon Sep 17 00:00:00 2001 From: Henk Date: Tue, 19 May 2026 14:52:50 +1000 Subject: [PATCH] fix: avif tracking + new generate-missing-siblings command (v0.2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- h4b-image-optim.php | 4 +- includes/class-cli-siblings.php | 202 ++++++++++++++++++++++++++++ includes/class-cli.php | 1 + includes/class-format-generator.php | 78 ++++++++++- includes/class-plugin.php | 1 + 5 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 includes/class-cli-siblings.php diff --git a/h4b-image-optim.php b/h4b-image-optim.php index cd3abe3..6e8c32a 100644 --- a/h4b-image-optim.php +++ b/h4b-image-optim.php @@ -3,7 +3,7 @@ * Plugin Name: H4B Image Optim * Plugin URI: https://gitea.help4bis.com/help4bis/h4b-image-optim * Description: ICC-safe image optimisation with WebP + AVIF generation. Replaces Smush Pro without the grey-wash bug. No CDN. - * Version: 0.2.0 + * Version: 0.2.1 * Author: help4bis (Henk + Claude) * Author URI: https://help4bis.com * License: GPL-2.0-or-later @@ -17,7 +17,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'H4B_IMG_OPTIM_VERSION', '0.2.0' ); +define( 'H4B_IMG_OPTIM_VERSION', '0.2.1' ); define( 'H4B_IMG_OPTIM_FILE', __FILE__ ); define( 'H4B_IMG_OPTIM_DIR', plugin_dir_path( __FILE__ ) ); define( 'H4B_IMG_OPTIM_URL', plugin_dir_url( __FILE__ ) ); diff --git a/includes/class-cli-siblings.php b/includes/class-cli-siblings.php new file mode 100644 index 0000000..e169579 --- /dev/null +++ b/includes/class-cli-siblings.php @@ -0,0 +1,202 @@ +] + * : 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'] + ) ); + } + + /** + * 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; + } +} diff --git a/includes/class-cli.php b/includes/class-cli.php index 2e28fae..1061bcc 100644 --- a/includes/class-cli.php +++ b/includes/class-cli.php @@ -18,6 +18,7 @@ final class CLI { \WP_CLI::add_command( 'h4b-img bulk', CLI_Bulk::class ); \WP_CLI::add_command( 'h4b-img rescue', CLI_Rescue::class ); \WP_CLI::add_command( 'h4b-img migrate-from-smush', CLI_Migrate::class ); + \WP_CLI::add_command( 'h4b-img generate-missing-siblings', CLI_Siblings::class ); } /** diff --git a/includes/class-format-generator.php b/includes/class-format-generator.php index 23f7e62..2ba993e 100644 --- a/includes/class-format-generator.php +++ b/includes/class-format-generator.php @@ -119,14 +119,88 @@ final class Format_Generator { } /** - * Cron entry point — encodes one queued path. + * Cron entry point — encodes one queued path and updates per-attachment meta. */ public static function process_avif_job( string $source ): void { if ( ! is_readable( $source ) ) { + self::record_avif_outcome( $source, [ 'status' => 'error', 'error' => 'source_unreadable' ] ); return; } $settings = Settings::all(); - self::encode_avif_now( $source, $settings ); + $result = self::encode_avif_now( $source, $settings ); + self::record_avif_outcome( $source, $result ); + } + + /** + * Find the attachment + size_key that owns a given absolute path on disk, + * then update its h4b postmeta with the AVIF outcome. + */ + private static function record_avif_outcome( string $source_path, array $result ): void { + global $wpdb; + $uploads = wp_get_upload_dir(); + $basedir = trailingslashit( $uploads['basedir'] ); + if ( strpos( $source_path, $basedir ) !== 0 ) { + return; + } + $rel = substr( $source_path, strlen( $basedir ) ); + $dir = dirname( $rel ); + $name = basename( $rel ); + + // Reverse the -x filename suffix to find the parent attachment file + if ( preg_match( '/^(.+)-\d+x\d+(\.[a-z]+)$/i', $name, $m ) ) { + $parent_name = $m[1] . $m[2]; + } else { + $parent_name = $name; + } + $parent_rel = ( $dir === '.' ) ? $parent_name : "$dir/$parent_name"; + + $att_id = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key=%s AND meta_value=%s LIMIT 1", + '_wp_attached_file', $parent_rel + ) ); + if ( $att_id <= 0 ) { + return; + } + + // Find the size key that matches this file + $meta = wp_get_attachment_metadata( $att_id ); + $size_key = null; + if ( $name === $parent_name ) { + $size_key = 'full'; + } elseif ( is_array( $meta ) && ! empty( $meta['sizes'] ) ) { + foreach ( $meta['sizes'] as $sk => $sd ) { + if ( ( $sd['file'] ?? '' ) === $name ) { + $size_key = (string) $sk; + break; + } + } + } + if ( $size_key === null ) { + return; + } + + $h4b_meta = Attachment_Meta::get( $att_id ); + if ( ! isset( $h4b_meta['sizes'][ $size_key ] ) ) { + $h4b_meta['sizes'][ $size_key ] = []; + } + $entry = &$h4b_meta['sizes'][ $size_key ]; + if ( $result['status'] === 'done' ) { + $entry['avif'] = $result['size'] ?? null; + $entry['avif_status'] = 'done'; + } elseif ( $result['status'] === 'skipped' ) { + $entry['avif_status'] = 'skipped'; + } else { + $entry['avif_status'] = 'error'; + $entry['avif_error'] = $result['error'] ?? 'unknown'; + } + // Clear from the pending bag too + if ( isset( $h4b_meta['avif_pending'][ $size_key ] ) ) { + unset( $h4b_meta['avif_pending'][ $size_key ] ); + if ( empty( $h4b_meta['avif_pending'] ) ) { + unset( $h4b_meta['avif_pending'] ); + } + } + Attachment_Meta::set( $att_id, $h4b_meta ); } private static function encode_avif_now( string $source, array $settings ): array { diff --git a/includes/class-plugin.php b/includes/class-plugin.php index 93d95b0..802cdfc 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -23,6 +23,7 @@ require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli.php'; require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-bulk.php'; require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-rescue.php'; require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-migrate.php'; +require_once H4B_IMG_OPTIM_DIR . 'includes/class-cli-siblings.php'; require_once H4B_IMG_OPTIM_DIR . 'includes/class-picture-tag.php'; final class Plugin {