Skip to content
106 changes: 83 additions & 23 deletions lib/media/class-gutenberg-rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<id>[\d]+)/sideload',
Expand All @@ -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' ),
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion packages/upload-media/src/store/private-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand All @@ -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[] = [
Expand All @@ -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,
Expand All @@ -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,
Expand Down
126 changes: 126 additions & 0 deletions packages/upload-media/src/store/test/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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( {
Expand Down
10 changes: 7 additions & 3 deletions packages/upload-media/src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Loading
Loading