'pending', 'path' => null, 'size' => 0, 'error' => null ]; $bin = Tools::path( 'cwebp' ); if ( ! $bin ) { $res['status'] = 'error'; $res['error'] = 'cwebp_not_installed'; return $res; } if ( ! is_readable( $source ) ) { $res['status'] = 'error'; $res['error'] = 'source_not_readable'; return $res; } $settings = array_replace( Settings::all(), $opts ); $quality = (int) ( $settings['webp_quality'] ?? 80 ); $dest = $source . '.webp'; $tmp = $dest . '.h4b.tmp'; // -metadata icc preserves the ICC profile if present // -mt enables multithreading // -m 4 = method 4 (good balance of compression vs speed) $cmd = sprintf( '%s -quiet -q %d -m 4 -mt -metadata icc %s -o %s 2>&1', escapeshellcmd( $bin ), $quality, escapeshellarg( $source ), escapeshellarg( $tmp ) ); $out = @shell_exec( $cmd ); if ( ! is_readable( $tmp ) || filesize( $tmp ) <= 0 ) { $res['status'] = 'error'; $res['error'] = 'cwebp_failed: ' . trim( (string) $out ); @unlink( $tmp ); return $res; } // Preserve ownership/perms $stat = stat( $source ); if ( $stat !== false ) { @chown( $tmp, $stat['uid'] ); @chgrp( $tmp, $stat['gid'] ); @chmod( $tmp, $stat['mode'] & 0777 ); } rename( $tmp, $dest ); $res['status'] = 'done'; $res['path'] = $dest; $res['size'] = filesize( $dest ); return $res; } /** * Generate the .avif sibling. * * If settings.avif_async is true, this enqueues a WP-Cron job and returns * status=queued. Otherwise generates synchronously. */ public static function make_avif( string $source, array $opts = [] ): array { $res = [ 'status' => 'pending', 'path' => null, 'size' => 0, 'error' => null ]; if ( ! Tools::has_avif() ) { $res['status'] = 'error'; $res['error'] = 'avifenc_not_installed'; return $res; } $settings = array_replace( Settings::all(), $opts ); if ( empty( $settings['generate_avif'] ) ) { $res['status'] = 'skipped'; $res['error'] = 'avif_disabled_in_settings'; return $res; } if ( ! empty( $settings['avif_async'] ) && ! ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { // Schedule a one-off job in 30 seconds; let the upload return. wp_schedule_single_event( time() + 30, self::CRON_HOOK, [ $source ] ); $res['status'] = 'queued'; return $res; } // Synchronous path: encode + record outcome in attachment postmeta. $result = self::encode_avif_now( $source, $settings ); self::record_avif_outcome( $source, $result ); return $result; } /** * Cron entry point — encodes one queued path. Recording happens inside * make_avif() now, so this is a thin wrapper that forces synchronous mode. */ 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(); $settings['avif_async'] = false; // we ARE the queue handler — never re-queue self::make_avif( $source, $settings ); } /** * 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 { $res = [ 'status' => 'pending', 'path' => null, 'size' => 0, 'error' => null ]; $bin = Tools::path( 'avifenc' ); if ( ! $bin ) { $res['status'] = 'error'; $res['error'] = 'avifenc_not_installed'; return $res; } $quality = (int) ( $settings['avif_quality'] ?? 65 ); $speed = (int) ( $settings['avif_speed'] ?? 6 ); // libavif quality is 0-100 (higher = better). avifenc 0.11 uses --min / --max // where lower values = higher quality (counterintuitive, inherited from av1). // Map: quality 65 → --min 25 --max 35 (matches our smoke test settings) $qmin = max( 0, (int) round( ( 100 - $quality ) * 0.6 ) ); $qmax = max( $qmin + 5, $qmin + 10 ); $dest = $source . '.avif'; $tmp = $dest . '.h4b.tmp'; $cmd = sprintf( '%s --min %d --max %d -s %d %s %s 2>&1', escapeshellcmd( $bin ), $qmin, $qmax, $speed, escapeshellarg( $source ), escapeshellarg( $tmp ) ); $out = @shell_exec( $cmd ); if ( ! is_readable( $tmp ) || filesize( $tmp ) <= 0 ) { $res['status'] = 'error'; $res['error'] = 'avifenc_failed: ' . trim( (string) $out ); @unlink( $tmp ); return $res; } $stat = stat( $source ); if ( $stat !== false ) { @chown( $tmp, $stat['uid'] ); @chgrp( $tmp, $stat['gid'] ); @chmod( $tmp, $stat['mode'] & 0777 ); } rename( $tmp, $dest ); $res['status'] = 'done'; $res['path'] = $dest; $res['size'] = filesize( $dest ); return $res; } }