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,89 @@
<?php
/**
* Settings — single option, schema in defaults().
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class Settings {
public const OPTION_KEY = 'h4b_image_optim_settings';
public static function register(): void {
// No admin UI in v0.1 — settings read-only via WP-CLI for now.
}
public static function defaults(): array {
return [
// Quality
'jpeg_quality' => 85,
'png_quality_min' => 70,
'png_quality_max' => 90,
'jpeg_chroma_subsampling' => '4:4:4', // preserve edges; the fix for the Smush bug
'preserve_icc_profile' => true, // CRITICAL — the bug fix
'strip_gps_exif' => true,
'strip_camera_exif' => false, // keep camera info for art provenance
// Format generation
'generate_webp' => true,
'generate_avif' => true, // design decision 1: on by default everywhere
'webp_quality' => 80,
'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
// Behaviour
'optimise_on_upload' => true,
'backup_originals' => true, // design decision 4: always
'backup_prune_days' => 90, // 0 = keep forever
'resize_max_width' => 2560,
'resize_max_height' => 2560,
'skip_already_processed' => true,
'min_optimise_bytes' => 20480, // skip files < 20KB (thumbnails)
// Serving
'rewrite_content_images' => true, // Picture tag rewriting
'use_htaccess_fallback' => true, // .htaccess content negotiation
// Bulk
'bulk_batch_size' => 10,
'bulk_pause_seconds' => 1,
// Exclusions
'exclude_mime_types' => [ 'image/svg+xml', 'image/x-icon', 'image/gif' ],
'exclude_paths_regex' => '',
];
}
public static function install_defaults(): void {
$existing = get_option( self::OPTION_KEY );
if ( false === $existing ) {
add_option( self::OPTION_KEY, self::defaults(), '', false );
return;
}
// Merge new keys without clobbering user changes
$merged = array_replace( self::defaults(), is_array( $existing ) ? $existing : [] );
update_option( self::OPTION_KEY, $merged, false );
}
public static function get( string $key, mixed $default = null ): mixed {
$settings = get_option( self::OPTION_KEY, self::defaults() );
return $settings[ $key ] ?? $default;
}
public static function set( string $key, mixed $value ): void {
$settings = get_option( self::OPTION_KEY, self::defaults() );
$settings[ $key ] = $value;
update_option( self::OPTION_KEY, $settings, false );
}
public static function all(): array {
return get_option( self::OPTION_KEY, self::defaults() );
}
}