fix: avif tracking + new generate-missing-siblings command (v0.2.1)

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>
This commit is contained in:
Henk
2026-05-19 14:52:50 +10:00
parent 4cd1390a94
commit 868dbe0ff4
5 changed files with 282 additions and 4 deletions

View File

@@ -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 -<W>x<H> 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 {