Files
h4b-image-optim/includes/class-optimizer.php
Henk 7e1c86f215 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>
2026-05-19 13:41:03 +10:00

354 lines
11 KiB
PHP

<?php
/**
* Core optimisation pipeline.
*
* For each image:
* 1. Backup the original to wp-content/h4b-img-originals/<rel-path>
* 2. Optionally rotate based on EXIF Orientation
* 3. Re-encode with ICC preserved and 4:4:4 chroma (the bug fix)
* 4. Pass through jpegoptim/pngquant for lossless tail
* 5. Queue WebP + AVIF sibling generation (Format_Generator)
*
* @package H4B\ImageOptim
*/
namespace H4B\ImageOptim;
use Imagick;
use ImagickException;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class Optimizer {
public const ORIGINALS_DIRNAME = 'h4b-img-originals';
public static function ensure_dirs(): void {
$root = self::originals_root();
if ( ! is_dir( $root ) ) {
wp_mkdir_p( $root );
}
// Prevent any web-side access; originals are operations-only.
$ht = $root . '/.htaccess';
if ( ! file_exists( $ht ) ) {
file_put_contents( $ht, "Order allow,deny\nDeny from all\n" );
}
}
public static function originals_root(): string {
$uploads = wp_get_upload_dir();
return trailingslashit( $uploads['basedir'] ) . self::ORIGINALS_DIRNAME;
}
/**
* Optimise one file in place.
*
* @param string $path Absolute path to a JPEG or PNG file under uploads/.
* @param array $opts Override settings (jpeg_quality, etc).
* @return array{
* status:string,
* path:string,
* mime:string,
* before:int,
* after:int,
* percent:float,
* icc_preserved:bool,
* backup_path:?string,
* tool_chain:array<string>,
* error:?string
* }
*/
public static function optimise( string $path, array $opts = [] ): array {
$result = [
'status' => 'pending',
'path' => $path,
'mime' => '',
'before' => 0,
'after' => 0,
'percent' => 0.0,
'icc_preserved' => false,
'backup_path' => null,
'tool_chain' => [],
'error' => null,
];
if ( ! is_readable( $path ) || ! is_writable( $path ) ) {
$result['status'] = 'error';
$result['error'] = 'not_readable_or_writable';
return $result;
}
$settings = array_replace( Settings::all(), $opts );
$result['before'] = filesize( $path );
$mime = self::detect_mime( $path );
$result['mime'] = $mime;
if ( ! in_array( $mime, [ 'image/jpeg', 'image/png' ], true ) ) {
$result['status'] = 'skipped';
$result['error'] = 'unsupported_mime:' . $mime;
return $result;
}
// Don't re-encode small thumbnails. JPEG q=85 + ICC profile on a tiny
// file INCREASES size (2.5KB profile is huge relative to <20KB images).
// At those dimensions humans can't see chroma artefacts anyway, so the
// Smush-bug fix isn't relevant. WebP/AVIF siblings still get made.
// 20KB threshold matches WordPress's typical thumbnail/medium sizes.
$threshold = (int) ( $settings['min_optimise_bytes'] ?? 20480 );
if ( $result['before'] < $threshold ) {
$result['status'] = 'skipped';
$result['error'] = 'too_small_keep_as_is';
$result['after'] = $result['before'];
return $result;
}
// 1. Backup the original (atomic copy, only once per file)
if ( ! empty( $settings['backup_originals'] ) ) {
$backup = self::backup( $path );
if ( $backup === null ) {
$result['status'] = 'error';
$result['error'] = 'backup_failed';
return $result;
}
$result['backup_path'] = $backup;
}
// 2. Re-encode via Imagick (handles ICC + orientation cleanly)
try {
$reencoded = self::reencode( $path, $mime, $settings );
} catch ( \Throwable $e ) {
$result['status'] = 'error';
$result['error'] = 'reencode_failed: ' . $e->getMessage();
return $result;
}
$result['icc_preserved'] = $reencoded['icc_preserved'];
$result['tool_chain'][] = 'imagick';
// 3. Lossless tail pass to squeeze the last few percent
if ( $mime === 'image/jpeg' && Tools::path( 'jpegoptim' ) ) {
self::jpegoptim_pass( $path, $settings );
$result['tool_chain'][] = 'jpegoptim';
}
if ( $mime === 'image/png' && Tools::path( 'pngquant' ) ) {
self::pngquant_pass( $path, $settings );
$result['tool_chain'][] = 'pngquant';
}
clearstatcache( true, $path );
$result['after'] = filesize( $path );
$result['percent'] = $result['before'] > 0
? round( ( $result['before'] - $result['after'] ) / $result['before'] * 100, 2 )
: 0.0;
$result['status'] = 'done';
return $result;
}
/**
* Re-encode through Imagick to enforce: ICC preserved, 4:4:4 chroma,
* EXIF orientation applied, sRGB colorspace.
*
* @return array{icc_preserved:bool}
*/
private static function reencode( string $path, string $mime, array $settings ): array {
$img = new Imagick( $path );
// Apply EXIF orientation so the raw pixels are upright, then strip orientation tag.
try {
$orient = $img->getImageOrientation();
switch ( $orient ) {
case Imagick::ORIENTATION_BOTTOMRIGHT:
$img->rotateImage( 'transparent', 180 ); break;
case Imagick::ORIENTATION_RIGHTTOP:
$img->rotateImage( 'transparent', 90 ); break;
case Imagick::ORIENTATION_LEFTBOTTOM:
$img->rotateImage( 'transparent', -90 ); break;
}
if ( $orient !== Imagick::ORIENTATION_TOPLEFT ) {
$img->setImageOrientation( Imagick::ORIENTATION_TOPLEFT );
}
} catch ( ImagickException $e ) {
// Some images have no orientation tag — that's fine.
}
// Preserve ICC profile — the critical fix
$icc = ICC_Profile::preserve_or_inject( $img );
$icc_ok = $icc['result'];
$had_source_profile = $icc['source'] === 'original';
// Strip EXIF privacy fields but KEEP ICC profile.
// Imagick stripImage() removes everything including ICC, so we don't call it.
// Instead we remove specific profiles selectively.
if ( ! empty( $settings['strip_gps_exif'] ) ) {
try { $img->removeImageProfile( 'exif' ); } catch ( ImagickException $e ) {}
// Note: this drops ALL exif including camera. For art use-case we
// might want to re-add ICC. ICC is already attached above.
}
if ( $mime === 'image/jpeg' ) {
$quality = (int) ( $settings['jpeg_quality'] ?? 85 );
// For small thumbnails, the grey-wash bug doesn't matter (browser
// renders them too small to see ringing). Use the standard 4:2:0
// subsampling to keep file size reasonable. For larger images use
// 4:4:4 (the bug-fix subsampling). Threshold: 400x400 = 160000 px.
$width = $img->getImageWidth();
$height = $img->getImageHeight();
$is_small = ( $width * $height ) < 160000;
$subsamp = $is_small
? '4:2:0'
: ( $settings['jpeg_chroma_subsampling'] ?? '4:4:4' );
$img->setImageCompressionQuality( $quality );
$img->setSamplingFactors( self::sampling_factors( $subsamp ) );
$img->setInterlaceScheme( Imagick::INTERLACE_NO );
$img->setImageColorspace( Imagick::COLORSPACE_SRGB );
// Skip ICC injection on tiny files — the profile costs 2.5KB which
// dwarfs the actual image bytes for thumbnails under 20KB
if ( $is_small && empty( $had_source_profile ) ) {
try { $img->removeImageProfile( 'icc' ); } catch ( ImagickException $e ) {}
}
} elseif ( $mime === 'image/png' ) {
// Lossless PNG via Imagick — actual compression happens in pngquant pass.
$img->setImageCompressionQuality( 95 );
}
// Write back to the same path atomically
$tmp = $path . '.h4b.tmp';
if ( $mime === 'image/jpeg' ) {
$img->writeImage( $tmp );
} else {
$img->writeImage( $tmp );
}
$img->clear();
$img->destroy();
// Preserve original ownership and mtime
$stat = stat( $path );
if ( $stat !== false ) {
@chown( $tmp, $stat['uid'] );
@chgrp( $tmp, $stat['gid'] );
@chmod( $tmp, $stat['mode'] & 0777 );
}
rename( $tmp, $path );
return [ 'icc_preserved' => $icc_ok ];
}
private static function sampling_factors( string $mode ): array {
switch ( $mode ) {
case '4:2:0': return [ '2x2', '1x1', '1x1' ];
case '4:2:2': return [ '2x1', '1x1', '1x1' ];
case '4:4:4':
default: return [ '1x1', '1x1', '1x1' ];
}
}
private static function jpegoptim_pass( string $path, array $settings ): void {
$bin = Tools::path( 'jpegoptim' );
if ( ! $bin ) {
return;
}
// --strip-none keeps EXIF + ICC intact. -P preserves file timestamps.
// -o overwrites only if smaller. -q quiet.
$cmd = sprintf(
'%s --strip-none -P -o -q -m%d %s 2>&1',
escapeshellcmd( $bin ),
(int) ( $settings['jpeg_quality'] ?? 85 ),
escapeshellarg( $path )
);
@shell_exec( $cmd );
}
private static function pngquant_pass( string $path, array $settings ): void {
$bin = Tools::path( 'pngquant' );
if ( ! $bin ) {
return;
}
$min = (int) ( $settings['png_quality_min'] ?? 70 );
$max = (int) ( $settings['png_quality_max'] ?? 90 );
$cmd = sprintf(
'%s --force --skip-if-larger --speed 3 --quality=%d-%d --output %s -- %s 2>&1',
escapeshellcmd( $bin ),
$min,
$max,
escapeshellarg( $path . '.pngq.tmp' ),
escapeshellarg( $path )
);
@shell_exec( $cmd );
if ( is_readable( $path . '.pngq.tmp' ) && filesize( $path . '.pngq.tmp' ) > 0 ) {
$stat = stat( $path );
if ( $stat !== false ) {
@chown( $path . '.pngq.tmp', $stat['uid'] );
@chgrp( $path . '.pngq.tmp', $stat['gid'] );
@chmod( $path . '.pngq.tmp', $stat['mode'] & 0777 );
}
rename( $path . '.pngq.tmp', $path );
}
}
private static function detect_mime( string $path ): string {
$info = @getimagesize( $path );
return $info['mime'] ?? '';
}
/**
* Copy the original to wp-content/h4b-img-originals/<relative>, preserving mtime.
* Skips if a backup already exists (don't overwrite the *original* with a re-processed file).
*/
private static function backup( string $path ): ?string {
$uploads = wp_get_upload_dir();
$basedir = trailingslashit( $uploads['basedir'] );
if ( strpos( $path, $basedir ) !== 0 ) {
return null; // outside uploads/, refuse
}
$rel = substr( $path, strlen( $basedir ) );
$dest = trailingslashit( self::originals_root() ) . $rel;
if ( file_exists( $dest ) ) {
return $dest; // already backed up — don't overwrite
}
wp_mkdir_p( dirname( $dest ) );
if ( ! copy( $path, $dest ) ) {
return null;
}
// Preserve mtime so prune_originals can age files reliably
@touch( $dest, filemtime( $path ) );
return $dest;
}
/**
* Cron callback: delete originals older than settings.backup_prune_days.
*/
public static function prune_originals(): void {
$days = (int) Settings::get( 'backup_prune_days', 90 );
if ( $days <= 0 ) {
return;
}
$cutoff = time() - $days * DAY_IN_SECONDS;
$root = self::originals_root();
if ( ! is_dir( $root ) ) {
return;
}
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ),
\RecursiveIteratorIterator::CHILD_FIRST
);
$deleted = 0;
foreach ( $it as $entry ) {
/** @var \SplFileInfo $entry */
if ( $entry->isFile() && $entry->getMTime() < $cutoff ) {
@unlink( $entry->getPathname() );
$deleted++;
} elseif ( $entry->isDir() ) {
@rmdir( $entry->getPathname() ); // succeeds only if empty
}
}
do_action( 'h4b_img_originals_pruned', $deleted, $days );
}
}