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>
354 lines
11 KiB
PHP
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 );
|
|
}
|
|
}
|