PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | media-optimization/class-image-converter.php +840 -126 2.6.10 → 2.7.2 View file →
@@ -27,8 +27,64 @@
27 27 private const ORIGINAL_EXT_PATTERN = '/\.(jpe?g|png)$/i';
28 28 private const MAX_CONVERT_BYTES = 10 * 1024 * 1024;
29 29
30 30 /**
31 + * Minimum available memory (bytes) required before attempting a sub-size conversion.
32 + * Remaining sub-sizes are skipped when available memory drops below this threshold.
33 + */
34 + private const MIN_MEMORY_FOR_SUBSIZE = 8 * 1024 * 1024;
35 +
36 + /**
37 + * Fresh execution budget (seconds) granted before heavy conversion work,
38 + * and the cap on a single synchronous sub-size pass.
39 + */
40 + private const UPLOAD_TIME_LIMIT = 300;
41 +
42 + /**
43 + * Seconds reserved before PHP's hard limit, mirroring class-media-batch-optimizer.php.
44 + */
45 + private const TIME_SAFETY_MARGIN = 5;
46 +
47 + /**
48 + * When the PHP execution timer last (re)started, as a microtime(true)
49 + * timestamp. 0.0 means the timer has never been reset, so it has been
50 + * running since the request started ($_SERVER['REQUEST_TIME_FLOAT']).
51 + */
52 + private static float $timer_started_at = 0.0;
53 +
54 + /**
55 + * While a batch run is in flight, per-image cache purges are suppressed
56 + * (a thousand-image batch would otherwise trigger a thousand full-site
57 + * purges) and a single purge fires once the batch finishes. See
58 + * set_cache_purge_suppressed().
59 + */
60 + private static bool $cache_purge_suppressed = false;
61 +
62 + /**
63 + * Strip server-identifying absolute paths from log messages, keeping the
64 + * upload-relative portion that actually identifies the file.
65 + */
66 + private static function redact_path(string $path): string {
67 + $replacements = [];
68 +
69 + $upload_dir = wp_get_upload_dir();
70 + if (!empty($upload_dir['basedir'])) {
71 + $replacements[] = [$upload_dir['basedir'] . '/', 'uploads/'];
72 + }
73 + if (defined('ABSPATH')) {
74 + $replacements[] = [ABSPATH, ''];
75 + }
76 +
77 + foreach ($replacements as [$needle, $replacement]) {
78 + if (strpos($path, $needle) === 0) {
79 + return $replacement . substr($path, strlen($needle));
80 + }
81 + }
82 +
83 + return basename($path);
84 + }
85 +
86 + /**
31 87 * Get file extension for a given format.
32 88 */
33 89 private static function get_format_extension(string $format): string {
34 90 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
@@ -33,8 +89,43 @@
33 89 private static function get_format_extension(string $format): string {
34 90 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
35 91 }
36 92
93 + /**
94 + * Suppress (or re-enable) the per-image page-cache purge. The batch
95 + * optimizer suppresses around its ticks and purges once at completion.
96 + */
97 + public static function set_cache_purge_suppressed(bool $suppressed): void {
98 + self::$cache_purge_suppressed = $suppressed;
99 + }
100 +
101 + /**
102 + * Purge page caches after an image's on-disk filenames changed (replace
103 + * conversion or revert), so cached/edge pages cannot keep serving
104 + * references to deleted files.
105 + */
106 + public static function purge_page_caches(): void {
107 + if (self::$cache_purge_suppressed) {
108 + return;
109 + }
110 +
111 + // Indirect guard so static analysis cannot narrow the class name and
112 + // flag the check as redundant — the purger is plugin-loaded, but the
113 + // converter can be exercised standalone (tests, tooling).
114 + if (!self::class_is_available('Metasync_Cache_Purge')) {
115 + return;
116 + }
117 +
118 + Metasync_Cache_Purge::purge_all('media_optimization');
119 + }
120 +
121 + /**
122 + * @param string $class Class name to check.
123 + */
124 + private static function class_is_available(string $class): bool {
125 + return class_exists($class);
126 + }
127 +
37 128 public function __construct(array $settings) {
38 129 $this->settings = $settings;
39 130
40 131 // Fires after WP generates all thumbnail sizes
@@ -40,9 +131,9 @@
40 131 // Fires after WP generates all thumbnail sizes
41 132 add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2);
42 133
43 134 // If "alongside" strategy, rewrite <img> tags to <picture>
44 - if ($settings['conversion_strategy'] === 'alongside') {
135 + if (($settings['conversion_strategy'] ?? '') === 'alongside') {
45 136 // Core WordPress
46 137 add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20);
47 138 add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
48 139 add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20);
@@ -51,8 +142,11 @@
51 142 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
52 143 add_filter('woocommerce_product_get_image', [$this, 'rewrite_to_picture_tags'], 20);
53 144 add_filter('woocommerce_cart_item_thumbnail', [$this, 'rewrite_to_picture_tags'], 20);
54 145 add_filter('woocommerce_placeholder_img', [$this, 'rewrite_to_picture_tags'], 20);
146 +
147 + // Output buffer catch-all for themes/builders bypassing WP filters (Divi, Elementor, etc.)
148 + add_action('template_redirect', [$this, 'start_output_buffer'], 1);
55 149 }
56 150 }
57 151
58 152 /**
@@ -73,38 +167,39 @@
73 167
74 168 $format = $this->settings['conversion_format'];
75 169 $quality = (int) $this->settings['conversion_quality'];
76 170 $strategy = $this->settings['conversion_strategy'];
171 + $max_dim = (int) ($this->settings['max_image_dimensions'] ?? 0);
77 172
78 - // Convert main/full file
79 - $converted = $this->convert_file($file, $format, $quality);
173 + // Capture original size before any replacement deletes the source file.
174 + $original_size = file_exists($file) ? (int) filesize($file) : 0;
80 175
176 + // Give a heavy multi-size upload a fresh execution budget instead of
177 + // inheriting what WP's own thumbnail generation left of the request's
178 + // default cap.
179 + static::reset_time_limit();
180 +
181 + // Convert main/full file (downscaled to $max_dim if it exceeds the limit)
182 + $converted = $this->convert_file($file, $format, $quality, $max_dim);
183 +
81 184 if ($converted && $strategy === 'replace') {
82 185 $this->replace_original($attachment_id, $file, $converted, $metadata, $format);
83 186 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
84 187 }
85 188
86 - // Convert sub-sizes (thumbnails, medium, large, etc.)
189 + // Convert sub-sizes (thumbnails, medium, large, etc.) with memory management
87 190 if (!empty($this->settings['convert_existing_sizes']) && !empty($metadata['sizes'])) {
88 - $upload_dir = dirname($file);
89 - foreach ($metadata['sizes'] as $size_name => &$size_data) {
90 - $size_file = $upload_dir . '/' . $size_data['file'];
91 - $size_converted = $this->convert_file($size_file, $format, $quality);
92 -
93 - if ($size_converted && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
94 - @unlink($size_file);
95 - $size_data['file'] = basename($size_converted);
96 - $size_data['mime-type'] = "image/{$format}";
97 - } elseif ($size_converted && $strategy === 'replace') {
98 - error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . $size_file);
99 - }
100 - }
101 - unset($size_data);
191 + static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
102 192 }
103 193
104 - // Store converted format in meta for later use by picture tag rewriter
105 - if ($converted && $strategy === 'alongside') {
194 + // Record the conversion for BOTH strategies so the image reports as
195 + // optimized in the library and is not re-queued by the batch optimizer.
196 + // (The picture tag rewriter also relies on this meta for "alongside".)
197 + if ($converted) {
106 198 update_post_meta($attachment_id, '_metasync_converted_format', $format);
199 + if ($original_size) {
200 + update_post_meta($attachment_id, '_metasync_original_filesize', $original_size);
201 + }
107 202 }
108 203
109 204 return $metadata;
110 205 }
@@ -122,16 +217,33 @@
122 217 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
123 218 return false;
124 219 }
125 220
221 + // The exclusion list must gate batch/bulk/single-image conversions
222 + // too, not just uploads — otherwise a bulk run converts files the
223 + // site owner explicitly asked the module to leave alone. Both the
224 + // stored path and the attachment URL are matched because exclusion
225 + // entries may be written as either.
226 + $exclusion_check = new self($settings);
227 + if ($exclusion_check->is_excluded($file) || $exclusion_check->is_url_excluded((string) wp_get_attachment_url($attachment_id))) {
228 + return false;
229 + }
230 +
231 + // Request WordPress's image processing memory limit (typically 256MB) before heavy work
232 + wp_raise_memory_limit('image');
233 +
234 + // Give a heavy multi-size conversion a fresh execution budget.
235 + static::reset_time_limit();
236 +
126 237 $format = $settings['conversion_format'] ?? 'webp';
127 238 $quality = (int) ($settings['conversion_quality'] ?? 82);
128 239 $strategy = $settings['conversion_strategy'] ?? 'alongside';
240 + $max_dim = (int) ($settings['max_image_dimensions'] ?? 0);
129 241
130 242 // Store original file size before conversion for savings display
131 243 $original_size = filesize($file);
132 244
133 - $converted = self::do_convert_file($file, $format, $quality);
245 + $converted = self::do_convert_file($file, $format, $quality, $max_dim);
134 246 if (!$converted) {
135 247 return false;
136 248 }
137 249
@@ -147,26 +259,13 @@
147 259 wp_update_attachment_metadata($attachment_id, $metadata);
148 260 }
149 261 }
150 262
151 - // Convert sub-sizes
263 + // Convert sub-sizes with memory management
152 264 if (!empty($settings['convert_existing_sizes'])) {
153 265 $metadata = wp_get_attachment_metadata($attachment_id);
154 266 if ($metadata && !empty($metadata['sizes'])) {
155 - $upload_dir = dirname($file);
156 - foreach ($metadata['sizes'] as $size_name => &$size_data) {
157 - $size_file = $upload_dir . '/' . $size_data['file'];
158 - $size_converted = self::do_convert_file($size_file, $format, $quality);
159 -
160 - if ($size_converted && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
161 - @unlink($size_file);
162 - $size_data['file'] = basename($size_converted);
163 - $size_data['mime-type'] = "image/{$format}";
164 - } elseif ($size_converted && $strategy === 'replace') {
165 - error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . $size_file);
166 - }
167 - }
168 - unset($size_data);
267 + static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
169 268 if ($strategy === 'replace') {
170 269 wp_update_attachment_metadata($attachment_id, $metadata);
171 270 }
172 271 }
@@ -173,11 +272,15 @@
173 272 }
174 273
175 274 if ($strategy === 'replace') {
176 275 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
276 + // The original files are gone from disk now — cached pages still
277 + // referencing them would serve broken images until their TTL.
278 + self::purge_page_caches();
177 279 }
178 280
179 281 update_post_meta($attachment_id, '_metasync_converted_format', $format);
282 + Metasync_Media_Batch_Optimizer::flush_stats_cache();
180 283 return true;
181 284 }
182 285
183 286 /**
@@ -209,8 +312,15 @@
209 312 if (!$format) {
210 313 return false;
211 314 }
212 315
316 + // Replace strategy has no original to restore — the attached file IS the
317 + // converted file, so the extension swap below would leave the path
318 + // unchanged and unlink the attachment's only copy. Refuse instead.
319 + if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) {
320 + return false;
321 + }
322 +
213 323 $file = get_attached_file($attachment_id);
214 324 if (!$file) {
215 325 return false;
216 326 }
@@ -222,11 +332,14 @@
222 332
223 333 $ext = self::get_format_extension($format);
224 334
225 335 // Delete converted full-size file
336 + // `$converted_path !== $file` guards against deleting the attachment's
337 + // own file when the extension pattern doesn't match (e.g. the attached file is
338 + // already .webp/.avif and preg_replace returns the path unchanged).
226 339 $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file);
227 - if ($converted_path && file_exists($converted_path)) {
228 - @unlink($converted_path);
340 + if ($converted_path && $converted_path !== $file && file_exists($converted_path)) {
341 + @wp_delete_file($converted_path);
229 342 }
230 343
231 344 // Delete converted sub-sizes
232 345 $metadata = wp_get_attachment_metadata($attachment_id);
@@ -232,11 +345,12 @@
232 345 $metadata = wp_get_attachment_metadata($attachment_id);
233 346 if ($metadata && !empty($metadata['sizes'])) {
234 347 $upload_dir = dirname($file);
235 348 foreach ($metadata['sizes'] as $size_data) {
236 - $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $upload_dir . '/' . $size_data['file']);
237 - if ($size_converted && file_exists($size_converted)) {
238 - @unlink($size_converted);
349 + $size_path = $upload_dir . '/' . $size_data['file'];
350 + $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $size_path);
351 + if ($size_converted && $size_converted !== $size_path && file_exists($size_converted)) {
352 + @wp_delete_file($size_converted);
239 353 }
240 354 }
241 355 }
242 356
@@ -242,26 +356,302 @@
242 356
243 357 delete_post_meta($attachment_id, '_metasync_converted_format');
244 358 delete_post_meta($attachment_id, '_metasync_original_filesize');
245 359 delete_post_meta($attachment_id, '_metasync_replaced_original');
360 +
361 + // Cached pages may still contain <picture> markup referencing the
362 + // converted files just deleted — flush them.
363 + self::purge_page_caches();
364 + Metasync_Media_Batch_Optimizer::flush_stats_cache();
246 365 return true;
247 366 }
248 367
368 + /**
369 + * Delete converted sibling files when an attachment is deleted.
370 + *
371 + * Hooked on `delete_attachment` (fires before WordPress removes the
372 + * attachment's own files). The "alongside" strategy writes .webp/.avif
373 + * files next to the originals that are NOT tracked in attachment metadata,
374 + * so WordPress core never deletes them and they leak on disk. The "replace"
375 + * strategy's converted files ARE the attachment files and are removed by
376 + * core, so nothing extra is needed there.
377 + *
378 + * @param int $attachment_id Attachment being deleted.
379 + */
380 + public static function cleanup_on_delete(int $attachment_id): void {
381 + // Replace-strategy converted files are the attachment files themselves
382 + // (deleted by core). Only "alongside" siblings need manual cleanup.
383 + if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) {
384 + return;
385 + }
386 +
387 + $file = get_attached_file($attachment_id);
388 + if (!$file) {
389 + return;
390 + }
391 +
392 + // Prefer the recorded format; fall back to both next-gen extensions
393 + // when the format is unknown (e.g. created by an older plugin version).
394 + $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
395 + $exts = $format ? [self::get_format_extension($format)] : [self::EXT_WEBP, self::EXT_AVIF];
396 +
397 + // Collect the full-size file plus every registered sub-size.
398 + $paths = [$file];
399 + $metadata = wp_get_attachment_metadata($attachment_id);
400 + if (is_array($metadata) && !empty($metadata['sizes'])) {
401 + $dir = dirname($file);
402 + foreach ($metadata['sizes'] as $size_data) {
403 + if (!empty($size_data['file'])) {
404 + $paths[] = $dir . '/' . $size_data['file'];
405 + }
406 + }
407 + }
408 +
409 + foreach ($paths as $path) {
410 + foreach ($exts as $ext) {
411 + $converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $path);
412 + if ($converted && $converted !== $path && file_exists($converted)) {
413 + @wp_delete_file($converted);
414 + }
415 + }
416 + }
417 + }
418 +
249 419 // ── Core Conversion (static, reusable) ──
250 420
251 421 /**
422 + * Get available PHP memory in bytes.
423 + * Returns PHP_INT_MAX when no limit is set (-1) or unreadable.
424 + * Returns 0 when memory_limit is "0" or empty.
425 + */
426 + protected static function get_available_memory(): int {
427 + $limit = ini_get('memory_limit');
428 +
429 + if ($limit === false || $limit === '-1') {
430 + return PHP_INT_MAX;
431 + }
432 +
433 + $limit = trim($limit);
434 + if ($limit === '' || $limit === '0') {
435 + return 0;
436 + }
437 +
438 + $value = (int) $limit;
439 + $unit = strtolower(substr($limit, -1));
440 +
441 + $value = match ($unit) {
442 + 'g' => $value * 1024 * 1024 * 1024,
443 + 'm' => $value * 1024 * 1024,
444 + 'k' => $value * 1024,
445 + default => $value,
446 + };
447 +
448 + return max(0, $value - memory_get_usage(true));
449 + }
450 +
451 + /**
452 + * Grant the current request a fresh execution budget before heavy
453 + * conversion work. Returns true when the timer was actually
454 + * reset; false on hosts where set_time_limit() is disabled, in which
455 + * case callers must rely on get_remaining_time() to bail out early.
456 + */
457 + protected static function reset_time_limit(): bool {
458 + if (function_exists('set_time_limit') && @set_time_limit(self::UPLOAD_TIME_LIMIT)) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running batch job; WordPress has no equivalent API
459 + self::$timer_started_at = microtime(true);
460 + return true;
461 + }
462 + return false;
463 + }
464 +
465 + /**
466 + * Seconds left before PHP's execution limit kills the request, minus
467 + * TIME_SAFETY_MARGIN.
468 + *
469 + * Defense-in-depth for hosts where set_time_limit() is disabled: the
470 + * elapsed time is measured from the request start
471 + * ($_SERVER['REQUEST_TIME_FLOAT']) — not from when conversion began — so
472 + * time WordPress already spent generating thumbnails counts against the
473 + * budget. Once reset_time_limit() succeeds, the baseline moves to the
474 + * moment of the reset, matching PHP's own restarted timer.
475 + *
476 + * On Linux max_execution_time counts CPU time while this measures wall
477 + * clock, so the estimate only errs on the early-bail (safe) side.
478 + *
479 + * @param int $max_execution_time PHP max_execution_time (0 = unlimited). Accepts parameter for testability.
480 + * @return float Remaining seconds; PHP_INT_MAX when no limit applies.
481 + */
482 + protected static function get_remaining_time(int $max_execution_time = -1): float {
483 + if ($max_execution_time < 0) {
484 + $max_execution_time = (int) ini_get('max_execution_time');
485 + }
486 +
487 + // No execution limit (CLI, unlimited hosts): a timeout fatal is
488 + // impossible, so never cut conversions short.
489 + if ($max_execution_time <= 0) {
490 + return (float) PHP_INT_MAX;
491 + }
492 +
493 + $started = self::$timer_started_at > 0.0
494 + ? self::$timer_started_at
495 + : (float) ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true));
496 +
497 + return $max_execution_time - (microtime(true) - $started) - self::TIME_SAFETY_MARGIN;
498 + }
499 +
500 + /**
501 + * Convert sub-sizes with memory and execution-time management.
502 + *
503 + * Runs gc_collect_cycles() between each sub-size to release memory and
504 + * checks available memory before each iteration, skipping remaining
505 + * sub-sizes when memory drops below MIN_MEMORY_FOR_SUBSIZE. Each
506 + * iteration also refreshes the execution timer (or, where that is
507 + * disabled, bails out before PHP's limit is hit) so a heavy multi-size
508 + * image cannot trigger a max_execution_time fatal.
509 + *
510 + * @param array $sizes Reference to $metadata['sizes'].
511 + * @param string $upload_dir Directory containing the sub-size files.
512 + * @param string $format Target format (webp|avif).
513 + * @param int $quality Compression quality.
514 + * @param string $strategy Conversion strategy (replace|alongside).
515 + */
516 + protected static function convert_subsizes(array &$sizes, string $upload_dir, string $format, int $quality, string $strategy): void {
517 + $pass_started = microtime(true);
518 +
519 + foreach ($sizes as $size_name => &$size_data) {
520 + // Cap the total synchronous sub-size pass so the
521 + // per-iteration timer resets below cannot keep one request busy
522 + // indefinitely.
523 + if ((microtime(true) - $pass_started) >= self::UPLOAD_TIME_LIMIT) {
524 + error_log('[MetaSync Media Opt] Sub-size time budget exceeded, skipping remaining sub-sizes from: ' . $size_name); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
525 + break;
526 + }
527 +
528 + // Refresh the execution timer between encodes; when the host
529 + // disables set_time_limit(), bail out before PHP's limit kills
530 + // the request.
531 + if (!static::reset_time_limit() && static::get_remaining_time() <= 0) {
532 + error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping remaining sub-sizes from: ' . $size_name); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
533 + break;
534 + }
535 +
536 + // Check available memory before each sub-size conversion
537 + $available = static::get_available_memory();
538 + if ($available < self::MIN_MEMORY_FOR_SUBSIZE) {
539 + error_log('[MetaSync Media Opt] Low memory (' . size_format($available) . '), skipping remaining sub-sizes from: ' . $size_name); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
540 + break;
541 + }
542 +
543 + $size_file = $upload_dir . '/' . $size_data['file'];
544 +
545 + // A sub-size that is already in the target format (e.g. a prior
546 + // replace run) maps onto itself: converting would rewrite the file
547 + // in place and the unlink below would then delete it. Skip.
548 + $size_ext = self::get_format_extension($format);
549 + $size_dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $size_ext, $size_file);
550 + if ($size_dest === $size_file) {
551 + continue;
552 + }
553 +
554 + $size_converted = static::do_convert_file($size_file, $format, $quality);
555 +
556 + if ($size_converted && $size_converted !== $size_file && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
557 + // Rewrite content references BEFORE unlinking — galleries,
558 + // widgets and hardcoded <img> tags point at sub-size URLs
559 + // (photo-1024x768.jpg), and the original is unrecoverable
560 + // once deleted.
561 + self::rewrite_content_paths($size_file, $size_converted);
562 + @wp_delete_file($size_file);
563 + $size_data['file'] = basename($size_converted);
564 + $size_data['mime-type'] = "image/{$format}";
565 + } elseif ($size_converted && $strategy === 'replace') {
566 + error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . self::redact_path($size_file)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
567 + }
568 +
569 + // Release cyclic references between sub-size conversions
570 + gc_collect_cycles();
571 + }
572 + unset($size_data);
573 + }
574 +
575 + /**
576 + * Calculate downscaled dimensions that fit within a square bound while
577 + * preserving aspect ratio.
578 + *
579 + * Returns [width, height] when the image exceeds $max on either axis, or
580 + * null when no downscale is needed (already within bounds, downscaling
581 + * disabled, or invalid dimensions). The result is never upscaled.
582 + *
583 + * @param int $width Original width in pixels.
584 + * @param int $height Original height in pixels.
585 + * @param int $max Maximum allowed width/height; 0 disables downscaling.
586 + * @return array{0:int,1:int}|null
587 + */
588 + protected static function calc_scaled_dimensions(int $width, int $height, int $max): ?array {
589 + if ($max <= 0 || $width <= 0 || $height <= 0) {
590 + return null;
591 + }
592 +
593 + if ($width <= $max && $height <= $max) {
594 + return null;
595 + }
596 +
597 + $ratio = min($max / $width, $max / $height);
598 + $new_w = max(1, (int) round($width * $ratio));
599 + $new_h = max(1, (int) round($height * $ratio));
600 +
601 + return [$new_w, $new_h];
602 + }
603 +
604 + /**
252 605 * Convert a single image file. Returns path to converted file or null on failure.
606 + *
607 + * When $max_dimensions is greater than zero and the source exceeds it on
608 + * either axis, the image is downscaled (aspect ratio preserved) before
609 + * encoding. This lowers the encoder's pixel buffer and shrinks output.
253 610 */
254 - protected static function do_convert_file(string $source, string $format, int $quality): ?string {
611 + protected static function do_convert_file(string $source, string $format, int $quality, int $max_dimensions = 0): ?string {
255 612 if (!file_exists($source)) {
256 613 return null;
257 614 }
258 615
259 616 if (filesize($source) > self::MAX_CONVERT_BYTES) {
260 - error_log('[MetaSync Media Opt] Source file exceeds MAX_CONVERT_BYTES limit, skipping: ' . $source);
617 + error_log('[MetaSync Media Opt] Source file exceeds MAX_CONVERT_BYTES limit, skipping: ' . self::redact_path($source)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
261 618 return null;
262 619 }
263 620
621 + // Bail out instead of starting an encode PHP may kill mid-write
622 + // On hosts where set_time_limit() is disabled the request
623 + // keeps its original cap, and the fatal reported by Sentry fired here.
624 + if (static::get_remaining_time() <= 0) {
625 + error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping conversion: ' . basename($source)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
626 + return null;
627 + }
628 +
629 + // Request WordPress's image processing memory limit
630 + wp_raise_memory_limit('image');
631 +
632 + // Pre-flight memory check using pixel dimensions when available.
633 + // Note: the estimate intentionally uses the ORIGINAL dimensions because
634 + // both encoders must decode the full-resolution source into memory
635 + // before any downscale can be applied.
636 + $info = getimagesize($source);
637 + if ($info && $info[0] > 0 && $info[1] > 0) {
638 + $bpp = ($info['mime'] === 'image/png') ? 4 : 3;
639 + $estimated = (int) ($info[0] * $info[1] * $bpp * 1.8);
640 + } else {
641 + $estimated = filesize($source) * 3;
642 + }
643 + $available = self::get_available_memory();
644 + if ($estimated > $available * 0.8) {
645 + error_log('[MetaSync Media Opt] Skipping ' . basename($source) . ': estimated memory (' . size_format($estimated) . ') exceeds 80% of available (' . size_format($available) . ')'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
646 + return null;
647 + }
648 +
649 + // Determine target dimensions when the source exceeds the configured cap.
650 + $target_dimensions = ($info && $info[0] > 0 && $info[1] > 0)
651 + ? self::calc_scaled_dimensions((int) $info[0], (int) $info[1], $max_dimensions)
652 + : null;
653 +
264 654 $ext = self::get_format_extension($format);
265 655 $dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $source);
266 656
267 657 // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
@@ -266,22 +656,22 @@
266 656
267 657 // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
268 658 if (extension_loaded('imagick')) {
269 659 try {
270 - $result = self::do_convert_with_imagick($source, $dest, $format, $quality);
660 + $result = self::do_convert_with_imagick($source, $dest, $format, $quality, $target_dimensions);
271 661 if ($result) {
272 662 return $result;
273 663 }
274 664 } catch (\Exception $e) {
275 - error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage());
665 + error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
276 666 }
277 667 }
278 668
279 669 if (extension_loaded('gd')) {
280 670 try {
281 - return self::do_convert_with_gd($source, $dest, $format, $quality);
671 + return self::do_convert_with_gd($source, $dest, $format, $quality, $target_dimensions);
282 672 } catch (\Exception $e) {
283 - error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage());
673 + error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
284 674 }
285 675 }
286 676
287 677 return null;
@@ -286,10 +676,20 @@
286 676
287 677 return null;
288 678 }
289 679
290 - private static function do_convert_with_imagick(string $src, string $dest, string $fmt, int $q): ?string {
291 - $img = new \Imagick($src);
680 + private static function do_convert_with_imagick(string $src, string $dest, string $fmt, int $q, ?array $target_dimensions = null): ?string {
681 + $img = new \Imagick();
682 + $img->setResourceLimit(\Imagick::RESOURCETYPE_MEMORY, 64 * 1024 * 1024);
683 + $img->setResourceLimit(\Imagick::RESOURCETYPE_MAP, 128 * 1024 * 1024);
684 + $img->readImage($src);
685 +
686 + // Downscale before encoding when the image exceeds the configured cap.
687 + if ($target_dimensions !== null) {
688 + [$new_w, $new_h] = $target_dimensions;
689 + $img->scaleImage($new_w, $new_h);
690 + }
691 +
292 692 $img->setImageFormat($fmt === 'avif' ? 'avif' : 'webp');
293 693 $img->setImageCompressionQuality($q);
294 694 $img->stripImage();
295 695
@@ -294,10 +694,10 @@
294 694 $img->stripImage();
295 695
296 696 if ($img->writeImage($dest)) {
297 697 if (!file_exists($dest) || !filesize($dest)) {
298 - @unlink($dest);
299 - error_log('[MetaSync Media Opt] Imagick wrote 0-byte or missing output, discarding: ' . $dest);
698 + @wp_delete_file($dest);
699 + error_log('[MetaSync Media Opt] Imagick wrote 0-byte or missing output, discarding: ' . self::redact_path($dest)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
300 700 $img->destroy();
301 701 return null;
302 702 }
303 703 $img->destroy();
@@ -307,14 +707,16 @@
307 707 $img->destroy();
308 708 return null;
309 709 }
310 710
311 - private static function do_convert_with_gd(string $src, string $dest, string $fmt, int $q): ?string {
711 + private static function do_convert_with_gd(string $src, string $dest, string $fmt, int $q, ?array $target_dimensions = null): ?string {
312 712 $info = getimagesize($src);
313 713 if (!$info) {
314 714 return null;
315 715 }
316 716
717 + $is_png = ($info['mime'] === 'image/png');
718 +
317 719 $gd_img = match ($info['mime']) {
318 720 'image/jpeg' => imagecreatefromjpeg($src),
319 721 'image/png' => imagecreatefrompng($src),
320 722 default => null,
@@ -323,14 +725,37 @@
323 725 if (!$gd_img) {
324 726 return null;
325 727 }
326 728
327 - if ($info['mime'] === 'image/png') {
729 + if ($is_png) {
328 730 imagepalettetotruecolor($gd_img);
329 731 imagealphablending($gd_img, true);
330 732 imagesavealpha($gd_img, true);
331 733 }
332 734
735 + // Downscale before encoding when the image exceeds the configured cap.
736 + if ($target_dimensions !== null) {
737 + [$new_w, $new_h] = $target_dimensions;
738 + $resized = imagecreatetruecolor($new_w, $new_h);
739 + if ($resized !== false) {
740 + if ($is_png) {
741 + // Preserve transparency on the resized canvas.
742 + imagealphablending($resized, false);
743 + imagesavealpha($resized, true);
744 + $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
745 + imagefilledrectangle($resized, 0, 0, $new_w, $new_h, $transparent);
746 + }
747 + imagecopyresampled(
748 + $resized, $gd_img,
749 + 0, 0, 0, 0,
750 + $new_w, $new_h,
751 + imagesx($gd_img), imagesy($gd_img)
752 + );
753 + imagedestroy($gd_img);
754 + $gd_img = $resized;
755 + }
756 + }
757 +
333 758 $success = match ($fmt) {
334 759 'webp' => imagewebp($gd_img, $dest, $q),
335 760 'avif' => function_exists('imageavif') ? imageavif($gd_img, $dest, $q) : false,
336 761 default => false,
@@ -338,10 +763,10 @@
338 763
339 764 imagedestroy($gd_img);
340 765
341 766 if (!$success || !file_exists($dest) || !filesize($dest)) {
342 - @unlink($dest);
343 - error_log('[MetaSync Media Opt] GD produced empty or missing output, discarding: ' . $dest);
767 + @wp_delete_file($dest);
768 + error_log('[MetaSync Media Opt] GD produced empty or missing output, discarding: ' . self::redact_path($dest)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
344 769 return null;
345 770 }
346 771
347 772 return $dest;
@@ -351,17 +776,16 @@
351 776 * Replace original file with converted version.
352 777 */
353 778 private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
354 779 if (!file_exists($new_path) || !filesize($new_path)) {
355 - error_log('[MetaSync Media Opt] Converted file is missing or empty, original preserved: ' . $old_path);
780 + error_log('[MetaSync Media Opt] Converted file is missing or empty, original preserved: ' . self::redact_path($old_path)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
356 781 return;
357 782 }
358 783
359 - // Capture old URL before deleting so we can rewrite post content references
360 - $old_url = wp_get_attachment_url($id);
361 -
362 - @unlink($old_path);
363 -
784 + // Update the DB pointers and rewrite post content FIRST — only once
785 + // every content reference points at the new file is it safe to unlink
786 + // the original. Deleting first left a window where a failed rewrite
787 + // meant permanent 404s with no original left to fall back to.
364 788 wp_update_post([
365 789 'ID' => $id,
366 790 'post_mime_type' => "image/{$fmt}",
367 791 ]);
@@ -368,46 +792,124 @@
368 792
369 793 update_attached_file($id, $new_path);
370 794 $meta['file'] = _wp_relative_upload_path($new_path);
371 795
372 - // Rewrite hardcoded image URLs in post content to point to the new file
373 - $new_url = wp_get_attachment_url($id);
374 - if ($old_url && $new_url && $old_url !== $new_url) {
375 - self::rewrite_content_urls($old_url, $new_url);
796 + // Sync the full-size dimensions to the converted file. When a pre-conversion
797 + // downscale shrank the image, the original width/height in metadata are now
798 + // stale; otherwise this is a harmless no-op.
799 + $new_dims = @getimagesize($new_path);
800 + if ($new_dims && $new_dims[0] > 0 && $new_dims[1] > 0) {
801 + $meta['width'] = (int) $new_dims[0];
802 + $meta['height'] = (int) $new_dims[1];
376 803 }
804 +
805 + // Rewrite hardcoded image URLs in post content to point to the new file.
806 + // Path-based, so it matches regardless of the hostname in the stored URL.
807 + self::rewrite_content_paths($old_path, $new_path);
808 +
809 + // Content now points at the converted file — the original is redundant.
810 + @wp_delete_file($old_path);
377 811 }
378 812
379 813 /**
380 - * Rewrite image URLs in all post content that references the old file path.
381 - * Uses the path portion (e.g. /wp-content/uploads/…) so it works regardless
382 - * of hostname changes (e.g. Cloudflare tunnel rotations).
814 + * Rewrite image references in all post content from one upload file to
815 + * another.
816 + *
817 + * Works on the URL path portion (e.g. /wp-content/uploads/2026/08/photo.jpg)
818 + * derived from the absolute file paths, so rewrites are hostname-agnostic
819 + * (surviving e.g. Cloudflare tunnel rotations) and can be driven by the
820 + * file paths the converter already has — no need for the attachment URL,
821 + * which is only trustworthy before the DB pointer flips.
822 + *
823 + * References with URL-encoded special characters (e.g. "my%20photo.jpg")
824 + * never match the raw path, so the encoded variant is rewritten too when
825 + * it differs.
826 + *
827 + * @param string $old_abspath Absolute path of the file being replaced.
828 + * @param string $new_abspath Absolute path of its replacement.
383 829 */
384 - private static function rewrite_content_urls(string $old_url, string $new_url): void {
385 - global $wpdb;
830 + private static function rewrite_content_paths(string $old_abspath, string $new_abspath): void {
831 + $old_path = self::abspath_to_url_path($old_abspath);
832 + $new_path = self::abspath_to_url_path($new_abspath);
386 833
387 - // Extract path portions to be hostname-agnostic
388 - $old_path = wp_parse_url($old_url, PHP_URL_PATH);
389 - $new_path = wp_parse_url($new_url, PHP_URL_PATH);
390 -
391 834 if (!$old_path || !$new_path || $old_path === $new_path) {
392 835 return;
393 836 }
394 837
395 - $wpdb->query($wpdb->prepare(
396 - "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) WHERE post_content LIKE %s",
397 - $old_path,
398 - $new_path,
399 - '%' . $wpdb->esc_like($old_path) . '%'
400 - ));
838 + self::rewrite_content_path_pair($old_path, $new_path);
839 +
840 + // Also rewrite URL-encoded references ("photo%20name-300x200.jpg")
841 + // when encoding changes the string.
842 + $old_encoded = implode('/', array_map('rawurlencode', explode('/', $old_path)));
843 + $new_encoded = implode('/', array_map('rawurlencode', explode('/', $new_path)));
844 + if ($old_encoded !== $old_path) {
845 + self::rewrite_content_path_pair($old_encoded, $new_encoded);
846 + }
401 847 }
402 848
849 + /**
850 + * Map an absolute upload file path to its URL path portion
851 + * (e.g. /wp-content/uploads/2026/08/photo.jpg). Returns null when the
852 + * file lives outside the uploads directory.
853 + */
854 + private static function abspath_to_url_path(string $abspath): ?string {
855 + $upload_dir = wp_get_upload_dir();
856 + $base_path = $base_url_path = null;
857 +
858 + if (strpos($abspath, $upload_dir['basedir']) === 0) {
859 + $base_path = $upload_dir['basedir'];
860 + $base_url_path = wp_parse_url($upload_dir['baseurl'], PHP_URL_PATH);
861 + }
862 +
863 + if (!$base_path || !is_string($base_url_path) || $base_url_path === '') {
864 + return null;
865 + }
866 +
867 + $relative = substr($abspath, strlen($base_path));
868 + return rtrim($base_url_path, '/') . '/' . ltrim($relative, '/');
869 + }
870 +
871 + /**
872 + * Batched REPLACE of one URL path for another across wp_posts.
873 + *
874 + * Batching keeps a large wp_posts table from being locked by a single
875 + * unbounded REPLACE. Each batch only rewrites rows that still contain the
876 + * old path; once replaced they no longer match the LIKE, so the loop
877 + * converges. The batch ceiling is a safety net against an unexpected
878 + * non-converging loop (e.g. a DB-level error returning false).
879 + */
880 + private static function rewrite_content_path_pair(string $old_path, string $new_path): void {
881 + global $wpdb;
882 +
883 + $batch_size = 500;
884 + $like = '%' . $wpdb->esc_like($old_path) . '%';
885 + $max_batches = 100000;
886 +
887 + for ($batch = 0; $batch < $max_batches; $batch++) {
888 + $affected = $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- batched bulk post_content rewrite — no bulk WordPress API; chunked by LIMIT to bound lock time
889 + "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s)
890 + WHERE post_content LIKE %s ORDER BY ID LIMIT %d",
891 + $old_path,
892 + $new_path,
893 + $like,
894 + $batch_size
895 + ));
896 +
897 + // false → query error; a short batch (< batch_size) means the last
898 + // matching rows were just rewritten. Either way there is no more work.
899 + if ($affected === false || $affected < $batch_size) {
900 + break;
901 + }
902 + }
903 + }
904 +
403 905 // ── Instance Wrappers (Upload Hook) ──
404 906
405 907 /**
406 908 * Instance wrapper around static conversion method.
407 909 */
408 - private function convert_file(string $source, string $format, int $quality): ?string {
409 - return self::do_convert_file($source, $format, $quality);
910 + private function convert_file(string $source, string $format, int $quality, int $max_dimensions = 0): ?string {
911 + return self::do_convert_file($source, $format, $quality, $max_dimensions);
410 912 }
411 913
412 914 /**
413 915 * Instance wrapper around static replace method.
@@ -416,78 +918,249 @@
416 918 self::do_replace_original($id, $old_path, $new_path, $meta, $fmt);
417 919 }
418 920
419 921 /**
922 + * Start output buffering on frontend to catch images from themes/builders
923 + * that bypass standard WordPress image filters (e.g. Divi, Elementor).
924 + */
925 + public function start_output_buffer(): void {
926 + if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
927 + return;
928 + }
929 +
930 + if (defined('REST_REQUEST') && REST_REQUEST) {
931 + return;
932 + }
933 +
934 + if (is_feed() || is_robots() || is_trackback()) {
935 + return;
936 + }
937 +
938 + ob_start([$this, 'rewrite_full_html']);
939 + }
940 +
941 + /**
942 + * Output buffer callback: rewrite remaining <img> tags to <picture>.
943 + * Protects existing <picture>, <script>, and <noscript> blocks from rewriting.
944 + */
945 + public function rewrite_full_html(string $html): string {
946 + if (empty($html) || stripos($html, '</html>') === false || $this->is_amp_request()) {
947 + return $html;
948 + }
949 +
950 + // Protect blocks that must not be rewritten (shared with the
951 + // content-filter path): existing <picture>, <script> (JSON-LD holds
952 + // image URLs), and <noscript> (lazy-loading fallbacks).
953 + [$html, $protected] = $this->protect_rewritable_blocks($html);
954 +
955 + // Rewrite remaining <img> tags
956 + $html = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
957 + return $this->maybe_wrap_img_tag($matches[0]);
958 + }, $html);
959 +
960 + return $this->restore_protected_blocks($html, $protected);
961 + }
962 +
963 + /**
420 964 * Rewrite <img> tags to <picture> with next-gen source.
965 + * Used by WordPress filter hooks for content fragments.
421 966 */
422 967 public function rewrite_to_picture_tags(string $content): string {
423 - if (empty($content)) {
968 + if (empty($content) || is_feed() || $this->is_amp_request()) {
424 969 return $content;
425 970 }
426 971
427 - // Skip if already wrapped in <picture> (avoid double-wrapping from multiple filters)
428 - if (strpos($content, '<picture>') !== false) {
429 - return $content;
972 + // Existing <picture> blocks (attributed or not) are protected per
973 + // block below; a single earlier wrap no longer disables rewriting
974 + // for the whole fragment, and <img>s inside script/noscript blocks
975 + // are never touched.
976 + [$content, $protected] = $this->protect_rewritable_blocks($content);
977 +
978 + $content = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
979 + return $this->maybe_wrap_img_tag($matches[0]);
980 + }, $content);
981 +
982 + return $this->restore_protected_blocks($content, $protected);
983 + }
984 +
985 + /**
986 + * Extract blocks that must never be rewritten (existing <picture>,
987 + * <script>, <noscript>) into placeholders so the <img> rewrite cannot
988 + * reach inside them.
989 + *
990 + * @return array{0:string, 1:array<string, string>} Rewritten HTML and placeholder map.
991 + */
992 + private function protect_rewritable_blocks(string $html): array {
993 + $protected = [];
994 + $counter = 0;
995 + $extract = function ($m) use (&$protected, &$counter) {
996 + $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
997 + $protected[$key] = $m[0];
998 + return $key;
999 + };
1000 +
1001 + $html = preg_replace_callback('/<picture\b[^>]*>.*?<\/picture>/is', $extract, $html);
1002 + $html = preg_replace_callback('/<script\b[^>]*>.*?<\/script>/is', $extract, $html);
1003 + $html = preg_replace_callback('/<noscript\b[^>]*>.*?<\/noscript>/is', $extract, $html);
1004 +
1005 + return [$html, $protected];
1006 + }
1007 +
1008 + /**
1009 + * Restore blocks extracted by protect_rewritable_blocks().
1010 + */
1011 + private function restore_protected_blocks(string $html, array $protected): string {
1012 + if (empty($protected)) {
1013 + return $html;
430 1014 }
1015 + return strtr($html, $protected);
1016 + }
431 1017
432 - return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
433 - $img_tag = $matches[0];
1018 + /**
1019 + * True when the current request renders an AMP page. The AMP runtime
1020 + * validates markup, so the <picture> wrapper is skipped there. The
1021 + * helper indirection keeps static analysis from assuming the AMP
1022 + * plugin's functions always exist.
1023 + */
1024 + private function is_amp_request(): bool {
1025 + if (self::func_is_available('amp_is_request')) {
1026 + return (bool) amp_is_request();
1027 + }
1028 + if (self::func_is_available('is_amp_endpoint')) {
1029 + return (bool) is_amp_endpoint();
1030 + }
1031 + return false;
1032 + }
434 1033
435 - // Check if this image should be excluded
436 - if ($this->is_tag_excluded($img_tag)) {
437 - return $img_tag;
438 - }
1034 + private static function func_is_available(string $function): bool {
1035 + return function_exists($function);
1036 + }
439 1037
440 - // Extract src
441 - if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) {
442 - return $img_tag;
443 - }
1038 + /**
1039 + * Wrap a single <img> tag in <picture> with next-gen <source>.
1040 + * Returns the original tag unchanged if conversion is not applicable.
1041 + */
1042 + private function maybe_wrap_img_tag(string $img_tag): string {
1043 + if ($this->is_tag_excluded($img_tag)) {
1044 + return $img_tag;
1045 + }
444 1046
445 - $original_src = $src_match[1];
446 - $format = $this->settings['conversion_format'];
447 - $ext = self::get_format_extension($format);
1047 + if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) {
1048 + return $img_tag;
1049 + }
448 1050
449 - // Build the converted file URL
450 - $converted_url = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $original_src);
1051 + $original_src = $src_match[1];
451 1052
452 - // Only wrap if the converted URL is different
453 - if ($converted_url === $original_src) {
454 - return $img_tag;
1053 + // Exclusion entries may target the URL (full URL or path fragment).
1054 + if ($this->is_url_excluded($original_src)) {
1055 + return $img_tag;
1056 + }
1057 +
1058 + // Serve whichever converted file actually exists: the current format
1059 + // first, then the other one. This keeps legacy conversions alive when
1060 + // the configured format flips (webp → avif or back) instead of
1061 + // silently orphaning every previously converted image.
1062 + $resolved = $this->resolve_converted_url($original_src);
1063 + if (!$resolved) {
1064 + return $img_tag;
1065 + }
1066 +
1067 + [$converted_url, $format] = $resolved;
1068 + $mime = $format === 'avif' ? 'image/avif' : 'image/webp';
1069 +
1070 + $source_srcset = '';
1071 + if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) {
1072 + $source_srcset = sprintf(' srcset="%s"', esc_attr($this->resolve_srcset_candidates($srcset_match[1])));
1073 + }
1074 +
1075 + $sizes_attr = '';
1076 + if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) {
1077 + $sizes_attr = sprintf(' sizes="%s"', esc_attr($sizes_match[1]));
1078 + }
1079 +
1080 + return sprintf(
1081 + '<picture><source type="%s"%s%s>%s</picture>',
1082 + esc_attr($mime),
1083 + $source_srcset ?: sprintf(' srcset="%s"', esc_attr($converted_url)),
1084 + $sizes_attr,
1085 + $img_tag
1086 + );
1087 + }
1088 +
1089 + /**
1090 + * Resolve the converted counterpart of an image URL.
1091 + *
1092 + * Tries the currently configured format first, then the other format, and
1093 + * only returns a URL whose file provably exists on disk. Null means "no
1094 + * converted file for this URL" — the caller leaves the tag untouched.
1095 + *
1096 + * @return array{0:string,1:string}|null [converted URL, format] or null.
1097 + */
1098 + private function resolve_converted_url(string $url): ?array {
1099 + $current = $this->settings['conversion_format'] ?? 'webp';
1100 + $formats = [$current];
1101 + $other = $current === 'webp' ? 'avif' : 'webp';
1102 + $formats[] = $other;
1103 +
1104 + foreach ($formats as $format) {
1105 + $candidate = preg_replace(self::ORIGINAL_EXT_PATTERN, self::get_format_extension($format), $url);
1106 +
1107 + if ($candidate === null || $candidate === $url) {
1108 + continue;
455 1109 }
456 1110
457 - // Verify the converted file actually exists on disk before rewriting
458 - $converted_path = $this->url_to_path($converted_url);
459 - if (!$converted_path || !file_exists($converted_path)) {
460 - return $img_tag;
1111 + $candidate_path = $this->url_to_path($candidate);
1112 + if ($candidate_path && file_exists($candidate_path)) {
1113 + return [$candidate, $format];
461 1114 }
1115 + }
462 1116
463 - $mime = $format === 'avif' ? 'image/avif' : 'image/webp';
1117 + return null;
1118 + }
464 1119
465 - // Also handle srcset if present
466 - $source_srcset = '';
467 - if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) {
468 - $converted_srcset = preg_replace('/\.(jpe?g|png)/i', $ext, $srcset_match[1]);
469 - $source_srcset = sprintf(' srcset="%s"', esc_attr($converted_srcset));
1120 + /**
1121 + * Build the <source> srcset with per-candidate file-existence checks.
1122 + *
1123 + * A srcset whose candidates were blindly extension-swapped serves 404s
1124 + * whenever a sub-size was never converted (conversion of sub-sizes off, a
1125 + * size added after conversion, a format switch) — and browsers do not fall
1126 + * back to the <img> when a <source> candidate fails to fetch. Candidates
1127 + * without a converted file on disk keep their ORIGINAL URL instead, which
1128 + * still loads, just unoptimized. When no candidate has a converted file
1129 + * the original srcset is returned unchanged.
1130 + */
1131 + private function resolve_srcset_candidates(string $srcset): string {
1132 + $candidates = preg_split('/\s*,\s*/', trim($srcset));
1133 + if (!$candidates) {
1134 + return $srcset;
1135 + }
1136 +
1137 + $any_converted = false;
1138 + $out = [];
1139 +
1140 + foreach ($candidates as $candidate) {
1141 + // Candidate is "URL [descriptor]" — rewrite only the URL part.
1142 + if (!preg_match('/^(\S+)(.*)$/', $candidate, $parts)) {
1143 + $out[] = $candidate;
1144 + continue;
470 1145 }
471 1146
472 - // Extract sizes attribute to pass to <source>
473 - $sizes_attr = '';
474 - if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) {
475 - $sizes_attr = sprintf(' sizes="%s"', esc_attr($sizes_match[1]));
1147 + $resolved = $this->resolve_converted_url($parts[1]);
1148 + if ($resolved) {
1149 + $any_converted = true;
1150 + $out[] = $resolved[0] . $parts[2];
1151 + } else {
1152 + // Keep the original URL so this descriptor still resolves.
1153 + $out[] = $candidate;
476 1154 }
1155 + }
477 1156
478 - return sprintf(
479 - '<picture><source type="%s"%s%s>%s</picture>',
480 - esc_attr($mime),
481 - $source_srcset ?: sprintf(' srcset="%s"', esc_attr($converted_url)),
482 - $sizes_attr,
483 - $img_tag
484 - );
485 - }, $content);
1157 + return $any_converted ? implode(', ', $out) : $srcset;
486 1158 }
487 1159
488 1160 /**
489 1161 * Convert a URL to a local file path. Returns null if URL is external.
1162 + * Falls back to path-portion matching when hostnames differ (e.g. Cloudflare tunnel rotation).
490 1163 */
491 1164 private function url_to_path(string $url): ?string {
492 1165 $upload_dir = wp_get_upload_dir();
493 1166 $base_url = $upload_dir['baseurl'];
@@ -492,12 +1165,28 @@
492 1165 $upload_dir = wp_get_upload_dir();
493 1166 $base_url = $upload_dir['baseurl'];
494 1167 $base_path = $upload_dir['basedir'];
495 1168
1169 + // Filenames with URL-encoded characters (spaces, accents, UTF-8)
1170 + // must be decoded before mapping to a disk path — the existence
1171 + // checks that gate serving silently reject them otherwise and a
1172 + // successfully converted file is never served.
1173 + $url = rawurldecode($url);
1174 +
1175 + // Direct match (same hostname)
496 1176 if (strpos($url, $base_url) === 0) {
497 1177 return str_replace($base_url, $base_path, $url);
498 1178 }
499 1179
1180 + // Path-based fallback: match uploads path regardless of hostname
1181 + $base_url_path = wp_parse_url($base_url, PHP_URL_PATH);
1182 + $url_path = wp_parse_url($url, PHP_URL_PATH);
1183 +
1184 + if ($base_url_path && $url_path && strpos($url_path, $base_url_path) === 0) {
1185 + $relative = substr($url_path, strlen($base_url_path));
1186 + return $base_path . $relative;
1187 + }
1188 +
500 1189 return null;
501 1190 }
502 1191
503 1192 /**
@@ -511,8 +1200,33 @@
511 1200
512 1201 foreach ($exclude_urls as $pattern) {
513 1202 if (stripos($file, $pattern) !== false) {
514 1203 return true;
1204 + }
1205 + }
1206 + return false;
1207 + }
1208 +
1209 + /**
1210 + * Check if a URL matches the URL-based exclusion patterns.
1211 + *
1212 + * Entries may be full URLs (scheme + host) or bare path fragments; the
1213 + * full-URL form never matches the path-only check in is_excluded(), so
1214 + * both the raw URL and its path portion are tested here.
1215 + */
1216 + private function is_url_excluded(string $url): bool {
1217 + $exclude_urls = array_filter(array_map('trim', explode(',', $this->settings['exclude_urls'] ?? '')));
1218 + if (empty($exclude_urls)) {
1219 + return false;
1220 + }
1221 +
1222 + $candidates = [$url, (string) wp_parse_url($url, PHP_URL_PATH)];
1223 +
1224 + foreach ($exclude_urls as $pattern) {
1225 + foreach ($candidates as $candidate) {
1226 + if ($candidate !== '' && stripos($candidate, $pattern) !== false) {
1227 + return true;
1228 + }
515 1229 }
516 1230 }
517 1231 return false;
518 1232 }