PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.10
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.10
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.10, at media-optimization/class-image-converter.php

541 lines 19.4 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 * Get file extension for a given format.
32 */
33 private static function get_format_extension(string $format): string {
34 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
35 }
36
37 public function __construct(array $settings) {
38 $this->settings = $settings;
39
40 // Fires after WP generates all thumbnail sizes
41 add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2);
42
43 // If "alongside" strategy, rewrite <img> tags to <picture>
44 if ($settings['conversion_strategy'] === 'alongside') {
45 // Core WordPress
46 add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20);
47 add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
48 add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20);
49
50 // WooCommerce frontend images
51 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
52 add_filter('woocommerce_product_get_image', [$this, 'rewrite_to_picture_tags'], 20);
53 add_filter('woocommerce_cart_item_thumbnail', [$this, 'rewrite_to_picture_tags'], 20);
54 add_filter('woocommerce_placeholder_img', [$this, 'rewrite_to_picture_tags'], 20);
55 }
56 }
57
58 /**
59 * Hook into metadata generation to convert the main file and all sub-sizes.
60 */
61 public function convert_on_upload(array $metadata, int $attachment_id): array {
62 $file = get_attached_file($attachment_id);
63 $mime = get_post_mime_type($attachment_id);
64
65 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
66 return $metadata;
67 }
68
69 // Check exclusions
70 if ($this->is_excluded($file)) {
71 return $metadata;
72 }
73
74 $format = $this->settings['conversion_format'];
75 $quality = (int) $this->settings['conversion_quality'];
76 $strategy = $this->settings['conversion_strategy'];
77
78 // Convert main/full file
79 $converted = $this->convert_file($file, $format, $quality);
80
81 if ($converted && $strategy === 'replace') {
82 $this->replace_original($attachment_id, $file, $converted, $metadata, $format);
83 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
84 }
85
86 // Convert sub-sizes (thumbnails, medium, large, etc.)
87 if (!empty($this->settings['convert_existing_sizes']) && !empty($metadata['sizes'])) {
88 $upload_dir = dirname($file);
89 foreach ($metadata['sizes'] as $size_name => &$size_data) {
90 $size_file = $upload_dir . '/' . $size_data['file'];
91 $size_converted = $this->convert_file($size_file, $format, $quality);
92
93 if ($size_converted && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
94 @unlink($size_file);
95 $size_data['file'] = basename($size_converted);
96 $size_data['mime-type'] = "image/{$format}";
97 } elseif ($size_converted && $strategy === 'replace') {
98 error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . $size_file);
99 }
100 }
101 unset($size_data);
102 }
103
104 // Store converted format in meta for later use by picture tag rewriter
105 if ($converted && $strategy === 'alongside') {
106 update_post_meta($attachment_id, '_metasync_converted_format', $format);
107 }
108
109 return $metadata;
110 }
111
112 // ── Static Methods for External Use (Batch Optimizer, AJAX) ──
113
114 /**
115 * Convert an existing attachment to next-gen format.
116 * Used by batch optimizer and single-image AJAX actions.
117 */
118 public static function convert_attachment(int $attachment_id, array $settings): bool {
119 $file = get_attached_file($attachment_id);
120 $mime = get_post_mime_type($attachment_id);
121
122 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
123 return false;
124 }
125
126 $format = $settings['conversion_format'] ?? 'webp';
127 $quality = (int) ($settings['conversion_quality'] ?? 82);
128 $strategy = $settings['conversion_strategy'] ?? 'alongside';
129
130 // Store original file size before conversion for savings display
131 $original_size = filesize($file);
132
133 $converted = self::do_convert_file($file, $format, $quality);
134 if (!$converted) {
135 return false;
136 }
137
138 // Store original size meta for savings calculation
139 if ($original_size) {
140 update_post_meta($attachment_id, '_metasync_original_filesize', $original_size);
141 }
142
143 if ($strategy === 'replace') {
144 $metadata = wp_get_attachment_metadata($attachment_id);
145 if ($metadata) {
146 self::do_replace_original($attachment_id, $file, $converted, $metadata, $format);
147 wp_update_attachment_metadata($attachment_id, $metadata);
148 }
149 }
150
151 // Convert sub-sizes
152 if (!empty($settings['convert_existing_sizes'])) {
153 $metadata = wp_get_attachment_metadata($attachment_id);
154 if ($metadata && !empty($metadata['sizes'])) {
155 $upload_dir = dirname($file);
156 foreach ($metadata['sizes'] as $size_name => &$size_data) {
157 $size_file = $upload_dir . '/' . $size_data['file'];
158 $size_converted = self::do_convert_file($size_file, $format, $quality);
159
160 if ($size_converted && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
161 @unlink($size_file);
162 $size_data['file'] = basename($size_converted);
163 $size_data['mime-type'] = "image/{$format}";
164 } elseif ($size_converted && $strategy === 'replace') {
165 error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . $size_file);
166 }
167 }
168 unset($size_data);
169 if ($strategy === 'replace') {
170 wp_update_attachment_metadata($attachment_id, $metadata);
171 }
172 }
173 }
174
175 if ($strategy === 'replace') {
176 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
177 }
178
179 update_post_meta($attachment_id, '_metasync_converted_format', $format);
180 return true;
181 }
182
183 /**
184 * Check whether an optimized attachment can be reverted.
185 * Returns false when the original was replaced (no backup exists).
186 */
187 public static function can_revert(int $attachment_id): bool {
188 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
189 if (!$format) {
190 return false; // Not optimized
191 }
192
193 // If the original was replaced, revert is impossible
194 if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) {
195 return false;
196 }
197
198 // Verify original file still exists on disk (alongside strategy)
199 $file = get_attached_file($attachment_id);
200 return $file && file_exists($file);
201 }
202
203 /**
204 * Revert an attachment's conversion (alongside strategy only).
205 * Deletes the converted files and removes the meta marker.
206 */
207 public static function revert_attachment(int $attachment_id): bool {
208 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
209 if (!$format) {
210 return false;
211 }
212
213 $file = get_attached_file($attachment_id);
214 if (!$file) {
215 return false;
216 }
217
218 // Check if original still exists (alongside strategy)
219 if (!file_exists($file)) {
220 return false; // Cannot revert replace strategy
221 }
222
223 $ext = self::get_format_extension($format);
224
225 // Delete converted full-size file
226 $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file);
227 if ($converted_path && file_exists($converted_path)) {
228 @unlink($converted_path);
229 }
230
231 // Delete converted sub-sizes
232 $metadata = wp_get_attachment_metadata($attachment_id);
233 if ($metadata && !empty($metadata['sizes'])) {
234 $upload_dir = dirname($file);
235 foreach ($metadata['sizes'] as $size_data) {
236 $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $upload_dir . '/' . $size_data['file']);
237 if ($size_converted && file_exists($size_converted)) {
238 @unlink($size_converted);
239 }
240 }
241 }
242
243 delete_post_meta($attachment_id, '_metasync_converted_format');
244 delete_post_meta($attachment_id, '_metasync_original_filesize');
245 delete_post_meta($attachment_id, '_metasync_replaced_original');
246 return true;
247 }
248
249 // ── Core Conversion (static, reusable) ──
250
251 /**
252 * Convert a single image file. Returns path to converted file or null on failure.
253 */
254 protected static function do_convert_file(string $source, string $format, int $quality): ?string {
255 if (!file_exists($source)) {
256 return null;
257 }
258
259 if (filesize($source) > self::MAX_CONVERT_BYTES) {
260 error_log('[MetaSync Media Opt] Source file exceeds MAX_CONVERT_BYTES limit, skipping: ' . $source);
261 return null;
262 }
263
264 $ext = self::get_format_extension($format);
265 $dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $source);
266
267 // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
268 if (extension_loaded('imagick')) {
269 try {
270 $result = self::do_convert_with_imagick($source, $dest, $format, $quality);
271 if ($result) {
272 return $result;
273 }
274 } catch (\Exception $e) {
275 error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage());
276 }
277 }
278
279 if (extension_loaded('gd')) {
280 try {
281 return self::do_convert_with_gd($source, $dest, $format, $quality);
282 } catch (\Exception $e) {
283 error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage());
284 }
285 }
286
287 return null;
288 }
289
290 private static function do_convert_with_imagick(string $src, string $dest, string $fmt, int $q): ?string {
291 $img = new \Imagick($src);
292 $img->setImageFormat($fmt === 'avif' ? 'avif' : 'webp');
293 $img->setImageCompressionQuality($q);
294 $img->stripImage();
295
296 if ($img->writeImage($dest)) {
297 if (!file_exists($dest) || !filesize($dest)) {
298 @unlink($dest);
299 error_log('[MetaSync Media Opt] Imagick wrote 0-byte or missing output, discarding: ' . $dest);
300 $img->destroy();
301 return null;
302 }
303 $img->destroy();
304 return $dest;
305 }
306
307 $img->destroy();
308 return null;
309 }
310
311 private static function do_convert_with_gd(string $src, string $dest, string $fmt, int $q): ?string {
312 $info = getimagesize($src);
313 if (!$info) {
314 return null;
315 }
316
317 $gd_img = match ($info['mime']) {
318 'image/jpeg' => imagecreatefromjpeg($src),
319 'image/png' => imagecreatefrompng($src),
320 default => null,
321 };
322
323 if (!$gd_img) {
324 return null;
325 }
326
327 if ($info['mime'] === 'image/png') {
328 imagepalettetotruecolor($gd_img);
329 imagealphablending($gd_img, true);
330 imagesavealpha($gd_img, true);
331 }
332
333 $success = match ($fmt) {
334 'webp' => imagewebp($gd_img, $dest, $q),
335 'avif' => function_exists('imageavif') ? imageavif($gd_img, $dest, $q) : false,
336 default => false,
337 };
338
339 imagedestroy($gd_img);
340
341 if (!$success || !file_exists($dest) || !filesize($dest)) {
342 @unlink($dest);
343 error_log('[MetaSync Media Opt] GD produced empty or missing output, discarding: ' . $dest);
344 return null;
345 }
346
347 return $dest;
348 }
349
350 /**
351 * Replace original file with converted version.
352 */
353 private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
354 if (!file_exists($new_path) || !filesize($new_path)) {
355 error_log('[MetaSync Media Opt] Converted file is missing or empty, original preserved: ' . $old_path);
356 return;
357 }
358
359 // Capture old URL before deleting so we can rewrite post content references
360 $old_url = wp_get_attachment_url($id);
361
362 @unlink($old_path);
363
364 wp_update_post([
365 'ID' => $id,
366 'post_mime_type' => "image/{$fmt}",
367 ]);
368
369 update_attached_file($id, $new_path);
370 $meta['file'] = _wp_relative_upload_path($new_path);
371
372 // Rewrite hardcoded image URLs in post content to point to the new file
373 $new_url = wp_get_attachment_url($id);
374 if ($old_url && $new_url && $old_url !== $new_url) {
375 self::rewrite_content_urls($old_url, $new_url);
376 }
377 }
378
379 /**
380 * Rewrite image URLs in all post content that references the old file path.
381 * Uses the path portion (e.g. /wp-content/uploads/…) so it works regardless
382 * of hostname changes (e.g. Cloudflare tunnel rotations).
383 */
384 private static function rewrite_content_urls(string $old_url, string $new_url): void {
385 global $wpdb;
386
387 // Extract path portions to be hostname-agnostic
388 $old_path = wp_parse_url($old_url, PHP_URL_PATH);
389 $new_path = wp_parse_url($new_url, PHP_URL_PATH);
390
391 if (!$old_path || !$new_path || $old_path === $new_path) {
392 return;
393 }
394
395 $wpdb->query($wpdb->prepare(
396 "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) WHERE post_content LIKE %s",
397 $old_path,
398 $new_path,
399 '%' . $wpdb->esc_like($old_path) . '%'
400 ));
401 }
402
403 // ── Instance Wrappers (Upload Hook) ──
404
405 /**
406 * Instance wrapper around static conversion method.
407 */
408 private function convert_file(string $source, string $format, int $quality): ?string {
409 return self::do_convert_file($source, $format, $quality);
410 }
411
412 /**
413 * Instance wrapper around static replace method.
414 */
415 private function replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
416 self::do_replace_original($id, $old_path, $new_path, $meta, $fmt);
417 }
418
419 /**
420 * Rewrite <img> tags to <picture> with next-gen source.
421 */
422 public function rewrite_to_picture_tags(string $content): string {
423 if (empty($content)) {
424 return $content;
425 }
426
427 // Skip if already wrapped in <picture> (avoid double-wrapping from multiple filters)
428 if (strpos($content, '<picture>') !== false) {
429 return $content;
430 }
431
432 return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
433 $img_tag = $matches[0];
434
435 // Check if this image should be excluded
436 if ($this->is_tag_excluded($img_tag)) {
437 return $img_tag;
438 }
439
440 // Extract src
441 if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) {
442 return $img_tag;
443 }
444
445 $original_src = $src_match[1];
446 $format = $this->settings['conversion_format'];
447 $ext = self::get_format_extension($format);
448
449 // Build the converted file URL
450 $converted_url = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $original_src);
451
452 // Only wrap if the converted URL is different
453 if ($converted_url === $original_src) {
454 return $img_tag;
455 }
456
457 // Verify the converted file actually exists on disk before rewriting
458 $converted_path = $this->url_to_path($converted_url);
459 if (!$converted_path || !file_exists($converted_path)) {
460 return $img_tag;
461 }
462
463 $mime = $format === 'avif' ? 'image/avif' : 'image/webp';
464
465 // Also handle srcset if present
466 $source_srcset = '';
467 if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) {
468 $converted_srcset = preg_replace('/\.(jpe?g|png)/i', $ext, $srcset_match[1]);
469 $source_srcset = sprintf(' srcset="%s"', esc_attr($converted_srcset));
470 }
471
472 // Extract sizes attribute to pass to <source>
473 $sizes_attr = '';
474 if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) {
475 $sizes_attr = sprintf(' sizes="%s"', esc_attr($sizes_match[1]));
476 }
477
478 return sprintf(
479 '<picture><source type="%s"%s%s>%s</picture>',
480 esc_attr($mime),
481 $source_srcset ?: sprintf(' srcset="%s"', esc_attr($converted_url)),
482 $sizes_attr,
483 $img_tag
484 );
485 }, $content);
486 }
487
488 /**
489 * Convert a URL to a local file path. Returns null if URL is external.
490 */
491 private function url_to_path(string $url): ?string {
492 $upload_dir = wp_get_upload_dir();
493 $base_url = $upload_dir['baseurl'];
494 $base_path = $upload_dir['basedir'];
495
496 if (strpos($url, $base_url) === 0) {
497 return str_replace($base_url, $base_path, $url);
498 }
499
500 return null;
501 }
502
503 /**
504 * Check if a file path matches exclusion patterns.
505 */
506 private function is_excluded(string $file): bool {
507 $exclude_urls = array_filter(array_map('trim', explode(',', $this->settings['exclude_urls'] ?? '')));
508 if (empty($exclude_urls)) {
509 return false;
510 }
511
512 foreach ($exclude_urls as $pattern) {
513 if (stripos($file, $pattern) !== false) {
514 return true;
515 }
516 }
517 return false;
518 }
519
520 /**
521 * Check if an img tag has an excluded CSS class.
522 */
523 private function is_tag_excluded(string $tag): bool {
524 $exclude_classes = array_filter(array_map('trim', explode(',', $this->settings['exclude_classes'] ?? '')));
525 if (empty($exclude_classes)) {
526 return false;
527 }
528
529 if (preg_match('/class=["\']([^"\']+)["\']/i', $tag, $class_match)) {
530 $classes = explode(' ', $class_match[1]);
531 foreach ($exclude_classes as $excluded) {
532 if (in_array($excluded, $classes, true)) {
533 return true;
534 }
535 }
536 }
537 return false;
538 }
539
540 }
541