PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.18
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.18
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 2.5.23 All 138 releases
metasync / media-optimization / class-image-converter.php

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

971 lines 37.3 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 - support@searchatlas.com
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 * Get file extension for a given format.
56 */
57 private static function get_format_extension(string $format): string {
58 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
59 }
60
61 public function __construct(array $settings) {
62 $this->settings = $settings;
63
64 // Fires after WP generates all thumbnail sizes
65 add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2);
66
67 // If "alongside" strategy, rewrite <img> tags to <picture>
68 if ($settings['conversion_strategy'] === 'alongside') {
69 // Core WordPress
70 add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20);
71 add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
72 add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20);
73
74 // WooCommerce frontend images
75 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
76 add_filter('woocommerce_product_get_image', [$this, 'rewrite_to_picture_tags'], 20);
77 add_filter('woocommerce_cart_item_thumbnail', [$this, 'rewrite_to_picture_tags'], 20);
78 add_filter('woocommerce_placeholder_img', [$this, 'rewrite_to_picture_tags'], 20);
79
80 // Output buffer catch-all for themes/builders bypassing WP filters (Divi, Elementor, etc.)
81 add_action('template_redirect', [$this, 'start_output_buffer'], 1);
82 }
83 }
84
85 /**
86 * Hook into metadata generation to convert the main file and all sub-sizes.
87 */
88 public function convert_on_upload(array $metadata, int $attachment_id): array {
89 $file = get_attached_file($attachment_id);
90 $mime = get_post_mime_type($attachment_id);
91
92 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
93 return $metadata;
94 }
95
96 // Check exclusions
97 if ($this->is_excluded($file)) {
98 return $metadata;
99 }
100
101 $format = $this->settings['conversion_format'];
102 $quality = (int) $this->settings['conversion_quality'];
103 $strategy = $this->settings['conversion_strategy'];
104 $max_dim = (int) ($this->settings['max_image_dimensions'] ?? 0);
105
106 // Capture original size before any replacement deletes the source file.
107 $original_size = file_exists($file) ? (int) filesize($file) : 0;
108
109 // Give a heavy multi-size upload a fresh execution budget instead of
110 // inheriting what WP's own thumbnail generation left of the request's
111 // default cap.
112 static::reset_time_limit();
113
114 // Convert main/full file (downscaled to $max_dim if it exceeds the limit)
115 $converted = $this->convert_file($file, $format, $quality, $max_dim);
116
117 if ($converted && $strategy === 'replace') {
118 $this->replace_original($attachment_id, $file, $converted, $metadata, $format);
119 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
120 }
121
122 // Convert sub-sizes (thumbnails, medium, large, etc.) with memory management
123 if (!empty($this->settings['convert_existing_sizes']) && !empty($metadata['sizes'])) {
124 static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
125 }
126
127 // Record the conversion for BOTH strategies so the image reports as
128 // optimized in the library and is not re-queued by the batch optimizer.
129 // (The picture tag rewriter also relies on this meta for "alongside".)
130 if ($converted) {
131 update_post_meta($attachment_id, '_metasync_converted_format', $format);
132 if ($original_size) {
133 update_post_meta($attachment_id, '_metasync_original_filesize', $original_size);
134 }
135 }
136
137 return $metadata;
138 }
139
140 // ── Static Methods for External Use (Batch Optimizer, AJAX) ──
141
142 /**
143 * Convert an existing attachment to next-gen format.
144 * Used by batch optimizer and single-image AJAX actions.
145 */
146 public static function convert_attachment(int $attachment_id, array $settings): bool {
147 $file = get_attached_file($attachment_id);
148 $mime = get_post_mime_type($attachment_id);
149
150 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
151 return false;
152 }
153
154 // Request WordPress's image processing memory limit (typically 256MB) before heavy work
155 wp_raise_memory_limit('image');
156
157 // Give a heavy multi-size conversion a fresh execution budget.
158 static::reset_time_limit();
159
160 $format = $settings['conversion_format'] ?? 'webp';
161 $quality = (int) ($settings['conversion_quality'] ?? 82);
162 $strategy = $settings['conversion_strategy'] ?? 'alongside';
163 $max_dim = (int) ($settings['max_image_dimensions'] ?? 0);
164
165 // Store original file size before conversion for savings display
166 $original_size = filesize($file);
167
168 $converted = self::do_convert_file($file, $format, $quality, $max_dim);
169 if (!$converted) {
170 return false;
171 }
172
173 // Store original size meta for savings calculation
174 if ($original_size) {
175 update_post_meta($attachment_id, '_metasync_original_filesize', $original_size);
176 }
177
178 if ($strategy === 'replace') {
179 $metadata = wp_get_attachment_metadata($attachment_id);
180 if ($metadata) {
181 self::do_replace_original($attachment_id, $file, $converted, $metadata, $format);
182 wp_update_attachment_metadata($attachment_id, $metadata);
183 }
184 }
185
186 // Convert sub-sizes with memory management
187 if (!empty($settings['convert_existing_sizes'])) {
188 $metadata = wp_get_attachment_metadata($attachment_id);
189 if ($metadata && !empty($metadata['sizes'])) {
190 static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
191 if ($strategy === 'replace') {
192 wp_update_attachment_metadata($attachment_id, $metadata);
193 }
194 }
195 }
196
197 if ($strategy === 'replace') {
198 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
199 }
200
201 update_post_meta($attachment_id, '_metasync_converted_format', $format);
202 return true;
203 }
204
205 /**
206 * Check whether an optimized attachment can be reverted.
207 * Returns false when the original was replaced (no backup exists).
208 */
209 public static function can_revert(int $attachment_id): bool {
210 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
211 if (!$format) {
212 return false; // Not optimized
213 }
214
215 // If the original was replaced, revert is impossible
216 if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) {
217 return false;
218 }
219
220 // Verify original file still exists on disk (alongside strategy)
221 $file = get_attached_file($attachment_id);
222 return $file && file_exists($file);
223 }
224
225 /**
226 * Revert an attachment's conversion (alongside strategy only).
227 * Deletes the converted files and removes the meta marker.
228 */
229 public static function revert_attachment(int $attachment_id): bool {
230 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
231 if (!$format) {
232 return false;
233 }
234
235 // Replace strategy has no original to restore — the attached file IS the
236 // converted file, so the extension swap below would leave the path
237 // unchanged and unlink the attachment's only copy. Refuse instead.
238 if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) {
239 return false;
240 }
241
242 $file = get_attached_file($attachment_id);
243 if (!$file) {
244 return false;
245 }
246
247 // Check if original still exists (alongside strategy)
248 if (!file_exists($file)) {
249 return false; // Cannot revert replace strategy
250 }
251
252 $ext = self::get_format_extension($format);
253
254 // Delete converted full-size file
255 // `$converted_path !== $file` guards against deleting the attachment's
256 // own file when the extension pattern doesn't match (e.g. the attached file is
257 // already .webp/.avif and preg_replace returns the path unchanged).
258 $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file);
259 if ($converted_path && $converted_path !== $file && file_exists($converted_path)) {
260 @unlink($converted_path);
261 }
262
263 // Delete converted sub-sizes
264 $metadata = wp_get_attachment_metadata($attachment_id);
265 if ($metadata && !empty($metadata['sizes'])) {
266 $upload_dir = dirname($file);
267 foreach ($metadata['sizes'] as $size_data) {
268 $size_path = $upload_dir . '/' . $size_data['file'];
269 $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $size_path);
270 if ($size_converted && $size_converted !== $size_path && file_exists($size_converted)) {
271 @unlink($size_converted);
272 }
273 }
274 }
275
276 delete_post_meta($attachment_id, '_metasync_converted_format');
277 delete_post_meta($attachment_id, '_metasync_original_filesize');
278 delete_post_meta($attachment_id, '_metasync_replaced_original');
279 return true;
280 }
281
282 /**
283 * Delete converted sibling files when an attachment is deleted.
284 *
285 * Hooked on `delete_attachment` (fires before WordPress removes the
286 * attachment's own files). The "alongside" strategy writes .webp/.avif
287 * files next to the originals that are NOT tracked in attachment metadata,
288 * so WordPress core never deletes them and they leak on disk. The "replace"
289 * strategy's converted files ARE the attachment files and are removed by
290 * core, so nothing extra is needed there.
291 *
292 * @param int $attachment_id Attachment being deleted.
293 */
294 public static function cleanup_on_delete(int $attachment_id): void {
295 // Replace-strategy converted files are the attachment files themselves
296 // (deleted by core). Only "alongside" siblings need manual cleanup.
297 if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) {
298 return;
299 }
300
301 $file = get_attached_file($attachment_id);
302 if (!$file) {
303 return;
304 }
305
306 // Prefer the recorded format; fall back to both next-gen extensions
307 // when the format is unknown (e.g. created by an older plugin version).
308 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
309 $exts = $format ? [self::get_format_extension($format)] : [self::EXT_WEBP, self::EXT_AVIF];
310
311 // Collect the full-size file plus every registered sub-size.
312 $paths = [$file];
313 $metadata = wp_get_attachment_metadata($attachment_id);
314 if (is_array($metadata) && !empty($metadata['sizes'])) {
315 $dir = dirname($file);
316 foreach ($metadata['sizes'] as $size_data) {
317 if (!empty($size_data['file'])) {
318 $paths[] = $dir . '/' . $size_data['file'];
319 }
320 }
321 }
322
323 foreach ($paths as $path) {
324 foreach ($exts as $ext) {
325 $converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $path);
326 if ($converted && $converted !== $path && file_exists($converted)) {
327 @unlink($converted);
328 }
329 }
330 }
331 }
332
333 // ── Core Conversion (static, reusable) ──
334
335 /**
336 * Get available PHP memory in bytes.
337 * Returns PHP_INT_MAX when no limit is set (-1) or unreadable.
338 * Returns 0 when memory_limit is "0" or empty.
339 */
340 protected static function get_available_memory(): int {
341 $limit = ini_get('memory_limit');
342
343 if ($limit === false || $limit === '-1') {
344 return PHP_INT_MAX;
345 }
346
347 $limit = trim($limit);
348 if ($limit === '' || $limit === '0') {
349 return 0;
350 }
351
352 $value = (int) $limit;
353 $unit = strtolower(substr($limit, -1));
354
355 $value = match ($unit) {
356 'g' => $value * 1024 * 1024 * 1024,
357 'm' => $value * 1024 * 1024,
358 'k' => $value * 1024,
359 default => $value,
360 };
361
362 return max(0, $value - memory_get_usage(true));
363 }
364
365 /**
366 * Grant the current request a fresh execution budget before heavy
367 * conversion work. Returns true when the timer was actually
368 * reset; false on hosts where set_time_limit() is disabled, in which
369 * case callers must rely on get_remaining_time() to bail out early.
370 */
371 protected static function reset_time_limit(): bool {
372 if (function_exists('set_time_limit') && @set_time_limit(self::UPLOAD_TIME_LIMIT)) {
373 self::$timer_started_at = microtime(true);
374 return true;
375 }
376 return false;
377 }
378
379 /**
380 * Seconds left before PHP's execution limit kills the request, minus
381 * TIME_SAFETY_MARGIN.
382 *
383 * Defense-in-depth for hosts where set_time_limit() is disabled: the
384 * elapsed time is measured from the request start
385 * ($_SERVER['REQUEST_TIME_FLOAT']) — not from when conversion began — so
386 * time WordPress already spent generating thumbnails counts against the
387 * budget. Once reset_time_limit() succeeds, the baseline moves to the
388 * moment of the reset, matching PHP's own restarted timer.
389 *
390 * On Linux max_execution_time counts CPU time while this measures wall
391 * clock, so the estimate only errs on the early-bail (safe) side.
392 *
393 * @param int $max_execution_time PHP max_execution_time (0 = unlimited). Accepts parameter for testability.
394 * @return float Remaining seconds; PHP_INT_MAX when no limit applies.
395 */
396 protected static function get_remaining_time(int $max_execution_time = -1): float {
397 if ($max_execution_time < 0) {
398 $max_execution_time = (int) ini_get('max_execution_time');
399 }
400
401 // No execution limit (CLI, unlimited hosts): a timeout fatal is
402 // impossible, so never cut conversions short.
403 if ($max_execution_time <= 0) {
404 return (float) PHP_INT_MAX;
405 }
406
407 $started = self::$timer_started_at > 0.0
408 ? self::$timer_started_at
409 : (float) ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true));
410
411 return $max_execution_time - (microtime(true) - $started) - self::TIME_SAFETY_MARGIN;
412 }
413
414 /**
415 * Convert sub-sizes with memory and execution-time management.
416 *
417 * Runs gc_collect_cycles() between each sub-size to release memory and
418 * checks available memory before each iteration, skipping remaining
419 * sub-sizes when memory drops below MIN_MEMORY_FOR_SUBSIZE. Each
420 * iteration also refreshes the execution timer (or, where that is
421 * disabled, bails out before PHP's limit is hit) so a heavy multi-size
422 * image cannot trigger a max_execution_time fatal.
423 *
424 * @param array $sizes Reference to $metadata['sizes'].
425 * @param string $upload_dir Directory containing the sub-size files.
426 * @param string $format Target format (webp|avif).
427 * @param int $quality Compression quality.
428 * @param string $strategy Conversion strategy (replace|alongside).
429 */
430 protected static function convert_subsizes(array &$sizes, string $upload_dir, string $format, int $quality, string $strategy): void {
431 $pass_started = microtime(true);
432
433 foreach ($sizes as $size_name => &$size_data) {
434 // Cap the total synchronous sub-size pass so the
435 // per-iteration timer resets below cannot keep one request busy
436 // indefinitely.
437 if ((microtime(true) - $pass_started) >= self::UPLOAD_TIME_LIMIT) {
438 error_log('[MetaSync Media Opt] Sub-size time budget exceeded, skipping remaining sub-sizes from: ' . $size_name);
439 break;
440 }
441
442 // Refresh the execution timer between encodes; when the host
443 // disables set_time_limit(), bail out before PHP's limit kills
444 // the request.
445 if (!static::reset_time_limit() && static::get_remaining_time() <= 0) {
446 error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping remaining sub-sizes from: ' . $size_name);
447 break;
448 }
449
450 // Check available memory before each sub-size conversion
451 $available = static::get_available_memory();
452 if ($available < self::MIN_MEMORY_FOR_SUBSIZE) {
453 error_log('[MetaSync Media Opt] Low memory (' . size_format($available) . '), skipping remaining sub-sizes from: ' . $size_name);
454 break;
455 }
456
457 $size_file = $upload_dir . '/' . $size_data['file'];
458 $size_converted = static::do_convert_file($size_file, $format, $quality);
459
460 if ($size_converted && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
461 @unlink($size_file);
462 $size_data['file'] = basename($size_converted);
463 $size_data['mime-type'] = "image/{$format}";
464 } elseif ($size_converted && $strategy === 'replace') {
465 error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . $size_file);
466 }
467
468 // Release cyclic references between sub-size conversions
469 gc_collect_cycles();
470 }
471 unset($size_data);
472 }
473
474 /**
475 * Calculate downscaled dimensions that fit within a square bound while
476 * preserving aspect ratio.
477 *
478 * Returns [width, height] when the image exceeds $max on either axis, or
479 * null when no downscale is needed (already within bounds, downscaling
480 * disabled, or invalid dimensions). The result is never upscaled.
481 *
482 * @param int $width Original width in pixels.
483 * @param int $height Original height in pixels.
484 * @param int $max Maximum allowed width/height; 0 disables downscaling.
485 * @return array{0:int,1:int}|null
486 */
487 protected static function calc_scaled_dimensions(int $width, int $height, int $max): ?array {
488 if ($max <= 0 || $width <= 0 || $height <= 0) {
489 return null;
490 }
491
492 if ($width <= $max && $height <= $max) {
493 return null;
494 }
495
496 $ratio = min($max / $width, $max / $height);
497 $new_w = max(1, (int) round($width * $ratio));
498 $new_h = max(1, (int) round($height * $ratio));
499
500 return [$new_w, $new_h];
501 }
502
503 /**
504 * Convert a single image file. Returns path to converted file or null on failure.
505 *
506 * When $max_dimensions is greater than zero and the source exceeds it on
507 * either axis, the image is downscaled (aspect ratio preserved) before
508 * encoding. This lowers the encoder's pixel buffer and shrinks output.
509 */
510 protected static function do_convert_file(string $source, string $format, int $quality, int $max_dimensions = 0): ?string {
511 if (!file_exists($source)) {
512 return null;
513 }
514
515 if (filesize($source) > self::MAX_CONVERT_BYTES) {
516 error_log('[MetaSync Media Opt] Source file exceeds MAX_CONVERT_BYTES limit, skipping: ' . $source);
517 return null;
518 }
519
520 // Bail out instead of starting an encode PHP may kill mid-write
521 // On hosts where set_time_limit() is disabled the request
522 // keeps its original cap, and the fatal reported by Sentry fired here.
523 if (static::get_remaining_time() <= 0) {
524 error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping conversion: ' . basename($source));
525 return null;
526 }
527
528 // Request WordPress's image processing memory limit
529 wp_raise_memory_limit('image');
530
531 // Pre-flight memory check using pixel dimensions when available.
532 // Note: the estimate intentionally uses the ORIGINAL dimensions because
533 // both encoders must decode the full-resolution source into memory
534 // before any downscale can be applied.
535 $info = getimagesize($source);
536 if ($info && $info[0] > 0 && $info[1] > 0) {
537 $bpp = ($info['mime'] === 'image/png') ? 4 : 3;
538 $estimated = (int) ($info[0] * $info[1] * $bpp * 1.8);
539 } else {
540 $estimated = filesize($source) * 3;
541 }
542 $available = self::get_available_memory();
543 if ($estimated > $available * 0.8) {
544 error_log('[MetaSync Media Opt] Skipping ' . basename($source) . ': estimated memory (' . size_format($estimated) . ') exceeds 80% of available (' . size_format($available) . ')');
545 return null;
546 }
547
548 // Determine target dimensions when the source exceeds the configured cap.
549 $target_dimensions = ($info && $info[0] > 0 && $info[1] > 0)
550 ? self::calc_scaled_dimensions((int) $info[0], (int) $info[1], $max_dimensions)
551 : null;
552
553 $ext = self::get_format_extension($format);
554 $dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $source);
555
556 // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
557 if (extension_loaded('imagick')) {
558 try {
559 $result = self::do_convert_with_imagick($source, $dest, $format, $quality, $target_dimensions);
560 if ($result) {
561 return $result;
562 }
563 } catch (\Exception $e) {
564 error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage());
565 }
566 }
567
568 if (extension_loaded('gd')) {
569 try {
570 return self::do_convert_with_gd($source, $dest, $format, $quality, $target_dimensions);
571 } catch (\Exception $e) {
572 error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage());
573 }
574 }
575
576 return null;
577 }
578
579 private static function do_convert_with_imagick(string $src, string $dest, string $fmt, int $q, ?array $target_dimensions = null): ?string {
580 $img = new \Imagick();
581 $img->setResourceLimit(\Imagick::RESOURCETYPE_MEMORY, 64 * 1024 * 1024);
582 $img->setResourceLimit(\Imagick::RESOURCETYPE_MAP, 128 * 1024 * 1024);
583 $img->readImage($src);
584
585 // Downscale before encoding when the image exceeds the configured cap.
586 if ($target_dimensions !== null) {
587 [$new_w, $new_h] = $target_dimensions;
588 $img->scaleImage($new_w, $new_h);
589 }
590
591 $img->setImageFormat($fmt === 'avif' ? 'avif' : 'webp');
592 $img->setImageCompressionQuality($q);
593 $img->stripImage();
594
595 if ($img->writeImage($dest)) {
596 if (!file_exists($dest) || !filesize($dest)) {
597 @unlink($dest);
598 error_log('[MetaSync Media Opt] Imagick wrote 0-byte or missing output, discarding: ' . $dest);
599 $img->destroy();
600 return null;
601 }
602 $img->destroy();
603 return $dest;
604 }
605
606 $img->destroy();
607 return null;
608 }
609
610 private static function do_convert_with_gd(string $src, string $dest, string $fmt, int $q, ?array $target_dimensions = null): ?string {
611 $info = getimagesize($src);
612 if (!$info) {
613 return null;
614 }
615
616 $is_png = ($info['mime'] === 'image/png');
617
618 $gd_img = match ($info['mime']) {
619 'image/jpeg' => imagecreatefromjpeg($src),
620 'image/png' => imagecreatefrompng($src),
621 default => null,
622 };
623
624 if (!$gd_img) {
625 return null;
626 }
627
628 if ($is_png) {
629 imagepalettetotruecolor($gd_img);
630 imagealphablending($gd_img, true);
631 imagesavealpha($gd_img, true);
632 }
633
634 // Downscale before encoding when the image exceeds the configured cap.
635 if ($target_dimensions !== null) {
636 [$new_w, $new_h] = $target_dimensions;
637 $resized = imagecreatetruecolor($new_w, $new_h);
638 if ($resized !== false) {
639 if ($is_png) {
640 // Preserve transparency on the resized canvas.
641 imagealphablending($resized, false);
642 imagesavealpha($resized, true);
643 $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
644 imagefilledrectangle($resized, 0, 0, $new_w, $new_h, $transparent);
645 }
646 imagecopyresampled(
647 $resized, $gd_img,
648 0, 0, 0, 0,
649 $new_w, $new_h,
650 imagesx($gd_img), imagesy($gd_img)
651 );
652 imagedestroy($gd_img);
653 $gd_img = $resized;
654 }
655 }
656
657 $success = match ($fmt) {
658 'webp' => imagewebp($gd_img, $dest, $q),
659 'avif' => function_exists('imageavif') ? imageavif($gd_img, $dest, $q) : false,
660 default => false,
661 };
662
663 imagedestroy($gd_img);
664
665 if (!$success || !file_exists($dest) || !filesize($dest)) {
666 @unlink($dest);
667 error_log('[MetaSync Media Opt] GD produced empty or missing output, discarding: ' . $dest);
668 return null;
669 }
670
671 return $dest;
672 }
673
674 /**
675 * Replace original file with converted version.
676 */
677 private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
678 if (!file_exists($new_path) || !filesize($new_path)) {
679 error_log('[MetaSync Media Opt] Converted file is missing or empty, original preserved: ' . $old_path);
680 return;
681 }
682
683 // Capture old URL before deleting so we can rewrite post content references
684 $old_url = wp_get_attachment_url($id);
685
686 @unlink($old_path);
687
688 wp_update_post([
689 'ID' => $id,
690 'post_mime_type' => "image/{$fmt}",
691 ]);
692
693 update_attached_file($id, $new_path);
694 $meta['file'] = _wp_relative_upload_path($new_path);
695
696 // Sync the full-size dimensions to the converted file. When a pre-conversion
697 // downscale shrank the image, the original width/height in metadata are now
698 // stale; otherwise this is a harmless no-op.
699 $new_dims = @getimagesize($new_path);
700 if ($new_dims && $new_dims[0] > 0 && $new_dims[1] > 0) {
701 $meta['width'] = (int) $new_dims[0];
702 $meta['height'] = (int) $new_dims[1];
703 }
704
705 // Rewrite hardcoded image URLs in post content to point to the new file
706 $new_url = wp_get_attachment_url($id);
707 if ($old_url && $new_url && $old_url !== $new_url) {
708 self::rewrite_content_urls($old_url, $new_url);
709 }
710 }
711
712 /**
713 * Rewrite image URLs in all post content that references the old file path.
714 * Uses the path portion (e.g. /wp-content/uploads/…) so it works regardless
715 * of hostname changes (e.g. Cloudflare tunnel rotations).
716 */
717 private static function rewrite_content_urls(string $old_url, string $new_url): void {
718 global $wpdb;
719
720 // Extract path portions to be hostname-agnostic
721 $old_path = wp_parse_url($old_url, PHP_URL_PATH);
722 $new_path = wp_parse_url($new_url, PHP_URL_PATH);
723
724 if (!$old_path || !$new_path || $old_path === $new_path) {
725 return;
726 }
727
728 // Batch the UPDATE so a large wp_posts table is never locked by a single
729 // unbounded REPLACE. Each batch only rewrites rows that still
730 // contain the old path; once replaced they no longer match the LIKE, so
731 // the loop converges. The batch ceiling is a safety net against an
732 // unexpected non-converging loop (e.g. a DB-level error returning false).
733 $batch_size = 500;
734 $like = '%' . $wpdb->esc_like($old_path) . '%';
735 $max_batches = 100000;
736
737 for ($batch = 0; $batch < $max_batches; $batch++) {
738 $affected = $wpdb->query($wpdb->prepare(
739 "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s)
740 WHERE post_content LIKE %s ORDER BY ID LIMIT %d",
741 $old_path,
742 $new_path,
743 $like,
744 $batch_size
745 ));
746
747 // false → query error; a short batch (< batch_size) means the last
748 // matching rows were just rewritten. Either way there is no more work.
749 if ($affected === false || $affected < $batch_size) {
750 break;
751 }
752 }
753 }
754
755 // ── Instance Wrappers (Upload Hook) ──
756
757 /**
758 * Instance wrapper around static conversion method.
759 */
760 private function convert_file(string $source, string $format, int $quality, int $max_dimensions = 0): ?string {
761 return self::do_convert_file($source, $format, $quality, $max_dimensions);
762 }
763
764 /**
765 * Instance wrapper around static replace method.
766 */
767 private function replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
768 self::do_replace_original($id, $old_path, $new_path, $meta, $fmt);
769 }
770
771 /**
772 * Start output buffering on frontend to catch images from themes/builders
773 * that bypass standard WordPress image filters (e.g. Divi, Elementor).
774 */
775 public function start_output_buffer(): void {
776 if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
777 return;
778 }
779
780 if (defined('REST_REQUEST') && REST_REQUEST) {
781 return;
782 }
783
784 if (is_feed() || is_robots() || is_trackback()) {
785 return;
786 }
787
788 ob_start([$this, 'rewrite_full_html']);
789 }
790
791 /**
792 * Output buffer callback: rewrite remaining <img> tags to <picture>.
793 * Protects existing <picture>, <script>, and <noscript> blocks from rewriting.
794 */
795 public function rewrite_full_html(string $html): string {
796 if (empty($html) || stripos($html, '</html>') === false) {
797 return $html;
798 }
799
800 // Protect blocks that must not be rewritten
801 $protected = [];
802 $counter = 0;
803
804 // Existing <picture> blocks (already wrapped by filter hooks)
805 $html = preg_replace_callback('/<picture\b[^>]*>.*?<\/picture>/is', function ($m) use (&$protected, &$counter) {
806 $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
807 $protected[$key] = $m[0];
808 return $key;
809 }, $html);
810
811 // <script> blocks (JSON-LD contains image URLs)
812 $html = preg_replace_callback('/<script\b[^>]*>.*?<\/script>/is', function ($m) use (&$protected, &$counter) {
813 $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
814 $protected[$key] = $m[0];
815 return $key;
816 }, $html);
817
818 // <noscript> blocks (lazy-loading fallbacks)
819 $html = preg_replace_callback('/<noscript\b[^>]*>.*?<\/noscript>/is', function ($m) use (&$protected, &$counter) {
820 $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
821 $protected[$key] = $m[0];
822 return $key;
823 }, $html);
824
825 // Rewrite remaining <img> tags
826 $html = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
827 return $this->maybe_wrap_img_tag($matches[0]);
828 }, $html);
829
830 // Restore protected blocks
831 if (!empty($protected)) {
832 $html = strtr($html, $protected);
833 }
834
835 return $html;
836 }
837
838 /**
839 * Rewrite <img> tags to <picture> with next-gen source.
840 * Used by WordPress filter hooks for content fragments.
841 */
842 public function rewrite_to_picture_tags(string $content): string {
843 if (empty($content)) {
844 return $content;
845 }
846
847 // Skip if already wrapped in <picture> (avoid double-wrapping from multiple filters)
848 if (strpos($content, '<picture>') !== false) {
849 return $content;
850 }
851
852 return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
853 return $this->maybe_wrap_img_tag($matches[0]);
854 }, $content);
855 }
856
857 /**
858 * Wrap a single <img> tag in <picture> with next-gen <source>.
859 * Returns the original tag unchanged if conversion is not applicable.
860 */
861 private function maybe_wrap_img_tag(string $img_tag): string {
862 if ($this->is_tag_excluded($img_tag)) {
863 return $img_tag;
864 }
865
866 if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) {
867 return $img_tag;
868 }
869
870 $original_src = $src_match[1];
871 $format = $this->settings['conversion_format'];
872 $ext = self::get_format_extension($format);
873
874 $converted_url = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $original_src);
875
876 if ($converted_url === $original_src) {
877 return $img_tag;
878 }
879
880 $converted_path = $this->url_to_path($converted_url);
881 if (!$converted_path || !file_exists($converted_path)) {
882 return $img_tag;
883 }
884
885 $mime = $format === 'avif' ? 'image/avif' : 'image/webp';
886
887 $source_srcset = '';
888 if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) {
889 $converted_srcset = preg_replace('/\.(jpe?g|png)/i', $ext, $srcset_match[1]);
890 $source_srcset = sprintf(' srcset="%s"', esc_attr($converted_srcset));
891 }
892
893 $sizes_attr = '';
894 if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) {
895 $sizes_attr = sprintf(' sizes="%s"', esc_attr($sizes_match[1]));
896 }
897
898 return sprintf(
899 '<picture><source type="%s"%s%s>%s</picture>',
900 esc_attr($mime),
901 $source_srcset ?: sprintf(' srcset="%s"', esc_attr($converted_url)),
902 $sizes_attr,
903 $img_tag
904 );
905 }
906
907 /**
908 * Convert a URL to a local file path. Returns null if URL is external.
909 * Falls back to path-portion matching when hostnames differ (e.g. Cloudflare tunnel rotation).
910 */
911 private function url_to_path(string $url): ?string {
912 $upload_dir = wp_get_upload_dir();
913 $base_url = $upload_dir['baseurl'];
914 $base_path = $upload_dir['basedir'];
915
916 // Direct match (same hostname)
917 if (strpos($url, $base_url) === 0) {
918 return str_replace($base_url, $base_path, $url);
919 }
920
921 // Path-based fallback: match uploads path regardless of hostname
922 $base_url_path = wp_parse_url($base_url, PHP_URL_PATH);
923 $url_path = wp_parse_url($url, PHP_URL_PATH);
924
925 if ($base_url_path && $url_path && strpos($url_path, $base_url_path) === 0) {
926 $relative = substr($url_path, strlen($base_url_path));
927 return $base_path . $relative;
928 }
929
930 return null;
931 }
932
933 /**
934 * Check if a file path matches exclusion patterns.
935 */
936 private function is_excluded(string $file): bool {
937 $exclude_urls = array_filter(array_map('trim', explode(',', $this->settings['exclude_urls'] ?? '')));
938 if (empty($exclude_urls)) {
939 return false;
940 }
941
942 foreach ($exclude_urls as $pattern) {
943 if (stripos($file, $pattern) !== false) {
944 return true;
945 }
946 }
947 return false;
948 }
949
950 /**
951 * Check if an img tag has an excluded CSS class.
952 */
953 private function is_tag_excluded(string $tag): bool {
954 $exclude_classes = array_filter(array_map('trim', explode(',', $this->settings['exclude_classes'] ?? '')));
955 if (empty($exclude_classes)) {
956 return false;
957 }
958
959 if (preg_match('/class=["\']([^"\']+)["\']/i', $tag, $class_match)) {
960 $classes = explode(' ', $class_match[1]);
961 foreach ($exclude_classes as $excluded) {
962 if (in_array($excluded, $classes, true)) {
963 return true;
964 }
965 }
966 }
967 return false;
968 }
969
970 }
971