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 +673 -106 2.6.12 → 2.7.2 View file →
@@ -33,8 +33,58 @@
33 33 */
34 34 private const MIN_MEMORY_FOR_SUBSIZE = 8 * 1024 * 1024;
35 35
36 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 + /**
37 87 * Get file extension for a given format.
38 88 */
39 89 private static function get_format_extension(string $format): string {
40 90 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
@@ -39,8 +89,43 @@
39 89 private static function get_format_extension(string $format): string {
40 90 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
41 91 }
42 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 +
43 128 public function __construct(array $settings) {
44 129 $this->settings = $settings;
45 130
46 131 // Fires after WP generates all thumbnail sizes
@@ -46,9 +131,9 @@
46 131 // Fires after WP generates all thumbnail sizes
47 132 add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2);
48 133
49 134 // If "alongside" strategy, rewrite <img> tags to <picture>
50 - if ($settings['conversion_strategy'] === 'alongside') {
135 + if (($settings['conversion_strategy'] ?? '') === 'alongside') {
51 136 // Core WordPress
52 137 add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20);
53 138 add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
54 139 add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20);
@@ -82,12 +167,21 @@
82 167
83 168 $format = $this->settings['conversion_format'];
84 169 $quality = (int) $this->settings['conversion_quality'];
85 170 $strategy = $this->settings['conversion_strategy'];
171 + $max_dim = (int) ($this->settings['max_image_dimensions'] ?? 0);
86 172
87 - // Convert main/full file
88 - $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;
89 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 +
90 184 if ($converted && $strategy === 'replace') {
91 185 $this->replace_original($attachment_id, $file, $converted, $metadata, $format);
92 186 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
93 187 }
@@ -96,11 +190,16 @@
96 190 if (!empty($this->settings['convert_existing_sizes']) && !empty($metadata['sizes'])) {
97 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
@@ -156,11 +272,15 @@
156 272 }
157 273
158 274 if ($strategy === 'replace') {
159 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();
160 279 }
161 280
162 281 update_post_meta($attachment_id, '_metasync_converted_format', $format);
282 + Metasync_Media_Batch_Optimizer::flush_stats_cache();
163 283 return true;
164 284 }
165 285
166 286 /**
@@ -192,8 +312,15 @@
192 312 if (!$format) {
193 313 return false;
194 314 }
195 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 +
196 323 $file = get_attached_file($attachment_id);
197 324 if (!$file) {
198 325 return false;
199 326 }
@@ -205,11 +332,14 @@
205 332
206 333 $ext = self::get_format_extension($format);
207 334
208 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).
209 339 $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file);
210 - if ($converted_path && file_exists($converted_path)) {
211 - @unlink($converted_path);
340 + if ($converted_path && $converted_path !== $file && file_exists($converted_path)) {
341 + @wp_delete_file($converted_path);
212 342 }
213 343
214 344 // Delete converted sub-sizes
215 345 $metadata = wp_get_attachment_metadata($attachment_id);
@@ -215,11 +345,12 @@
215 345 $metadata = wp_get_attachment_metadata($attachment_id);
216 346 if ($metadata && !empty($metadata['sizes'])) {
217 347 $upload_dir = dirname($file);
218 348 foreach ($metadata['sizes'] as $size_data) {
219 - $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $upload_dir . '/' . $size_data['file']);
220 - if ($size_converted && file_exists($size_converted)) {
221 - @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);
222 353 }
223 354 }
224 355 }
225 356
@@ -225,11 +356,67 @@
225 356
226 357 delete_post_meta($attachment_id, '_metasync_converted_format');
227 358 delete_post_meta($attachment_id, '_metasync_original_filesize');
228 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();
229 365 return true;
230 366 }
231 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 +
232 419 // ── Core Conversion (static, reusable) ──
233 420
234 421 /**
235 422 * Get available PHP memory in bytes.
@@ -261,13 +448,65 @@
261 448 return max(0, $value - memory_get_usage(true));
262 449 }
263 450
264 451 /**
265 - * Convert sub-sizes with memory management.
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.
266 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 + *
267 503 * Runs gc_collect_cycles() between each sub-size to release memory and
268 504 * checks available memory before each iteration, skipping remaining
269 - * sub-sizes when memory drops below MIN_MEMORY_FOR_SUBSIZE.
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.
270 509 *
271 510 * @param array $sizes Reference to $metadata['sizes'].
272 511 * @param string $upload_dir Directory containing the sub-size files.
273 512 * @param string $format Target format (webp|avif).
@@ -274,25 +513,58 @@
274 513 * @param int $quality Compression quality.
275 514 * @param string $strategy Conversion strategy (replace|alongside).
276 515 */
277 516 protected static function convert_subsizes(array &$sizes, string $upload_dir, string $format, int $quality, string $strategy): void {
517 + $pass_started = microtime(true);
518 +
278 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 +
279 536 // Check available memory before each sub-size conversion
280 537 $available = static::get_available_memory();
281 538 if ($available < self::MIN_MEMORY_FOR_SUBSIZE) {
282 - error_log('[MetaSync Media Opt] Low memory (' . size_format($available) . '), skipping remaining sub-sizes from: ' . $size_name);
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
283 540 break;
284 541 }
285 542
286 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 +
287 554 $size_converted = static::do_convert_file($size_file, $format, $quality);
288 555
289 - if ($size_converted && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
290 - @unlink($size_file);
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);
291 563 $size_data['file'] = basename($size_converted);
292 564 $size_data['mime-type'] = "image/{$format}";
293 565 } elseif ($size_converted && $strategy === 'replace') {
294 - error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . $size_file);
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
295 567 }
296 568
297 569 // Release cyclic references between sub-size conversions
298 570 gc_collect_cycles();
@@ -300,24 +572,68 @@
300 572 unset($size_data);
301 573 }
302 574
303 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 + /**
304 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.
305 610 */
306 - 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 {
307 612 if (!file_exists($source)) {
308 613 return null;
309 614 }
310 615
311 616 if (filesize($source) > self::MAX_CONVERT_BYTES) {
312 - 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
313 618 return null;
314 619 }
315 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 +
316 629 // Request WordPress's image processing memory limit
317 630 wp_raise_memory_limit('image');
318 631
319 - // Pre-flight memory check using pixel dimensions when available
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.
320 636 $info = getimagesize($source);
321 637 if ($info && $info[0] > 0 && $info[1] > 0) {
322 638 $bpp = ($info['mime'] === 'image/png') ? 4 : 3;
323 639 $estimated = (int) ($info[0] * $info[1] * $bpp * 1.8);
@@ -325,12 +641,17 @@
325 641 $estimated = filesize($source) * 3;
326 642 }
327 643 $available = self::get_available_memory();
328 644 if ($estimated > $available * 0.8) {
329 - error_log('[MetaSync Media Opt] Skipping ' . basename($source) . ': estimated memory (' . size_format($estimated) . ') exceeds 80% of available (' . size_format($available) . ')');
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
330 646 return null;
331 647 }
332 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 +
333 654 $ext = self::get_format_extension($format);
334 655 $dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $source);
335 656
336 657 // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
@@ -335,22 +656,22 @@
335 656
336 657 // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
337 658 if (extension_loaded('imagick')) {
338 659 try {
339 - $result = self::do_convert_with_imagick($source, $dest, $format, $quality);
660 + $result = self::do_convert_with_imagick($source, $dest, $format, $quality, $target_dimensions);
340 661 if ($result) {
341 662 return $result;
342 663 }
343 664 } catch (\Exception $e) {
344 - 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
345 666 }
346 667 }
347 668
348 669 if (extension_loaded('gd')) {
349 670 try {
350 - return self::do_convert_with_gd($source, $dest, $format, $quality);
671 + return self::do_convert_with_gd($source, $dest, $format, $quality, $target_dimensions);
351 672 } catch (\Exception $e) {
352 - 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
353 674 }
354 675 }
355 676
356 677 return null;
@@ -355,10 +676,20 @@
355 676
356 677 return null;
357 678 }
358 679
359 - private static function do_convert_with_imagick(string $src, string $dest, string $fmt, int $q): ?string {
360 - $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 +
361 692 $img->setImageFormat($fmt === 'avif' ? 'avif' : 'webp');
362 693 $img->setImageCompressionQuality($q);
363 694 $img->stripImage();
364 695
@@ -363,10 +694,10 @@
363 694 $img->stripImage();
364 695
365 696 if ($img->writeImage($dest)) {
366 697 if (!file_exists($dest) || !filesize($dest)) {
367 - @unlink($dest);
368 - 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
369 700 $img->destroy();
370 701 return null;
371 702 }
372 703 $img->destroy();
@@ -376,14 +707,16 @@
376 707 $img->destroy();
377 708 return null;
378 709 }
379 710
380 - 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 {
381 712 $info = getimagesize($src);
382 713 if (!$info) {
383 714 return null;
384 715 }
385 716
717 + $is_png = ($info['mime'] === 'image/png');
718 +
386 719 $gd_img = match ($info['mime']) {
387 720 'image/jpeg' => imagecreatefromjpeg($src),
388 721 'image/png' => imagecreatefrompng($src),
389 722 default => null,
@@ -392,14 +725,37 @@
392 725 if (!$gd_img) {
393 726 return null;
394 727 }
395 728
396 - if ($info['mime'] === 'image/png') {
729 + if ($is_png) {
397 730 imagepalettetotruecolor($gd_img);
398 731 imagealphablending($gd_img, true);
399 732 imagesavealpha($gd_img, true);
400 733 }
401 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 +
402 758 $success = match ($fmt) {
403 759 'webp' => imagewebp($gd_img, $dest, $q),
404 760 'avif' => function_exists('imageavif') ? imageavif($gd_img, $dest, $q) : false,
405 761 default => false,
@@ -407,10 +763,10 @@
407 763
408 764 imagedestroy($gd_img);
409 765
410 766 if (!$success || !file_exists($dest) || !filesize($dest)) {
411 - @unlink($dest);
412 - 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
413 769 return null;
414 770 }
415 771
416 772 return $dest;
@@ -420,17 +776,16 @@
420 776 * Replace original file with converted version.
421 777 */
422 778 private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
423 779 if (!file_exists($new_path) || !filesize($new_path)) {
424 - 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
425 781 return;
426 782 }
427 783
428 - // Capture old URL before deleting so we can rewrite post content references
429 - $old_url = wp_get_attachment_url($id);
430 -
431 - @unlink($old_path);
432 -
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.
433 788 wp_update_post([
434 789 'ID' => $id,
435 790 'post_mime_type' => "image/{$fmt}",
436 791 ]);
@@ -437,46 +792,124 @@
437 792
438 793 update_attached_file($id, $new_path);
439 794 $meta['file'] = _wp_relative_upload_path($new_path);
440 795
441 - // Rewrite hardcoded image URLs in post content to point to the new file
442 - $new_url = wp_get_attachment_url($id);
443 - if ($old_url && $new_url && $old_url !== $new_url) {
444 - 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];
445 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);
446 811 }
447 812
448 813 /**
449 - * Rewrite image URLs in all post content that references the old file path.
450 - * Uses the path portion (e.g. /wp-content/uploads/…) so it works regardless
451 - * 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.
452 829 */
453 - private static function rewrite_content_urls(string $old_url, string $new_url): void {
454 - 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);
455 833
456 - // Extract path portions to be hostname-agnostic
457 - $old_path = wp_parse_url($old_url, PHP_URL_PATH);
458 - $new_path = wp_parse_url($new_url, PHP_URL_PATH);
459 -
460 834 if (!$old_path || !$new_path || $old_path === $new_path) {
461 835 return;
462 836 }
463 837
464 - $wpdb->query($wpdb->prepare(
465 - "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) WHERE post_content LIKE %s",
466 - $old_path,
467 - $new_path,
468 - '%' . $wpdb->esc_like($old_path) . '%'
469 - ));
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 + }
470 847 }
471 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 +
472 905 // ── Instance Wrappers (Upload Hook) ──
473 906
474 907 /**
475 908 * Instance wrapper around static conversion method.
476 909 */
477 - private function convert_file(string $source, string $format, int $quality): ?string {
478 - 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);
479 912 }
480 913
481 914 /**
482 915 * Instance wrapper around static replace method.
@@ -509,48 +942,23 @@
509 942 * Output buffer callback: rewrite remaining <img> tags to <picture>.
510 943 * Protects existing <picture>, <script>, and <noscript> blocks from rewriting.
511 944 */
512 945 public function rewrite_full_html(string $html): string {
513 - if (empty($html) || stripos($html, '</html>') === false) {
946 + if (empty($html) || stripos($html, '</html>') === false || $this->is_amp_request()) {
514 947 return $html;
515 948 }
516 949
517 - // Protect blocks that must not be rewritten
518 - $protected = [];
519 - $counter = 0;
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);
520 954
521 - // Existing <picture> blocks (already wrapped by filter hooks)
522 - $html = preg_replace_callback('/<picture\b[^>]*>.*?<\/picture>/is', function ($m) use (&$protected, &$counter) {
523 - $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
524 - $protected[$key] = $m[0];
525 - return $key;
526 - }, $html);
527 -
528 - // <script> blocks (JSON-LD contains image URLs)
529 - $html = preg_replace_callback('/<script\b[^>]*>.*?<\/script>/is', function ($m) use (&$protected, &$counter) {
530 - $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
531 - $protected[$key] = $m[0];
532 - return $key;
533 - }, $html);
534 -
535 - // <noscript> blocks (lazy-loading fallbacks)
536 - $html = preg_replace_callback('/<noscript\b[^>]*>.*?<\/noscript>/is', function ($m) use (&$protected, &$counter) {
537 - $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
538 - $protected[$key] = $m[0];
539 - return $key;
540 - }, $html);
541 -
542 955 // Rewrite remaining <img> tags
543 956 $html = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
544 957 return $this->maybe_wrap_img_tag($matches[0]);
545 958 }, $html);
546 959
547 - // Restore protected blocks
548 - if (!empty($protected)) {
549 - $html = strtr($html, $protected);
550 - }
551 -
552 - return $html;
960 + return $this->restore_protected_blocks($html, $protected);
553 961 }
554 962
555 963 /**
556 964 * Rewrite <img> tags to <picture> with next-gen source.
@@ -556,23 +964,79 @@
556 964 * Rewrite <img> tags to <picture> with next-gen source.
557 965 * Used by WordPress filter hooks for content fragments.
558 966 */
559 967 public function rewrite_to_picture_tags(string $content): string {
560 - if (empty($content)) {
968 + if (empty($content) || is_feed() || $this->is_amp_request()) {
561 969 return $content;
562 970 }
563 971
564 - // Skip if already wrapped in <picture> (avoid double-wrapping from multiple filters)
565 - if (strpos($content, '<picture>') !== false) {
566 - return $content;
567 - }
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);
568 977
569 - return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
978 + $content = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
570 979 return $this->maybe_wrap_img_tag($matches[0]);
571 980 }, $content);
981 +
982 + return $this->restore_protected_blocks($content, $protected);
572 983 }
573 984
574 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;
1014 + }
1015 + return strtr($html, $protected);
1016 + }
1017 +
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 + }
1033 +
1034 + private static function func_is_available(string $function): bool {
1035 + return function_exists($function);
1036 + }
1037 +
1038 + /**
575 1039 * Wrap a single <img> tag in <picture> with next-gen <source>.
576 1040 * Returns the original tag unchanged if conversion is not applicable.
577 1041 */
578 1042 private function maybe_wrap_img_tag(string $img_tag): string {
@@ -584,28 +1048,29 @@
584 1048 return $img_tag;
585 1049 }
586 1050
587 1051 $original_src = $src_match[1];
588 - $format = $this->settings['conversion_format'];
589 - $ext = self::get_format_extension($format);
590 1052
591 - $converted_url = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $original_src);
592 -
593 - if ($converted_url === $original_src) {
1053 + // Exclusion entries may target the URL (full URL or path fragment).
1054 + if ($this->is_url_excluded($original_src)) {
594 1055 return $img_tag;
595 1056 }
596 1057
597 - $converted_path = $this->url_to_path($converted_url);
598 - if (!$converted_path || !file_exists($converted_path)) {
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) {
599 1064 return $img_tag;
600 1065 }
601 1066
1067 + [$converted_url, $format] = $resolved;
602 1068 $mime = $format === 'avif' ? 'image/avif' : 'image/webp';
603 1069
604 1070 $source_srcset = '';
605 1071 if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) {
606 - $converted_srcset = preg_replace('/\.(jpe?g|png)/i', $ext, $srcset_match[1]);
607 - $source_srcset = sprintf(' srcset="%s"', esc_attr($converted_srcset));
1072 + $source_srcset = sprintf(' srcset="%s"', esc_attr($this->resolve_srcset_candidates($srcset_match[1])));
608 1073 }
609 1074
610 1075 $sizes_attr = '';
611 1076 if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) {
@@ -621,8 +1086,79 @@
621 1086 );
622 1087 }
623 1088
624 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;
1109 + }
1110 +
1111 + $candidate_path = $this->url_to_path($candidate);
1112 + if ($candidate_path && file_exists($candidate_path)) {
1113 + return [$candidate, $format];
1114 + }
1115 + }
1116 +
1117 + return null;
1118 + }
1119 +
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;
1145 + }
1146 +
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;
1154 + }
1155 + }
1156 +
1157 + return $any_converted ? implode(', ', $out) : $srcset;
1158 + }
1159 +
1160 + /**
625 1161 * Convert a URL to a local file path. Returns null if URL is external.
626 1162 * Falls back to path-portion matching when hostnames differ (e.g. Cloudflare tunnel rotation).
627 1163 */
628 1164 private function url_to_path(string $url): ?string {
@@ -629,8 +1165,14 @@
629 1165 $upload_dir = wp_get_upload_dir();
630 1166 $base_url = $upload_dir['baseurl'];
631 1167 $base_path = $upload_dir['basedir'];
632 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 +
633 1175 // Direct match (same hostname)
634 1176 if (strpos($url, $base_url) === 0) {
635 1177 return str_replace($base_url, $base_path, $url);
636 1178 }
@@ -658,8 +1200,33 @@
658 1200
659 1201 foreach ($exclude_urls as $pattern) {
660 1202 if (stripos($file, $pattern) !== false) {
661 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 + }
662 1229 }
663 1230 }
664 1231 return false;
665 1232 }