PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / 2.3.3
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF v2.3.3
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.9 2.2.8 trunk 1.10 1.3.3 1.3.4 1.3.5 1.3.5.1 1.3.5.2 1.3.6 1.3.6.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 All 103 releases
imagify / classes / Optimization / Process / AbstractProcess.php

AbstractProcess.php in Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF 2.3.3, at classes/Optimization/Process/AbstractProcess.php

2,138 lines 60.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 declare(strict_types=1);
3
4 namespace Imagify\Optimization\Process;
5
6 use Imagify_Filesystem;
7 use Imagify\Deprecated\Traits\Optimization\Process\AbstractProcessDeprecatedTrait;
8 use Imagify\Job\MediaOptimization;
9 use Imagify\Optimization\Data\DataInterface;
10 use Imagify\Optimization\File;
11 use Imagify\Media\MediaInterface;
12 use WP_Error;
13
14 /**
15 * Abstract class used to optimize medias.
16 *
17 * @since 1.9
18 */
19 abstract class AbstractProcess implements ProcessInterface {
20 use AbstractProcessDeprecatedTrait;
21
22 /**
23 * The suffix used in file name to create a temporary copy of the full size.
24 *
25 * @var string
26 * @since 1.9
27 */
28 const TMP_SUFFIX = '@imagify-tmp';
29
30 /**
31 * Used for the name of the transient telling if a media is locked.
32 * %1$s is the context, %2$s is the media ID.
33 *
34 * @var string
35 * @since 1.9
36 */
37 const LOCK_NAME = 'imagify_%1$s_%2$s_process_locked';
38
39 /**
40 * The data optimization object.
41 *
42 * @var DataInterface
43 * @since 1.9
44 */
45 protected $data;
46
47 /**
48 * The optimization data format.
49 *
50 * @var array
51 * @since 1.9
52 */
53 protected $data_format = [
54 'level' => null,
55 'status' => null,
56 'success' => null,
57 'error' => null,
58 'original_size' => null,
59 'optimized_size' => null,
60 ];
61
62 /**
63 * A File instance.
64 *
65 * @var File
66 * @since 1.9
67 */
68 protected $file;
69
70 /**
71 * Filesystem object.
72 *
73 * @var Imagify_Filesystem
74 * @since 1.9
75 */
76 protected $filesystem;
77
78 /**
79 * Used to cache the plugin’s options.
80 *
81 * @var array
82 * @since 1.9
83 */
84 protected $options = [];
85
86 /**
87 * Tells the format we are currently processing
88 *
89 * @var string
90 * @since 2.2
91 */
92 protected $format;
93
94 /**
95 * Array of image extensions processed.
96 *
97 * @var array
98 */
99 protected $extensions = [
100 'webp',
101 'avif',
102 ];
103
104 /**
105 * The constructor.
106 *
107 * @since 1.9
108 * @see self::constructor_accepts()
109 *
110 * @param mixed $id An ID, or whatever type the constructor accepts.
111 */
112 public function __construct( $id ) {
113 if ( $id instanceof DataInterface ) {
114 $this->data = $id;
115 } elseif ( static::constructor_accepts( $id ) ) {
116 $data_class = str_replace( '\\Optimization\\Process\\', '\\Optimization\\Data\\', get_called_class() );
117 $data_class = '\\' . ltrim( $data_class, '\\' );
118 $this->data = new $data_class( $id );
119 } else {
120 $this->data = false;
121 }
122
123 $this->filesystem = \Imagify_Filesystem::get_instance();
124 $this->format = $this->get_current_format();
125 }
126
127 /**
128 * Tell if the given entry can be accepted in the constructor.
129 *
130 * @since 1.9
131 *
132 * @param mixed $id Whatever.
133 *
134 * @return bool
135 */
136 public static function constructor_accepts( $id ) {
137 if ( $id instanceof DataInterface ) {
138 return true;
139 }
140
141 $data_class = str_replace( '\\Optimization\\Process\\', '\\Optimization\\Data\\', get_called_class() );
142 $data_class = '\\' . ltrim( $data_class, '\\' );
143
144 return $data_class::constructor_accepts( $id );
145 }
146
147 /**
148 * Get the data instance.
149 *
150 * @since 1.9
151 *
152 * @return DataInterface|false
153 */
154 public function get_data() {
155 return $this->data;
156 }
157
158 /**
159 * Get the media instance.
160 *
161 * @since 1.9
162 *
163 * @return MediaInterface|false
164 */
165 public function get_media() {
166 if ( ! $this->get_data() ) {
167 return false;
168 }
169
170 return $this->get_data()->get_media();
171 }
172
173 /**
174 * Get the File instance of the original file.
175 *
176 * @since 1.9.8
177 *
178 * @return File|false
179 */
180 public function get_original_file() {
181 if ( isset( $this->file ) ) {
182 return $this->file;
183 }
184
185 $this->file = false;
186
187 if ( $this->get_media() ) {
188 $this->file = new File( $this->get_media()->get_raw_original_path() );
189 }
190
191 return $this->file;
192 }
193
194 /**
195 * Get the File instance of the full size file.
196 *
197 * @since 1.9.8
198 *
199 * @return File|false
200 */
201 public function get_fullsize_file() {
202 if ( isset( $this->file ) ) {
203 return $this->file;
204 }
205
206 $this->file = false;
207
208 if ( $this->get_media() ) {
209 $this->file = new File( $this->get_media()->get_raw_fullsize_path() );
210 }
211
212 return $this->file;
213 }
214
215 /**
216 * Tell if the current media is valid.
217 *
218 * @since 1.9
219 *
220 * @return bool
221 */
222 public function is_valid() {
223 return $this->get_media() && $this->get_media()->is_valid();
224 }
225
226 /**
227 * Tell if the current user is allowed to operate Imagify in this context.
228 *
229 * @since 1.9
230 *
231 * @param string $describer Capacity describer. See \Imagify\Context\ContextInterface->get_capacity() for possible values. Can also be a "real" user capacity.
232 * @return bool
233 */
234 public function current_user_can( $describer ) {
235 if ( ! $this->is_valid() ) {
236 return false;
237 }
238
239 $media = $this->get_media();
240
241 return $media->get_context_instance()->current_user_can( $describer, $media->get_id() );
242 }
243
244 /**
245 * Optimize a media files.
246 *
247 * @since 1.9
248 * @since 2.3.2 Added the $args parameter (e.g. 'bulk', 'priority'). See self::optimize_sizes()
249 * for the full list. 'bulk'/'priority' are extracted before the item is queued,
250 * any other key ends up in the queued item's 'data', forwarded verbatim to the
251 * 'imagify_before_*'/'imagify_after_*' hooks.
252 *
253 * @param int $optimization_level The optimization level (0=normal, 1=aggressive, 2=ultra).
254 * @param array $args An array of optionnal arguments.
255 *
256 * @return bool|WP_Error True if successfully launched. A \WP_Error instance on failure.
257 */
258 public function optimize( $optimization_level = null, $args = [] ) {
259 if ( ! $this->is_valid() ) {
260 return new WP_Error( 'invalid_media', __( 'This media is not valid.', 'imagify' ) );
261 }
262
263 $media = $this->get_media();
264
265 if ( ! $media->is_supported() ) {
266 return new WP_Error( 'media_not_supported', __( 'This media is not supported.', 'imagify' ) );
267 }
268
269 $data = $this->get_data();
270
271 if ( $data->is_optimized() ) {
272 return new WP_Error( 'optimized', __( 'This media has already been optimized by Imagify.', 'imagify' ) );
273 }
274
275 if ( $data->is_already_optimized() && $this->has_next_gen() ) {
276 // If already optimized but has next-gen, delete next-gen versions and optimization data.
277 $data->delete_optimization_data();
278 $deleted = $this->delete_nextgen_files();
279
280 if ( is_wp_error( $deleted ) ) {
281 return new WP_Error( 'next_gen_not_deleted', __( 'Previous Next-Gen files could not be deleted.', 'imagify' ) );
282 }
283 }
284
285 $sizes = $media->get_media_files();
286 $args = is_array( $args ) ? $args : [];
287
288 $args['hook_suffix'] = 'optimize_media';
289
290 // Optimize.
291 return $this->optimize_sizes( array_keys( $sizes ), $optimization_level, $args );
292 }
293
294 /**
295 * Re-optimize a media files with a different level.
296 *
297 * @since 1.9
298 * @since 2.3.2 Added the $args parameter (e.g. 'bulk', 'priority'). See self::optimize_sizes()
299 * for the full list. 'bulk'/'priority' are extracted before the item is queued,
300 * any other key ends up in the queued item's 'data', forwarded verbatim to the
301 * 'imagify_before_*'/'imagify_after_*' hooks.
302 *
303 * @param int $optimization_level The optimization level (0=normal, 1=aggressive, 2=ultra).
304 * @param array $args An array of optionnal arguments.
305 *
306 * @return bool|WP_Error True if successfully launched. A \WP_Error instance on failure.
307 */
308 public function reoptimize( $optimization_level = null, $args = [] ) {
309 if ( ! $this->is_valid() ) {
310 return new WP_Error( 'invalid_media', __( 'This media is not valid.', 'imagify' ) );
311 }
312
313 $media = $this->get_media();
314
315 if ( ! $media->is_supported() ) {
316 return new WP_Error( 'media_not_supported', __( 'This media is not supported.', 'imagify' ) );
317 }
318
319 $data = $this->get_data();
320
321 if ( ! $data->get_optimization_status() ) {
322 return new WP_Error( 'not_processed_yet', __( 'This media has not been processed yet.', 'imagify' ) );
323 }
324
325 $optimization_level = $this->sanitize_optimization_level( $optimization_level );
326
327 if ( $data->get_optimization_level() === $optimization_level ) {
328 return new WP_Error( 'identical_optimization_level', __( 'This media is already optimized with this level.', 'imagify' ) );
329 }
330
331 $this->restore();
332
333 $sizes = $media->get_media_files();
334 $args = is_array( $args ) ? $args : [];
335
336 $args['hook_suffix'] = 'reoptimize_media';
337
338 // Optimize.
339 return $this->optimize_sizes( array_keys( $sizes ), $optimization_level, $args );
340 }
341
342 /**
343 * Optimize several file sizes by pushing tasks into the queue.
344 *
345 * @since 1.9
346 * @see MediaOptimization->task_before()
347 * @see MediaOptimization->task_after()
348 *
349 * @since 2.2
350 * Addition of the image format
351 *
352 * @param array $sizes An array of media sizes (strings). Use "full" for the size of the main file.
353 * @param int $optimization_level The optimization level (0=normal, 1=aggressive, 2=ultra).
354 * @param array $args {
355 * An array of optionnal arguments.
356 *
357 * @type string $hook_suffix Suffix used to trigger hooks before and after optimization.
358 * @type bool $priority Whether this optimization should jump ahead of the queue (e.g. new uploads, manual clicks). Default false. Extracted before queueing: not part of the queued item's 'data'.
359 * @type bool $bulk Whether this optimization was triggered by a bulk run (see Bulk::force_optimize()). Default false. Kept in the queued item's 'data', so it is forwarded verbatim to the 'imagify_before_*'/'imagify_after_*' hook callbacks.
360 * }
361 *
362 * @return bool|WP_Error True if successfully launched. A \WP_Error instance on failure.
363 */
364 public function optimize_sizes( $sizes, $optimization_level = null, $args = [] ) {
365 if ( ! $this->is_valid() ) {
366 return new WP_Error( 'invalid_media', __( 'This media is not valid.', 'imagify' ) );
367 }
368
369 $media = $this->get_media();
370
371 if ( ! $media->is_supported() ) {
372 return new WP_Error( 'media_not_supported', __( 'This media is not supported.', 'imagify' ) );
373 }
374
375 if ( ! $sizes ) {
376 return new WP_Error( 'no_sizes', __( 'No sizes given to be optimized.', 'imagify' ) );
377 }
378
379 if ( empty( $args['locked'] ) ) {
380 if ( $this->is_locked() ) {
381 return new WP_Error( 'media_locked', __( 'This media is already being processed.', 'imagify' ) );
382 }
383
384 $this->lock();
385 }
386
387 if ( $media->is_image() ) {
388 // Add Next-Gen conversion.
389 $formats = imagify_nextgen_images_formats();
390
391 foreach ( $formats as $format ) {
392 if ( 'avif' === $format ) {
393 $format_suffix = static::AVIF_SUFFIX;
394 } elseif ( 'webp' === $format ) {
395 $format_suffix = static::WEBP_SUFFIX;
396 }
397
398 $files = $media->get_media_files();
399
400 foreach ( $sizes as $size_name ) {
401 if ( empty( $files[ $size_name ] ) ) {
402 continue;
403 }
404
405 if ( $this->get_mime_type( $format ) === $files[ $size_name ]['mime-type'] ) {
406 continue;
407 }
408
409 if ( in_array( $size_name . $format_suffix, $sizes, true ) ) {
410 continue;
411 }
412
413 array_unshift( $sizes, $size_name . $format_suffix );
414 }
415 }
416
417 if ( ! $media->get_context_instance()->can_backup() && ! $media->get_backup_path() && ! $this->get_data()->get_size_data( 'full', 'success' ) ) {
418 /**
419 * Backup is NOT activated, and a backup file does NOT exist yet, and the full size is NOT optimized yet.
420 * Next-Gen conversion needs a backup file, even a temporary one: we’ll create one.
421 */
422 $next_gen = false;
423
424 foreach ( $sizes as $size_name ) {
425 if ( $this->is_size_next_gen( $size_name ) ) {
426 $next_gen = true;
427 break;
428 }
429 }
430
431 if ( $next_gen ) {
432 // We have at least one next-gen conversion to do: create a temporary backup.
433 $backuped = $this->get_original_file()->backup( $media->get_raw_backup_path() );
434
435 if ( $backuped ) {
436 $args['delete_backup'] = true;
437 }
438 }
439 }
440 }
441
442 $sizes = array_unique( $sizes );
443 $optimization_level = $this->sanitize_optimization_level( $optimization_level );
444
445 /**
446 * Filter the data sent to the optimization process.
447 *
448 * @since 1.9
449 *
450 * @param array $new_args Additional data to send to the optimization process.
451 * @param array $args Current data sent to the process.
452 * @param ProcessInterface $process The current optimization process.
453 * @param array $sizes Sizes being processed.
454 * @param int $optimization_level Optimization level.
455 */
456 $new_args = apply_filters( 'imagify_optimize_sizes_args', [], $args, $this, $sizes, $optimization_level );
457
458 if ( $new_args && is_array( $new_args ) ) {
459 $args = array_merge( $new_args, $args );
460 }
461
462 // 'priority' is a queue-mechanics flag: it must be a sibling key of 'id'/'sizes'/etc.,
463 // not nested inside 'data' (which is opaque payload forwarded verbatim to the
464 // 'imagify_before_*'/'imagify_after_*' hook callbacks). Extract it before building 'data'.
465 $is_priority = ! empty( $args['priority'] );
466 unset( $args['priority'] );
467
468 /**
469 * Push the item to the queue, save the queue in the DB, empty the queue.
470 * A "batch" is then created in the DB with this unique item, it is then free to loop through its steps (files) without another item interfering (each media optimization has its own dedicated batch/queue).
471 */
472 $item = [
473 'id' => $media->get_id(),
474 'sizes' => $sizes,
475 'optimization_level' => $optimization_level,
476 'process_class' => get_class( $this ),
477 'data' => $args,
478 ];
479
480 if ( $is_priority ) {
481 $item['priority'] = true;
482 }
483
484 MediaOptimization::get_instance()->push_to_queue( $item )->save();
485
486 return true;
487 }
488
489 /**
490 * Optimize one file with Imagify directly.
491 *
492 * @since 1.9
493 *
494 * @param string $size The media size.
495 * @param int $optimization_level The optimization level (0=normal, 1=aggressive, 2=ultra).
496 *
497 * @return array|WP_Error Optimized image data. A WP_Error object on error.
498 */
499 public function optimize_size( $size, $optimization_level = null ) {
500 if ( ! $this->is_valid() ) { // Bail out.
501 return new WP_Error( 'invalid_media', __( 'This media is not valid.', 'imagify' ) );
502 }
503
504 $media = $this->get_media();
505 $sizes = $media->get_media_files();
506 $thumb_size = $size;
507 $next_gen = $this->is_size_next_gen( $size );
508 $path_is_temp = false;
509
510 if ( $next_gen ) {
511 // We'll make sure the file is an image later.
512 $thumb_size = $next_gen; // Contains the name of the non-next-gen size.
513 $next_gen = true;
514 }
515
516 if ( empty( $sizes[ $thumb_size ]['path'] ) ) { // Bail out.
517 // This size is not in our list.
518 return new WP_Error(
519 'unknown_size',
520 sprintf(
521 /* translators: %s is a size name. */
522 __( 'The size %s is unknown.', 'imagify' ),
523 '<code>' . esc_html( $thumb_size ) . '</code>'
524 )
525 );
526 }
527
528 if ( $this->get_data()->get_size_data( $size, 'success' ) ) {
529 /*
530 * The data says this size is already optimized. For a next-gen size, this can be
531 * stale: an API response carrying a `message` (see File::optimize()) could have
532 * been recorded as `success` without any next-gen file ever being written. In that
533 * case, don't bail out: fall through and let the file be (re)generated.
534 */
535 $next_gen_file_is_missing = $next_gen && $this->is_next_gen_file_missing( $size, $sizes[ $thumb_size ]['path'] );
536
537 if ( ! $next_gen_file_is_missing ) { // Bail out.
538 // This size is already optimized with Imagify, and must not be optimized again.
539 if ( $next_gen ) {
540 return new WP_Error(
541 'size_is_successfully_optimized',
542 sprintf(
543 /* translators: %s is a size name. */
544 __( 'The Next-Gen format for the size %s already exists.', 'imagify' ),
545 '<code>' . esc_html( $thumb_size ) . '</code>'
546 )
547 );
548 } else {
549 return new WP_Error(
550 'size_is_successfully_optimized',
551 sprintf(
552 /* translators: %s is a size name. */
553 __( 'The size %s is already optimized by Imagify.', 'imagify' ),
554 '<code>' . esc_html( $thumb_size ) . '</code>'
555 )
556 );
557 }
558 }
559 }
560
561 /**
562 * Starting from here, errors will be stored in the optimization data of the size.
563 */
564 $path = $sizes[ $thumb_size ]['path'];
565
566 $optimization_level = $this->sanitize_optimization_level( $optimization_level );
567
568 if ( $next_gen && $this->get_data()->get_size_data( $thumb_size, 'success' ) ) {
569 // We want a next-gen version but the source file is already optimized by Imagify.
570 $result = $this->create_temporary_copy( $thumb_size, $sizes );
571
572 if ( ! $result ) { // Bail out.
573 // Could not create a copy of the non-next-gen version.
574 $response = new WP_Error(
575 'non_next_gen_copy_failed',
576 sprintf(
577 /* translators: %s is a size name. */
578 __( 'Could not create an unoptimized copy of the size %s.', 'imagify' ),
579 '<code>' . esc_html( $thumb_size ) . '</code>'
580 )
581 );
582
583 $this->update_size_optimization_data( $response, $size, $optimization_level );
584
585 return $response;
586 }
587
588 /**
589 * $path now targets a temporary file.
590 */
591 $path = $this->get_temporary_copy_path( $thumb_size, $sizes );
592 $path_is_temp = true;
593 }
594
595 $file = new File( $path ); // Original file or temporary copy.
596
597 if ( ! $file->is_supported( $media->get_allowed_mime_types() ) ) { // Bail out.
598 // This file type is not supported.
599 $extension = $file->get_extension();
600
601 if ( ! $extension ) {
602 $response = new WP_Error(
603 'extension_not_mime',
604 __( 'This file has an extension that does not match a mime type.', 'imagify' )
605 );
606 } elseif ( '' === $extension ) {
607 $response = new WP_Error(
608 'no_extension',
609 __( 'With no extension, this file cannot be optimized.', 'imagify' )
610 );
611 } elseif ( ! $extension ) {
612 $response = new WP_Error(
613 'extension_not_mime',
614 __( 'This file has an extension that does not match a mime type.', 'imagify' )
615 );
616 } else {
617 $response = new WP_Error(
618 'extension_not_supported',
619 sprintf(
620 /* translators: %s is a file extension. */
621 __( '%s cannot be optimized.', 'imagify' ),
622 '<code>' . esc_html( strtolower( $extension ) ) . '</code>'
623 )
624 );
625 }
626
627 if ( $path_is_temp ) {
628 $this->filesystem->delete( $path );
629 }
630
631 $this->update_size_optimization_data( $response, $size, $optimization_level );
632
633 return $response;
634 }
635
636 if ( $next_gen && ! $file->is_image() ) { // Bail out.
637 if ( $path_is_temp ) {
638 $this->filesystem->delete( $path );
639 }
640
641 $response = new WP_Error(
642 'no_next_gen',
643 __( 'This file is not an image and cannot be converted to Next-Gen format.', 'imagify' )
644 );
645
646 $this->update_size_optimization_data( $response, $size, $optimization_level );
647
648 return $response;
649 }
650
651 $is_disabled = ! empty( $sizes[ $thumb_size ]['disabled'] );
652
653 /**
654 * Fires before optimizing a file.
655 * Return a WP_Error object to prevent the optimization.
656 *
657 * @since 1.9
658 *
659 * @param null|WP_Error $response Null by default. Return a WP_Error object to prevent optimization.
660 * @param ProcessInterface $process The optimization process instance.
661 * @param File $file The file instance. If $webp is true, $file references the non-WebP file.
662 * @param string $thumb_size The media size.
663 * @param int $optimization_level The optimization level (0=normal, 1=aggressive, 2=ultra).
664 * @param bool $webp The image will be converted to WebP.
665 * @param bool $is_disabled Tell if this size is disabled from optimization.
666 */
667 $response = apply_filters( 'imagify_before_optimize_size', null, $this, $file, $thumb_size, $optimization_level, $next_gen, $is_disabled );
668
669 if ( ! is_wp_error( $response ) ) {
670 if ( $is_disabled ) {
671 // This size must not be optimized.
672 $response = new WP_Error(
673 'unauthorized_size',
674 sprintf(
675 /* translators: %s is a size name. */
676 __( 'The size %s is not authorized to be optimized. Update your Imagify settings if you want to optimize it.', 'imagify' ),
677 '<code>' . esc_html( $thumb_size ) . '</code>'
678 )
679 );
680 } elseif ( ! $this->filesystem->exists( $file->get_path() ) ) {
681 $response = new WP_Error(
682 'file_not_exists',
683 sprintf(
684 /* translators: %s is a file path. */
685 __( 'The file %s does not seem to exist.', 'imagify' ),
686 '<code>' . esc_html( $this->filesystem->make_path_relative( $file->get_path() ) ) . '</code>'
687 )
688 );
689 } elseif ( $next_gen && ! $this->can_create_next_gen_version( $file->get_path() ) ) {
690 $response = new WP_Error(
691 'is_animated_gif',
692 __( 'This file is an animated gif: since Imagify does not support animated WebP/AVIF, WebP/AVIF creation for animated gif is disabled.', 'imagify' )
693 );
694 } elseif ( ! $this->filesystem->is_writable( $file->get_path() ) ) {
695 $response = new WP_Error(
696 'file_not_writable',
697 sprintf(
698 /* translators: %s is a file path. */
699 __( 'The file %s does not seem to be writable.', 'imagify' ),
700 '<code>' . esc_html( $this->filesystem->make_path_relative( $file->get_path() ) ) . '</code>'
701 )
702 );
703 } else {
704 // Maybe resize the file.
705 $response = $this->maybe_resize( $thumb_size, $file );
706
707 $convert = '';
708
709 if ( $next_gen ) {
710 if ( strpos( $size, static::AVIF_SUFFIX ) ) {
711 $convert = 'avif';
712 } elseif ( strpos( $size, static::WEBP_SUFFIX ) ) {
713 $convert = 'webp';
714 }
715 }
716
717 if ( ! is_wp_error( $response ) ) {
718 // Resizing succeeded: optimize the file.
719 $response = $file->optimize(
720 [
721 'backup' => ! $response['backuped'] && $this->can_backup( $size ),
722 'backup_path' => $media->get_raw_backup_path(),
723 'backup_source' => 'full' === $thumb_size ? $media->get_original_path() : null,
724 'optimization_level' => $optimization_level,
725 'convert' => $convert,
726 'keep_exif' => true,
727 'context' => $media->get_context(),
728 'original_size' => $response['file_size'],
729 ]
730 );
731
732 $response = $this->compare_next_gen_file_size(
733 [
734 'response' => $response,
735 'file' => $file,
736 'is_next_gen' => $next_gen,
737 'next_gen_format' => $convert,
738 'non_next_gen_thumb_size' => $thumb_size,
739 'non_next_gen_file_path' => $sizes[ $thumb_size ]['path'], // Don't use $path nor $file->get_path(), it may return the path to a temporary file.
740 'optimization_level' => $optimization_level,
741 ]
742 );
743
744 if ( property_exists( $response, 'message' ) ) {
745 $path_is_temp = false;
746 if ( $path !== $sizes[ $thumb_size ]['path'] ) {
747 $this->filesystem->delete( $path );
748 }
749 $path = $sizes[ $thumb_size ]['path'];
750 }
751 }
752 }
753 }
754
755 $data = $this->update_size_optimization_data( $response, $size, $optimization_level );
756
757 /**
758 * Fires after optimizing a file.
759 *
760 * @since 1.9
761 *
762 * @param ProcessInterface $process The optimization process instance.
763 * @param File $file The file instance.
764 * @param string $thumb_size The media size.
765 * @param int $optimization_level The optimization level (0=normal, 1=aggressive, 2=ultra).
766 * @param bool $webp The image was supposed to be converted to WebP.
767 * @param bool $is_disabled Tell if this size is disabled from optimization.
768 */
769 do_action( 'imagify_after_optimize_size', $this, $file, $thumb_size, $optimization_level, $next_gen, $is_disabled );
770
771 if ( ! $path_is_temp ) {
772 return $data;
773 }
774
775 // Delete the temporary copy.
776 $this->filesystem->delete( $path );
777
778 if ( is_wp_error( $response ) ) {
779 return $data;
780 }
781
782 // Rename the optimized file.
783 $destination_path = str_replace( static::TMP_SUFFIX . '.', '.', $file->get_path() );
784
785 $this->filesystem->move( $file->get_path(), $destination_path, true );
786
787 return $data;
788 }
789
790 /**
791 * Compare the file size of a file and its Next-Gen version: if the Next-Gen version is heavier than the non-next-gen file, delete it.
792 *
793 * @since 2.2
794 *
795 * @param array $args {
796 * A list of mandatory arguments.
797 *
798 * @type \sdtClass|WP_Error $response Optimized image data. A WP_Error object on error.
799 * @type File $file The File instance of the file currently being optimized.
800 * @type bool $is_next_gen Tell if we're requesting a next-gen file.
801 * @type string $non_next_gen_thumb_size Name of the corresponding non-next-gen thumbnail size. If we're not creating a Next-Gen file, this corresponds to the current thumbnail size.
802 * @type string $non_next_gen_file_path Path to the corresponding non-next-gen file. If we're not creating a Next-Gen file, this corresponds to the current file path.
803 * @type string $optimization_level The optimization level.
804 * }
805 *
806 * @return \sdtClass|WP_Error Optimized image data. A WP_Error object on error.
807 */
808 protected function compare_next_gen_file_size( $args ) {
809 static $keep_large_next_gen;
810
811 if ( ! isset( $keep_large_next_gen ) ) {
812 /**
813 * Allow to not store next-gen images that are larger than their non-next-gen version.
814 *
815 * @since 1.9.4
816 *
817 * @param bool $keep_large_next-gen Set to false if you prefer your visitors over your Pagespeed score. Default value is true.
818 */
819 $keep_large_next_gen = apply_filters( 'imagify_keep_large_next_gen', true );
820 }
821
822 if ( $keep_large_next_gen || is_wp_error( $args['response'] ) || ! $args['file']->is_image() ) {
823 return $args['response'];
824 }
825
826 // Optimization succeeded.
827 if ( ! property_exists( $args['response'], 'message' ) && $args['is_next_gen'] ) {
828 /**
829 * We just created a next-gen version:
830 * Check if it is lighter than the (maybe optimized) non-next-gen file.
831 */
832 $data = $this->get_data()->get_size_data( $args['non_next_gen_thumb_size'] );
833
834 if ( ! $data ) {
835 // We haven’t tried to optimize the non-next-gen size yet.
836 return $args['response'];
837 }
838
839 if ( ! empty( $data['optimized_size'] ) ) {
840 // The non-next-gen size is optimized, we know the file size.
841 $non_next_gen_file_size = $data['optimized_size'];
842 } else {
843 // The non-next-gen size is "already optimized" or "error": grab the file size directly from the file.
844 $non_next_gen_file_size = $this->filesystem->size( $args['non_next_gen_file_path'] );
845 }
846
847 if ( ! $non_next_gen_file_size || $non_next_gen_file_size > $args['response']->new_size ) {
848 // The new next-gen file is lighter.
849 return $args['response'];
850 }
851
852 // The new next-gen file is heavier than the non-next-gen file: delete it and return an error.
853 $this->filesystem->delete( $args['file']->get_path() );
854
855 return new WP_Error(
856 'next_gen_heavy',
857 sprintf(
858 /* translators: %s is a size name. */
859 __( 'The Next-Gen version of the size %s is heavier than its non-next-gen version.', 'imagify' ),
860 '<code>' . esc_html( $args['non_next_gen_thumb_size'] ) . '</code>'
861 )
862 );
863 }
864
865 /**
866 * We just created a non-next-gen version:
867 * Check if its next-gen version file is lighter than this one.
868 */
869 $next_gen_size = $args['non_next_gen_thumb_size'] . $args['next_gen_format'];
870 $next_gen_file_size = $this->get_data()->get_size_data( $next_gen_size, 'optimized_size' );
871
872 if ( property_exists( $args['response'], 'message' ) || ! $next_gen_file_size || $next_gen_file_size < $args['response']->new_size ) {
873 // The next-gen file is lighter than this one.
874 return $args['response'];
875 }
876
877 // The new optimized file is lighter than the next-gen file: delete the next-gen file and store an error.
878 $next_gen_path = $args['file']->get_path_to_nextgen( $args['next_gen_format'] );
879
880 if ( $next_gen_path && $this->filesystem->is_writable( $next_gen_path ) ) {
881 $this->filesystem->delete( $next_gen_path );
882 }
883
884 $next_gen_response = new WP_Error(
885 'next_gen_heavy',
886 sprintf(
887 /* translators: %s is a size name. */
888 __( 'The Next-Gen version of the size %s is heavier than its non-next-gen version.', 'imagify' ),
889 '<code>' . esc_html( $args['non_next_gen_thumb_size'] ) . '</code>'
890 )
891 );
892
893 $this->update_size_optimization_data( $next_gen_response, $next_gen_size, $args['optimization_level'] );
894
895 return $args['response'];
896 }
897
898 /**
899 * Restore the media files from the backup file.
900 *
901 * @since 1.9
902 *
903 * @return bool|WP_Error True on success. A WP_Error instance on failure.
904 */
905 public function restore() {
906 if ( ! $this->is_valid() ) {
907 return new WP_Error( 'invalid_media', __( 'This media is not valid.', 'imagify' ) );
908 }
909
910 $media = $this->get_media();
911
912 if ( ! $media->is_supported() ) {
913 return new WP_Error( 'media_not_supported', __( 'This media is not supported.', 'imagify' ) );
914 }
915
916 if ( ! $media->has_backup() ) {
917 return new WP_Error( 'no_backup', __( 'This media has no backup file.', 'imagify' ) );
918 }
919
920 if ( $this->is_locked() ) {
921 return new WP_Error( 'media_locked', __( 'This media is already being processed.', 'imagify' ) );
922 }
923
924 $this->lock( 'restoring' );
925
926 $backup_path = $media->get_backup_path();
927 $original_path = $media->get_raw_original_path();
928
929 if ( $backup_path === $original_path ) {
930 $this->unlock();
931 return new WP_Error( 'same_path', __( 'Image path and backup path are identical.', 'imagify' ) );
932 }
933
934 $dest_dir = $this->filesystem->dir_path( $original_path );
935
936 if ( ! $this->filesystem->exists( $dest_dir ) ) {
937 $this->filesystem->make_dir( $dest_dir );
938 }
939
940 $dest_file_is_writable = ! $this->filesystem->exists( $original_path ) || $this->filesystem->is_writable( $original_path );
941
942 if ( ! $dest_file_is_writable || ! $this->filesystem->is_writable( $dest_dir ) ) {
943 $this->unlock();
944 return new WP_Error( 'destination_not_writable', __( 'The image to replace is not writable.', 'imagify' ) );
945 }
946
947 // Get some data before doing anything.
948 $data = $this->get_data()->get_optimization_data();
949 $files = $media->get_media_files();
950
951 /**
952 * Fires before restoring a media.
953 * Return a WP_Error object to prevent the restoration.
954 *
955 * @since 1.9
956 *
957 * @param null|WP_Error $response Null by default. Return a WP_Error object to prevent optimization.
958 * @param ProcessInterface $process Instance of this process.
959 */
960 $response = apply_filters( 'imagify_before_restore_media', null, $this );
961
962 if ( ! is_wp_error( $response ) ) {
963 // Create the original image from the backup.
964 $response = $this->filesystem->copy( $backup_path, $original_path, true );
965
966 if ( ! $response ) {
967 // Failure.
968 $response = new WP_Error( 'copy_failed', __( 'The backup file could not be copied over the optimized one.', 'imagify' ) );
969 } else {
970 // Backup successfully copied.
971 $this->filesystem->chmod_file( $original_path );
972
973 // Remove old optimization data.
974 $this->get_data()->delete_optimization_data();
975
976 if ( $media->is_image() ) {
977 // Restore the original dimensions in the database.
978 $media->update_dimensions();
979
980 // Delete the WebP version.
981 $this->delete_nextgen_file( $original_path, true );
982
983 // Restore the thumbnails.
984 $response = $this->restore_thumbnails();
985 }
986 }
987 }
988
989 /**
990 * Fires after restoring a media.
991 *
992 * @since 1.9
993 *
994 * @param ProcessInterface $process Instance of this process.
995 * @param bool|WP_Error $response The result of the operation: true on success, a WP_Error object on failure.
996 * @param array $files The list of files, before restoring them.
997 * @param array $data The optimization data, before deleting it.
998 */
999 do_action( 'imagify_after_restore_media', $this, $response, $files, $data );
1000
1001 $this->unlock();
1002
1003 return $response;
1004 }
1005
1006 /**
1007 * Restore the thumbnails.
1008 *
1009 * @since 1.9
1010 *
1011 * @return bool|WP_Error True on success. A WP_Error instance on failure.
1012 */
1013 protected function restore_thumbnails() {
1014 $media = $this->get_media();
1015
1016 /**
1017 * Delete the next-gen versions.
1018 * If the full size file and the original file are not the same, the full size is considered like a thumbnail.
1019 * In that case we must also delete the next-gen file associated to the full size.
1020 */
1021 $keep_full_next_gen = $media->get_raw_original_path() === $media->get_raw_fullsize_path();
1022 $this->delete_nextgen_files( $keep_full_next_gen, true );
1023
1024 // Generate new thumbnails.
1025 return $media->generate_thumbnails();
1026 }
1027
1028 /**
1029 * Delete the backup file.
1030 *
1031 * @since 1.9
1032 */
1033 public function delete_backup() {
1034 if ( ! $this->is_valid() ) {
1035 return;
1036 }
1037
1038 $backup_path = $this->get_media()->get_backup_path();
1039
1040 if ( $backup_path ) {
1041 $this->filesystem->delete( $backup_path );
1042
1043 // Check for the -scaled version in the backup.
1044 $scaled_backup_path = preg_replace( '/(\.)([^\.]+)$/', '-scaled.$2', $backup_path );
1045 if ( $this->filesystem->exists( $scaled_backup_path ) ) {
1046 // Delete the -scaled version from the backup.
1047 $this->filesystem->delete( $scaled_backup_path );
1048 }
1049 }
1050 }
1051
1052 /**
1053 * Create a temporary copy of a size file.
1054 *
1055 * If we need to create a next-gen version, we must create it from an unoptimized image.
1056 * The full size is always optimized before the next-gen version creation, and in some cases it’s the same for the thumbnails.
1057 * Then we use the backup file to create temporary files.
1058 *
1059 * @since 1.9
1060 *
1061 * @param string $size The image size name.
1062 * @param array $sizes A list of thumbnail sizes being optimized.
1063 *
1064 * @return bool True if the file exists/is created. False on failure.
1065 */
1066 protected function create_temporary_copy( $size, $sizes = null ) {
1067 $media = $this->get_media();
1068
1069 if ( ! isset( $sizes ) ) {
1070 $sizes = $media->get_media_files();
1071 }
1072
1073 if ( empty( $sizes[ $size ] ) ) {
1074 // What?
1075 return false;
1076 }
1077
1078 $tmp_path = $this->get_temporary_copy_path( $size, $sizes );
1079
1080 if ( $tmp_path && $this->filesystem->exists( $tmp_path ) ) {
1081 // The temporary file already exists.
1082 return true;
1083 }
1084
1085 $tmp_file = new File( $tmp_path );
1086
1087 if ( ! $tmp_file->is_image() ) {
1088 // The file is not an image.
1089 return false;
1090 }
1091
1092 if ( ! $tmp_file->is_supported( $media->get_allowed_mime_types() ) ) {
1093 // The file is not supported.
1094 return false;
1095 }
1096
1097 /**
1098 * Use the backup file as source.
1099 */
1100 $backup_path = $media->get_backup_path();
1101
1102 if ( ! $backup_path ) {
1103 // No backup, no hope for you.
1104 return false;
1105 }
1106
1107 /**
1108 * In all cases we must make a copy of the backup file, and not use the backup directly:
1109 * sometimes the backup image does not have a valid file extension (yes I’m looking at you NextGEN Gallery).
1110 */
1111 $copied = $this->filesystem->copy( $backup_path, $tmp_path, true );
1112
1113 if ( ! $copied ) {
1114 return false;
1115 }
1116
1117 /**
1118 * The backup file may be the original, un-rotated JPEG that WordPress auto-rotated on
1119 * upload (WordPress resets the orientation on the rotated file, but keeps the original
1120 * orientation in the backup). Correct the orientation of this disposable temporary copy
1121 * so that a Next-Gen version (or a thumbnail) generated from it isn't mis-oriented.
1122 * The backup file itself is never touched.
1123 */
1124 $tmp_file->maybe_correct_exif_orientation();
1125
1126 if ( 'full' === $size ) {
1127 /**
1128 * We create a copy of the backup to be able to create a next-gen version from it.
1129 * That means the optimization process will resize the file if needed, so there is nothing more to do here.
1130 */
1131 return true;
1132 }
1133
1134 // We need to create a thumbnail from it.
1135 $size_data = $sizes[ $size ];
1136 $context_sizes = $media->get_context_instance()->get_thumbnail_sizes();
1137
1138 if ( ! empty( $context_sizes[ $size ] ) ) {
1139 // Not a dynamic size, yay!
1140 $size_data = array_merge( $size_data, $context_sizes[ $size ] );
1141 }
1142
1143 if ( empty( $size_data['path'] ) ) {
1144 // Should not happen.
1145 return false;
1146 }
1147
1148 if ( ! isset( $size_data['crop'] ) ) {
1149 /**
1150 * In case of a dynamic thumbnail we don’t know if the image must be croped or resized.
1151 *
1152 * @since 1.9
1153 *
1154 * @param bool $crop True to crop the thumbnail, false to resize. Null by default.
1155 * @param string $size Name of the thumbnail size.
1156 * @param array $size_data Data of the thumbnail being processed. Contains at least 'width', 'height', and 'path'.
1157 * @param MediaInterface $media The MediaInterface instance corresponding to the image being processed.
1158 */
1159 $crop = apply_filters( 'imagify_crop_thumbnail', null, $size, $size_data, $media );
1160
1161 if ( null !== $crop ) {
1162 $size_data['crop'] = (bool) $crop;
1163 }
1164 }
1165
1166 if ( ! isset( $size_data['crop'] ) ) {
1167 // We don't have the 'crop' data in that case: let’s try to guess it.
1168 if ( ! $size_data['height'] || ! $size_data['width'] ) {
1169 // One of the size dimensions is 0, that means crop is probably disabled.
1170 $size_data['crop'] = false;
1171 } else {
1172 if ( ! $this->filesystem->exists( $size_data['path'] ) ) {
1173 // Screwed.
1174 return false;
1175 }
1176
1177 $thumb_dimensions = $this->filesystem->get_image_size( $size_data['path'] );
1178
1179 if ( ! $thumb_dimensions || ! $thumb_dimensions['width'] || ! $thumb_dimensions['height'] ) {
1180 return false;
1181 }
1182
1183 // Compare dimensions.
1184 $new_height = $thumb_dimensions['width'] * $size_data['height'] / $size_data['width'];
1185 // If the difference is > to 1px, let's assume that crop is enabled.
1186 $size_data['crop'] = abs( $thumb_dimensions['height'] - $new_height ) > 1;
1187 }
1188 }
1189
1190 $resized = $tmp_file->create_thumbnail(
1191 [
1192 'path' => $tmp_path,
1193 'width' => $size_data['width'],
1194 'height' => $size_data['height'],
1195 'crop' => $size_data['crop'],
1196 'adjust_filename' => false,
1197 ]
1198 );
1199
1200 if ( is_wp_error( $resized ) ) {
1201 return false;
1202 }
1203
1204 // Make sure the new file has the expected name.
1205 $new_tmp_path = $this->filesystem->dir_path( $tmp_path ) . $resized['file'];
1206
1207 if ( $new_tmp_path === $tmp_path ) {
1208 return true;
1209 }
1210
1211 return $this->filesystem->move( $new_tmp_path, $tmp_path, true );
1212 }
1213
1214 /**
1215 * Get the path to a temporary copy of a size file.
1216 *
1217 * @since 1.9
1218 *
1219 * @param string $size The image size name.
1220 * @param array $sizes A list of thumbnail sizes being optimized.
1221 *
1222 * @return string|bool An image path. False on failure.
1223 */
1224 protected function get_temporary_copy_path( $size, $sizes = null ) {
1225 if ( 'full' === $size ) {
1226 $path = $this->get_media()->get_raw_fullsize_path();
1227 } else {
1228 if ( ! isset( $sizes ) ) {
1229 $sizes = $this->get_media()->get_media_files();
1230 }
1231
1232 $path = ! empty( $sizes[ $size ]['path'] ) ? $sizes[ $size ]['path'] : false;
1233 }
1234
1235 if ( ! $path ) {
1236 return false;
1237 }
1238
1239 $info = $this->filesystem->path_info( $path );
1240
1241 if ( ! $info['file_base'] ) {
1242 return false;
1243 }
1244
1245 return $info['dir_path'] . $info['file_base'] . static::TMP_SUFFIX . '.' . $info['extension'];
1246 }
1247
1248 /**
1249 * Maybe resize an image.
1250 *
1251 * @since 1.9
1252 *
1253 * @param string $size The size name.
1254 * @param File $file A File instance.
1255 *
1256 * @return array|WP_Error A WP_Error instance on failure, an array on success as follow: {
1257 * @type bool $resized True when the image has been resized.
1258 * @type bool $backuped True when the image has been backuped.
1259 * @type int $file_size The file size in bytes.
1260 * }
1261 */
1262 public function maybe_resize( $size, $file ) {
1263 if ( ! $this->can_resize( $size, $file ) ) {
1264 // This file should not be resized.
1265 return [
1266 'resized' => false,
1267 'backuped' => false,
1268 'file_size' => 0,
1269 ];
1270 }
1271
1272 $dimensions = $file->get_dimensions();
1273
1274 if ( ! $dimensions['width'] ) {
1275 // Could not get the image dimensions.
1276 return new WP_Error(
1277 'no_dimensions',
1278 sprintf(
1279 /* translators: %s is an error message. */
1280 __( 'Resizing failed: %s', 'imagify' ),
1281 __( 'Imagify could not get the image dimensions.', 'imagify' )
1282 )
1283 );
1284 }
1285
1286 $media = $this->get_media();
1287 $resize_width = $media->get_context_instance()->get_resizing_threshold();
1288
1289 if ( $resize_width >= $dimensions['width'] ) {
1290 // No need to resize.
1291 return [
1292 'resized' => false,
1293 'backuped' => false,
1294 'file_size' => 0,
1295 ];
1296 }
1297
1298 $resized_path = $file->resize( $dimensions, $resize_width );
1299
1300 if ( is_wp_error( $resized_path ) ) {
1301 // The resizement failed.
1302 return new WP_Error(
1303 'resize_failure',
1304 sprintf(
1305 /* translators: %s is an error message. */
1306 __( 'Resizing failed: %s', 'imagify' ),
1307 $resized_path->get_error_message()
1308 )
1309 );
1310 }
1311
1312 if ( $this->can_backup( $size ) ) {
1313 $source = 'full' === $size ? $media->get_original_path() : null;
1314 $backuped = $file->backup( $media->get_raw_backup_path(), $source );
1315
1316 if ( is_wp_error( $backuped ) ) {
1317 // The backup failed.
1318 return new WP_Error(
1319 'backup_failure',
1320 sprintf(
1321 /* translators: %s is an error message. */
1322 __( 'Backup failed: %s', 'imagify' ),
1323 $backuped->get_error_message()
1324 )
1325 );
1326 }
1327 } else {
1328 $backuped = false;
1329 }
1330
1331 $file_size = (int) $this->filesystem->size( $file->get_path() );
1332 $resized = $this->filesystem->move( $resized_path, $file->get_path(), true );
1333
1334 if ( ! $resized ) {
1335 // The resizement failed.
1336 return new WP_Error(
1337 'resize_move_failure',
1338 __( 'The image could not be replaced by the resized one.', 'imagify' )
1339 );
1340 }
1341
1342 // Store the new dimensions.
1343 $media->update_dimensions();
1344
1345 return [
1346 'resized' => true,
1347 'backuped' => $backuped,
1348 'file_size' => $file_size,
1349 ];
1350 }
1351
1352 /**
1353 * Tell if a size should be resized.
1354 *
1355 * @since 1.9
1356 *
1357 * @param string $size The size name.
1358 * @param File $file A File instance.
1359 *
1360 * @return bool
1361 */
1362 protected function can_resize( $size, $file ) {
1363 if ( ! $this->is_valid() ) {
1364 return false;
1365 }
1366
1367 if (
1368 'full' !== $size
1369 &&
1370 (
1371 'full' . static::WEBP_SUFFIX !== $size
1372 ||
1373 'full' . static::AVIF_SUFFIX !== $size
1374 )
1375 ) {
1376 // We resize only the main file and its next-gen version.
1377 return false;
1378 }
1379
1380 if ( ! $file->is_image() ) {
1381 return false;
1382 }
1383
1384 return $this->get_media()->get_context_instance()->can_resize();
1385 }
1386
1387 /**
1388 * Tell if a size should be backuped.
1389 *
1390 * @since 1.9
1391 *
1392 * @param string $size The size name.
1393 *
1394 * @return bool
1395 */
1396 protected function can_backup( $size ) {
1397 if ( ! $this->is_valid() ) {
1398 return false;
1399 }
1400
1401 if ( 'full' !== $size ) {
1402 // We backup only the main file.
1403 return false;
1404 }
1405
1406 return $this->get_media()->get_context_instance()->can_backup();
1407 }
1408
1409 /**
1410 * Get mime type
1411 *
1412 * @param string $format nextgen image format.
1413 */
1414 private function get_mime_type( $format ) {
1415 $mime_types = [
1416 'avif' => 'image/avif',
1417 'webp' => 'image/webp',
1418 ];
1419
1420 return isset( $mime_types[ $format ] ) ? $mime_types[ $format ] : false;
1421 }
1422
1423 /**
1424 * Delete the next gen format images.
1425 * This doesn't delete the related optimization data.
1426 *
1427 * @since 2.2
1428 *
1429 * @param bool $keep_full Set to true to keep the full size.
1430 * @param bool $all_next_gen True: will delete every next-gen format. False: will delete only the current enabled format.
1431 *
1432 * @return bool|WP_Error True on success. A WP_Error object on failure.
1433 */
1434 public function delete_nextgen_files( $keep_full = false, $all_next_gen = false ) {
1435 if ( ! $this->is_valid() ) {
1436 return new WP_Error( 'invalid_media', __( 'This media is not valid.', 'imagify' ) );
1437 }
1438
1439 $media = $this->get_media();
1440
1441 if ( ! $media->is_image() ) {
1442 return new WP_Error( 'media_not_an_image', __( 'This media is not an image.', 'imagify' ) );
1443 }
1444
1445 $files = $media->get_media_files();
1446
1447 if ( $keep_full ) {
1448 unset( $files['full'] );
1449 }
1450
1451 if ( ! $files ) {
1452 return true;
1453 }
1454
1455 $error_count = 0;
1456
1457 foreach ( $files as $file ) {
1458 if ( 0 === strpos( $file['mime-type'], 'image/' ) ) {
1459 $deleted = $this->delete_nextgen_file( $file['path'], $all_next_gen );
1460
1461 if ( is_wp_error( $deleted ) ) {
1462 ++$error_count;
1463 }
1464 }
1465 }
1466
1467 if ( $error_count ) {
1468 return new WP_Error(
1469 'files_not_deleted',
1470 sprintf(
1471 /* translators: %s is a formatted number, don’t use %d. */
1472 _n( '%s file could not be deleted.', '%s files could not be deleted.', $error_count, 'imagify' ),
1473 number_format_i18n( $error_count )
1474 )
1475 );
1476 }
1477
1478 return true;
1479 }
1480
1481 /**
1482 * Delete a next gen format image, given its non-next-gen version's path.
1483 * This doesn't delete the related optimization data.
1484 *
1485 * @since 2.2
1486 *
1487 * @param string $file_path Path to the non-next-gen file.
1488 * @param bool $all_next_gen True: will delete every next-gen format. False: will delete only the current enabled format.
1489 *
1490 * @return void|WP_Error A \WP_Error object on failure.
1491 */
1492 protected function delete_nextgen_file( $file_path, $all_next_gen = false ) {
1493 if ( ! $file_path ) {
1494 return new WP_Error( 'no_path', __( 'Path to non-next-gen file not provided.', 'imagify' ) );
1495 }
1496
1497 $next_gen_file = new File( $file_path );
1498 $formats = $this->extensions;
1499
1500 if ( ! $all_next_gen ) {
1501 $formats = imagify_nextgen_images_formats();
1502 }
1503 // Delete next-gen images.
1504 foreach ( $formats as $extension ) {
1505 $path = $next_gen_file->get_path_to_nextgen( $extension );
1506
1507 if ( ! $path ) {
1508 continue;
1509 }
1510
1511 $this->delete_file( $path );
1512 }
1513 }
1514
1515 /**
1516 * Delete a next gen format image, given its non-next-gen version's path.
1517 *
1518 * @param string $next_gen_path Path to the non-next-gen file.
1519 *
1520 * @return bool|WP_Error True on success. A WP_Error object on failure.
1521 */
1522 protected function delete_file( string $next_gen_path ) {
1523 if ( empty( $next_gen_path ) ) {
1524 return new WP_Error( 'no_$next_gen_path', __( 'Could not get the path to the Next-Gen format file.', 'imagify' ) );
1525 }
1526
1527 if ( ! $this->filesystem->exists( $next_gen_path ) ) {
1528 return true;
1529 }
1530
1531 if ( ! $this->filesystem->is_writable( $next_gen_path ) ) {
1532 return new WP_Error(
1533 'file_not_writable',
1534 sprintf(
1535 /* translators: %s is a file path. */
1536 __( 'The file %s does not seem to be writable.', 'imagify' ),
1537 '<code>' . esc_html( $this->filesystem->make_path_relative( $next_gen_path ) ) . '</code>'
1538 )
1539 );
1540 }
1541
1542 if ( ! $this->filesystem->is_file( $next_gen_path ) ) {
1543 return new WP_Error(
1544 'not_a_file',
1545 sprintf(
1546 /* translators: %s is a file path. */
1547 __( 'This does not seem to be a file: %s.', 'imagify' ),
1548 '<code>' . esc_html( $this->filesystem->make_path_relative( $next_gen_path ) ) . '</code>'
1549 )
1550 );
1551 }
1552
1553 $deleted = $this->filesystem->delete( $next_gen_path, false, 'f' );
1554
1555 if ( ! $deleted ) {
1556 return new WP_Error(
1557 'file_not_deleted',
1558 sprintf(
1559 /* translators: %s is a file path. */
1560 __( 'The file %s could not be deleted.', 'imagify' ),
1561 '<code>' . esc_html( $this->filesystem->make_path_relative( $next_gen_path ) ) . '</code>'
1562 )
1563 );
1564 }
1565
1566 return true;
1567 }
1568
1569 /**
1570 * Gives the next-gen image format we are processing.
1571 *
1572 * @return string Current format we are targeting.
1573 */
1574 public function get_current_format() {
1575 $format = get_imagify_option( 'optimization_format' );
1576
1577 return ( 'avif' === $format ) ? static::AVIF_SUFFIX : static::WEBP_SUFFIX;
1578 }
1579
1580 /**
1581 * Tell if a thumbnail size is an "Imagify Next-Gen" size.
1582 *
1583 * @since 1.9
1584 * @since 2.2 addition of the format parameter.
1585 *
1586 * @param string $size_name The size name.
1587 *
1588 * @return string|bool The unsuffixed name of the size if next-gen. False if not next-gen.
1589 */
1590 public function is_size_next_gen( $size_name ) {
1591 $formats = imagify_nextgen_images_formats();
1592
1593 foreach ( $formats as $format ) {
1594 $suffix = preg_quote( $this->get_suffix_from_format( $format ), '/' );
1595
1596 if ( preg_match( '/^(?<size>.+)' . $suffix . '$/', (string) $size_name, $matches ) ) {
1597 return $matches['size'];
1598 }
1599 }
1600
1601 return false;
1602 }
1603
1604 /**
1605 * Tell if the next-gen file for a size is missing from disk.
1606 *
1607 * @since 2.3.2
1608 *
1609 * @param string $size The (next-gen) size name, e.g. "thumbnail@imagify-avif".
1610 * @param string $original_path Path to the non-next-gen version of that size.
1611 * @return bool
1612 */
1613 protected function is_next_gen_file_missing( string $size, string $original_path ): bool {
1614 $format = strpos( $size, static::AVIF_SUFFIX ) ? 'avif' : 'webp';
1615 $next_gen_path = imagify_path_to_nextgen( $original_path, $format );
1616
1617 return ! $next_gen_path || ! $this->filesystem->exists( $next_gen_path );
1618 }
1619
1620 /**
1621 * Get suffix from format.
1622 *
1623 * @param string $format Format extension of next-gen image.
1624 * @return string
1625 */
1626 private function get_suffix_from_format( string $format ): string {
1627 $suffixes = [
1628 'avif' => static::AVIF_SUFFIX,
1629 'webp' => static::WEBP_SUFFIX,
1630 ];
1631
1632 return $suffixes[ $format ];
1633 }
1634
1635 /**
1636 * Tell if the media has a next gen format.
1637 *
1638 * @since 2.2
1639 *
1640 * @return bool
1641 */
1642 public function has_next_gen() {
1643 if ( ! $this->is_valid() ) {
1644 return false;
1645 }
1646
1647 if ( ! $this->get_media()->is_image() ) {
1648 return false;
1649 }
1650
1651 $data = $this->get_data()->get_optimization_data();
1652
1653 if ( empty( $data['sizes'] ) ) {
1654 return false;
1655 }
1656
1657 $needle = $this->format . '";a:4:{s:7:"success";b:1;';
1658 $data = maybe_serialize( $data['sizes'] );
1659
1660 return is_string( $data ) && strpos( $data, $needle );
1661 }
1662
1663 /**
1664 * Tell if the media has all Next-Gen versions.
1665 *
1666 * @return bool
1667 */
1668 public function is_full_next_gen() {
1669 if ( ! $this->is_valid() ) {
1670 return false;
1671 }
1672
1673 if ( ! $this->get_media()->is_image() ) {
1674 return false;
1675 }
1676
1677 $data = $this->get_data()->get_optimization_data();
1678
1679 $sizes = $data['sizes'];
1680
1681 if ( empty( $sizes ) ) {
1682 return false;
1683 }
1684
1685 $keys = array_keys( $sizes );
1686
1687 $non_next_gen_keys = array_values(
1688 array_filter(
1689 $keys,
1690 function ( $key ) {
1691 return strpos( (string) $key, $this->format ) === false;
1692 }
1693 )
1694 );
1695
1696 return array_reduce(
1697 $non_next_gen_keys,
1698 function ( $is_fully, $key ) use ( $sizes ) {
1699 return key_exists( $key . $this->format, $sizes ) && $is_fully;
1700 },
1701 true
1702 );
1703 }
1704
1705 /**
1706 * Tell if a Next-Gen version can be created for the given file.
1707 * Make sure the file is an image before using this method.
1708 *
1709 * @since 1.9.5
1710 *
1711 * @param string $file_path Path to the file.
1712 *
1713 * @return bool
1714 */
1715 public function can_create_next_gen_version( $file_path ) {
1716 if ( ! $file_path ) {
1717 return false;
1718 }
1719
1720 $can = apply_filters_deprecated( 'imagify_pre_can_create_webp_version', [ null, $file_path ], '2.2', 'imagify_pre_can_create_next_gen_version' );
1721
1722 /**
1723 * Tell if a next-gen version can be created for the given file.
1724 * The file is an image.
1725 *
1726 * @since 1.9.5
1727 *
1728 * @param bool $can True to create a next-gen version, false otherwise. Null by default.
1729 * @param string $file_path Path to the file.
1730 */
1731 $can = apply_filters( 'imagify_pre_can_create_next_gen_version', $can, $file_path );
1732
1733 if ( isset( $can ) ) {
1734 return (bool) $can;
1735 }
1736
1737 $is_animated_gif = $this->filesystem->is_animated_gif( $file_path );
1738
1739 if ( is_bool( $is_animated_gif ) ) {
1740 // Ok if it’s not an animated gif.
1741 return ! $is_animated_gif;
1742 }
1743
1744 // At this point $is_animated_gif is null, which means the file cannot be read (yet).
1745 return true;
1746 }
1747
1748 /**
1749 * Generate next-gen images if they are missing.
1750 *
1751 * @since 1.9
1752 *
1753 * @return bool|WP_Error True if successfully launched. A WP_Error instance on failure.
1754 */
1755 public function generate_nextgen_versions() {
1756 if ( ! $this->is_valid() ) {
1757 return new WP_Error( 'invalid_media', __( 'This media is not valid.', 'imagify' ) );
1758 }
1759
1760 $media = $this->get_media();
1761
1762 if ( ! $media->is_image() ) {
1763 return new WP_Error( 'no_next_gen', __( 'This media is not an image and cannot be converted to next-gen format.', 'imagify' ) );
1764 }
1765
1766 if ( ! $media->has_backup() ) {
1767 return new WP_Error( 'no_backup', __( 'This media has no backup file.', 'imagify' ) );
1768 }
1769
1770 $data = $this->get_data();
1771
1772 if ( ! $data->is_optimized() && ! $data->is_already_optimized() ) {
1773 return new WP_Error( 'not_optimized', __( 'This media has not been optimized by Imagify yet.', 'imagify' ) );
1774 }
1775
1776 if ( $this->has_next_gen() ) {
1777 return new WP_Error( 'has_next_gen', __( 'This media already has next-gen versions.', 'imagify' ) );
1778 }
1779
1780 $files = $media->get_media_files();
1781 $sizes = [];
1782 $args = [
1783 'hook_suffix' => 'generate_nextgen_versions',
1784 ];
1785
1786 foreach ( $files as $size_name => $file ) {
1787 $formats = imagify_nextgen_images_formats();
1788
1789 foreach ( $formats as $format ) {
1790 if ( 'avif' === $format ) {
1791 $format_suffix = static::AVIF_SUFFIX;
1792 } elseif ( 'webp' === $format ) {
1793 $format_suffix = static::WEBP_SUFFIX;
1794 }
1795
1796 if ( $this->get_mime_type( $format ) === $files[ $size_name ]['mime-type'] ) {
1797 continue;
1798 }
1799
1800 array_unshift( $sizes, $size_name . $format_suffix );
1801 }
1802 }
1803
1804 if ( ! $sizes ) {
1805 return new WP_Error( 'no_sizes', __( 'This media does not have files that can be converted to next-gen format.', 'imagify' ) );
1806 }
1807
1808 $optimization_level = $data->get_optimization_level();
1809
1810 // Optimize.
1811 return $this->optimize_sizes( $sizes, $optimization_level, $args );
1812 }
1813
1814 /**
1815 * Tell if a process is running for this media.
1816 *
1817 * @since 1.9
1818 *
1819 * @return string|bool The action if locked ('optimizing' or 'restoring'). False if not locked.
1820 */
1821 public function is_locked() {
1822 $name = $this->get_lock_name();
1823
1824 if ( ! $name ) {
1825 return false;
1826 }
1827
1828 $callback = $this->get_media()->get_context_instance()->is_network_wide() ? 'get_site_transient' : 'get_transient';
1829 $action = call_user_func( $callback, $name );
1830
1831 if ( ! $action ) {
1832 return false;
1833 }
1834
1835 return $this->validate_lock_action( $action );
1836 }
1837
1838 /**
1839 * Set the running status to "running" for 10 minutes.
1840 *
1841 * @since 1.9
1842 *
1843 * @param string $action The action performed behind this lock: 'optimizing' or 'restoring'.
1844 */
1845 public function lock( $action = 'optimizing' ) {
1846 $name = $this->get_lock_name();
1847
1848 if ( ! $name ) {
1849 return;
1850 }
1851
1852 $action = $this->validate_lock_action( $action );
1853 $media = $this->get_media();
1854 $callback = $media->get_context_instance()->is_network_wide() ? 'set_site_transient' : 'set_transient';
1855
1856 call_user_func( $callback, $name, $action, 10 * MINUTE_IN_SECONDS );
1857 }
1858
1859 /**
1860 * Unset the running status.
1861 *
1862 * @since 1.9
1863 */
1864 public function unlock() {
1865 $name = $this->get_lock_name();
1866
1867 if ( ! $name ) {
1868 return false;
1869 }
1870
1871 $callback = $this->get_media()->get_context_instance()->is_network_wide() ? 'delete_site_transient' : 'delete_transient';
1872
1873 call_user_func( $callback, $name );
1874 }
1875
1876 /**
1877 * Get the name of the transient that stores the lock status.
1878 *
1879 * @since 1.9
1880 *
1881 * @return string|bool The name on success. False on failure.
1882 */
1883 protected function get_lock_name() {
1884 $media = $this->get_media();
1885
1886 if ( ! $media ) {
1887 return false;
1888 }
1889
1890 /**
1891 * Note that the site transient used by WP Background is named '*_process_lock'.
1892 * That would give something like 'imagify_optimize_media_process_lock' for the optimization process, while here it would be 'imagify_wp_42_process_locked'.
1893 */
1894 return sprintf( static::LOCK_NAME, $media->get_context(), $media->get_id() );
1895 }
1896
1897 /**
1898 * Validate the lock action.
1899 *
1900 * @since 1.9
1901 *
1902 * @param string $action The action performed behind this lock: 'optimizing' or 'restoring'.
1903 * @return string The valid action.
1904 */
1905 protected function validate_lock_action( $action ) {
1906 switch ( $action ) {
1907 case 'restore':
1908 case 'restoring':
1909 $action = 'restoring';
1910 break;
1911
1912 default:
1913 $action = 'optimizing';
1914 }
1915
1916 return $action;
1917 }
1918
1919 /**
1920 * Tell if a size already has optimization data.
1921 *
1922 * @since 1.9
1923 *
1924 * @param string $size The size name.
1925 * @return bool
1926 */
1927 public function size_has_optimization_data( $size ) {
1928 $data = $this->get_data()->get_optimization_data();
1929
1930 return ! empty( $data['sizes'][ $size ] );
1931 }
1932
1933 /**
1934 * Update the optimization data for a size.
1935 *
1936 * @since 1.9
1937 * @since 2.2 - Addition of the format in the call.
1938 *
1939 * @param object $response The API response.
1940 * @param string $size The size name.
1941 * @param int $level The optimization level (0=normal, 1=aggressive, 2=ultra).
1942 *
1943 * @return array {
1944 * The optimization data.
1945 *
1946 * @type int $level The optimization level.
1947 * @type string $status The status: 'success', 'already_optimized', 'error'.
1948 * @type bool $success True if successfully optimized. False on error or if already optimized.
1949 * @type string $error An error message.
1950 * @type int $original_size The weight of the file, before optimization.
1951 * @type int $optimized_size The weight of the file, once optimized.
1952 * }
1953 */
1954 public function update_size_optimization_data( $response, $size, $level ) {
1955 $disabled = false;
1956 $data = $this->data_format;
1957
1958 $data['level'] = is_numeric( $level ) ? (int) $level : $this->get_option( 'optimization_level' );
1959
1960 if ( is_wp_error( $response ) ) {
1961 /**
1962 * Error.
1963 */
1964 $disabled = 'unauthorized_size' === $response->get_error_code();
1965
1966 // Size data.
1967 $data['success'] = false;
1968 $data['error'] = $response->get_error_message();
1969
1970 // Status.
1971 if ( false !== strpos( $data['error'], 'This image is already compressed' ) ) {
1972 $data['status'] = 'already_optimized';
1973 } else {
1974 $data['status'] = 'error';
1975 }
1976 } else {
1977 /**
1978 * Success.
1979 */
1980 $response = (object) array_merge(
1981 [
1982 'original_size' => 0,
1983 'new_size' => 0,
1984 'percent' => 0,
1985 ],
1986 (array) $response
1987 );
1988
1989 // Status.
1990 $data['status'] = 'success';
1991 $data['error'] = null;
1992
1993 // Size data.
1994 $data['success'] = true;
1995
1996 if ( property_exists( $response, 'message' ) ) {
1997 $data['message'] = imagify_translate_api_message( $response->message );
1998 }
1999
2000 $data['original_size'] = $response->original_size;
2001 $data['optimized_size'] = $response->new_size;
2002 }
2003
2004 $_unauthorized = $disabled ? '_unauthorized' : '';
2005
2006 /**
2007 * Filter the optimization data.
2008 *
2009 * @since 1.9
2010 *
2011 * @param array $data {
2012 * The optimization data.
2013 *
2014 * @type int $level The optimization level.
2015 * @type string $status The status: 'success', 'already_optimized', 'error'.
2016 * @type bool $success True if successfully optimized. False on error or if already optimized.
2017 * @type string $error An error message.
2018 * @type int $original_size The weight of the file, before optimization.
2019 * @type int $optimized_size The weight of the file, once optimized.
2020 * }
2021 * @param object $response The API response.
2022 * @param string $size The size name.
2023 * @param int $level The optimization level.
2024 * @param object $media_data The DataInterface instance of the media.
2025 */
2026 $data = (array) apply_filters( "imagify{$_unauthorized}_file_optimization_data", $data, $response, $size, $level, $this->get_data() );
2027
2028 if ( property_exists( $response, 'message' ) && $this->is_next_gen_size( $size ) ) {
2029 /**
2030 * The API answered with a 200 and a message instead of a converted file: it declined the
2031 * conversion, because the next-gen version would be heavier than the original or the file is
2032 * already compressed. No next-gen file is written to disk in that case (see
2033 * Imagify\Optimization\File::optimize()), so recording a success here would make the plugin
2034 * report a next-gen version that does not exist.
2035 *
2036 * Store a terminal entry instead, flagged as a permanent refusal so the bulk queries can skip
2037 * the media without re-sending it to the API on every run. Transient failures go through the
2038 * WP_Error branch above and are not flagged, so they keep being retried.
2039 */
2040 $data['success'] = false;
2041 $data['error'] = $data['message'];
2042 $data['permanent_error'] = true;
2043 }
2044 // Store.
2045 $this->get_data()->update_size_optimization_data( $size, $data );
2046
2047 return $data;
2048 }
2049
2050 /**
2051 * Tell if a size name refers to a next-gen (AVIF or WebP) version.
2052 *
2053 * @param string $size The size name.
2054 *
2055 * @return bool
2056 */
2057 private function is_next_gen_size( $size ) {
2058 $size = (string) $size;
2059
2060 foreach ( [ static::AVIF_SUFFIX, static::WEBP_SUFFIX ] as $suffix ) {
2061 if ( '' !== $suffix && substr( $size, - strlen( $suffix ) ) === $suffix ) {
2062 return true;
2063 }
2064 }
2065
2066 return false;
2067 }
2068
2069 /**
2070 * Get a plugin’s option.
2071 *
2072 * @since 1.9
2073 *
2074 * @param string $option_name The option name.
2075 *
2076 * @return mixed
2077 */
2078 protected function get_option( $option_name ) {
2079 if ( isset( $this->options[ $option_name ] ) ) {
2080 return $this->options[ $option_name ];
2081 }
2082
2083 $this->options[ $option_name ] = get_imagify_option( $option_name );
2084
2085 return $this->options[ $option_name ];
2086 }
2087
2088 /**
2089 * Sanitize and validate an optimization level.
2090 * If not provided (false, null), fallback to the level set in the plugin's settings.
2091 *
2092 * @since 1.9
2093 *
2094 * @param mixed $optimization_level The optimization level.
2095 *
2096 * @return int
2097 */
2098 protected function sanitize_optimization_level( $optimization_level ) {
2099 if ( ! is_numeric( $optimization_level ) ) {
2100 if ( $this->get_option( 'lossless' ) ) {
2101 return 0;
2102 }
2103
2104 return $this->get_option( 'optimization_level' );
2105 }
2106
2107 return \Imagify_Options::get_instance()->sanitize_and_validate( 'optimization_level', $optimization_level );
2108 }
2109
2110 /**
2111 * Tell if the media has AVIF versions.
2112 *
2113 * @since 2.2
2114 *
2115 * @return bool
2116 */
2117 public function has_avif() {
2118 if ( ! $this->is_valid() ) {
2119 return false;
2120 }
2121
2122 if ( ! $this->get_media()->is_image() ) {
2123 return false;
2124 }
2125
2126 $data = $this->get_data()->get_optimization_data();
2127
2128 if ( empty( $data['sizes'] ) ) {
2129 return false;
2130 }
2131
2132 $needle = static::AVIF_SUFFIX . '";a:4:{s:7:"success";b:1;';
2133 $data = maybe_serialize( $data['sizes'] );
2134
2135 return is_string( $data ) && strpos( $data, $needle );
2136 }
2137 }
2138