PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.17
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.17
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
metasync / media-optimization / class-image-converter.php

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

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