PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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 +895 -94 2.6.5 → 2.7.1 View file →
@@ -24,10 +24,67 @@
24 24
25 25 private const EXT_AVIF = '.avif';
26 26 private const EXT_WEBP = '.webp';
27 27 private const ORIGINAL_EXT_PATTERN = '/\.(jpe?g|png)$/i';
28 + private const MAX_CONVERT_BYTES = 10 * 1024 * 1024;
28 29
29 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 + /**
30 87 * Get file extension for a given format.
31 88 */
32 89 private static function get_format_extension(string $format): string {
33 90 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
@@ -32,8 +89,43 @@
32 89 private static function get_format_extension(string $format): string {
33 90 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
34 91 }
35 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 +
36 128 public function __construct(array $settings) {
37 129 $this->settings = $settings;
38 130
39 131 // Fires after WP generates all thumbnail sizes
@@ -39,9 +131,9 @@
39 131 // Fires after WP generates all thumbnail sizes
40 132 add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2);
41 133
42 134 // If "alongside" strategy, rewrite <img> tags to <picture>
43 - if ($settings['conversion_strategy'] === 'alongside') {
135 + if (($settings['conversion_strategy'] ?? '') === 'alongside') {
44 136 // Core WordPress
45 137 add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20);
46 138 add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
47 139 add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20);
@@ -50,8 +142,11 @@
50 142 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
51 143 add_filter('woocommerce_product_get_image', [$this, 'rewrite_to_picture_tags'], 20);
52 144 add_filter('woocommerce_cart_item_thumbnail', [$this, 'rewrite_to_picture_tags'], 20);
53 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);
54 149 }
55 150 }
56 151
57 152 /**
@@ -72,35 +167,39 @@
72 167
73 168 $format = $this->settings['conversion_format'];
74 169 $quality = (int) $this->settings['conversion_quality'];
75 170 $strategy = $this->settings['conversion_strategy'];
171 + $max_dim = (int) ($this->settings['max_image_dimensions'] ?? 0);
76 172
77 - // Convert main/full file
78 - $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;
79 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 +
80 184 if ($converted && $strategy === 'replace') {
81 185 $this->replace_original($attachment_id, $file, $converted, $metadata, $format);
186 + update_post_meta($attachment_id, '_metasync_replaced_original', '1');
82 187 }
83 188
84 - // Convert sub-sizes (thumbnails, medium, large, etc.)
189 + // Convert sub-sizes (thumbnails, medium, large, etc.) with memory management
85 190 if (!empty($this->settings['convert_existing_sizes']) && !empty($metadata['sizes'])) {
86 - $upload_dir = dirname($file);
87 - foreach ($metadata['sizes'] as $size_name => &$size_data) {
88 - $size_file = $upload_dir . '/' . $size_data['file'];
89 - $size_converted = $this->convert_file($size_file, $format, $quality);
90 -
91 - if ($size_converted && $strategy === 'replace') {
92 - @unlink($size_file);
93 - $size_data['file'] = basename($size_converted);
94 - $size_data['mime-type'] = "image/{$format}";
95 - }
96 - }
97 - unset($size_data);
191 + static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
98 192 }
99 193
100 - // Store converted format in meta for later use by picture tag rewriter
101 - 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) {
102 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 + }
103 202 }
104 203
105 204 return $metadata;
106 205 }
@@ -118,16 +217,33 @@
118 217 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
119 218 return false;
120 219 }
121 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 +
122 237 $format = $settings['conversion_format'] ?? 'webp';
123 238 $quality = (int) ($settings['conversion_quality'] ?? 82);
124 239 $strategy = $settings['conversion_strategy'] ?? 'alongside';
240 + $max_dim = (int) ($settings['max_image_dimensions'] ?? 0);
125 241
126 242 // Store original file size before conversion for savings display
127 243 $original_size = filesize($file);
128 244
129 - $converted = self::do_convert_file($file, $format, $quality);
245 + $converted = self::do_convert_file($file, $format, $quality, $max_dim);
130 246 if (!$converted) {
131 247 return false;
132 248 }
133 249
@@ -143,24 +259,13 @@
143 259 wp_update_attachment_metadata($attachment_id, $metadata);
144 260 }
145 261 }
146 262
147 - // Convert sub-sizes
263 + // Convert sub-sizes with memory management
148 264 if (!empty($settings['convert_existing_sizes'])) {
149 265 $metadata = wp_get_attachment_metadata($attachment_id);
150 266 if ($metadata && !empty($metadata['sizes'])) {
151 - $upload_dir = dirname($file);
152 - foreach ($metadata['sizes'] as $size_name => &$size_data) {
153 - $size_file = $upload_dir . '/' . $size_data['file'];
154 - $size_converted = self::do_convert_file($size_file, $format, $quality);
155 -
156 - if ($size_converted && $strategy === 'replace') {
157 - @unlink($size_file);
158 - $size_data['file'] = basename($size_converted);
159 - $size_data['mime-type'] = "image/{$format}";
160 - }
161 - }
162 - unset($size_data);
267 + static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
163 268 if ($strategy === 'replace') {
164 269 wp_update_attachment_metadata($attachment_id, $metadata);
165 270 }
166 271 }
@@ -165,13 +270,41 @@
165 270 }
166 271 }
167 272 }
168 273
274 + if ($strategy === 'replace') {
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();
279 + }
280 +
169 281 update_post_meta($attachment_id, '_metasync_converted_format', $format);
282 + Metasync_Media_Batch_Optimizer::flush_stats_cache();
170 283 return true;
171 284 }
172 285
173 286 /**
287 + * Check whether an optimized attachment can be reverted.
288 + * Returns false when the original was replaced (no backup exists).
289 + */
290 + public static function can_revert(int $attachment_id): bool {
291 + $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
292 + if (!$format) {
293 + return false; // Not optimized
294 + }
295 +
296 + // If the original was replaced, revert is impossible
297 + if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) {
298 + return false;
299 + }
300 +
301 + // Verify original file still exists on disk (alongside strategy)
302 + $file = get_attached_file($attachment_id);
303 + return $file && file_exists($file);
304 + }
305 +
306 + /**
174 307 * Revert an attachment's conversion (alongside strategy only).
175 308 * Deletes the converted files and removes the meta marker.
176 309 */
177 310 public static function revert_attachment(int $attachment_id): bool {
@@ -179,8 +312,15 @@
179 312 if (!$format) {
180 313 return false;
181 314 }
182 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 +
183 323 $file = get_attached_file($attachment_id);
184 324 if (!$file) {
185 325 return false;
186 326 }
@@ -192,11 +332,14 @@
192 332
193 333 $ext = self::get_format_extension($format);
194 334
195 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).
196 339 $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file);
197 - if ($converted_path && file_exists($converted_path)) {
198 - @unlink($converted_path);
340 + if ($converted_path && $converted_path !== $file && file_exists($converted_path)) {
341 + @wp_delete_file($converted_path);
199 342 }
200 343
201 344 // Delete converted sub-sizes
202 345 $metadata = wp_get_attachment_metadata($attachment_id);
@@ -202,11 +345,12 @@
202 345 $metadata = wp_get_attachment_metadata($attachment_id);
203 346 if ($metadata && !empty($metadata['sizes'])) {
204 347 $upload_dir = dirname($file);
205 348 foreach ($metadata['sizes'] as $size_data) {
206 - $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $upload_dir . '/' . $size_data['file']);
207 - if ($size_converted && file_exists($size_converted)) {
208 - @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);
209 353 }
210 354 }
211 355 }
212 356
@@ -211,21 +355,303 @@
211 355 }
212 356
213 357 delete_post_meta($attachment_id, '_metasync_converted_format');
214 358 delete_post_meta($attachment_id, '_metasync_original_filesize');
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();
215 365 return true;
216 366 }
217 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 +
218 419 // ── Core Conversion (static, reusable) ──
219 420
220 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 + /**
221 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.
222 610 */
223 - 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 {
224 612 if (!file_exists($source)) {
225 613 return null;
226 614 }
227 615
616 + if (filesize($source) > self::MAX_CONVERT_BYTES) {
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
618 + return null;
619 + }
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 +
228 654 $ext = self::get_format_extension($format);
229 655 $dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $source);
230 656
231 657 // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
@@ -230,22 +656,22 @@
230 656
231 657 // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
232 658 if (extension_loaded('imagick')) {
233 659 try {
234 - $result = self::do_convert_with_imagick($source, $dest, $format, $quality);
660 + $result = self::do_convert_with_imagick($source, $dest, $format, $quality, $target_dimensions);
235 661 if ($result) {
236 662 return $result;
237 663 }
238 664 } catch (\Exception $e) {
239 - 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
240 666 }
241 667 }
242 668
243 669 if (extension_loaded('gd')) {
244 670 try {
245 - return self::do_convert_with_gd($source, $dest, $format, $quality);
671 + return self::do_convert_with_gd($source, $dest, $format, $quality, $target_dimensions);
246 672 } catch (\Exception $e) {
247 - 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
248 674 }
249 675 }
250 676
251 677 return null;
@@ -250,15 +676,31 @@
250 676
251 677 return null;
252 678 }
253 679
254 - private static function do_convert_with_imagick(string $src, string $dest, string $fmt, int $q): ?string {
255 - $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 +
256 692 $img->setImageFormat($fmt === 'avif' ? 'avif' : 'webp');
257 693 $img->setImageCompressionQuality($q);
258 694 $img->stripImage();
259 695
260 696 if ($img->writeImage($dest)) {
697 + if (!file_exists($dest) || !filesize($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
700 + $img->destroy();
701 + return null;
702 + }
261 703 $img->destroy();
262 704 return $dest;
263 705 }
264 706
@@ -265,14 +707,16 @@
265 707 $img->destroy();
266 708 return null;
267 709 }
268 710
269 - 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 {
270 712 $info = getimagesize($src);
271 713 if (!$info) {
272 714 return null;
273 715 }
274 716
717 + $is_png = ($info['mime'] === 'image/png');
718 +
275 719 $gd_img = match ($info['mime']) {
276 720 'image/jpeg' => imagecreatefromjpeg($src),
277 721 'image/png' => imagecreatefrompng($src),
278 722 default => null,
@@ -281,14 +725,37 @@
281 725 if (!$gd_img) {
282 726 return null;
283 727 }
284 728
285 - if ($info['mime'] === 'image/png') {
729 + if ($is_png) {
286 730 imagepalettetotruecolor($gd_img);
287 731 imagealphablending($gd_img, true);
288 732 imagesavealpha($gd_img, true);
289 733 }
290 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 +
291 758 $success = match ($fmt) {
292 759 'webp' => imagewebp($gd_img, $dest, $q),
293 760 'avif' => function_exists('imageavif') ? imageavif($gd_img, $dest, $q) : false,
294 761 default => false,
@@ -294,9 +761,16 @@
294 761 default => false,
295 762 };
296 763
297 764 imagedestroy($gd_img);
298 - return $success ? $dest : null;
765 +
766 + if (!$success || !file_exists($dest) || !filesize($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
769 + return null;
770 + }
771 +
772 + return $dest;
299 773 }
300 774
301 775 /**
302 776 * Replace original file with converted version.
@@ -301,10 +775,17 @@
301 775 /**
302 776 * Replace original file with converted version.
303 777 */
304 778 private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
305 - @unlink($old_path);
779 + if (!file_exists($new_path) || !filesize($new_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
781 + return;
782 + }
306 783
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.
307 788 wp_update_post([
308 789 'ID' => $id,
309 790 'post_mime_type' => "image/{$fmt}",
310 791 ]);
@@ -310,17 +791,125 @@
310 791 ]);
311 792
312 793 update_attached_file($id, $new_path);
313 794 $meta['file'] = _wp_relative_upload_path($new_path);
795 +
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];
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);
314 811 }
315 812
813 + /**
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.
829 + */
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);
833 +
834 + if (!$old_path || !$new_path || $old_path === $new_path) {
835 + return;
836 + }
837 +
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 + }
847 + }
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 +
316 905 // ── Instance Wrappers (Upload Hook) ──
317 906
318 907 /**
319 908 * Instance wrapper around static conversion method.
320 909 */
321 - private function convert_file(string $source, string $format, int $quality): ?string {
322 - 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);
323 912 }
324 913
325 914 /**
326 915 * Instance wrapper around static replace method.
@@ -329,78 +918,249 @@
329 918 self::do_replace_original($id, $old_path, $new_path, $meta, $fmt);
330 919 }
331 920
332 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 + /**
333 964 * Rewrite <img> tags to <picture> with next-gen source.
965 + * Used by WordPress filter hooks for content fragments.
334 966 */
335 967 public function rewrite_to_picture_tags(string $content): string {
336 - if (empty($content)) {
968 + if (empty($content) || is_feed() || $this->is_amp_request()) {
337 969 return $content;
338 970 }
339 971
340 - // Skip if already wrapped in <picture> (avoid double-wrapping from multiple filters)
341 - if (strpos($content, '<picture>') !== false) {
342 - 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;
343 1014 }
1015 + return strtr($html, $protected);
1016 + }
344 1017
345 - return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
346 - $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 + }
347 1033
348 - // Check if this image should be excluded
349 - if ($this->is_tag_excluded($img_tag)) {
350 - return $img_tag;
351 - }
1034 + private static function func_is_available(string $function): bool {
1035 + return function_exists($function);
1036 + }
352 1037
353 - // Extract src
354 - if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) {
355 - return $img_tag;
356 - }
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 + }
357 1046
358 - $original_src = $src_match[1];
359 - $format = $this->settings['conversion_format'];
360 - $ext = self::get_format_extension($format);
1047 + if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) {
1048 + return $img_tag;
1049 + }
361 1050
362 - // Build the converted file URL
363 - $converted_url = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $original_src);
1051 + $original_src = $src_match[1];
364 1052
365 - // Only wrap if the converted URL is different
366 - if ($converted_url === $original_src) {
367 - 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;
368 1109 }
369 1110
370 - // Verify the converted file actually exists on disk before rewriting
371 - $converted_path = $this->url_to_path($converted_url);
372 - if (!$converted_path || !file_exists($converted_path)) {
373 - return $img_tag;
1111 + $candidate_path = $this->url_to_path($candidate);
1112 + if ($candidate_path && file_exists($candidate_path)) {
1113 + return [$candidate, $format];
374 1114 }
1115 + }
375 1116
376 - $mime = $format === 'avif' ? 'image/avif' : 'image/webp';
1117 + return null;
1118 + }
377 1119
378 - // Also handle srcset if present
379 - $source_srcset = '';
380 - if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) {
381 - $converted_srcset = preg_replace('/\.(jpe?g|png)/i', $ext, $srcset_match[1]);
382 - $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;
383 1145 }
384 1146
385 - // Extract sizes attribute to pass to <source>
386 - $sizes_attr = '';
387 - if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) {
388 - $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;
389 1154 }
1155 + }
390 1156
391 - return sprintf(
392 - '<picture><source type="%s"%s%s>%s</picture>',
393 - esc_attr($mime),
394 - $source_srcset ?: sprintf(' srcset="%s"', esc_attr($converted_url)),
395 - $sizes_attr,
396 - $img_tag
397 - );
398 - }, $content);
1157 + return $any_converted ? implode(', ', $out) : $srcset;
399 1158 }
400 1159
401 1160 /**
402 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).
403 1163 */
404 1164 private function url_to_path(string $url): ?string {
405 1165 $upload_dir = wp_get_upload_dir();
406 1166 $base_url = $upload_dir['baseurl'];
@@ -405,12 +1165,28 @@
405 1165 $upload_dir = wp_get_upload_dir();
406 1166 $base_url = $upload_dir['baseurl'];
407 1167 $base_path = $upload_dir['basedir'];
408 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)
409 1176 if (strpos($url, $base_url) === 0) {
410 1177 return str_replace($base_url, $base_path, $url);
411 1178 }
412 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 +
413 1189 return null;
414 1190 }
415 1191
416 1192 /**
@@ -424,8 +1200,33 @@
424 1200
425 1201 foreach ($exclude_urls as $pattern) {
426 1202 if (stripos($file, $pattern) !== false) {
427 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 + }
428 1229 }
429 1230 }
430 1231 return false;
431 1232 }