PluginProbe
Image Optimizer – Compress Images and Convert to WebP or AVIF / 1.7.7
Image Optimizer – Compress Images and Convert to WebP or AVIF v1.7.7
1.7.7 1.7.6 1.7.5 1.7.4 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.2.0 1.2.1 1.3.0 1.4.0 1.4.1 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 All 33 releases
← All changes | modules/optimization/classes/optimize-image.php +179 -73 1.0.11.7.7 View file →
@@ -1,30 +1,44 @@
1 1 <?php
2 2
3 -namespace ImageOptimizer\Modules\Optimization\Classes;
3 +namespace ImageOptimization\Modules\Optimization\Classes;
4 4
5 -use ImageOptimizer\Classes\File_Utils;
6 -use ImageOptimizer\Classes\Image\{
5 +use ImageOptimization\Classes\File_System\{
6 + Exceptions\File_System_Operation_Error,
7 + File_System,
8 +};
9 +use ImageOptimization\Classes\File_Utils;
10 +use ImageOptimization\Classes\Image\{
11 + Exceptions\Image_Backup_Creation_Error,
7 12 Exceptions\Invalid_Image_Exception,
8 13 Image,
9 14 Image_Backup,
15 + Image_Conversion,
16 + Image_DB_Update,
17 + Image_Dimensions,
10 18 Image_Meta,
11 19 Image_Status,
12 20 WP_Image_Meta
13 21 };
14 -use ImageOptimizer\Classes\Utils;
15 -use ImageOptimizer\Modules\Oauth\Classes\Exceptions\Quota_Exceeded_Error;
16 -use ImageOptimizer\Modules\Oauth\Components\Connect;
17 -use ImageOptimizer\Modules\Optimization\Classes\Exceptions\Image_Optimization_Error;
18 -use ImageOptimizer\Modules\Settings\Classes\Settings;
22 +use ImageOptimization\Classes\Logger;
23 +use ImageOptimization\Classes\Utils;
24 +use ImageOptimization\Classes\Exceptions\Quota_Exceeded_Error;
25 +use ImageOptimization\Modules\Optimization\Classes\{
26 + Exceptions\Image_File_Already_Exists_Error,
27 + Exceptions\Image_Optimization_Error,
28 + Exceptions\Bulk_Token_Expired_Error,
29 + Exceptions\Image_Already_Optimized_Error,
30 +};
31 +use ImageOptimization\Modules\Connect\Classes\Exceptions\Connection_Error;
32 +use ImageOptimization\Modules\Settings\Classes\Settings;
33 +use Throwable;
19 34
35 +use ImageOptimization\Plugin;
36 +
20 37 if ( ! defined( 'ABSPATH' ) ) {
21 38 exit; // Exit if accessed directly.
22 39 }
23 40
24 -require_once ABSPATH . 'wp-admin/includes/file.php';
25 -WP_Filesystem();
26 -
27 41 /**
28 42 * The class is responsible for the optimization logic itself. It gets an image file, runs
29 43 * backup process if needed, sends a file to the service, stores the result and updates image meta.
30 44 *
@@ -30,9 +44,9 @@
30 44 *
31 45 * This class is used by manual, bulk and on-upload optimization flows.
32 46 */
33 47 class Optimize_Image {
34 - private const IMAGE_OPTIMIZE_ENDPOINT = 'image/optimize';
48 + public const IMAGE_OPTIMIZE_ENDPOINT = 'image/optimize';
35 49
36 50 protected ?Image $image;
37 51 protected WP_Image_Meta $wp_meta;
38 52 protected string $initiator;
@@ -38,17 +52,22 @@
38 52 protected string $initiator;
39 53 protected ?string $bulk_token;
40 54 private string $current_image_path;
41 55 private string $current_image_size;
42 - private bool $convert_to_webp;
56 + private bool $keep_backups;
57 + private array $current_size_duplicates;
58 + protected Image_Conversion $image_conversion;
59 + private bool $is_reoptimize;
43 60
44 61 /**
45 - * @throws Image_Optimization_Error|Quota_Exceeded_Error
62 + * @throws Quota_Exceeded_Error|Connection_Error|Bulk_Token_Expired_Error|Image_File_Already_Exists_Error|Image_Optimization_Error
46 63 */
47 64 public function optimize(): void {
48 65 $sizes_enabled = Settings::get( Settings::CUSTOM_SIZES_OPTION_NAME );
49 66 $sizes_exist = $this->wp_meta->get_size_keys();
50 67
68 + Logger::debug( "Start optimization of {$this->image->get_id()}" );
69 +
51 70 foreach ( $sizes_exist as $size_exist ) {
52 71 // If some image sizes optimization is disabled in settings, we check if the current one is still enabled
53 72 if (
54 73 'all' !== $sizes_enabled &&
@@ -57,63 +76,82 @@
57 76 ) {
58 77 continue;
59 78 }
60 79
80 + // Elementor editor generates thumbnails we don't need to optimize.
81 + if ( str_starts_with( $size_exist, 'elementor_' ) ) {
82 + continue;
83 + }
84 +
61 85 $image_meta = new Image_Meta( $this->image->get_id() );
62 86
63 87 // If the current size was already optimized -- ignore it.
64 - if ( in_array( $size_exist, $image_meta->get_optimized_sizes(), true ) ) {
88 + if ( ! $this->is_reoptimize && in_array( $size_exist, $image_meta->get_optimized_sizes(), true ) ) {
89 + Logger::debug( "Size `$size_exist` is already optimized" );
90 +
65 91 continue;
66 92 }
67 93
68 94 if ( ! file_exists( $this->image->get_file_path( $size_exist ) ) ) {
69 - throw new Image_Optimization_Error( esc_html__( 'File is missing. Verify the upload', 'image-optimizer' ) );
95 + Logger::debug( "Can't access file for size `$size_exist`" );
96 +
97 + continue;
70 98 }
71 99
72 100 $this->current_image_size = $size_exist;
101 + $this->current_size_duplicates = $this->wp_meta->get_size_duplicates( $size_exist );
73 102 $this->current_image_path = $this->image->get_file_path( $size_exist );
74 103
75 104 $this->optimize_current_size();
76 105
77 106 $this->current_image_size = '';
107 + $this->current_size_duplicates = [];
78 108 $this->current_image_path = '';
79 109 }
80 110
81 111 $this->mark_as_optimized();
112 +
113 + if ( ! $this->keep_backups ) {
114 + Image_Backup::remove( $this->image->get_id() );
115 + }
116 +
117 + Logger::debug( "End optimization of {$this->image->get_id()}" );
82 118 }
83 119
84 - /**
85 - * @throws Image_Optimization_Error|Quota_Exceeded_Error
86 - */
87 120 private function optimize_current_size(): void {
88 - $response = $this->send_file();
121 + try {
122 + $original_path = $this->current_image_path;
123 + $response = $this->send_file();
89 124
90 - if ( is_wp_error( $response ) ) {
91 - if ( $response->get_error_message() === 'user reached limit' ) {
92 - throw new Quota_Exceeded_Error( esc_html__( 'Plan quota reached', 'image-optimizer' ) );
125 + $optimized_size = (int) $response->optimizedSize; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
126 + $optimized_image_binary = base64_decode( $response->image, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
127 +
128 + $this->replace_image_file( $optimized_image_binary );
129 + $this->update_attachment_meta( $optimized_size );
130 +
131 + if ( $original_path !== $this->current_image_path ) {
132 + $this->update_posts( $original_path, $this->current_image_path );
93 133 }
94 134
95 - throw new Image_Optimization_Error( $response->get_error_message() );
96 - }
135 + // This should only be updated after meta
136 + $this->update_attachment_post();
137 + } catch ( Image_Already_Optimized_Error $iao ) {
138 + // If we can't optimize it further, just update the meta
139 + $original_size = $this->wp_meta->get_file_size( $this->current_image_size )
140 + ?? File_System::size( $this->image->get_file_path( $this->current_image_size ) );
97 141
98 - $original_size = (int) $response->originalSize; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
99 - $optimized_size = (int) $response->optimizedSize; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
100 - $optimized_image_binary = base64_decode( $response->image, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
101 -
102 - // Sometimes the BE returns images bigger than original ones. In that case, we don't want to replace the original.
103 - if ( $original_size > $optimized_size ) {
104 - $this->replace_image_file( $optimized_image_binary );
105 - $this->update_attachment_meta( $optimized_size );
106 - } else {
107 - $this->update_attachment_meta( $original_size );
142 + $this->update_attachment_meta( $original_size, true );
143 + $this->update_attachment_post( true );
144 + } catch ( Bulk_Token_Expired_Error | Quota_Exceeded_Error | Connection_Error | Image_File_Already_Exists_Error $e ) {
145 + throw $e;
146 + } catch ( Throwable $t ) {
147 + // In case of anything else
148 + throw new Image_Optimization_Error( esc_html( $t->getMessage() ) );
108 149 }
109 -
110 - // This should only be updated after meta
111 - $this->update_attachment_post();
112 150 }
113 151
114 152 private function send_file() {
115 - $connect_status = Connect::check_connect_status();
153 + $connect_status = Plugin::instance()->modules_manager->get_modules( 'connect-manager' )->connect_instance->get_connect_status();
116 154 $headers = [
117 155 'access_token' => $connect_status->access_token ?? '',
118 156 ];
119 157
@@ -122,9 +160,9 @@
122 160 }
123 161
124 162 $optimization_options = [
125 163 'compression_level' => Settings::get( Settings::COMPRESSION_LEVEL_OPTION_NAME ),
126 - 'convert_to_webp' => $this->convert_to_webp,
164 + 'convert_to_format' => $this->image_conversion->get_current_conversion_option(),
127 165 'strip_exif' => Settings::get( Settings::STRIP_EXIF_METADATA_OPTION_NAME ),
128 166 ];
129 167
130 168 if ( Settings::get( Settings::RESIZE_LARGER_IMAGES_OPTION_NAME ) ) {
@@ -130,14 +168,18 @@
130 168 if ( Settings::get( Settings::RESIZE_LARGER_IMAGES_OPTION_NAME ) ) {
131 169 $optimization_options['resize'] = Settings::get( Settings::RESIZE_LARGER_IMAGES_SIZE_OPTION_NAME );
132 170 }
133 171
134 - return Utils::get_api_client()->make_request(
172 + $image_key = wp_generate_password( 15, false );
173 +
174 + $response = Utils::get_api_client()->make_request(
135 175 'POST',
136 176 self::IMAGE_OPTIMIZE_ENDPOINT,
137 177 [
138 178 'initiator' => $this->initiator,
139 179 'image_url' => $this->image->get_url( $this->current_image_size ),
180 + 'image_key' => $image_key,
181 + 'checksum' => md5_file( $this->current_image_path ),
140 182 'attachment_id' => $this->image->get_id(),
141 183 'attachment_parent_id' => Image::SIZE_FULL === $this->current_image_size ? 0 : $this->image->get_id(),
142 184 'image_optimization_settings' => base64_encode( wp_json_encode( $optimization_options ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
143 185 ],
@@ -144,13 +186,34 @@
144 186 $headers,
145 187 $this->current_image_path,
146 188 'image'
147 189 );
190 +
191 + if ( isset( $response->stats ) ) {
192 + Plugin::instance()->modules_manager->get_modules( 'connect-manager' )->connect_instance->update_usage_data( $response->stats );
193 + }
194 +
195 + if ( ! isset( $response->imageKey ) || $image_key !== $response->imageKey ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
196 + Logger::error( "Image key must be $image_key, instead got $response->imageKey" );
197 +
198 + throw new Image_Optimization_Error( esc_html__( 'Service response is incorrect', 'image-optimization' ) );
199 + }
200 +
201 + $received_file_hash = md5( base64_decode( $response->image, true ) );
202 +
203 + if ( ! isset( $response->checksum ) || $received_file_hash !== $response->checksum ) {
204 + Logger::error( "Image key must be $response->checksum, instead calculated $received_file_hash" );
205 +
206 + throw new Image_Optimization_Error( esc_html__( 'Service response is incorrect', 'image-optimization' ) );
207 + }
208 +
209 + return $response;
148 210 }
149 211
212 + /**
213 + * @throws File_System_Operation_Error|Image_Backup_Creation_Error|Image_File_Already_Exists_Error
214 + */
150 215 private function replace_image_file( string $file_data ): void {
151 - global $wp_filesystem;
152 -
153 216 $path = $this->current_image_path;
154 217
155 218 // If we have backups disabled, we want to make sure we can download and save new file before we
156 219 // remove an existing one.
@@ -155,23 +218,40 @@
155 218 // If we have backups disabled, we want to make sure we can download and save new file before we
156 219 // remove an existing one.
157 220 $tmp_path = File_Utils::replace_extension( $path, 'tmp' );
158 221
159 - $wp_filesystem->put_contents( $tmp_path, $file_data );
222 + File_System::put_contents( $tmp_path, $file_data );
160 223
161 - // The original file doesn't exist after any of these operations.
162 - if ( Settings::get( Settings::BACKUP_ORIGINAL_IMAGES_OPTION_NAME ) ) {
224 + if ( $this->image_conversion->is_enabled() ) {
225 + $converted_path = File_Utils::replace_extension( $tmp_path, $this->image_conversion->get_current_file_extension() );
226 + $original_is_already_converted = strtolower( $path ) === strtolower( $converted_path );
227 +
228 + if ( ! $original_is_already_converted && File_System::exists( $converted_path ) ) {
229 + throw new Image_File_Already_Exists_Error();
230 + }
231 +
232 + // We want to keep both original as a fallback and optimized webp to prevent 404s
233 + if ( ! $original_is_already_converted ) {
234 + $this->keep_backups = true;
235 +
236 + ( new Image_Meta( $this->image->get_id() ) )
237 + ->set_image_backup_path( $this->current_image_size, $this->current_image_path )
238 + ->save();
239 + }
240 +
241 + if ( $original_is_already_converted ) {
242 + Image_Backup::create( $this->image->get_id(), $this->current_image_size, $this->current_image_path );
243 + }
244 +
245 + File_System::move( $tmp_path, $converted_path, true );
246 +
247 + $path = $converted_path;
248 + } else {
163 249 Image_Backup::create( $this->image->get_id(), $this->current_image_size, $this->current_image_path );
164 - } else {
165 - $wp_filesystem->delete( $path, false, 'f' );
166 - }
167 250
168 - if ( $this->convert_to_webp ) {
169 - $path = File_Utils::replace_extension( $tmp_path, 'webp' );
251 + File_System::move( $tmp_path, $path, true );
170 252 }
171 253
172 - $wp_filesystem->move( $tmp_path, $path, true );
173 -
174 254 if ( Image::SIZE_FULL === $this->current_image_size ) {
175 255 // Drop WP caches
176 256 update_attached_file( $this->image->get_id(), $path );
177 257 }
@@ -184,16 +264,20 @@
184 264 * Updates attachment records in the `wp_posts` table.
185 265 *
186 266 * @return void
187 267 */
188 - private function update_attachment_post() {
268 + private function update_attachment_post( ?bool $is_fully_optimized = false ) {
189 269 $update_query = [];
190 270
191 - if ( $this->convert_to_webp ) {
271 + if ( $this->image_conversion->is_enabled() && ! $is_fully_optimized ) {
192 272 $attachment_object = $this->image->get_attachment_object();
193 273
194 - $update_query['guid'] = File_Utils::replace_extension( $attachment_object->guid, 'webp' );
195 - $update_query['post_mime_type'] = 'image/webp';
274 + $update_query['guid'] = File_Utils::replace_extension(
275 + $attachment_object->guid,
276 + $this->image_conversion->get_current_file_extension()
277 + );
278 +
279 + $update_query['post_mime_type'] = $this->image_conversion->get_current_mime_type();
196 280 }
197 281
198 282 $update_query['post_modified'] = current_time( 'mysql' );
199 283 $update_query['post_modified_gmt'] = current_time( 'mysql', true );
@@ -204,30 +288,34 @@
204 288 /**
205 289 * Updates attachment records in the `wp_postmeta` table.
206 290 *
207 291 * @param int $optimized_size
292 + * @param bool|null $is_fully_optimized
208 293 *
209 294 * @return void
210 295 */
211 - private function update_attachment_meta( int $optimized_size ) {
296 + private function update_attachment_meta( int $optimized_size, ?bool $is_fully_optimized = false ) {
212 297 $meta = new Image_Meta( $this->image->get_id() );
298 + $dimensions = Image_Dimensions::get_by_path( $this->current_image_path );
213 299
214 - list($width, $height) = getimagesize( $this->current_image_path );
300 + $sizes_to_update = [ $this->current_image_size, ...$this->current_size_duplicates ];
215 301
216 - $meta
217 - ->set_compression_level( Settings::get( Settings::COMPRESSION_LEVEL_OPTION_NAME ) )
218 - ->add_optimized_size( $this->current_image_size )
219 - ->add_original_data( $this->current_image_size, $this->wp_meta->get_size_data( $this->current_image_size ) );
302 + foreach ( $sizes_to_update as $size ) {
303 + $meta
304 + ->set_compression_level( Settings::get( Settings::COMPRESSION_LEVEL_OPTION_NAME ) )
305 + ->add_optimized_size( $size )
306 + ->add_original_data( $size, $this->wp_meta->get_size_data( $size ) );
220 307
221 - $this->wp_meta
222 - ->set_width( $this->current_image_size, $width )
223 - ->set_height( $this->current_image_size, $height )
224 - ->set_file_size( $this->current_image_size, $optimized_size );
308 + $this->wp_meta
309 + ->set_width( $size, $dimensions->width )
310 + ->set_height( $size, $dimensions->height )
311 + ->set_file_size( $size, $optimized_size );
225 312
226 - if ( $this->convert_to_webp ) {
227 - $this->wp_meta
228 - ->set_file_path( $this->current_image_size, $this->current_image_path )
229 - ->set_mime_type( $this->current_image_size, 'image/webp' );
313 + if ( $this->image_conversion->is_enabled() && ! $is_fully_optimized ) {
314 + $this->wp_meta
315 + ->set_file_path( $size, $this->current_image_path )
316 + ->set_mime_type( $size, $this->image_conversion->get_current_mime_type() );
317 + }
230 318 }
231 319
232 320 $meta->save();
233 321 $this->wp_meta->save();
@@ -233,8 +321,24 @@
233 321 $this->wp_meta->save();
234 322 }
235 323
236 324 /**
325 + * If we change an image extension, we should walk through the wp_posts table and update all the
326 + * hardcoded image links to prevent 404s.
327 + *
328 + * @param string $old_path Previous image path
329 + * @param string $new_path Current image path
330 + *
331 + * @return void
332 + */
333 + private function update_posts( string $old_path, string $new_path ) {
334 + Image_DB_Update::update_posts_table_urls(
335 + File_Utils::get_url_from_path( $old_path ),
336 + File_Utils::get_url_from_path( $new_path )
337 + );
338 + }
339 +
340 + /**
237 341 * Changes image status after all image sizes were optimized.
238 342 *
239 343 * @return void
240 344 */
@@ -247,12 +351,14 @@
247 351
248 352 /**
249 353 * @throws Invalid_Image_Exception
250 354 */
251 - public function __construct( int $image_id, string $initiator, ?string $bulk_token = null ) {
355 + public function __construct( int $image_id, string $initiator, ?string $bulk_token = null, ?bool $is_reoptimize = false ) {
252 356 $this->image = new Image( $image_id );
253 357 $this->wp_meta = new WP_Image_Meta( $image_id, $this->image );
254 358 $this->initiator = $initiator;
255 359 $this->bulk_token = $bulk_token;
256 - $this->convert_to_webp = Settings::get( Settings::CONVERT_TO_WEBP_OPTION_NAME );
360 + $this->is_reoptimize = $is_reoptimize;
361 + $this->image_conversion = new Image_Conversion();
362 + $this->keep_backups = Settings::get( Settings::BACKUP_ORIGINAL_IMAGES_OPTION_NAME );
257 363 }
258 364 }