diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index 03ec72efd4fd23..5c3ea2d7a34bb6 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -20,22 +20,6 @@ class Gutenberg_REST_Attachments_Controller extends WP_REST_Attachments_Controll public function register_routes(): void { parent::register_routes(); - // Override the parent's sideload route so that 'scaled' is included - // in the image_size enum. Without the override, core's handler - // validates first and rejects 'scaled' before ours is tried. - $valid_image_sizes = array_keys( wp_get_registered_image_subsizes() ); - - // Special case to set 'original_image' in attachment metadata. - $valid_image_sizes[] = 'original'; - // HEIC/HEIF companion original preserved alongside the JPEG derivative. - // Stored under its own meta key so it never collides with 'original' - // (which the scaled-sideload flow also writes to). - $valid_image_sizes[] = 'original-heic'; - // Client-side big image threshold: sideload the scaled version. - $valid_image_sizes[] = 'scaled'; - // Used for PDF thumbnails. - $valid_image_sizes[] = 'full'; - register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)/sideload', @@ -50,10 +34,47 @@ public function register_routes(): void { 'type' => 'integer', ), 'image_size' => array( - 'description' => __( 'Image size.', 'gutenberg' ), - 'type' => 'string', - 'enum' => $valid_image_sizes, - 'required' => true, + 'description' => __( 'Image size. Can be a single size name or an array of size names to register the same file under multiple sizes.', 'gutenberg' ), + 'type' => array( 'string', 'array' ), + 'items' => array( + 'type' => 'string', + ), + 'required' => true, + // A custom callback is used instead of the default `rest_validate_request_arg` + // because WordPress's `rest_is_array()` treats scalar strings as single-element + // lists (via wp_parse_list), so a oneOf with both a string and array schema + // matches a plain string twice and validation fails with "matches more than one + // of the expected formats". The callback validates the enum per-item using the + // current list of registered sizes, which reflects any sizes added after the + // route was registered (e.g. via add_image_size() in tests). + 'validate_callback' => static function ( $value, $request, $param ) { + $valid_sizes = array_keys( wp_get_registered_image_subsizes() ); + $valid_sizes[] = 'original'; + $valid_sizes[] = 'original-heic'; + $valid_sizes[] = 'scaled'; + $valid_sizes[] = 'full'; + + $items = is_string( $value ) ? array( $value ) : ( is_array( $value ) ? $value : null ); + if ( null === $items ) { + return new WP_Error( + 'rest_invalid_type', + /* translators: %s: Parameter name. */ + sprintf( __( '%s must be a string or an array of strings.', 'gutenberg' ), $param ) + ); + } + + foreach ( $items as $item ) { + if ( ! is_string( $item ) || ! in_array( $item, $valid_sizes, true ) ) { + return new WP_Error( + 'rest_not_in_enum', + /* translators: %s: Parameter name. */ + sprintf( __( '%s contains an invalid image size.', 'gutenberg' ), $param ) + ); + } + } + + return true; + }, ), 'generate_sub_sizes' => array( 'description' => __( 'Whether to generate image sub sizes from the sideloaded file.', 'gutenberg' ), @@ -89,8 +110,17 @@ public function register_routes(): void { 'type' => 'object', 'properties' => array( 'image_size' => array( - 'type' => 'string', - 'required' => true, + // Uses a multi-type schema instead of `oneOf` because WordPress's + // `rest_is_array()` treats scalar strings as single-element lists, + // so both a `{type: string}` and `{type: array}` oneOf schema would + // match a plain string and trigger a "matches more than one" + // validation error. + 'description' => __( 'Size name, or an array of size names when a single file is registered under multiple sizes with matching dimensions.', 'gutenberg' ), + 'type' => array( 'string', 'array' ), + 'items' => array( + 'type' => 'string', + ), + 'required' => true, ), 'width' => array( 'type' => 'integer', @@ -353,6 +383,24 @@ public function finalize_item( WP_REST_Request $request ) { foreach ( $sub_sizes as $sub_size ) { $image_size = $sub_size['image_size']; + // When multiple size names share identical dimensions the client + // sends a single sub-size entry with an array of names. Register the + // same file under each name. Arrays only contain regular sizes. + if ( is_array( $image_size ) ) { + $metadata['sizes'] = $metadata['sizes'] ?? array(); + + foreach ( $image_size as $name ) { + $metadata['sizes'][ $name ] = array( + 'width' => $sub_size['width'] ?? 0, + 'height' => $sub_size['height'] ?? 0, + 'file' => $sub_size['file'] ?? '', + 'mime-type' => $sub_size['mime_type'] ?? '', + 'filesize' => $sub_size['filesize'] ?? 0, + ); + } + continue; + } + if ( 'original' === $image_size ) { $metadata['original_image'] = $sub_size['file']; } elseif ( 'original-heic' === $image_size ) { @@ -561,11 +609,23 @@ public function sideload_item( WP_REST_Request $request ) { // Build sub-size data to return to the client. // The client accumulates these and sends them all to the finalize endpoint. + // `image_size` may be a single string or an array of names that share the + // same dimensions and therefore reuse a single sideloaded file. Arrays + // only carry regular sub-sizes; the special keys below ('original', + // 'scaled', 'original-heic') are always scalar strings. $sub_size_data = array( 'image_size' => $image_size, ); - if ( 'original' === $image_size ) { + if ( is_array( $image_size ) ) { + $size = wp_getimagesize( $path ); + + $sub_size_data['width'] = $size ? $size[0] : 0; + $sub_size_data['height'] = $size ? $size[1] : 0; + $sub_size_data['file'] = wp_basename( $path ); + $sub_size_data['mime_type'] = $type; + $sub_size_data['filesize'] = wp_filesize( $path ); + } elseif ( 'original' === $image_size ) { $sub_size_data['file'] = wp_basename( $path ); } elseif ( 'original-heic' === $image_size ) { // HEIC companion original. finalize_item() writes the filename to diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 75e3c91731e90c..a48dfb70521035 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -1194,6 +1194,11 @@ export function generateThumbnails( id: QueueItemId ) { ); } + // Group sizes by dimensions to avoid creating duplicate files. + // When multiple size names have the same width/height/crop, + // only one physical file is generated and registered under + // all matching size names via a single sideload request. + const dimensionGroups = new Map< string, string[] >(); for ( const name of sizesToGenerate ) { const imageSize = allImageSizes[ name ]; if ( ! imageSize ) { @@ -1203,6 +1208,17 @@ export function generateThumbnails( id: QueueItemId ) { ); continue; } + const key = `${ imageSize.width }x${ imageSize.height }x${ imageSize.crop }`; + const group = dimensionGroups.get( key ); + if ( group ) { + group.push( name ); + } else { + dimensionGroups.set( key, [ name ] ); + } + } + + for ( const [ , names ] of dimensionGroups ) { + const imageSize = allImageSizes[ names[ 0 ] ]; // Build operations list for this thumbnail. const thumbnailOperations: Operation[] = [ @@ -1217,6 +1233,10 @@ export function generateThumbnails( id: QueueItemId ) { thumbnailOperations.push( OperationType.Upload ); + // Pass all size names so the server registers the same + // file under every matching size name in metadata. + const imageSizeParam = names.length === 1 ? names[ 0 ] : names; + dispatch.addSideloadItem( { file, batchId, @@ -1225,7 +1245,7 @@ export function generateThumbnails( id: QueueItemId ) { // Sideloading does not use the parent post ID but the // attachment ID as the image sizes need to be added to it. post: attachment.id, - image_size: name, + image_size: imageSizeParam, convert_format: false, }, operations: thumbnailOperations, diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index e9f566b70166a2..3627ce5e03a234 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -898,6 +898,132 @@ describe( 'actions', () => { expect( mediumItems ).toHaveLength( 1 ); } ); + it( 'should deduplicate sizes with the same dimensions', async () => { + mockCreateImageBitmap( 800, 600 ); + + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + bigImageSizeThreshold: 2560, + allImageSizes: { + thumbnail: { width: 150, height: 150, crop: true }, + medium: { width: 300, height: 300, crop: false }, + // 'custom' has the same dimensions as 'medium'. + custom: { width: 300, height: 300, crop: false }, + }, + } ); + + const item = await setupItemForThumbnailGeneration( { + attachment: { + missing_image_sizes: [ 'thumbnail', 'medium', 'custom' ], + }, + } ); + await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( + item.id + ); + + const allItems = unlock( + registry.select( uploadStore ) + ).getAllItems(); + + // Should have the original item plus 2 sideload items (not 3), + // because medium and custom share the same dimensions. + const sideloadItems = allItems.filter( + ( i ) => i.parentId === item.id + ); + expect( sideloadItems ).toHaveLength( 2 ); + + // The deduplicated group should pass both size names. + const mediumCustomItem = sideloadItems.find( ( i ) => + Array.isArray( i.additionalData?.image_size ) + ); + expect( mediumCustomItem ).toBeDefined(); + expect( mediumCustomItem!.additionalData!.image_size ).toEqual( [ + 'medium', + 'custom', + ] ); + } ); + + it( 'should not deduplicate sizes that share dimensions but differ by crop', async () => { + mockCreateImageBitmap( 800, 600 ); + + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + bigImageSizeThreshold: 2560, + allImageSizes: { + // Same width/height, different crop — must be treated as distinct. + soft: { width: 300, height: 300, crop: false }, + hard: { width: 300, height: 300, crop: true }, + }, + } ); + + const item = await setupItemForThumbnailGeneration( { + attachment: { + missing_image_sizes: [ 'soft', 'hard' ], + }, + } ); + await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( + item.id + ); + + const allItems = unlock( + registry.select( uploadStore ) + ).getAllItems(); + + const sideloadItems = allItems.filter( + ( i ) => i.parentId === item.id + ); + // Two separate sideloads because crop differs. + expect( sideloadItems ).toHaveLength( 2 ); + + // Each sideload passes a single string (not an array). + for ( const sideload of sideloadItems ) { + expect( + typeof sideload.additionalData?.image_size === 'string' + ).toBe( true ); + } + const imageSizes = sideloadItems.map( + ( i ) => i.additionalData?.image_size + ); + expect( imageSizes ).toEqual( + expect.arrayContaining( [ 'soft', 'hard' ] ) + ); + } ); + + it( 'should group three sizes with identical dimensions into one sideload', async () => { + mockCreateImageBitmap( 800, 600 ); + + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + bigImageSizeThreshold: 2560, + allImageSizes: { + medium: { width: 300, height: 300, crop: false }, + alias_a: { width: 300, height: 300, crop: false }, + alias_b: { width: 300, height: 300, crop: false }, + }, + } ); + + const item = await setupItemForThumbnailGeneration( { + attachment: { + missing_image_sizes: [ 'medium', 'alias_a', 'alias_b' ], + }, + } ); + await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( + item.id + ); + + const allItems = unlock( + registry.select( uploadStore ) + ).getAllItems(); + + const sideloadItems = allItems.filter( + ( i ) => i.parentId === item.id + ); + // One sideload, all three names grouped together. + expect( sideloadItems ).toHaveLength( 1 ); + expect( sideloadItems[ 0 ].additionalData!.image_size ).toEqual( [ + 'medium', + 'alias_a', + 'alias_b', + ] ); + } ); + it( 'should skip thumbnail generation when item has no attachment', async () => { // Add an item without going through the attachment setup. unlock( registry.dispatch( uploadStore ) ).addItem( { diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index d87ed02b91a30f..a12fc4f3b3cfd8 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -5,7 +5,11 @@ * The client accumulates these and sends them all to the finalize endpoint. */ export interface SubSizeData { - image_size: string; + /** + * Size name, or an array of names when the same sideloaded file is + * registered under multiple sizes that share identical dimensions. + */ + image_size: string | string[]; width?: number; height?: number; file: string; @@ -329,8 +333,8 @@ export type AdditionalData = Record< string, unknown >; export interface SideloadAdditionalData extends AdditionalData { /** The attachment ID to add the image size to. */ post: number; - /** The name of the image size being generated (e.g., 'thumbnail', 'medium'). */ - image_size: string; + /** The name(s) of the image size being generated (e.g., 'thumbnail', 'medium'). When multiple size names share the same dimensions, an array can be passed to register one file under all names. */ + image_size: string | string[]; } export type ImageFormat = 'jpeg' | 'webp' | 'avif' | 'png' | 'gif'; diff --git a/phpunit/media/class-gutenberg-rest-attachments-controller-test.php b/phpunit/media/class-gutenberg-rest-attachments-controller-test.php index e8d99b8e7d4536..2093291e42e21c 100644 --- a/phpunit/media/class-gutenberg-rest-attachments-controller-test.php +++ b/phpunit/media/class-gutenberg-rest-attachments-controller-test.php @@ -892,6 +892,120 @@ public function test_sideload_scaled_filename_not_suffixed() { $this->assertStringNotContainsString( '-scaled-1', wp_basename( $attached_file ) ); } + /** + * Verifies that sideloading with an array of size names returns the array + * preserved in the sub_size response, and that finalize registers the same + * file under every name. + * + * This supports deduplication of client-side generated sub-sizes when multiple + * registered sizes share identical dimensions (e.g. Twenty Eleven's `large` + * is 768x1024, matching core's `medium_large`). One physical file should be + * registered under every matching size name. + * + * @covers ::sideload_item + * @covers ::finalize_item + * @covers ::register_routes + */ + public function test_sideload_item_accepts_array_of_image_sizes() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=dedup-array.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $attachment_id = $data['id']; + + // Register a custom size with the same dimensions as `medium` so both + // sizes resolve to one sideloaded file. + add_image_size( 'duplicate_of_medium', 300, 300, false ); + + // Sideload one physical file for both sizes. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=dedup-array-300x200.jpg' ); + $request->set_param( 'image_size', array( 'medium', 'duplicate_of_medium' ) ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $sub_size_data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + // The sideload response should preserve the array of size names so the + // client can forward it to finalize as a single sub-size entry. + $this->assertSame( + array( 'medium', 'duplicate_of_medium' ), + $sub_size_data['image_size'] + ); + $this->assertSame( 'dedup-array-300x200.jpg', $sub_size_data['file'] ); + + // Finalize: one sub-size entry with an array of names should register + // the same file under each name. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/finalize" ); + $request->set_param( 'sub_sizes', array( $sub_size_data ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + remove_image_size( 'duplicate_of_medium' ); + + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertArrayHasKey( 'medium', $metadata['sizes'] ); + $this->assertArrayHasKey( 'duplicate_of_medium', $metadata['sizes'] ); + $this->assertSame( 'dedup-array-300x200.jpg', $metadata['sizes']['medium']['file'] ); + $this->assertSame( 'dedup-array-300x200.jpg', $metadata['sizes']['duplicate_of_medium']['file'] ); + $this->assertSame( + $metadata['sizes']['medium']['file'], + $metadata['sizes']['duplicate_of_medium']['file'] + ); + } + + /** + * Verifies that sideloading with a single-element array of size names + * returns sub_size_data that finalize applies equivalently to a plain string. + * + * @covers ::sideload_item + * @covers ::finalize_item + */ + public function test_sideload_item_accepts_single_element_array() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=dedup-single.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $attachment_id = $data['id']; + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=dedup-single-thumb.jpg' ); + $request->set_param( 'image_size', array( 'thumbnail' ) ); + + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + $sub_size_data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array( 'thumbnail' ), $sub_size_data['image_size'] ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/finalize" ); + $request->set_param( 'sub_sizes', array( $sub_size_data ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertArrayHasKey( 'thumbnail', $metadata['sizes'] ); + $this->assertSame( 'dedup-single-thumb.jpg', $metadata['sizes']['thumbnail']['file'] ); + } + /** * Verifies that finalize writes sub-size metadata from the sub_sizes parameter. *