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']; $max_dim = (int) ($this->settings['max_image_dimensions'] ?? 0); // Capture original size before any replacement deletes the source file. $original_size = file_exists($file) ? (int) filesize($file) : 0; // Give a heavy multi-size upload a fresh execution budget instead of // inheriting what WP's own thumbnail generation left of the request's // default cap. static::reset_time_limit(); // Convert main/full file (downscaled to $max_dim if it exceeds the limit) $converted = $this->convert_file($file, $format, $quality, $max_dim); 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); } // Record the conversion for BOTH strategies so the image reports as // optimized in the library and is not re-queued by the batch optimizer. // (The picture tag rewriter also relies on this meta for "alongside".) if ($converted) { update_post_meta($attachment_id, '_metasync_converted_format', $format); if ($original_size) { update_post_meta($attachment_id, '_metasync_original_filesize', $original_size); } } 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; } // Request WordPress's image processing memory limit (typically 256MB) before heavy work wp_raise_memory_limit('image'); // Give a heavy multi-size conversion a fresh execution budget. static::reset_time_limit(); $format = $settings['conversion_format'] ?? 'webp'; $quality = (int) ($settings['conversion_quality'] ?? 82); $strategy = $settings['conversion_strategy'] ?? 'alongside'; $max_dim = (int) ($settings['max_image_dimensions'] ?? 0); // Store original file size before conversion for savings display $original_size = filesize($file); $converted = self::do_convert_file($file, $format, $quality, $max_dim); 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; } // Replace strategy has no original to restore — the attached file IS the // converted file, so the extension swap below would leave the path // unchanged and unlink the attachment's only copy. Refuse instead. if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) { 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 !== $file` guards against deleting the attachment's // own file when the extension pattern doesn't match (e.g. the attached file is // already .webp/.avif and preg_replace returns the path unchanged). $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file); if ($converted_path && $converted_path !== $file && 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_path = $upload_dir . '/' . $size_data['file']; $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $size_path); if ($size_converted && $size_converted !== $size_path && 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; } /** * Delete converted sibling files when an attachment is deleted. * * Hooked on `delete_attachment` (fires before WordPress removes the * attachment's own files). The "alongside" strategy writes .webp/.avif * files next to the originals that are NOT tracked in attachment metadata, * so WordPress core never deletes them and they leak on disk. The "replace" * strategy's converted files ARE the attachment files and are removed by * core, so nothing extra is needed there. * * @param int $attachment_id Attachment being deleted. */ public static function cleanup_on_delete(int $attachment_id): void { // Replace-strategy converted files are the attachment files themselves // (deleted by core). Only "alongside" siblings need manual cleanup. if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) { return; } $file = get_attached_file($attachment_id); if (!$file) { return; } // Prefer the recorded format; fall back to both next-gen extensions // when the format is unknown (e.g. created by an older plugin version). $format = get_post_meta($attachment_id, '_metasync_converted_format', true); $exts = $format ? [self::get_format_extension($format)] : [self::EXT_WEBP, self::EXT_AVIF]; // Collect the full-size file plus every registered sub-size. $paths = [$file]; $metadata = wp_get_attachment_metadata($attachment_id); if (is_array($metadata) && !empty($metadata['sizes'])) { $dir = dirname($file); foreach ($metadata['sizes'] as $size_data) { if (!empty($size_data['file'])) { $paths[] = $dir . '/' . $size_data['file']; } } } foreach ($paths as $path) { foreach ($exts as $ext) { $converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $path); if ($converted && $converted !== $path && file_exists($converted)) { @unlink($converted); } } } } // ── 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)); } /** * Grant the current request a fresh execution budget before heavy * conversion work. Returns true when the timer was actually * reset; false on hosts where set_time_limit() is disabled, in which * case callers must rely on get_remaining_time() to bail out early. */ protected static function reset_time_limit(): bool { if (function_exists('set_time_limit') && @set_time_limit(self::UPLOAD_TIME_LIMIT)) { self::$timer_started_at = microtime(true); return true; } return false; } /** * Seconds left before PHP's execution limit kills the request, minus * TIME_SAFETY_MARGIN. * * Defense-in-depth for hosts where set_time_limit() is disabled: the * elapsed time is measured from the request start * ($_SERVER['REQUEST_TIME_FLOAT']) — not from when conversion began — so * time WordPress already spent generating thumbnails counts against the * budget. Once reset_time_limit() succeeds, the baseline moves to the * moment of the reset, matching PHP's own restarted timer. * * On Linux max_execution_time counts CPU time while this measures wall * clock, so the estimate only errs on the early-bail (safe) side. * * @param int $max_execution_time PHP max_execution_time (0 = unlimited). Accepts parameter for testability. * @return float Remaining seconds; PHP_INT_MAX when no limit applies. */ protected static function get_remaining_time(int $max_execution_time = -1): float { if ($max_execution_time < 0) { $max_execution_time = (int) ini_get('max_execution_time'); } // No execution limit (CLI, unlimited hosts): a timeout fatal is // impossible, so never cut conversions short. if ($max_execution_time <= 0) { return (float) PHP_INT_MAX; } $started = self::$timer_started_at > 0.0 ? self::$timer_started_at : (float) ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true)); return $max_execution_time - (microtime(true) - $started) - self::TIME_SAFETY_MARGIN; } /** * Convert sub-sizes with memory and execution-time 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. Each * iteration also refreshes the execution timer (or, where that is * disabled, bails out before PHP's limit is hit) so a heavy multi-size * image cannot trigger a max_execution_time fatal. * * @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 { $pass_started = microtime(true); foreach ($sizes as $size_name => &$size_data) { // Cap the total synchronous sub-size pass so the // per-iteration timer resets below cannot keep one request busy // indefinitely. if ((microtime(true) - $pass_started) >= self::UPLOAD_TIME_LIMIT) { error_log('[MetaSync Media Opt] Sub-size time budget exceeded, skipping remaining sub-sizes from: ' . $size_name); break; } // Refresh the execution timer between encodes; when the host // disables set_time_limit(), bail out before PHP's limit kills // the request. if (!static::reset_time_limit() && static::get_remaining_time() <= 0) { error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping remaining sub-sizes from: ' . $size_name); break; } // 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); } /** * Calculate downscaled dimensions that fit within a square bound while * preserving aspect ratio. * * Returns [width, height] when the image exceeds $max on either axis, or * null when no downscale is needed (already within bounds, downscaling * disabled, or invalid dimensions). The result is never upscaled. * * @param int $width Original width in pixels. * @param int $height Original height in pixels. * @param int $max Maximum allowed width/height; 0 disables downscaling. * @return array{0:int,1:int}|null */ protected static function calc_scaled_dimensions(int $width, int $height, int $max): ?array { if ($max <= 0 || $width <= 0 || $height <= 0) { return null; } if ($width <= $max && $height <= $max) { return null; } $ratio = min($max / $width, $max / $height); $new_w = max(1, (int) round($width * $ratio)); $new_h = max(1, (int) round($height * $ratio)); return [$new_w, $new_h]; } /** * Convert a single image file. Returns path to converted file or null on failure. * * When $max_dimensions is greater than zero and the source exceeds it on * either axis, the image is downscaled (aspect ratio preserved) before * encoding. This lowers the encoder's pixel buffer and shrinks output. */ protected static function do_convert_file(string $source, string $format, int $quality, int $max_dimensions = 0): ?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; } // Bail out instead of starting an encode PHP may kill mid-write // On hosts where set_time_limit() is disabled the request // keeps its original cap, and the fatal reported by Sentry fired here. if (static::get_remaining_time() <= 0) { error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping conversion: ' . basename($source)); return null; } // Request WordPress's image processing memory limit wp_raise_memory_limit('image'); // Pre-flight memory check using pixel dimensions when available. // Note: the estimate intentionally uses the ORIGINAL dimensions because // both encoders must decode the full-resolution source into memory // before any downscale can be applied. $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; } // Determine target dimensions when the source exceeds the configured cap. $target_dimensions = ($info && $info[0] > 0 && $info[1] > 0) ? self::calc_scaled_dimensions((int) $info[0], (int) $info[1], $max_dimensions) : 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, $target_dimensions); 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, $target_dimensions); } 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, ?array $target_dimensions = null): ?string { $img = new \Imagick(); $img->setResourceLimit(\Imagick::RESOURCETYPE_MEMORY, 64 * 1024 * 1024); $img->setResourceLimit(\Imagick::RESOURCETYPE_MAP, 128 * 1024 * 1024); $img->readImage($src); // Downscale before encoding when the image exceeds the configured cap. if ($target_dimensions !== null) { [$new_w, $new_h] = $target_dimensions; $img->scaleImage($new_w, $new_h); } $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, ?array $target_dimensions = null): ?string { $info = getimagesize($src); if (!$info) { return null; } $is_png = ($info['mime'] === 'image/png'); $gd_img = match ($info['mime']) { 'image/jpeg' => imagecreatefromjpeg($src), 'image/png' => imagecreatefrompng($src), default => null, }; if (!$gd_img) { return null; } if ($is_png) { imagepalettetotruecolor($gd_img); imagealphablending($gd_img, true); imagesavealpha($gd_img, true); } // Downscale before encoding when the image exceeds the configured cap. if ($target_dimensions !== null) { [$new_w, $new_h] = $target_dimensions; $resized = imagecreatetruecolor($new_w, $new_h); if ($resized !== false) { if ($is_png) { // Preserve transparency on the resized canvas. imagealphablending($resized, false); imagesavealpha($resized, true); $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127); imagefilledrectangle($resized, 0, 0, $new_w, $new_h, $transparent); } imagecopyresampled( $resized, $gd_img, 0, 0, 0, 0, $new_w, $new_h, imagesx($gd_img), imagesy($gd_img) ); imagedestroy($gd_img); $gd_img = $resized; } } $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); // Sync the full-size dimensions to the converted file. When a pre-conversion // downscale shrank the image, the original width/height in metadata are now // stale; otherwise this is a harmless no-op. $new_dims = @getimagesize($new_path); if ($new_dims && $new_dims[0] > 0 && $new_dims[1] > 0) { $meta['width'] = (int) $new_dims[0]; $meta['height'] = (int) $new_dims[1]; } // 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; } // Batch the UPDATE so a large wp_posts table is never locked by a single // unbounded REPLACE. Each batch only rewrites rows that still // contain the old path; once replaced they no longer match the LIKE, so // the loop converges. The batch ceiling is a safety net against an // unexpected non-converging loop (e.g. a DB-level error returning false). $batch_size = 500; $like = '%' . $wpdb->esc_like($old_path) . '%'; $max_batches = 100000; for ($batch = 0; $batch < $max_batches; $batch++) { $affected = $wpdb->query($wpdb->prepare( "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) WHERE post_content LIKE %s ORDER BY ID LIMIT %d", $old_path, $new_path, $like, $batch_size )); // false → query error; a short batch (< batch_size) means the last // matching rows were just rewritten. Either way there is no more work. if ($affected === false || $affected < $batch_size) { break; } } } // ── Instance Wrappers (Upload Hook) ── /** * Instance wrapper around static conversion method. */ private function convert_file(string $source, string $format, int $quality, int $max_dimensions = 0): ?string { return self::do_convert_file($source, $format, $quality, $max_dimensions); } /** * 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 ,