diff --git a/Tests/Integration/inc/classes/AutoOptimization/ClientSideUploadSequenceTest.php b/Tests/Integration/inc/classes/AutoOptimization/ClientSideUploadSequenceTest.php new file mode 100644 index 00000000..7778596c --- /dev/null +++ b/Tests/Integration/inc/classes/AutoOptimization/ClientSideUploadSequenceTest.php @@ -0,0 +1,297 @@ +original_auto_optimize = get_imagify_option( 'auto_optimize' ); + update_imagify_option( 'auto_optimize', 1 ); + + $this->runs = []; + + /* + * Record every optimization the sequence triggers, along with the sizes readable from + * the stored metadata at that moment. What is under test is when the decision is taken + * and what it can see: the optimization itself only reaches a queue that nothing + * dispatches during the tests. + */ + add_action( + 'imagify_before_auto_optimization', + [ $this, 'record_run' ], + 5, + 2 + ); + + /* + * The plugin registers these on boot. Calling init() again is idempotent, since the + * callbacks and priorities are identical, and it keeps the test honest if the hooks + * were not registered for any reason. remove_hooks() is deliberately not called on + * tear down: it would leave auto optimization switched off for every later test. + */ + Imagify_Auto_Optimization::get_instance()->init(); + } + + /** + * Cleans up the test environment after each test. + */ + public function tear_down() { + remove_action( 'imagify_before_auto_optimization', [ $this, 'record_run' ], 5 ); + + update_imagify_option( 'auto_optimize', $this->original_auto_optimize ); + + foreach ( $this->created_files as $file ) { + if ( file_exists( $file ) ) { + wp_delete_file( $file ); + } + } + + $this->created_files = []; + + parent::tear_down(); + } + + /** + * Records an auto optimization run and what the stored metadata holds at that point. + * + * @param int $attachment_id Attachment ID. + * @param bool $is_new_upload Whether Imagify treats this as a new upload. + */ + public function record_run( $attachment_id, $is_new_upload ) { + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->runs[] = [ + 'is_new_upload' => $is_new_upload, + 'sizes' => is_array( $metadata ) && ! empty( $metadata['sizes'] ) ? array_keys( $metadata['sizes'] ) : [], + ]; + } + + /** + * Creates an attachment with a real file behind it, as the upload would. + * + * @return int + */ + private function create_attachment() { + $uploads = wp_upload_dir(); + $filename = 'imagify-client-side-' . uniqid() . '.jpg'; + $file_path = trailingslashit( $uploads['basedir'] ) . $filename; + + wp_mkdir_p( dirname( $file_path ) ); + file_put_contents( $file_path, 'not-a-real-jpeg' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + + $this->created_files[] = $file_path; + + $attachment_id = $this->factory()->attachment->create_object( + [ + 'file' => $filename, + 'post_mime_type' => 'image/jpeg', + 'post_status' => 'inherit', + ] + ); + + update_post_meta( $attachment_id, '_wp_attached_file', $filename ); + + return $attachment_id; + } + + /** + * Builds one entry of the metadata `sizes` array, as WordPress stores it. + * + * @param string $file File name. + * @param int $width Width in pixels. + * @param int $height Height in pixels. + * @return array + */ + private function size_data( $file, $width, $height ) { + return [ + 'file' => $file, + 'width' => $width, + 'height' => $height, + 'mime-type' => 'image/jpeg', + ]; + } + + /** + * Builds the REST request WordPress hands to `rest_after_insert_attachment` when the + * browser is going to send the sub sizes itself. + * + * @return WP_REST_Request + */ + private function client_side_request() { + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'generate_sub_sizes', false ); + + return $request; + } + + /** + * Runs the create phase: WordPress stores metadata that carries no sub size yet. + * + * @param int $attachment_id Attachment ID. + */ + private function run_create_phase( $attachment_id ) { + $metadata = [ + 'file' => get_post_meta( $attachment_id, '_wp_attached_file', true ), + 'width' => 3800, + 'height' => 2500, + 'sizes' => [], + ]; + + /** This filter is documented in wp-admin/includes/image.php */ + $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'create' ); + + wp_update_attachment_metadata( $attachment_id, $metadata ); + } + + /** + * Runs the finalize phase: every sideloaded sub size is stored in one go. + * + * @param int $attachment_id Attachment ID. + */ + private function run_finalize_phase( $attachment_id ) { + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $metadata['sizes'] = [ + 'thumbnail' => $this->size_data( 'thumb.jpg', 150, 150 ), + 'medium' => $this->size_data( 'medium.jpg', 300, 197 ), + 'medium_large' => $this->size_data( 'medium_large.jpg', 768, 505 ), + 'large' => $this->size_data( 'large.jpg', 1024, 674 ), + ]; + + /** This filter is documented in wp-admin/includes/image.php */ + $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' ); + + wp_update_attachment_metadata( $attachment_id, $metadata ); + } + + /** + * Test: the create phase optimizes nothing, and the finalize phase optimizes once, as a new + * upload, with every sub size readable from the stored metadata. + */ + public function testOptimizesOnceTheSubsizesAreStored() { + $attachment_id = $this->create_attachment(); + + Imagify_Auto_Optimization::get_instance()->flag_awaiting_client_side_subsizes( + get_post( $attachment_id ), + $this->client_side_request(), + true + ); + + $this->run_create_phase( $attachment_id ); + + $this->assertSame( [], $this->runs, 'Nothing should be optimized while the browser still owes its sub sizes.' ); + + $this->run_finalize_phase( $attachment_id ); + + $this->assertCount( 1, $this->runs, 'The media should be optimized exactly once.' ); + $this->assertTrue( $this->runs[0]['is_new_upload'], 'The finalize pass is still the new upload.' ); + $this->assertSame( + [ 'thumbnail', 'medium', 'medium_large', 'large' ], + $this->runs[0]['sizes'], + 'The optimization must run after the metadata is stored, so every sub size is visible.' + ); + } + + /** + * Test: an ordinary upload, where WordPress builds the sub sizes itself, is optimized on + * the create phase exactly as before, so the deferral is limited to the client side flow. + */ + public function testOptimizesImmediatelyOnAnOrdinaryUpload() { + $attachment_id = $this->create_attachment(); + + // No flag: WordPress is building the sub sizes itself. + $metadata = [ + 'file' => get_post_meta( $attachment_id, '_wp_attached_file', true ), + 'width' => 1200, + 'height' => 900, + 'sizes' => [ + 'thumbnail' => $this->size_data( 'thumb.jpg', 150, 150 ), + 'medium' => $this->size_data( 'medium.jpg', 300, 225 ), + ], + ]; + + /** This filter is documented in wp-admin/includes/image.php */ + $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'create' ); + + wp_update_attachment_metadata( $attachment_id, $metadata ); + + $this->assertCount( 1, $this->runs, 'An ordinary upload should still be optimized once, on the create phase.' ); + $this->assertTrue( $this->runs[0]['is_new_upload'] ); + } + + /** + * Test: the flag is not left behind once the sub sizes have arrived. + */ + public function testClearsTheFlagOnceTheSubsizesArrive() { + $attachment_id = $this->create_attachment(); + $auto = Imagify_Auto_Optimization::get_instance(); + + $auto->flag_awaiting_client_side_subsizes( get_post( $attachment_id ), $this->client_side_request(), true ); + + $this->assertTrue( $auto->is_awaiting_client_side_subsizes( $attachment_id ) ); + + $this->run_create_phase( $attachment_id ); + + $this->assertTrue( $auto->is_awaiting_client_side_subsizes( $attachment_id ), 'The flag survives the create phase.' ); + + $this->run_finalize_phase( $attachment_id ); + + $this->assertFalse( $auto->is_awaiting_client_side_subsizes( $attachment_id ), 'The flag is cleared once the sub sizes are in.' ); + } +} diff --git a/Tests/Unit/classes/Tools/InternalStateList/sharedList.php b/Tests/Unit/classes/Tools/InternalStateList/sharedList.php index ec6a487e..36f7ff77 100644 --- a/Tests/Unit/classes/Tools/InternalStateList/sharedList.php +++ b/Tests/Unit/classes/Tools/InternalStateList/sharedList.php @@ -70,6 +70,8 @@ public function testGetLockedTransientPatternsReturnsExpectedArray(): void { '_site_transient_imagify_%_process_lock%', '_transient_imagify_client_side_scaled_%', '_transient_timeout_imagify_client_side_scaled_%', + '_transient_imagify_awaiting_subsizes_%', + '_transient_timeout_imagify_awaiting_subsizes_%', ]; $this->assertSame( $expected, InternalStateList::get_locked_transient_patterns() ); diff --git a/Tests/Unit/classes/Tools/ResetInternalState/reset.php b/Tests/Unit/classes/Tools/ResetInternalState/reset.php index 01af2e86..64a06b75 100644 --- a/Tests/Unit/classes/Tools/ResetInternalState/reset.php +++ b/Tests/Unit/classes/Tools/ResetInternalState/reset.php @@ -174,6 +174,8 @@ function ( string $sql, string $pattern ) use ( &$patterns_queried ) { '\_site\_transient\_imagify\_%\_process\_lock%', '\_transient\_imagify\_client\_side\_scaled\_%', '\_transient\_timeout\_imagify\_client\_side\_scaled\_%', + '\_transient\_imagify\_awaiting\_subsizes\_%', + '\_transient\_timeout\_imagify\_awaiting\_subsizes\_%', ]; foreach ( $expected_patterns as $pattern ) { diff --git a/Tests/Unit/inc/classes/AutoOptimization/MaybeStoreGenerateStepTest.php b/Tests/Unit/inc/classes/AutoOptimization/MaybeStoreGenerateStepTest.php new file mode 100644 index 00000000..cd560d63 --- /dev/null +++ b/Tests/Unit/inc/classes/AutoOptimization/MaybeStoreGenerateStepTest.php @@ -0,0 +1,157 @@ +justReturn( true ); + } + + /** + * Test: on an ordinary upload the generate step is stored, as it always was. + */ + public function testStoresTheGenerateStepOnAnOrdinaryUpload(): void { + $this->stubDependencies(); + Functions\when( 'get_transient' )->justReturn( false ); + + $auto_optimization = new Imagify_Auto_Optimization(); + $metadata = [ 'file' => 'image.jpg' ]; + + $this->assertSame( $metadata, $auto_optimization->maybe_store_generate_step( $metadata, 42, 'create' ) ); + $this->assertTrue( $auto_optimization->has_step( 42, 'generate' ) ); + } + + /** + * Test: while the browser still owes its sub sizes, the create phase stores nothing, so + * no optimization is launched against the full size alone. + */ + public function testSkipsTheGenerateStepWhileAwaitingClientSideSubsizes(): void { + $this->stubDependencies(); + Functions\when( 'get_transient' )->justReturn( 1 ); + + $auto_optimization = new Imagify_Auto_Optimization(); + $metadata = [ 'file' => 'image.jpg' ]; + + $this->assertSame( $metadata, $auto_optimization->maybe_store_generate_step( $metadata, 42, 'create' ) ); + $this->assertFalse( $auto_optimization->has_step( 42, 'generate' ) ); + $this->assertFalse( $auto_optimization->has_step( 42, 'upload' ) ); + } + + /** + * Test: the request that brings the sub sizes in restores the upload step, so the media is + * treated as the new upload it still is, and clears the flag. + */ + public function testRestoresTheUploadStepWhenTheSubsizesArrive(): void { + $this->stubDependencies(); + Functions\when( 'get_transient' )->justReturn( 1 ); + + $deleted = []; + + Functions\when( 'delete_transient' )->alias( + function ( $name ) use ( &$deleted ) { + $deleted[] = $name; + return true; + } + ); + + $auto_optimization = new Imagify_Auto_Optimization(); + $metadata = [ 'file' => 'image.jpg' ]; + + $auto_optimization->maybe_store_generate_step( $metadata, 42, 'update' ); + + $this->assertTrue( $auto_optimization->has_step( 42, 'generate' ) ); + $this->assertTrue( $auto_optimization->has_step( 42, 'upload' ) ); + $this->assertSame( [ 'imagify_awaiting_subsizes_42' ], $deleted ); + } + + /** + * Build a request stub returning the given value for the 'generate_sub_sizes' parameter. + * + * @param mixed $value Value the parameter should return. + * @return Mockery\MockInterface + */ + private function requestReturning( $value ) { + $request = Mockery::mock( 'WP_REST_Request' ); + $request->shouldReceive( 'get_param' )->with( 'generate_sub_sizes' )->andReturn( $value ); + + return $request; + } + + /** + * Test: the attachment is flagged when WordPress hands the sub sizes over to the browser. + */ + public function testFlagsTheAttachmentWhenTheBrowserHandlesTheSubsizes(): void { + $this->stubDependencies(); + + $stored = []; + + Functions\when( 'set_transient' )->alias( + function ( $name, $value ) use ( &$stored ) { + $stored[ $name ] = $value; + return true; + } + ); + + ( new Imagify_Auto_Optimization() )->flag_awaiting_client_side_subsizes( (object) [ 'ID' => 42 ], $this->requestReturning( false ), true ); + + $this->assertSame( [ 'imagify_awaiting_subsizes_42' => 1 ], $stored ); + } + + /** + * Test: nothing is flagged for an ordinary upload, where WordPress builds the sub sizes, + * nor when the parameter is absent entirely. + */ + public function testDoesNotFlagWhenWordPressBuildsTheSubsizes(): void { + $this->stubDependencies(); + Functions\expect( 'set_transient' )->never(); + + $attachment = (object) [ 'ID' => 42 ]; + + ( new Imagify_Auto_Optimization() )->flag_awaiting_client_side_subsizes( $attachment, $this->requestReturning( true ), true ); + ( new Imagify_Auto_Optimization() )->flag_awaiting_client_side_subsizes( $attachment, $this->requestReturning( null ), true ); + } + + /** + * Test: an attachment Imagify cannot optimize is not flagged, since nothing would read it. + */ + public function testDoesNotFlagAnUnsupportedMimeType(): void { + Functions\when( 'imagify_is_attachment_mime_type_supported' )->justReturn( false ); + Functions\expect( 'set_transient' )->never(); + + ( new Imagify_Auto_Optimization() )->flag_awaiting_client_side_subsizes( (object) [ 'ID' => 42 ], $this->requestReturning( false ), true ); + } + + /** + * Test: nothing is flagged when an existing attachment is being updated rather than created. + */ + public function testDoesNotFlagWhenUpdatingAnAttachment(): void { + $this->stubDependencies(); + Functions\expect( 'set_transient' )->never(); + + ( new Imagify_Auto_Optimization() )->flag_awaiting_client_side_subsizes( (object) [ 'ID' => 42 ], $this->requestReturning( false ), false ); + } +} diff --git a/Tests/Unit/inc/classes/AutoOptimization/StoreIdsToOptimizeTest.php b/Tests/Unit/inc/classes/AutoOptimization/StoreIdsToOptimizeTest.php new file mode 100644 index 00000000..f582455b --- /dev/null +++ b/Tests/Unit/inc/classes/AutoOptimization/StoreIdsToOptimizeTest.php @@ -0,0 +1,94 @@ +justReturn( true ); + Functions\when( 'get_imagify_option' )->justReturn( 1 ); + Functions\when( 'delete_transient' )->justReturn( true ); + } + + /** + * Test: an ordinary upload launches straight away, as it always did. + */ + public function testLaunchesImmediatelyOnAnOrdinaryUpload(): void { + $this->stubDependencies(); + Functions\when( 'get_transient' )->justReturn( false ); + + $auto_optimization = new Imagify_Auto_Optimization(); + $metadata = [ 'file' => 'image.jpg' ]; + + $auto_optimization->set_step( 42, 'upload' ); + $auto_optimization->maybe_store_generate_step( $metadata, 42, 'create' ); + + Actions\expectDone( 'imagify_after_auto_optimization_init' )->once()->with( 42, true ); + + $auto_optimization->store_ids_to_optimize( $metadata, 42 ); + } + + /** + * Test: the pass that brings the client side sub sizes in does not launch from the filter, + * because the metadata it would read has not been written yet. + */ + public function testDefersWhenTheClientSideSubsizesJustArrived(): void { + $this->stubDependencies(); + Functions\when( 'get_transient' )->justReturn( 1 ); + + $auto_optimization = new Imagify_Auto_Optimization(); + $metadata = [ 'file' => 'image.jpg' ]; + + // The finalize request: this restores the upload step and marks the pass as deferred. + $auto_optimization->maybe_store_generate_step( $metadata, 42, 'update' ); + + Actions\expectDone( 'imagify_after_auto_optimization_init' )->never(); + + $this->assertSame( $metadata, $auto_optimization->store_ids_to_optimize( $metadata, 42 ) ); + + // The step is left in place for do_auto_optimization_after_meta_update() to pick up. + $this->assertTrue( $auto_optimization->has_step( 42, 'update' ) ); + $this->assertTrue( $auto_optimization->has_step( 42, 'upload' ) ); + } + + /** + * Test: deferring happens once. A later metadata write for the same attachment, with the + * flag gone, launches normally rather than being skipped again. + */ + public function testDeferringAppliesOnlyToThatPass(): void { + $this->stubDependencies(); + Functions\when( 'get_transient' )->justReturn( 1 ); + + $auto_optimization = new Imagify_Auto_Optimization(); + $metadata = [ 'file' => 'image.jpg' ]; + + $auto_optimization->maybe_store_generate_step( $metadata, 42, 'update' ); + $auto_optimization->store_ids_to_optimize( $metadata, 42 ); + + Actions\expectDone( 'imagify_after_auto_optimization_init' )->once(); + + $auto_optimization->store_ids_to_optimize( $metadata, 42 ); + } +} diff --git a/classes/Tools/InternalStateList.php b/classes/Tools/InternalStateList.php index 72ca93bc..ec9b2e61 100644 --- a/classes/Tools/InternalStateList.php +++ b/classes/Tools/InternalStateList.php @@ -67,6 +67,9 @@ public static function get_locked_transient_patterns(): array { // Flags an attachment whose scaled version came from the browser, on WP 7.1+. '_transient_imagify_client_side_scaled_%', '_transient_timeout_imagify_client_side_scaled_%', + // Flags an attachment whose sub sizes the browser is still to send, on WP 7.1+. + '_transient_imagify_awaiting_subsizes_%', + '_transient_timeout_imagify_awaiting_subsizes_%', ]; } diff --git a/inc/classes/class-imagify-auto-optimization.php b/inc/classes/class-imagify-auto-optimization.php index ddc5e343..1e81d532 100644 --- a/inc/classes/class-imagify-auto-optimization.php +++ b/inc/classes/class-imagify-auto-optimization.php @@ -38,6 +38,14 @@ class Imagify_Auto_Optimization extends Imagify_Auto_Optimization_Deprecated { */ private $is_wp_53; + /** + * Attachment IDs whose optimization must wait for the metadata to be stored. + * + * @var array + * @since 2.3.3 + */ + private $deferred = []; + /** * The ID of the attachment that failed to be uploaded. * @@ -75,7 +83,8 @@ public function init() { // Automatic optimization tunel. add_action( 'add_attachment', [ $this, 'store_upload_ids' ], $priority ); - add_filter( 'wp_generate_attachment_metadata', [ $this, 'maybe_store_generate_step' ], $priority, 2 ); + add_action( 'rest_after_insert_attachment', [ $this, 'flag_awaiting_client_side_subsizes' ], $priority, 3 ); + add_filter( 'wp_generate_attachment_metadata', [ $this, 'maybe_store_generate_step' ], $priority, 3 ); add_filter( 'wp_update_attachment_metadata', [ $this, 'store_ids_to_optimize' ], $priority, 2 ); if ( $this->is_wp_53 ) { @@ -83,11 +92,23 @@ public function init() { add_action( 'imagify_after_auto_optimization_init', [ $this, 'do_auto_optimization' ], $priority, 2 ); // Upload failure recovering. add_action( 'wp_ajax_media-create-image-subsizes', [ $this, 'prevent_auto_optimization_when_recovering_from_upload_failure' ], -5 ); // Before WP’s hook (priority 1). - } else { - add_action( 'updated_post_meta', [ $this, 'do_auto_optimization_after_meta_update' ], $priority, 4 ); - add_action( 'added_post_meta', [ $this, 'do_auto_optimization_after_meta_update' ], $priority, 4 ); } + /** + * Also used on WP 5.3+ to optimize once the metadata is stored, which the client side + * upload of WP 7.1 needs: it stores every sub size at once, so reading them before the + * write would only ever see the full size. Harmless the rest of the time, since an + * optimization that already ran has cleared its steps by then. + * + * The trade-off is that these two hooks now fire for every post meta write on the site + * rather than only on old WordPress versions. The callback returns on anything that is + * not '_wp_attachment_metadata', which is the first thing it checks, so the cost is one + * string comparison. Carrying the state on the attachment instead would mean an extra + * read on every upload, for a narrower guarantee. + */ + add_action( 'updated_post_meta', [ $this, 'do_auto_optimization_after_meta_update' ], $priority, 4 ); + add_action( 'added_post_meta', [ $this, 'do_auto_optimization_after_meta_update' ], $priority, 4 ); + add_action( 'deleted_post_meta', [ $this, 'unset_optimization' ], $priority, 3 ); // Prevent to re-optimize when updating the image width and height (when resizing the full image). @@ -105,6 +126,7 @@ public function remove_hooks() { // Automatic optimization tunel. remove_action( 'add_attachment', [ $this, 'store_upload_ids' ], $priority ); + remove_action( 'rest_after_insert_attachment', [ $this, 'flag_awaiting_client_side_subsizes' ], $priority ); remove_filter( 'wp_generate_attachment_metadata', [ $this, 'maybe_store_generate_step' ], $priority ); remove_filter( 'wp_update_attachment_metadata', [ $this, 'store_ids_to_optimize' ], $priority ); @@ -113,11 +135,11 @@ public function remove_hooks() { remove_action( 'imagify_after_auto_optimization_init', [ $this, 'do_auto_optimization' ], $priority ); // Upload failure recovering. remove_action( 'wp_ajax_media-create-image-subsizes', [ $this, 'prevent_auto_optimization_when_recovering_from_upload_failure' ], -5 ); - } else { - remove_action( 'updated_post_meta', [ $this, 'do_auto_optimization_after_meta_update' ], $priority ); - remove_action( 'added_post_meta', [ $this, 'do_auto_optimization_after_meta_update' ], $priority ); } + remove_action( 'updated_post_meta', [ $this, 'do_auto_optimization_after_meta_update' ], $priority ); + remove_action( 'added_post_meta', [ $this, 'do_auto_optimization_after_meta_update' ], $priority ); + remove_action( 'deleted_post_meta', [ $this, 'unset_optimization' ], $priority ); // Prevent to re-optimize when updating the image width and height (when resizing the full image). @@ -144,16 +166,58 @@ public function store_upload_ids( $attachment_id ) { } } + /** + * Remember that the browser will send the sub sizes of an attachment separately. + * + * WordPress 7.1 can let the browser process an upload. The attachment is then created + * with no sub sizes at all, each one is sent afterwards through the sideload endpoint, + * and a last request finalizes the metadata. Optimizing when the attachment is created + * would only ever cover the full size. + * + * This runs before the metadata is first generated, and the flag is stored because the + * sub sizes arrive in later requests. + * + * @since 2.3.3 + * + * @param object $attachment Inserted or updated attachment object. A \WP_Post when WordPress fires this. + * @param object $request Request object. A \WP_REST_Request when WordPress fires this. + * @param bool $creating True when creating an attachment, false when updating. + */ + public function flag_awaiting_client_side_subsizes( $attachment, $request, $creating ) { + if ( ! $creating || ! is_object( $attachment ) || ! isset( $attachment->ID ) ) { + return; + } + + if ( ! is_object( $request ) || ! is_callable( [ $request, 'get_param' ] ) ) { + return; + } + + if ( false !== $request->get_param( 'generate_sub_sizes' ) ) { + return; + } + + if ( ! imagify_is_attachment_mime_type_supported( $attachment->ID ) ) { + // Nothing would ever read the flag for this attachment. + return; + } + + set_transient( $this->get_awaiting_subsizes_transient_name( $attachment->ID ), 1, HOUR_IN_SECONDS ); + } + /** * Store the "generate step" when wp_generate_attachment_metadata() is used. * * @since 1.9.10 + * @since 2.3.3 Added the $context parameter. * - * @param array $metadata An array of attachment meta data. - * @param int $attachment_id Current attachment ID. + * @param array $metadata An array of attachment meta data. + * @param int $attachment_id Current attachment ID. + * @param string $context Can be 'create' when the metadata was initially created for a + * new attachment, or 'update' when it was updated. Passed by WordPress + * since 5.3, so only null if something else applies the filter. * @return array */ - public function maybe_store_generate_step( $metadata, $attachment_id ) { + public function maybe_store_generate_step( $metadata, $attachment_id, $context = null ) { if ( self::is_optimization_prevented( $attachment_id ) ) { return $metadata; } @@ -163,11 +227,70 @@ public function maybe_store_generate_step( $metadata, $attachment_id ) { return $metadata; } + if ( $this->is_awaiting_client_side_subsizes( $attachment_id ) ) { + if ( 'create' === $context ) { + /** + * The browser has not sent its sub sizes yet. Optimizing now would cover the + * full size only, and leave every thumbnail untouched. + */ + return $metadata; + } + + /** + * The sub sizes are in. The upload step was set in the request that created the + * attachment and did not outlive it, so set it again: as far as Imagify is + * concerned this still is a brand new upload. + */ + $this->clear_awaiting_client_side_subsizes( $attachment_id ); + $this->set_step( $attachment_id, 'upload' ); + + /** + * The sub sizes are all stored in one go, right after this, so the optimization + * has to wait for that write. Reading them now would only see the full size. + */ + $this->deferred[ $attachment_id ] = 1; + } + $this->set_step( $attachment_id, 'generate' ); return $metadata; } + /** + * Tell if the browser is still to send the sub sizes of an attachment. + * + * @since 2.3.3 + * + * @param int $attachment_id Current attachment ID. + * @return bool + */ + public function is_awaiting_client_side_subsizes( $attachment_id ) { + return (bool) get_transient( $this->get_awaiting_subsizes_transient_name( $attachment_id ) ); + } + + /** + * Forget that the browser was to send the sub sizes of an attachment. + * + * @since 2.3.3 + * + * @param int $attachment_id Current attachment ID. + */ + public function clear_awaiting_client_side_subsizes( $attachment_id ) { + delete_transient( $this->get_awaiting_subsizes_transient_name( $attachment_id ) ); + } + + /** + * Get the transient name used to await the sub sizes of an attachment. + * + * @since 2.3.3 + * + * @param int $attachment_id Current attachment ID. + * @return string + */ + private function get_awaiting_subsizes_transient_name( $attachment_id ) { + return 'imagify_awaiting_subsizes_' . (int) $attachment_id; + } + /** * After the attachment meta data has been generated (partially, since WP 5.3), init the auto-optimization. * Two cases are possible to trigger the optimization: @@ -284,6 +407,16 @@ public function store_ids_to_optimize( $metadata, $attachment_id ) { // Ready for the next step. $this->set_step( $attachment_id, 'update' ); + if ( ! empty( $this->deferred[ $attachment_id ] ) ) { + /** + * The metadata is not stored yet, so the sizes to optimize cannot be read. + * $this->do_auto_optimization_after_meta_update() takes over once it is. + */ + unset( $this->deferred[ $attachment_id ] ); + + return $metadata; + } + /** * Triggered after a media auto-optimization init. *