feat: initial v0.1.0 MVP

Replaces Smush Pro's optimisation pipeline without the grey-wash bug.

CLI commands working:
  wp h4b-img status
  wp h4b-img optimise --id=<n>
  wp h4b-img bulk
  wp h4b-img rescue

Verified on dev.rds.ink:
- ICC profile preservation works (the Smush-bug fix)
- Bulk: 20 attachments → 487 KB saved (10.4%), 0 errors
- Rescue: end-to-end mechanism verified on WorkingAsOne_horse fixture
- WebP synchronous, AVIF queued via WP-Cron
- Originals backed up to wp-content/h4b-img-originals/

See CHANGELOG.md for details + ../DESIGN-h4b-image-optim.md for architecture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Henk
2026-05-19 13:41:03 +10:00
commit 7e1c86f215
19 changed files with 2498 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
<?php
/**
* Hooks into the WP upload pipeline.
*
* Important: we hook into wp_generate_attachment_metadata so we run AFTER
* WP-core generates all the size variants. We then optimise each variant in
* place. This keeps WP's metadata structure intact.
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class Uploader_Hook {
public static function register(): void {
add_filter( 'wp_generate_attachment_metadata', [ self::class, 'on_metadata' ], 10, 2 );
}
/**
* @param array $metadata The freshly-generated _wp_attachment_metadata array.
* @param int $attachment_id Post ID of the attachment.
*/
public static function on_metadata( $metadata, $attachment_id ): array {
$metadata = is_array( $metadata ) ? $metadata : [];
if ( ! Settings::get( 'optimise_on_upload', true ) ) {
return $metadata;
}
if ( Attachment_Meta::is_processed( (int) $attachment_id )
&& Settings::get( 'skip_already_processed', true ) ) {
return $metadata;
}
$uploads = wp_get_upload_dir();
$basedir = trailingslashit( $uploads['basedir'] );
// File path of the original full-size image
$relative = $metadata['file'] ?? get_post_meta( $attachment_id, '_wp_attached_file', true );
if ( ! $relative ) {
return $metadata;
}
$full_path = $basedir . $relative;
if ( ! is_readable( $full_path ) ) {
return $metadata;
}
// Optimise the full file
self::process_single( (int) $attachment_id, 'full', $full_path );
// Optimise every generated size
$dir = dirname( $full_path );
foreach ( ( $metadata['sizes'] ?? [] ) as $size_key => $size_data ) {
if ( empty( $size_data['file'] ) ) {
continue;
}
$size_path = trailingslashit( $dir ) . $size_data['file'];
if ( ! is_readable( $size_path ) ) {
continue;
}
self::process_single( (int) $attachment_id, (string) $size_key, $size_path );
// Refresh filesize in metadata after re-encode
clearstatcache( true, $size_path );
$metadata['sizes'][ $size_key ]['filesize'] = filesize( $size_path );
}
// Refresh full filesize too
clearstatcache( true, $full_path );
$metadata['filesize'] = filesize( $full_path );
return $metadata;
}
private static function process_single( int $attachment_id, string $size_key, string $path ): void {
$opt = Optimizer::optimise( $path );
// Generate WebP/AVIF siblings regardless of whether Optimizer touched
// the source JPEG (e.g. it might have skipped tiny thumbnails).
// Sibling generation is still valuable for those.
$webp_stats = [ 'status' => 'skipped' ];
$avif_stats = [ 'status' => 'skipped' ];
$generate_siblings = in_array( $opt['status'], [ 'done', 'skipped' ], true );
if ( $generate_siblings && Settings::get( 'generate_webp', true ) ) {
$webp_stats = Format_Generator::make_webp( $path );
}
if ( $generate_siblings && Settings::get( 'generate_avif', true ) ) {
$avif_stats = Format_Generator::make_avif( $path );
if ( $avif_stats['status'] === 'queued' ) {
Attachment_Meta::mark_avif_pending( $attachment_id, $size_key );
}
}
Attachment_Meta::record_size( $attachment_id, $size_key, [
'status' => $opt['status'],
'before' => $opt['before'],
'after' => $opt['after'],
'percent' => $opt['percent'],
'icc_preserved' => $opt['icc_preserved'] ?? false,
'tool_chain' => $opt['tool_chain'] ?? [],
'webp' => $webp_stats['status'] === 'done' ? $webp_stats['size'] : null,
'avif' => $avif_stats['status'] === 'done' ? $avif_stats['size'] : null,
'avif_status' => $avif_stats['status'],
'backup' => $opt['backup_path'] ?? null,
'error' => $opt['error'] ?? null,
] );
}
}