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); } } /** * 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); } // Convert sub-sizes (thumbnails, medium, large, etc.) if (!empty($this->settings['convert_existing_sizes']) && !empty($metadata['sizes'])) { $upload_dir = dirname($file); foreach ($metadata['sizes'] as $size_name => &$size_data) { $size_file = $upload_dir . '/' . $size_data['file']; $size_converted = $this->convert_file($size_file, $format, $quality); if ($size_converted && $strategy === 'replace') { @unlink($size_file); $size_data['file'] = basename($size_converted); $size_data['mime-type'] = "image/{$format}"; } } unset($size_data); } // 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 if (!empty($settings['convert_existing_sizes'])) { $metadata = wp_get_attachment_metadata($attachment_id); if ($metadata && !empty($metadata['sizes'])) { $upload_dir = dirname($file); foreach ($metadata['sizes'] as $size_name => &$size_data) { $size_file = $upload_dir . '/' . $size_data['file']; $size_converted = self::do_convert_file($size_file, $format, $quality); if ($size_converted && $strategy === 'replace') { @unlink($size_file); $size_data['file'] = basename($size_converted); $size_data['mime-type'] = "image/{$format}"; } } unset($size_data); if ($strategy === 'replace') { wp_update_attachment_metadata($attachment_id, $metadata); } } } update_post_meta($attachment_id, '_metasync_converted_format', $format); return true; } /** * 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'); return true; } // ── Core Conversion (static, reusable) ── /** * 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; } $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)) { $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); return $success ? $dest : null; } /** * 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 { @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); } // ── 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); } /** * Rewrite tags to with next-gen source. */ public function rewrite_to_picture_tags(string $content): string { if (empty($content)) { return $content; } // Skip if already wrapped in (avoid double-wrapping from multiple filters) if (strpos($content, '') !== false) { return $content; } return preg_replace_callback('/]+>/i', function ($matches) { $img_tag = $matches[0]; // Check if this image should be excluded if ($this->is_tag_excluded($img_tag)) { return $img_tag; } // Extract src if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) { return $img_tag; } $original_src = $src_match[1]; $format = $this->settings['conversion_format']; $ext = self::get_format_extension($format); // Build the converted file URL $converted_url = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $original_src); // Only wrap if the converted URL is different if ($converted_url === $original_src) { return $img_tag; } // Verify the converted file actually exists on disk before rewriting $converted_path = $this->url_to_path($converted_url); if (!$converted_path || !file_exists($converted_path)) { return $img_tag; } $mime = $format === 'avif' ? 'image/avif' : 'image/webp'; // Also handle srcset if present $source_srcset = ''; if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) { $converted_srcset = preg_replace('/\.(jpe?g|png)/i', $ext, $srcset_match[1]); $source_srcset = sprintf(' srcset="%s"', esc_attr($converted_srcset)); } // Extract sizes attribute to pass to $sizes_attr = ''; if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) { $sizes_attr = sprintf(' sizes="%s"', esc_attr($sizes_match[1])); } return sprintf( '%s', esc_attr($mime), $source_srcset ?: sprintf(' srcset="%s"', esc_attr($converted_url)), $sizes_attr, $img_tag ); }, $content); } /** * Convert a URL to a local file path. Returns null if URL is external. */ private function url_to_path(string $url): ?string { $upload_dir = wp_get_upload_dir(); $base_url = $upload_dir['baseurl']; $base_path = $upload_dir['basedir']; if (strpos($url, $base_url) === 0) { return str_replace($base_url, $base_path, $url); } return null; } /** * Check if a file path matches exclusion patterns. */ private function is_excluded(string $file): bool { $exclude_urls = array_filter(array_map('trim', explode(',', $this->settings['exclude_urls'] ?? ''))); if (empty($exclude_urls)) { return false; } foreach ($exclude_urls as $pattern) { if (stripos($file, $pattern) !== false) { return true; } } return false; } /** * Check if an img tag has an excluded CSS class. */ private function is_tag_excluded(string $tag): bool { $exclude_classes = array_filter(array_map('trim', explode(',', $this->settings['exclude_classes'] ?? ''))); if (empty($exclude_classes)) { return false; } if (preg_match('/class=["\']([^"\']+)["\']/i', $tag, $class_match)) { $classes = explode(' ', $class_match[1]); foreach ($exclude_classes as $excluded) { if (in_array($excluded, $classes, true)) { return true; } } } return false; } }