Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e56ca6e63 | ||
|
|
c54eccc2d5 | ||
|
|
868dbe0ff4 |
@@ -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.3
|
||||
* 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.3' );
|
||||
define( 'H4B_IMG_OPTIM_FILE', __FILE__ );
|
||||
define( 'H4B_IMG_OPTIM_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'H4B_IMG_OPTIM_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
300
includes/class-cli-siblings.php
Normal file
300
includes/class-cli-siblings.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ 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 );
|
||||
\WP_CLI::add_command( 'h4b-img reconcile-meta',
|
||||
function ( $args, $assoc ) {
|
||||
( new CLI_Siblings() )->reconcile_meta( $args, $assoc );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -115,18 +115,96 @@ final class Format_Generator {
|
||||
return $res;
|
||||
}
|
||||
|
||||
return self::encode_avif_now( $source, $settings );
|
||||
// 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.
|
||||
* 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();
|
||||
self::encode_avif_now( $source, $settings );
|
||||
$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 -<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 {
|
||||
@@ -151,8 +229,14 @@ final class Format_Generator {
|
||||
$dest = $source . '.avif';
|
||||
$tmp = $dest . '.h4b.tmp';
|
||||
|
||||
// --min / --max set quality range.
|
||||
// -s sets encoder speed (0-10; we default to 6 = balanced).
|
||||
// -y 444 forces 4:4:4 chroma subsampling — preserves luminance of near-white
|
||||
// pixels exactly, otherwise avifenc rounds white-ish pixels darker (~2% shift)
|
||||
// which creates a visible grey halo against pure-white page backgrounds.
|
||||
// Tradeoff: ~5% larger AVIF files in exchange for true colour fidelity.
|
||||
$cmd = sprintf(
|
||||
'%s --min %d --max %d -s %d %s %s 2>&1',
|
||||
'%s --min %d --max %d -s %d -y 444 %s %s 2>&1',
|
||||
escapeshellcmd( $bin ),
|
||||
$qmin,
|
||||
$qmax,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -33,7 +33,7 @@ final class Settings {
|
||||
// Format generation
|
||||
'generate_webp' => true,
|
||||
'generate_avif' => true, // design decision 1: on by default everywhere
|
||||
'webp_quality' => 80,
|
||||
'webp_quality' => 90, // matches source JPG luminance for B&W ink on white
|
||||
'avif_quality' => 65, // ≈ JPEG q=85 visually
|
||||
'avif_speed' => 6, // 0-10; 6 is balanced
|
||||
'avif_async' => true, // background WP-Cron so upload UI is responsive
|
||||
|
||||
Reference in New Issue
Block a user