PluginProbe
Image Optimization – Compress Images and Convert to WebP or AVIF / 1.6.5
Image Optimization – Compress Images and Convert to WebP or AVIF v1.6.5
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 1.6.6 All 32 releases
image-optimization / modules / optimization / classes / optimize-image.php

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

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