PluginProbe
Image Optimizer – Compress Images and Convert to WebP or AVIF / 1.2.1
Image Optimizer – Compress Images and Convert to WebP or AVIF v1.2.1
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
image-optimization / modules / optimization / classes / optimize-image.php

optimize-image.php in Image Optimizer – Compress Images and Convert to WebP or AVIF 1.2.1, at modules/optimization/classes/optimize-image.php

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