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
image-optimization / modules / optimization / classes / optimize-image.php

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

365 lines 12.2 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\Connect\Classes\Exceptions\Connection_Error;
32 use ImageOptimization\Modules\Settings\Classes\Settings;
33 use Throwable;
34
35 use ImageOptimization\Plugin;
36
37 if ( ! defined( 'ABSPATH' ) ) {
38 exit; // Exit if accessed directly.
39 }
40
41 /**
42 * The class is responsible for the optimization logic itself. It gets an image file, runs
43 * backup process if needed, sends a file to the service, stores the result and updates image meta.
44 *
45 * This class is used by manual, bulk and on-upload optimization flows.
46 */
47 class Optimize_Image {
48 public const IMAGE_OPTIMIZE_ENDPOINT = 'image/optimize';
49
50 protected ?Image $image;
51 protected WP_Image_Meta $wp_meta;
52 protected string $initiator;
53 protected ?string $bulk_token;
54 private string $current_image_path;
55 private string $current_image_size;
56 private bool $keep_backups;
57 private array $current_size_duplicates;
58 protected Image_Conversion $image_conversion;
59 private bool $is_reoptimize;
60
61 /**
62 * @throws Quota_Exceeded_Error|Connection_Error|Bulk_Token_Expired_Error|Image_File_Already_Exists_Error|Image_Optimization_Error
63 */
64 public function optimize(): void {
65 $sizes_enabled = Settings::get( Settings::CUSTOM_SIZES_OPTION_NAME );
66 $sizes_exist = $this->wp_meta->get_size_keys();
67
68 Logger::debug( "Start optimization of {$this->image->get_id()}" );
69
70 foreach ( $sizes_exist as $size_exist ) {
71 // If some image sizes optimization is disabled in settings, we check if the current one is still enabled
72 if (
73 'all' !== $sizes_enabled &&
74 Image::SIZE_FULL !== $size_exist &&
75 ! in_array( $size_exist, $sizes_enabled, true )
76 ) {
77 continue;
78 }
79
80 // Elementor editor generates thumbnails we don't need to optimize.
81 if ( str_starts_with( $size_exist, 'elementor_' ) ) {
82 continue;
83 }
84
85 $image_meta = new Image_Meta( $this->image->get_id() );
86
87 // If the current size was already optimized -- ignore it.
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
91 continue;
92 }
93
94 if ( ! file_exists( $this->image->get_file_path( $size_exist ) ) ) {
95 Logger::debug( "Can't access file for size `$size_exist`" );
96
97 continue;
98 }
99
100 $this->current_image_size = $size_exist;
101 $this->current_size_duplicates = $this->wp_meta->get_size_duplicates( $size_exist );
102 $this->current_image_path = $this->image->get_file_path( $size_exist );
103
104 $this->optimize_current_size();
105
106 $this->current_image_size = '';
107 $this->current_size_duplicates = [];
108 $this->current_image_path = '';
109 }
110
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()}" );
118 }
119
120 private function optimize_current_size(): void {
121 try {
122 $original_path = $this->current_image_path;
123 $response = $this->send_file();
124
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 );
133 }
134
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 ) );
141
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() ) );
149 }
150 }
151
152 private function send_file() {
153 $connect_status = Plugin::instance()->modules_manager->get_modules( 'connect-manager' )->connect_instance->get_connect_status();
154 $headers = [
155 'access_token' => $connect_status->access_token ?? '',
156 ];
157
158 if ( $this->bulk_token ) {
159 $headers['x-elementor-bulk-token'] = $this->bulk_token;
160 }
161
162 $optimization_options = [
163 'compression_level' => Settings::get( Settings::COMPRESSION_LEVEL_OPTION_NAME ),
164 'convert_to_format' => $this->image_conversion->get_current_conversion_option(),
165 'strip_exif' => Settings::get( Settings::STRIP_EXIF_METADATA_OPTION_NAME ),
166 ];
167
168 if ( Settings::get( Settings::RESIZE_LARGER_IMAGES_OPTION_NAME ) ) {
169 $optimization_options['resize'] = Settings::get( Settings::RESIZE_LARGER_IMAGES_SIZE_OPTION_NAME );
170 }
171
172 $image_key = wp_generate_password( 15, false );
173
174 $response = Utils::get_api_client()->make_request(
175 'POST',
176 self::IMAGE_OPTIMIZE_ENDPOINT,
177 [
178 'initiator' => $this->initiator,
179 'image_url' => $this->image->get_url( $this->current_image_size ),
180 'image_key' => $image_key,
181 'checksum' => md5_file( $this->current_image_path ),
182 'attachment_id' => $this->image->get_id(),
183 'attachment_parent_id' => Image::SIZE_FULL === $this->current_image_size ? 0 : $this->image->get_id(),
184 'image_optimization_settings' => base64_encode( wp_json_encode( $optimization_options ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
185 ],
186 $headers,
187 $this->current_image_path,
188 'image'
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;
210 }
211
212 /**
213 * @throws File_System_Operation_Error|Image_Backup_Creation_Error|Image_File_Already_Exists_Error
214 */
215 private function replace_image_file( string $file_data ): void {
216 $path = $this->current_image_path;
217
218 // If we have backups disabled, we want to make sure we can download and save new file before we
219 // remove an existing one.
220 $tmp_path = File_Utils::replace_extension( $path, 'tmp' );
221
222 File_System::put_contents( $tmp_path, $file_data );
223
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 {
249 Image_Backup::create( $this->image->get_id(), $this->current_image_size, $this->current_image_path );
250
251 File_System::move( $tmp_path, $path, true );
252 }
253
254 if ( Image::SIZE_FULL === $this->current_image_size ) {
255 // Drop WP caches
256 update_attached_file( $this->image->get_id(), $path );
257 }
258
259 // Updating to the correct value
260 $this->current_image_path = $path;
261 }
262
263 /**
264 * Updates attachment records in the `wp_posts` table.
265 *
266 * @return void
267 */
268 private function update_attachment_post( ?bool $is_fully_optimized = false ) {
269 $update_query = [];
270
271 if ( $this->image_conversion->is_enabled() && ! $is_fully_optimized ) {
272 $attachment_object = $this->image->get_attachment_object();
273
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();
280 }
281
282 $update_query['post_modified'] = current_time( 'mysql' );
283 $update_query['post_modified_gmt'] = current_time( 'mysql', true );
284
285 $this->image->update_attachment( $update_query );
286 }
287
288 /**
289 * Updates attachment records in the `wp_postmeta` table.
290 *
291 * @param int $optimized_size
292 * @param bool|null $is_fully_optimized
293 *
294 * @return void
295 */
296 private function update_attachment_meta( int $optimized_size, ?bool $is_fully_optimized = false ) {
297 $meta = new Image_Meta( $this->image->get_id() );
298 $dimensions = Image_Dimensions::get_by_path( $this->current_image_path );
299
300 $sizes_to_update = [ $this->current_image_size, ...$this->current_size_duplicates ];
301
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 ) );
307
308 $this->wp_meta
309 ->set_width( $size, $dimensions->width )
310 ->set_height( $size, $dimensions->height )
311 ->set_file_size( $size, $optimized_size );
312
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 }
318 }
319
320 $meta->save();
321 $this->wp_meta->save();
322 }
323
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 /**
341 * Changes image status after all image sizes were optimized.
342 *
343 * @return void
344 */
345 private function mark_as_optimized() {
346 ( new Image_Meta( $this->image->get_id() ) )
347 ->set_status( Image_Status::OPTIMIZED )
348 ->set_error_type( null )
349 ->save();
350 }
351
352 /**
353 * @throws Invalid_Image_Exception
354 */
355 public function __construct( int $image_id, string $initiator, ?string $bulk_token = null, ?bool $is_reoptimize = false ) {
356 $this->image = new Image( $image_id );
357 $this->wp_meta = new WP_Image_Meta( $image_id, $this->image );
358 $this->initiator = $initiator;
359 $this->bulk_token = $bulk_token;
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 );
363 }
364 }
365