PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.24
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.24
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 2.6.4 2.6.3 All 139 releases
metasync / media-optimization / class-image-converter.php

class-image-converter.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.24, at media-optimization/class-image-converter.php

1,255 lines 49.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metasync_Image_Converter
4 * Converts uploaded JPEG/PNG images to WebP or AVIF on upload.
5 * Supports "replace" and "alongside" strategies.
6 *
7 * @package Search Atlas SEO
8 * @copyright Copyright (C) 2021-2025, Search Atlas Group - [email protected]
9 * @since 2.6.0
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 class Metasync_Image_Converter {
17
18 private array $settings;
19
20 private const SUPPORTED_MIMES = [
21 'image/jpeg',
22 'image/png',
23 ];
24
25 private const EXT_AVIF = '.avif';
26 private const EXT_WEBP = '.webp';
27 private const ORIGINAL_EXT_PATTERN = '/\.(jpe?g|png)$/i';
28 private const MAX_CONVERT_BYTES = 10 * 1024 * 1024;
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 /**
87 * Get file extension for a given format.
88 */
89 private static function get_format_extension(string $format): string {
90 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
91 }
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
128 public function __construct(array $settings) {
129 $this->settings = $settings;
130
131 // Fires after WP generates all thumbnail sizes
132 add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2);
133
134 // If "alongside" strategy, rewrite <img> tags to <picture>
135 if (($settings['conversion_strategy'] ?? '') === 'alongside') {
136 // Core WordPress
137 add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20);
138 add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
139 add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20);
140
141 // WooCommerce frontend images
142 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
143 add_filter('woocommerce_product_get_image', [$this, 'rewrite_to_picture_tags'], 20);
144 add_filter('woocommerce_cart_item_thumbnail', [$this, 'rewrite_to_picture_tags'], 20);
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);
149 }
150 }
151
152 /**
153 * Hook into metadata generation to convert the main file and all sub-sizes.
154 */
155 public function convert_on_upload(array $metadata, int $attachment_id): array {
156 $file = get_attached_file($attachment_id);
157 $mime = get_post_mime_type($attachment_id);
158
159 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
160 return $metadata;
161 }
162
163 // Check exclusions
164 if ($this->is_excluded($file)) {
165 return $metadata;
166 }
167
168 $format = $this->settings['conversion_format'];
169 $quality = (int) $this->settings['conversion_quality'];
170 $strategy = $this->settings['conversion_strategy'];
171 $max_dim = (int) ($this->settings['max_image_dimensions'] ?? 0);
172
173 // Capture original size before any replacement deletes the source file.
174 $original_size = file_exists($file) ? (int) filesize($file) : 0;
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
184 if ($converted && $strategy === 'replace') {
185 $this->replace_original($attachment_id, $file, $converted, $metadata, $format);
186 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
187 }
188
189 // Convert sub-sizes (thumbnails, medium, large, etc.) with memory management
190 if (!empty($this->settings['convert_existing_sizes']) && !empty($metadata['sizes'])) {
191 static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
192 }
193
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) {
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 }
202 }
203
204 return $metadata;
205 }
206
207 // ── Static Methods for External Use (Batch Optimizer, AJAX) ──
208
209 /**
210 * Convert an existing attachment to next-gen format.
211 * Used by batch optimizer and single-image AJAX actions.
212 */
213 public static function convert_attachment(int $attachment_id, array $settings): bool {
214 $file = get_attached_file($attachment_id);
215 $mime = get_post_mime_type($attachment_id);
216
217 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
218 return false;
219 }
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
237 $format = $settings['conversion_format'] ?? 'webp';
238 $quality = (int) ($settings['conversion_quality'] ?? 82);
239 $strategy = $settings['conversion_strategy'] ?? 'alongside';
240 $max_dim = (int) ($settings['max_image_dimensions'] ?? 0);
241
242 // Store original file size before conversion for savings display
243 $original_size = filesize($file);
244
245 $converted = self::do_convert_file($file, $format, $quality, $max_dim);
246 if (!$converted) {
247 return false;
248 }
249
250 // Store original size meta for savings calculation
251 if ($original_size) {
252 update_post_meta($attachment_id, '_metasync_original_filesize', $original_size);
253 }
254
255 if ($strategy === 'replace') {
256 $metadata = wp_get_attachment_metadata($attachment_id);
257 if ($metadata) {
258 self::do_replace_original($attachment_id, $file, $converted, $metadata, $format);
259 wp_update_attachment_metadata($attachment_id, $metadata);
260 }
261 }
262
263 // Convert sub-sizes with memory management
264 if (!empty($settings['convert_existing_sizes'])) {
265 $metadata = wp_get_attachment_metadata($attachment_id);
266 if ($metadata && !empty($metadata['sizes'])) {
267 static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
268 if ($strategy === 'replace') {
269 wp_update_attachment_metadata($attachment_id, $metadata);
270 }
271 }
272 }
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
281 update_post_meta($attachment_id, '_metasync_converted_format', $format);
282 Metasync_Media_Batch_Optimizer::flush_stats_cache();
283 return true;
284 }
285
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 /**
307 * Revert an attachment's conversion (alongside strategy only).
308 * Deletes the converted files and removes the meta marker.
309 */
310 public static function revert_attachment(int $attachment_id): bool {
311 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
312 if (!$format) {
313 return false;
314 }
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
323 $file = get_attached_file($attachment_id);
324 if (!$file) {
325 return false;
326 }
327
328 // Check if original still exists (alongside strategy)
329 if (!file_exists($file)) {
330 return false; // Cannot revert replace strategy
331 }
332
333 $ext = self::get_format_extension($format);
334
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).
339 $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file);
340 if ($converted_path && $converted_path !== $file && file_exists($converted_path)) {
341 @unlink($converted_path);
342 }
343
344 // Delete converted sub-sizes
345 $metadata = wp_get_attachment_metadata($attachment_id);
346 if ($metadata && !empty($metadata['sizes'])) {
347 $upload_dir = dirname($file);
348 foreach ($metadata['sizes'] as $size_data) {
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 @unlink($size_converted);
353 }
354 }
355 }
356
357 delete_post_meta($attachment_id, '_metasync_converted_format');
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();
365 return true;
366 }
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 @unlink($converted);
414 }
415 }
416 }
417 }
418
419 // ── Core Conversion (static, reusable) ──
420
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)) {
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);
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);
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);
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 @unlink($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));
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 /**
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.
610 */
611 protected static function do_convert_file(string $source, string $format, int $quality, int $max_dimensions = 0): ?string {
612 if (!file_exists($source)) {
613 return null;
614 }
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));
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));
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) . ')');
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
654 $ext = self::get_format_extension($format);
655 $dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $source);
656
657 // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
658 if (extension_loaded('imagick')) {
659 try {
660 $result = self::do_convert_with_imagick($source, $dest, $format, $quality, $target_dimensions);
661 if ($result) {
662 return $result;
663 }
664 } catch (\Exception $e) {
665 error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage());
666 }
667 }
668
669 if (extension_loaded('gd')) {
670 try {
671 return self::do_convert_with_gd($source, $dest, $format, $quality, $target_dimensions);
672 } catch (\Exception $e) {
673 error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage());
674 }
675 }
676
677 return null;
678 }
679
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
692 $img->setImageFormat($fmt === 'avif' ? 'avif' : 'webp');
693 $img->setImageCompressionQuality($q);
694 $img->stripImage();
695
696 if ($img->writeImage($dest)) {
697 if (!file_exists($dest) || !filesize($dest)) {
698 @unlink($dest);
699 error_log('[MetaSync Media Opt] Imagick wrote 0-byte or missing output, discarding: ' . self::redact_path($dest));
700 $img->destroy();
701 return null;
702 }
703 $img->destroy();
704 return $dest;
705 }
706
707 $img->destroy();
708 return null;
709 }
710
711 private static function do_convert_with_gd(string $src, string $dest, string $fmt, int $q, ?array $target_dimensions = null): ?string {
712 $info = getimagesize($src);
713 if (!$info) {
714 return null;
715 }
716
717 $is_png = ($info['mime'] === 'image/png');
718
719 $gd_img = match ($info['mime']) {
720 'image/jpeg' => imagecreatefromjpeg($src),
721 'image/png' => imagecreatefrompng($src),
722 default => null,
723 };
724
725 if (!$gd_img) {
726 return null;
727 }
728
729 if ($is_png) {
730 imagepalettetotruecolor($gd_img);
731 imagealphablending($gd_img, true);
732 imagesavealpha($gd_img, true);
733 }
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
758 $success = match ($fmt) {
759 'webp' => imagewebp($gd_img, $dest, $q),
760 'avif' => function_exists('imageavif') ? imageavif($gd_img, $dest, $q) : false,
761 default => false,
762 };
763
764 imagedestroy($gd_img);
765
766 if (!$success || !file_exists($dest) || !filesize($dest)) {
767 @unlink($dest);
768 error_log('[MetaSync Media Opt] GD produced empty or missing output, discarding: ' . self::redact_path($dest));
769 return null;
770 }
771
772 return $dest;
773 }
774
775 /**
776 * Replace original file with converted version.
777 */
778 private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
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));
781 return;
782 }
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.
788 wp_update_post([
789 'ID' => $id,
790 'post_mime_type' => "image/{$fmt}",
791 ]);
792
793 update_attached_file($id, $new_path);
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 @unlink($old_path);
811 }
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(
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
905 // ── Instance Wrappers (Upload Hook) ──
906
907 /**
908 * Instance wrapper around static conversion method.
909 */
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);
912 }
913
914 /**
915 * Instance wrapper around static replace method.
916 */
917 private function replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
918 self::do_replace_original($id, $old_path, $new_path, $meta, $fmt);
919 }
920
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 /**
964 * Rewrite <img> tags to <picture> with next-gen source.
965 * Used by WordPress filter hooks for content fragments.
966 */
967 public function rewrite_to_picture_tags(string $content): string {
968 if (empty($content) || is_feed() || $this->is_amp_request()) {
969 return $content;
970 }
971
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;
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 /**
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 }
1046
1047 if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) {
1048 return $img_tag;
1049 }
1050
1051 $original_src = $src_match[1];
1052
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;
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 /**
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).
1163 */
1164 private function url_to_path(string $url): ?string {
1165 $upload_dir = wp_get_upload_dir();
1166 $base_url = $upload_dir['baseurl'];
1167 $base_path = $upload_dir['basedir'];
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)
1176 if (strpos($url, $base_url) === 0) {
1177 return str_replace($base_url, $base_path, $url);
1178 }
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
1189 return null;
1190 }
1191
1192 /**
1193 * Check if a file path matches exclusion patterns.
1194 */
1195 private function is_excluded(string $file): bool {
1196 $exclude_urls = array_filter(array_map('trim', explode(',', $this->settings['exclude_urls'] ?? '')));
1197 if (empty($exclude_urls)) {
1198 return false;
1199 }
1200
1201 foreach ($exclude_urls as $pattern) {
1202 if (stripos($file, $pattern) !== false) {
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 }
1229 }
1230 }
1231 return false;
1232 }
1233
1234 /**
1235 * Check if an img tag has an excluded CSS class.
1236 */
1237 private function is_tag_excluded(string $tag): bool {
1238 $exclude_classes = array_filter(array_map('trim', explode(',', $this->settings['exclude_classes'] ?? '')));
1239 if (empty($exclude_classes)) {
1240 return false;
1241 }
1242
1243 if (preg_match('/class=["\']([^"\']+)["\']/i', $tag, $class_match)) {
1244 $classes = explode(' ', $class_match[1]);
1245 foreach ($exclude_classes as $excluded) {
1246 if (in_array($excluded, $classes, true)) {
1247 return true;
1248 }
1249 }
1250 }
1251 return false;
1252 }
1253
1254 }
1255