PluginProbe
Image Optimizer – Compress Images and Convert to WebP or AVIF / trunk
Image Optimizer – Compress Images and Convert to WebP or AVIF vtrunk
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 +178 -75 1.0.2 → trunk 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,66 +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' ) );
93 - }
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
94 127
95 - if ( $response->get_error_message() === 'Image already optimized' ) {
96 - $original_size = $this->wp_meta->get_file_size( $this->current_image_size );
128 + $this->replace_image_file( $optimized_image_binary );
129 + $this->update_attachment_meta( $optimized_size );
97 130
98 - $this->update_attachment_meta( $original_size );
99 - $this->update_attachment_post();
131 + if ( $original_path !== $this->current_image_path ) {
132 + $this->update_posts( $original_path, $this->current_image_path );
133 + }
100 134
101 - return;
102 - }
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 ) );
103 141
104 - throw new Image_Optimization_Error( $response->get_error_message() );
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() ) );
105 149 }
106 -
107 - $optimized_size = (int) $response->optimizedSize; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
108 - $optimized_image_binary = base64_decode( $response->image, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
109 -
110 - $this->replace_image_file( $optimized_image_binary );
111 - $this->update_attachment_meta( $optimized_size );
112 -
113 - // This should only be updated after meta
114 - $this->update_attachment_post();
115 150 }
116 151
117 152 private function send_file() {
118 - $connect_status = Connect::check_connect_status();
153 + $connect_status = Plugin::instance()->modules_manager->get_modules( 'connect-manager' )->connect_instance->get_connect_status();
119 154 $headers = [
120 155 'access_token' => $connect_status->access_token ?? '',
121 156 ];
122 157
@@ -125,9 +160,9 @@
125 160 }
126 161
127 162 $optimization_options = [
128 163 'compression_level' => Settings::get( Settings::COMPRESSION_LEVEL_OPTION_NAME ),
129 - 'convert_to_webp' => $this->convert_to_webp,
164 + 'convert_to_format' => $this->image_conversion->get_current_conversion_option(),
130 165 'strip_exif' => Settings::get( Settings::STRIP_EXIF_METADATA_OPTION_NAME ),
131 166 ];
132 167
133 168 if ( Settings::get( Settings::RESIZE_LARGER_IMAGES_OPTION_NAME ) ) {
@@ -133,14 +168,18 @@
133 168 if ( Settings::get( Settings::RESIZE_LARGER_IMAGES_OPTION_NAME ) ) {
134 169 $optimization_options['resize'] = Settings::get( Settings::RESIZE_LARGER_IMAGES_SIZE_OPTION_NAME );
135 170 }
136 171
137 - return Utils::get_api_client()->make_request(
172 + $image_key = wp_generate_password( 15, false );
173 +
174 + $response = Utils::get_api_client()->make_request(
138 175 'POST',
139 176 self::IMAGE_OPTIMIZE_ENDPOINT,
140 177 [
141 178 'initiator' => $this->initiator,
142 179 'image_url' => $this->image->get_url( $this->current_image_size ),
180 + 'image_key' => $image_key,
181 + 'checksum' => md5_file( $this->current_image_path ),
143 182 'attachment_id' => $this->image->get_id(),
144 183 'attachment_parent_id' => Image::SIZE_FULL === $this->current_image_size ? 0 : $this->image->get_id(),
145 184 'image_optimization_settings' => base64_encode( wp_json_encode( $optimization_options ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
146 185 ],
@@ -147,13 +186,34 @@
147 186 $headers,
148 187 $this->current_image_path,
149 188 'image'
150 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;
151 210 }
152 211
212 + /**
213 + * @throws File_System_Operation_Error|Image_Backup_Creation_Error|Image_File_Already_Exists_Error
214 + */
153 215 private function replace_image_file( string $file_data ): void {
154 - global $wp_filesystem;
155 -
156 216 $path = $this->current_image_path;
157 217
158 218 // If we have backups disabled, we want to make sure we can download and save new file before we
159 219 // remove an existing one.
@@ -158,23 +218,40 @@
158 218 // If we have backups disabled, we want to make sure we can download and save new file before we
159 219 // remove an existing one.
160 220 $tmp_path = File_Utils::replace_extension( $path, 'tmp' );
161 221
162 - $wp_filesystem->put_contents( $tmp_path, $file_data );
222 + File_System::put_contents( $tmp_path, $file_data );
163 223
164 - // The original file doesn't exist after any of these operations.
165 - 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 {
166 249 Image_Backup::create( $this->image->get_id(), $this->current_image_size, $this->current_image_path );
167 - } else {
168 - $wp_filesystem->delete( $path, false, 'f' );
169 - }
170 250
171 - if ( $this->convert_to_webp ) {
172 - $path = File_Utils::replace_extension( $tmp_path, 'webp' );
251 + File_System::move( $tmp_path, $path, true );
173 252 }
174 253
175 - $wp_filesystem->move( $tmp_path, $path, true );
176 -
177 254 if ( Image::SIZE_FULL === $this->current_image_size ) {
178 255 // Drop WP caches
179 256 update_attached_file( $this->image->get_id(), $path );
180 257 }
@@ -187,16 +264,20 @@
187 264 * Updates attachment records in the `wp_posts` table.
188 265 *
189 266 * @return void
190 267 */
191 - private function update_attachment_post() {
268 + private function update_attachment_post( ?bool $is_fully_optimized = false ) {
192 269 $update_query = [];
193 270
194 - if ( $this->convert_to_webp ) {
271 + if ( $this->image_conversion->is_enabled() && ! $is_fully_optimized ) {
195 272 $attachment_object = $this->image->get_attachment_object();
196 273
197 - $update_query['guid'] = File_Utils::replace_extension( $attachment_object->guid, 'webp' );
198 - $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();
199 280 }
200 281
201 282 $update_query['post_modified'] = current_time( 'mysql' );
202 283 $update_query['post_modified_gmt'] = current_time( 'mysql', true );
@@ -207,30 +288,34 @@
207 288 /**
208 289 * Updates attachment records in the `wp_postmeta` table.
209 290 *
210 291 * @param int $optimized_size
292 + * @param bool|null $is_fully_optimized
211 293 *
212 294 * @return void
213 295 */
214 - private function update_attachment_meta( int $optimized_size ) {
296 + private function update_attachment_meta( int $optimized_size, ?bool $is_fully_optimized = false ) {
215 297 $meta = new Image_Meta( $this->image->get_id() );
298 + $dimensions = Image_Dimensions::get_by_path( $this->current_image_path );
216 299
217 - list($width, $height) = getimagesize( $this->current_image_path );
300 + $sizes_to_update = [ $this->current_image_size, ...$this->current_size_duplicates ];
218 301
219 - $meta
220 - ->set_compression_level( Settings::get( Settings::COMPRESSION_LEVEL_OPTION_NAME ) )
221 - ->add_optimized_size( $this->current_image_size )
222 - ->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 ) );
223 307
224 - $this->wp_meta
225 - ->set_width( $this->current_image_size, $width )
226 - ->set_height( $this->current_image_size, $height )
227 - ->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 );
228 312
229 - if ( $this->convert_to_webp ) {
230 - $this->wp_meta
231 - ->set_file_path( $this->current_image_size, $this->current_image_path )
232 - ->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 + }
233 318 }
234 319
235 320 $meta->save();
236 321 $this->wp_meta->save();
@@ -236,8 +321,24 @@
236 321 $this->wp_meta->save();
237 322 }
238 323
239 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 + /**
240 341 * Changes image status after all image sizes were optimized.
241 342 *
242 343 * @return void
243 344 */
@@ -250,12 +351,14 @@
250 351
251 352 /**
252 353 * @throws Invalid_Image_Exception
253 354 */
254 - 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 ) {
255 356 $this->image = new Image( $image_id );
256 357 $this->wp_meta = new WP_Image_Meta( $image_id, $this->image );
257 358 $this->initiator = $initiator;
258 359 $this->bulk_token = $bulk_token;
259 - $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 );
260 363 }
261 364 }