diff --git a/modules/images/webp-uploads/load.php b/modules/images/webp-uploads/load.php index 3b69ed3bc0..60ca40ed6b 100644 --- a/modules/images/webp-uploads/load.php +++ b/modules/images/webp-uploads/load.php @@ -683,3 +683,189 @@ function webp_uploads_update_rest_attachment( WP_REST_Response $response, WP_Pos return rest_ensure_response( $data ); } add_filter( 'rest_prepare_attachment', 'webp_uploads_update_rest_attachment', 10, 3 ); + +/** + * Inspect if the current call to `wp_update_attachment_metadata()` was done from within the context + * of an edit to an attachment either restore or other type of edit, in that case we perform operations + * to save the sources properties, specifically for the `full` size image due this is a virtual image size. + * + * @since n.e.x.t + * + * @see wp_update_attachment_metadata() + * + * @param array $data The current metadata of the attachment. + * @param int $attachment_id The ID of the current attachment. + * @return array The updated metadata for the attachment to be stored in the meta table. + */ +function webp_uploads_update_attachment_metadata( $data, $attachment_id ) { + $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 10 ); + + foreach ( $trace as $element ) { + if ( ! isset( $element['function'] ) ) { + continue; + } + + switch ( $element['function'] ) { + case 'wp_save_image': + // Right after an image has been edited. + return webp_uploads_backup_sources( $attachment_id, $data ); + case 'wp_restore_image': + // When an image has been restored. + return webp_uploads_restore_image( $attachment_id, $data ); + } + } + + return $data; +} +add_filter( 'wp_update_attachment_metadata', 'webp_uploads_update_attachment_metadata', 10, 2 ); + +/** + * Before saving the metadata of the image store a backup values for the sources and file property + * those files would be used and deleted by the backup mechanism, right after the metadata has + * been updated. It removes the current sources property due once this function is executed + * right after an edit has taken place and the current sources are no longer accurate. + * + * @since n.e.x.t + * + * @param int $attachment_id The ID representing the attachment. + * @param array $data The current metadata of the attachment. + * @return array The updated metadata for the attachment. + */ +function webp_uploads_backup_sources( $attachment_id, $data ) { + $target = isset( $_REQUEST['target'] ) ? $_REQUEST['target'] : 'all'; + + // When an edit to an image is only applied to a thumbnail there's nothing we need to back up. + if ( 'thumbnail' === $target ) { + return $data; + } + + $metadata = wp_get_attachment_metadata( $attachment_id ); + // Nothing to back up. + if ( ! isset( $metadata['sources'] ) ) { + return $data; + } + + $sources = $metadata['sources']; + // Prevent execution of the callbacks more than once if the callback was already executed. + $has_been_processed = false; + + $hook = function ( $meta_id, $post_id, $meta_name ) use ( $attachment_id, $sources, &$has_been_processed ) { + // Make sure this hook is only executed in the same context for the provided $attachment_id. + if ( $post_id !== $attachment_id ) { + return; + } + + // This logic should work only if we are looking at the meta key: `_wp_attachment_backup_sizes`. + if ( '_wp_attachment_backup_sizes' !== $meta_name ) { + return; + } + + if ( $has_been_processed ) { + return; + } + + $has_been_processed = true; + webp_uploads_backup_full_image_sources( $post_id, $sources ); + }; + + add_action( 'added_post_meta', $hook, 10, 3 ); + add_action( 'updated_post_meta', $hook, 10, 3 ); + + // Remove the current sources as at this point the current values are no longer accurate. + // TODO: Requires to be updated from https://github.com/WordPress/performance/issues/158. + unset( $data['sources'] ); + + return $data; +} + +/** + * Stores the provided sources for the attachment ID in the `_wp_attachment_backup_sources` with + * the next available target if target is `null` no source would be stored. + * + * @since n.e.x.t + * + * @param int $attachment_id The ID of the attachment. + * @param array $sources An array with the full sources to be stored on the next available key. + */ +function webp_uploads_backup_full_image_sources( $attachment_id, $sources ) { + if ( empty( $sources ) ) { + return; + } + + $target = webp_uploads_get_next_full_size_key_from_backup( $attachment_id ); + if ( null === $target ) { + return; + } + + $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ); + $backup_sources = is_array( $backup_sources ) ? $backup_sources : array(); + $backup_sources[ $target ] = $sources; + // Store the `sources` property into the full size if present. + update_post_meta( $attachment_id, '_wp_attachment_backup_sources', $backup_sources ); +} + +/** + * It finds the next available `full-{orig or hash}` key on the images if the name + * has not been used as part of the backup sources it would be used if no size is + * found or backup exists `null` would be returned instead. + * + * @since n.e.x.t + * + * @param int $attachment_id The ID of the attachment. + * @return null|string The next available full size name. + */ +function webp_uploads_get_next_full_size_key_from_backup( $attachment_id ) { + $backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true ); + $backup_sizes = is_array( $backup_sizes ) ? $backup_sizes : array(); + + if ( empty( $backup_sizes ) ) { + return null; + } + + $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ); + $backup_sources = is_array( $backup_sources ) ? $backup_sources : array(); + foreach ( array_keys( $backup_sizes ) as $size_name ) { + // If the target already has the sources attributes find the next one. + if ( isset( $backup_sources[ $size_name ] ) ) { + continue; + } + + // We are only interested in the `full-` sizes. + if ( strpos( $size_name, 'full-' ) === false ) { + continue; + } + + return $size_name; + } + + return null; +} + +/** + * Restore an image from the backup sizes, the current hook moves the `sources` from the `full-orig` key into + * the top level `sources` into the metadata, in order to ensure the restore process has a reference to the right + * images. + * + * @since n.e.x.t + * + * @param int $attachment_id The ID of the attachment. + * @param array $data The current metadata to be stored in the attachment. + * @return array The updated metadata of the attachment. + */ +function webp_uploads_restore_image( $attachment_id, $data ) { + $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ); + + if ( ! is_array( $backup_sources ) ) { + return $data; + } + + if ( ! isset( $backup_sources['full-orig'] ) || ! is_array( $backup_sources['full-orig'] ) ) { + return $data; + } + + // TODO: Handle the case If `IMAGE_EDIT_OVERWRITE` is defined and is truthy remove any edited images if present before replacing the metadata. + // See: https://github.com/WordPress/performance/issues/158. + $data['sources'] = $backup_sources['full-orig']; + + return $data; +} diff --git a/tests/modules/images/webp-uploads/webp-uploads-test.php b/tests/modules/images/webp-uploads/webp-uploads-test.php index 6664697c48..bcb6fb081c 100644 --- a/tests/modules/images/webp-uploads/webp-uploads-test.php +++ b/tests/modules/images/webp-uploads/webp-uploads-test.php @@ -234,6 +234,7 @@ function () { return array( 'WP_Image_Doesnt_Support_WebP' ); } ); + $result = webp_uploads_generate_image_size( $attachment_id, 'medium', 'image/webp' ); $this->assertTrue( is_wp_error( $result ) ); $this->assertSame( 'image_mime_type_not_supported', $result->get_error_code() ); @@ -793,6 +794,7 @@ public function it_should_transform_jpeg_to_webp_subsizes_using_transform_filter function( $transforms ) { // Unset "image/jpeg" mime type for jpeg images. unset( $transforms['image/jpeg'][ array_search( 'image/jpeg', $transforms['image/jpeg'], true ) ] ); + return $transforms; } ); @@ -809,6 +811,168 @@ function( $transforms ) { } } + /** + * Backup the sources structure alongside the full size + * + * @test + */ + public function it_should_backup_the_sources_structure_alongside_the_full_size() { + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertEmpty( get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true ) ); + $this->assertEmpty( get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ) ); + + $editor = new WP_Image_Edit( $attachment_id ); + $editor->rotate_right()->save(); + + // Having a thumbnail ensures the process finished correctly. + $this->assertTrue( $editor->success() ); + + $backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true ); + + $this->assertNotEmpty( $backup_sizes ); + $this->assertIsArray( $backup_sizes ); + + $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ); + $this->assertIsArray( $backup_sources ); + $this->assertArrayHasKey( 'full-orig', $backup_sources ); + $this->assertSame( $metadata['sources'], $backup_sources['full-orig'] ); + + foreach ( $backup_sizes as $size => $properties ) { + $size_name = str_replace( '-orig', '', $size ); + + if ( 'full-orig' === $size ) { + continue; + } + + $this->assertArrayHasKey( 'sources', $properties ); + $this->assertSame( $metadata['sizes'][ $size_name ]['sources'], $properties['sources'] ); + } + + $metadata = wp_get_attachment_metadata( $attachment_id ); + } + + /** + * Restore the sources array from the backup when an image is edited + * + * @test + */ + public function it_should_restore_the_sources_array_from_the_backup_when_an_image_is_edited() { + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $editor = new WP_Image_Edit( $attachment_id ); + $editor->rotate_right()->save(); + $this->assertTrue( $editor->success() ); + + $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ); + $this->assertArrayHasKey( 'full-orig', $backup_sources ); + $this->assertIsArray( $backup_sources['full-orig'] ); + $this->assertSame( $metadata['sources'], $backup_sources['full-orig'] ); + + wp_restore_image( $attachment_id ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayHasKey( 'sources', $metadata ); + $this->assertSame( $backup_sources['full-orig'], $metadata['sources'] ); + $this->assertSame( $backup_sources, get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ) ); + + $backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true ); + foreach ( $metadata['sizes'] as $size_name => $properties ) { + $this->assertArrayHasKey( 'sources', $backup_sizes[ $size_name . '-orig' ] ); + $this->assertSame( $backup_sizes[ $size_name . '-orig' ]['sources'], $properties['sources'] ); + } + } + + /** + * Prevent to back up the sources when the sources attributes does not exists + * + * @test + */ + public function it_should_prevent_to_back_up_the_sources_when_the_sources_attributes_does_not_exists() { + // Disable the generation of the sources attributes. + add_filter( 'webp_uploads_upload_image_mime_transforms', '__return_empty_array' ); + + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertArrayNotHasKey( 'sources', $metadata ); + + $editor = new WP_Image_Edit( $attachment_id ); + $editor->flip_vertical()->save(); + $this->assertTrue( $editor->success() ); + + $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ); + $this->assertEmpty( $backup_sources ); + + $backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true ); + $this->assertIsArray( $backup_sizes ); + + foreach ( $backup_sizes as $size_name => $properties ) { + $this->assertArrayNotHasKey( 'sources', $properties ); + } + } + + /** + * Prevent to backup the full size image if only the thumbnail is edited + * + * @test + */ + public function it_should_prevent_to_backup_the_full_size_image_if_only_the_thumbnail_is_edited() { + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayHasKey( 'sources', $metadata ); + + $editor = new WP_Image_Edit( $attachment_id ); + $editor->flip_vertical()->only_thumbnail()->save(); + $this->assertTrue( $editor->success() ); + + $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ); + $this->assertEmpty( $backup_sources ); + + $backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true ); + $this->assertIsArray( $backup_sizes ); + $this->assertCount( 1, $backup_sizes ); + $this->assertArrayHasKey( 'thumbnail-orig', $backup_sizes ); + $this->assertArrayHasKey( 'sources', $backup_sizes['thumbnail-orig'] ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayHasKey( 'sources', $metadata ); + } + + /** + * Backup the image when all images except the thumbnail are updated + * + * @test + */ + public function it_should_backup_the_image_when_all_images_except_the_thumbnail_are_updated() { + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $editor = new WP_Image_Edit( $attachment_id ); + $editor->rotate_left()->all_except_thumbnail()->save(); + $this->assertTrue( $editor->success() ); + + $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ); + $this->assertIsArray( $backup_sources ); + $this->assertArrayHasKey( 'full-orig', $backup_sources ); + $this->assertSame( $metadata['sources'], $backup_sources['full-orig'] ); + + $this->assertArrayNotHasKey( 'sources', wp_get_attachment_metadata( $attachment_id ), 'The sources attributes was not removed from the metadata.' ); + + $backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true ); + $this->assertIsArray( $backup_sizes ); + $this->assertArrayNotHasKey( 'thumbnail-orig', $backup_sizes, 'The thumbnail-orig was stored in the back up' ); + + foreach ( $backup_sizes as $size_name => $properties ) { + if ( 'full-orig' === $size_name ) { + continue; + } + $this->assertArrayHasKey( 'sources', $properties, "The '{$size_name}' does not have the sources." ); + } + } + /** * Allow the upload of a WebP image if at least one editor supports the format * @@ -836,4 +1000,132 @@ function () { $this->assertImageHasSizeSource( $attachment_id, 'thumbnail', 'image/jpeg' ); $this->assertImageHasSizeSource( $attachment_id, 'thumbnail', 'image/webp' ); } + + /** + * Not return a target if no backup image exists + * + * @test + */ + public function it_should_not_return_a_target_if_no_backup_image_exists() { + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + $this->assertNull( webp_uploads_get_next_full_size_key_from_backup( $attachment_id ) ); + } + + /** + * Return the full-orig target key when only one edit image exists + * + * @test + */ + public function it_should_return_the_full_orig_target_key_when_only_one_edit_image_exists() { + // Remove the filter to prevent the usage of the next target. + remove_filter( 'wp_update_attachment_metadata', 'webp_uploads_update_attachment_metadata' ); + + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + $editor = new WP_Image_Edit( $attachment_id ); + $editor->rotate_right()->save(); + + $this->assertTrue( $editor->success() ); + $this->assertSame( 'full-orig', webp_uploads_get_next_full_size_key_from_backup( $attachment_id ) ); + } + + /** + * Return null when looking for a target that is already used + * + * @test + */ + public function it_should_return_null_when_looking_for_a_target_that_is_already_used() { + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + $editor = new WP_Image_Edit( $attachment_id ); + $editor->rotate_right()->save(); + + $this->assertTrue( $editor->success() ); + $this->assertNull( webp_uploads_get_next_full_size_key_from_backup( $attachment_id ) ); + } + + /** + * USe the next available hash for the full size image on multiple image edits + * + * @test + */ + public function it_should_u_se_the_next_available_hash_for_the_full_size_image_on_multiple_image_edits() { + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + $editor = new WP_Image_Edit( $attachment_id ); + $editor->rotate_right()->save(); + + $this->assertTrue( $editor->success() ); + $this->assertNull( webp_uploads_get_next_full_size_key_from_backup( $attachment_id ) ); + // Remove the filter to prevent the usage of the next target. + remove_filter( 'wp_update_attachment_metadata', 'webp_uploads_update_attachment_metadata' ); + + $editor->rotate_right()->save(); + $this->assertRegExp( '/full-\d{13}/', webp_uploads_get_next_full_size_key_from_backup( $attachment_id ) ); + } + + /** + * Save populate the backup sources with the next target + * + * @test + */ + public function it_should_save_populate_the_backup_sources_with_the_next_target() { + // Remove the filter to prevent the usage of the next target. + remove_filter( 'wp_update_attachment_metadata', 'webp_uploads_update_attachment_metadata' ); + + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + $editor = new WP_Image_Edit( $attachment_id ); + $editor->rotate_right()->save(); + $this->assertTrue( $editor->success() ); + + $sources = array( 'image/webp' => 'leafs.webp' ); + webp_uploads_backup_full_image_sources( $attachment_id, $sources ); + + $this->assertSame( array( 'full-orig' => $sources ), get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ) ); + } + + /** + * Store the metadata on the next available hash + * + * @test + */ + public function it_should_store_the_metadata_on_the_next_available_hash() { + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + + $editor = new WP_Image_Edit( $attachment_id ); + $editor->rotate_right()->save(); + $this->assertTrue( $editor->success() ); + + // Remove the filter to prevent the usage of the next target. + remove_filter( 'wp_update_attachment_metadata', 'webp_uploads_update_attachment_metadata' ); + $editor->rotate_right()->save(); + $this->assertTrue( $editor->success() ); + + $sources = array( 'image/webp' => 'leafs.webp' ); + webp_uploads_backup_full_image_sources( $attachment_id, $sources ); + + $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ); + $this->assertIsArray( $backup_sources ); + + $backup_sources_keys = array_keys( $backup_sources ); + $this->assertSame( 'full-orig', reset( $backup_sources_keys ) ); + $this->assertRegExp( '/full-\d{13}/', end( $backup_sources_keys ) ); + $this->assertSame( $sources, end( $backup_sources ) ); + } + + /** + * Prevent to store an empty set of sources + * + * @test + */ + public function it_should_prevent_to_store_an_empty_set_of_sources() { + // Remove the filter to prevent the usage of the next target. + remove_filter( 'wp_update_attachment_metadata', 'webp_uploads_update_attachment_metadata' ); + + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + $editor = new WP_Image_Edit( $attachment_id ); + $editor->rotate_right()->save(); + + webp_uploads_backup_full_image_sources( $attachment_id, array() ); + + $this->assertTrue( $editor->success() ); + $this->assertEmpty( get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ) ); + } } diff --git a/tests/testdata/modules/images/webp-uploads/class-wp-image-edit.php b/tests/testdata/modules/images/webp-uploads/class-wp-image-edit.php new file mode 100644 index 0000000000..b3591b0cf0 --- /dev/null +++ b/tests/testdata/modules/images/webp-uploads/class-wp-image-edit.php @@ -0,0 +1,177 @@ +attachment_id = $attachment_id; + } + + /** + * Once the object is removed make sure that `history` and `target` are + * removed from the $_REQUEST. + */ + public function __destruct() { + unset( $_REQUEST['history'], $_REQUEST['target'] ); + } + + /** + * Register a change to rotate an image to the right. + * + * @return $this + */ + public function rotate_right() { + $this->changes[] = array( 'r' => -90 ); + + return $this; + } + + /** + * Register a new change to rotate an image to the left. + * + * @return $this + */ + public function rotate_left() { + $this->changes[] = array( 'r' => 90 ); + + return $this; + } + + /** + * Add a new change, to flip an image vertically. + * + * @return $this + */ + public function flip_vertical() { + $this->changes[] = array( 'f' => 1 ); + + return $this; + } + + /** + * Add a new change to the image to flip it right. + * + * @return $this + */ + public function flip_right() { + $this->changes[] = array( 'f' => 2 ); + + return $this; + } + + /** + * Store a crop change for an image. + * + * @param int $width The width of the crop. + * @param int $height The height of the crop. + * @param int $x The X position on the axis where the image would be cropped. + * @param int $y The Y position on the axis where the image would be cropped. + * + * @return $this + */ + public function crop( $width, $height, $x, $y ) { + $this->changes[] = array( + 'c' => array( + 'x' => (int) $x, + 'y' => (int) $y, + 'w' => (int) $width, + 'h' => (int) $height, + ), + ); + + return $this; + } + + /** + * Set the target of the edits to all the image sizes. + * + * @return $this + */ + public function all() { + $this->target = 'all'; + + return $this; + } + + /** + * Set the target of the edit only to the thumbnail image. + * + * @return $this + */ + public function only_thumbnail() { + $this->target = 'thumbnail'; + + return $this; + } + + /** + * Set the target to all image sizes except the thumbnail. + * + * @return $this + */ + public function all_except_thumbnail() { + $this->target = 'nothumb'; + + return $this; + } + + /** + * Setup the $_REQUEST global so `wp_save_image` can process the image with the same editions + * performend into an image as it was performed from the editor. + * + * @see wp_save_image + * + * @return stdClass The operation resulted from calling `wp_save_image` + */ + public function save() { + $_REQUEST['target'] = $this->target; + $_REQUEST['history'] = wp_slash( wp_json_encode( $this->changes ) ); + + if ( ! function_exists( 'wp_save_image' ) ) { + include_once ABSPATH . 'wp-admin/includes/image-edit.php'; + } + + $this->result = wp_save_image( $this->attachment_id ); + + return $this->result; + } + + /** + * Determine if the last operation executed to edit the image was successfully or not. + * + * @return bool whether the operation to save the image was succesfully or not. + */ + public function success() { + if ( ! is_object( $this->result ) ) { + return false; + } + + $valid_target = true; + // The thumbnail property is only set in `all` and `thumbnail` target. + if ( 'all' === $this->target || 'thumbnail' === $this->target ) { + $valid_target = property_exists( $this->result, 'thumbnail' ); + } + + return property_exists( $this->result, 'msg' ) && $valid_target && 'Image saved' === $this->result->msg; + } +}