settings = $settings;
// Fires after WP generates all thumbnail sizes
add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2);
// If "alongside" strategy, rewrite
tags to
if ($settings['conversion_strategy'] === 'alongside') {
// Core WordPress
add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20);
add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20);
// WooCommerce frontend images
add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
add_filter('woocommerce_product_get_image', [$this, 'rewrite_to_picture_tags'], 20);
add_filter('woocommerce_cart_item_thumbnail', [$this, 'rewrite_to_picture_tags'], 20);
add_filter('woocommerce_placeholder_img', [$this, 'rewrite_to_picture_tags'], 20);
// Output buffer catch-all for themes/builders bypassing WP filters (Divi, Elementor, etc.)
add_action('template_redirect', [$this, 'start_output_buffer'], 1);
}
}
/**
* Hook into metadata generation to convert the main file and all sub-sizes.
*/
public function convert_on_upload(array $metadata, int $attachment_id): array {
$file = get_attached_file($attachment_id);
$mime = get_post_mime_type($attachment_id);
if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
return $metadata;
}
// Check exclusions
if ($this->is_excluded($file)) {
return $metadata;
}
$format = $this->settings['conversion_format'];
$quality = (int) $this->settings['conversion_quality'];
$strategy = $this->settings['conversion_strategy'];
// Convert main/full file
$converted = $this->convert_file($file, $format, $quality);
if ($converted && $strategy === 'replace') {
$this->replace_original($attachment_id, $file, $converted, $metadata, $format);
update_post_meta($attachment_id, '_metasync_replaced_original', '1');
}
// Convert sub-sizes (thumbnails, medium, large, etc.) with memory management
if (!empty($this->settings['convert_existing_sizes']) && !empty($metadata['sizes'])) {
static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
}
// Store converted format in meta for later use by picture tag rewriter
if ($converted && $strategy === 'alongside') {
update_post_meta($attachment_id, '_metasync_converted_format', $format);
}
return $metadata;
}
// ── Static Methods for External Use (Batch Optimizer, AJAX) ──
/**
* Convert an existing attachment to next-gen format.
* Used by batch optimizer and single-image AJAX actions.
*/
public static function convert_attachment(int $attachment_id, array $settings): bool {
$file = get_attached_file($attachment_id);
$mime = get_post_mime_type($attachment_id);
if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
return false;
}
$format = $settings['conversion_format'] ?? 'webp';
$quality = (int) ($settings['conversion_quality'] ?? 82);
$strategy = $settings['conversion_strategy'] ?? 'alongside';
// Store original file size before conversion for savings display
$original_size = filesize($file);
$converted = self::do_convert_file($file, $format, $quality);
if (!$converted) {
return false;
}
// Store original size meta for savings calculation
if ($original_size) {
update_post_meta($attachment_id, '_metasync_original_filesize', $original_size);
}
if ($strategy === 'replace') {
$metadata = wp_get_attachment_metadata($attachment_id);
if ($metadata) {
self::do_replace_original($attachment_id, $file, $converted, $metadata, $format);
wp_update_attachment_metadata($attachment_id, $metadata);
}
}
// Convert sub-sizes with memory management
if (!empty($settings['convert_existing_sizes'])) {
$metadata = wp_get_attachment_metadata($attachment_id);
if ($metadata && !empty($metadata['sizes'])) {
static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
if ($strategy === 'replace') {
wp_update_attachment_metadata($attachment_id, $metadata);
}
}
}
if ($strategy === 'replace') {
update_post_meta($attachment_id, '_metasync_replaced_original', '1');
}
update_post_meta($attachment_id, '_metasync_converted_format', $format);
return true;
}
/**
* Check whether an optimized attachment can be reverted.
* Returns false when the original was replaced (no backup exists).
*/
public static function can_revert(int $attachment_id): bool {
$format = get_post_meta($attachment_id, '_metasync_converted_format', true);
if (!$format) {
return false; // Not optimized
}
// If the original was replaced, revert is impossible
if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) {
return false;
}
// Verify original file still exists on disk (alongside strategy)
$file = get_attached_file($attachment_id);
return $file && file_exists($file);
}
/**
* Revert an attachment's conversion (alongside strategy only).
* Deletes the converted files and removes the meta marker.
*/
public static function revert_attachment(int $attachment_id): bool {
$format = get_post_meta($attachment_id, '_metasync_converted_format', true);
if (!$format) {
return false;
}
$file = get_attached_file($attachment_id);
if (!$file) {
return false;
}
// Check if original still exists (alongside strategy)
if (!file_exists($file)) {
return false; // Cannot revert replace strategy
}
$ext = self::get_format_extension($format);
// Delete converted full-size file
$converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file);
if ($converted_path && file_exists($converted_path)) {
@unlink($converted_path);
}
// Delete converted sub-sizes
$metadata = wp_get_attachment_metadata($attachment_id);
if ($metadata && !empty($metadata['sizes'])) {
$upload_dir = dirname($file);
foreach ($metadata['sizes'] as $size_data) {
$size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $upload_dir . '/' . $size_data['file']);
if ($size_converted && file_exists($size_converted)) {
@unlink($size_converted);
}
}
}
delete_post_meta($attachment_id, '_metasync_converted_format');
delete_post_meta($attachment_id, '_metasync_original_filesize');
delete_post_meta($attachment_id, '_metasync_replaced_original');
return true;
}
// ── Core Conversion (static, reusable) ──
/**
* Get available PHP memory in bytes.
* Returns PHP_INT_MAX when no limit is set (-1) or unreadable.
* Returns 0 when memory_limit is "0" or empty.
*/
protected static function get_available_memory(): int {
$limit = ini_get('memory_limit');
if ($limit === false || $limit === '-1') {
return PHP_INT_MAX;
}
$limit = trim($limit);
if ($limit === '' || $limit === '0') {
return 0;
}
$value = (int) $limit;
$unit = strtolower(substr($limit, -1));
$value = match ($unit) {
'g' => $value * 1024 * 1024 * 1024,
'm' => $value * 1024 * 1024,
'k' => $value * 1024,
default => $value,
};
return max(0, $value - memory_get_usage(true));
}
/**
* Convert sub-sizes with memory management.
*
* Runs gc_collect_cycles() between each sub-size to release memory and
* checks available memory before each iteration, skipping remaining
* sub-sizes when memory drops below MIN_MEMORY_FOR_SUBSIZE.
*
* @param array $sizes Reference to $metadata['sizes'].
* @param string $upload_dir Directory containing the sub-size files.
* @param string $format Target format (webp|avif).
* @param int $quality Compression quality.
* @param string $strategy Conversion strategy (replace|alongside).
*/
protected static function convert_subsizes(array &$sizes, string $upload_dir, string $format, int $quality, string $strategy): void {
foreach ($sizes as $size_name => &$size_data) {
// Check available memory before each sub-size conversion
$available = static::get_available_memory();
if ($available < self::MIN_MEMORY_FOR_SUBSIZE) {
error_log('[MetaSync Media Opt] Low memory (' . size_format($available) . '), skipping remaining sub-sizes from: ' . $size_name);
break;
}
$size_file = $upload_dir . '/' . $size_data['file'];
$size_converted = static::do_convert_file($size_file, $format, $quality);
if ($size_converted && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
@unlink($size_file);
$size_data['file'] = basename($size_converted);
$size_data['mime-type'] = "image/{$format}";
} elseif ($size_converted && $strategy === 'replace') {
error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . $size_file);
}
// Release cyclic references between sub-size conversions
gc_collect_cycles();
}
unset($size_data);
}
/**
* Convert a single image file. Returns path to converted file or null on failure.
*/
protected static function do_convert_file(string $source, string $format, int $quality): ?string {
if (!file_exists($source)) {
return null;
}
if (filesize($source) > self::MAX_CONVERT_BYTES) {
error_log('[MetaSync Media Opt] Source file exceeds MAX_CONVERT_BYTES limit, skipping: ' . $source);
return null;
}
// Request WordPress's image processing memory limit
wp_raise_memory_limit('image');
// Pre-flight memory check using pixel dimensions when available
$info = getimagesize($source);
if ($info && $info[0] > 0 && $info[1] > 0) {
$bpp = ($info['mime'] === 'image/png') ? 4 : 3;
$estimated = (int) ($info[0] * $info[1] * $bpp * 1.8);
} else {
$estimated = filesize($source) * 3;
}
$available = self::get_available_memory();
if ($estimated > $available * 0.8) {
error_log('[MetaSync Media Opt] Skipping ' . basename($source) . ': estimated memory (' . size_format($estimated) . ') exceeds 80% of available (' . size_format($available) . ')');
return null;
}
$ext = self::get_format_extension($format);
$dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $source);
// Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
if (extension_loaded('imagick')) {
try {
$result = self::do_convert_with_imagick($source, $dest, $format, $quality);
if ($result) {
return $result;
}
} catch (\Exception $e) {
error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage());
}
}
if (extension_loaded('gd')) {
try {
return self::do_convert_with_gd($source, $dest, $format, $quality);
} catch (\Exception $e) {
error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage());
}
}
return null;
}
private static function do_convert_with_imagick(string $src, string $dest, string $fmt, int $q): ?string {
$img = new \Imagick($src);
$img->setImageFormat($fmt === 'avif' ? 'avif' : 'webp');
$img->setImageCompressionQuality($q);
$img->stripImage();
if ($img->writeImage($dest)) {
if (!file_exists($dest) || !filesize($dest)) {
@unlink($dest);
error_log('[MetaSync Media Opt] Imagick wrote 0-byte or missing output, discarding: ' . $dest);
$img->destroy();
return null;
}
$img->destroy();
return $dest;
}
$img->destroy();
return null;
}
private static function do_convert_with_gd(string $src, string $dest, string $fmt, int $q): ?string {
$info = getimagesize($src);
if (!$info) {
return null;
}
$gd_img = match ($info['mime']) {
'image/jpeg' => imagecreatefromjpeg($src),
'image/png' => imagecreatefrompng($src),
default => null,
};
if (!$gd_img) {
return null;
}
if ($info['mime'] === 'image/png') {
imagepalettetotruecolor($gd_img);
imagealphablending($gd_img, true);
imagesavealpha($gd_img, true);
}
$success = match ($fmt) {
'webp' => imagewebp($gd_img, $dest, $q),
'avif' => function_exists('imageavif') ? imageavif($gd_img, $dest, $q) : false,
default => false,
};
imagedestroy($gd_img);
if (!$success || !file_exists($dest) || !filesize($dest)) {
@unlink($dest);
error_log('[MetaSync Media Opt] GD produced empty or missing output, discarding: ' . $dest);
return null;
}
return $dest;
}
/**
* Replace original file with converted version.
*/
private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
if (!file_exists($new_path) || !filesize($new_path)) {
error_log('[MetaSync Media Opt] Converted file is missing or empty, original preserved: ' . $old_path);
return;
}
// Capture old URL before deleting so we can rewrite post content references
$old_url = wp_get_attachment_url($id);
@unlink($old_path);
wp_update_post([
'ID' => $id,
'post_mime_type' => "image/{$fmt}",
]);
update_attached_file($id, $new_path);
$meta['file'] = _wp_relative_upload_path($new_path);
// Rewrite hardcoded image URLs in post content to point to the new file
$new_url = wp_get_attachment_url($id);
if ($old_url && $new_url && $old_url !== $new_url) {
self::rewrite_content_urls($old_url, $new_url);
}
}
/**
* Rewrite image URLs in all post content that references the old file path.
* Uses the path portion (e.g. /wp-content/uploads/…) so it works regardless
* of hostname changes (e.g. Cloudflare tunnel rotations).
*/
private static function rewrite_content_urls(string $old_url, string $new_url): void {
global $wpdb;
// Extract path portions to be hostname-agnostic
$old_path = wp_parse_url($old_url, PHP_URL_PATH);
$new_path = wp_parse_url($new_url, PHP_URL_PATH);
if (!$old_path || !$new_path || $old_path === $new_path) {
return;
}
$wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) WHERE post_content LIKE %s",
$old_path,
$new_path,
'%' . $wpdb->esc_like($old_path) . '%'
));
}
// ── Instance Wrappers (Upload Hook) ──
/**
* Instance wrapper around static conversion method.
*/
private function convert_file(string $source, string $format, int $quality): ?string {
return self::do_convert_file($source, $format, $quality);
}
/**
* Instance wrapper around static replace method.
*/
private function replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
self::do_replace_original($id, $old_path, $new_path, $meta, $fmt);
}
/**
* Start output buffering on frontend to catch images from themes/builders
* that bypass standard WordPress image filters (e.g. Divi, Elementor).
*/
public function start_output_buffer(): void {
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
return;
}
if (defined('REST_REQUEST') && REST_REQUEST) {
return;
}
if (is_feed() || is_robots() || is_trackback()) {
return;
}
ob_start([$this, 'rewrite_full_html']);
}
/**
* Output buffer callback: rewrite remaining
tags to .
* Protects existing ,