Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/upload-media/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Bug Fix

- Fix `-scaled` suffix propagating to every sub-size filename when an image exceeds `big_image_size_threshold`. Threshold scaling now runs as a sideload after the original is uploaded, so sub-sizes inherit the un-suffixed basename — matching WordPress core's `wp_create_image_subsizes()` naming.

## 0.31.0 (2026-05-14)

## 0.30.0 (2026-04-29)
Expand Down
37 changes: 14 additions & 23 deletions packages/upload-media/src/store/private-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,30 +665,21 @@ export function prepareItem( id: QueueItemId ) {
);
const isHeic = HEIC_MIME_TYPES.includes( file.type );

// For images that can be processed by vips, check if we need to scale down based on threshold.
// For images that can be processed by vips, upload the original and
// let generateThumbnails() handle threshold scaling as a sideload.
//
// Uploading the original (rather than a pre-scaled copy) preserves
// the un-suffixed basename in attachment.filename, so sub-size
// names are derived from the original — matching WordPress core's
// wp_create_image_subsizes() naming convention where only the
// scaled-down full-size copy carries the `-scaled` suffix and the
// original is kept alongside it as `original_image`.
//
// Main-file format conversion is handled server-side via the
// image_editor_output_format filter during create_item.
// The response carries image_output_format so generateThumbnails
// can transcode sub-sizes to the same target format.
if ( isImage && isVipsSupported ) {
const { bigImageSizeThreshold } = settings;

// If a threshold is set, add a resize operation to scale down large images.
// This matches WordPress core's behavior in wp_create_image_subsizes().
if ( bigImageSizeThreshold ) {
operations.push( [
OperationType.ResizeCrop,
{
resize: {
width: bigImageSizeThreshold,
height: bigImageSizeThreshold,
},
isThresholdResize: true,
},
] );
}

// Main-file format conversion is handled server-side via the
// image_editor_output_format filter during create_item.
// The response carries image_output_format so generateThumbnails
// can transcode sub-sizes to the same target format.

operations.push(
OperationType.Upload,
OperationType.ThumbnailGeneration,
Expand Down
132 changes: 132 additions & 0 deletions packages/upload-media/src/store/test/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,5 +1085,137 @@ describe( 'actions', () => {
// Exactly at threshold means no scaling (condition is > not >=).
expect( scaledItems ).toHaveLength( 0 );
} );

// Sub-size and scaled-sideload naming uses attachment.filename
// verbatim. The cases below cover both the everyday filename and
// edge cases that previously broke with a client-side strip:
// - a legitimate `-scaled` suffix in the user's filename
// - the literal basename `scaled.jpg`
// - `-scaled` appearing mid-name
// - the server's numeric conflict-resolution suffix
// - mixed case and multi-dot filenames
it.each( [
'IMG_2300.jpg',
'foo-scaled.jpg',
'scaled.jpg',
'my-scaled-image.jpg',
'IMG_2300-1.jpg',
'IMG-scaled-2.jpg',
'image.with.dots.jpg',
'FOO-SCALED.JPG',
'photo.jpeg',
] )( 'uses %s verbatim for thumbnail sideloads', async ( filename ) => {
mockCreateImageBitmap( 800, 600 );

unlock( registry.dispatch( uploadStore ) ).updateSettings( {
bigImageSizeThreshold: 2560,
allImageSizes: {
thumbnail: { width: 150, height: 150 },
medium: { width: 300, height: 300 },
},
} );

const item = await setupItemForThumbnailGeneration( {
attachment: { filename },
} );
await unlock( registry.dispatch( uploadStore ) ).generateThumbnails(
item.id
);

const thumbnailItems = unlock( registry.select( uploadStore ) )
.getAllItems()
.filter(
( i ) =>
i.additionalData?.image_size === 'thumbnail' ||
i.additionalData?.image_size === 'medium'
);
expect( thumbnailItems ).toHaveLength( 2 );
for ( const sideload of thumbnailItems ) {
expect( sideload.file.name ).toBe( filename );
}
} );

it.each( [
'IMG_2300.jpg',
'foo-scaled.jpg',
'scaled.jpg',
'IMG_2300-1.jpg',
'image.with.dots.jpg',
] )(
'uses %s verbatim for the scaled sideload when above threshold',
async ( filename ) => {
// Image above threshold triggers the scaled sideload path.
mockCreateImageBitmap( 4000, 3000 );

unlock( registry.dispatch( uploadStore ) ).updateSettings( {
bigImageSizeThreshold: 2560,
allImageSizes: {
thumbnail: { width: 150, height: 150 },
},
} );

const item = await setupItemForThumbnailGeneration( {
attachment: {
filename,
missing_image_sizes: [ 'thumbnail' ],
},
} );
await unlock(
registry.dispatch( uploadStore )
).generateThumbnails( item.id );

const scaledItems = unlock( registry.select( uploadStore ) )
.getAllItems()
.filter(
( i ) => i.additionalData?.image_size === 'scaled'
);
expect( scaledItems ).toHaveLength( 1 );
// vipsResizeImage adds the `-scaled` suffix during the
// ResizeCrop op; the sideload enters the queue under the
// server's filename so the resulting file matches WP core's
// naming (e.g. foo-scaled.jpg → foo-scaled-scaled.jpg, which
// is correct because the user really did have `-scaled` in
// their original name and the file was just scaled again).
expect( scaledItems[ 0 ].file.name ).toBe( filename );
}
);
} );

describe( 'prepareItem big image threshold', () => {
it( 'should not pre-scale the main upload when bigImageSizeThreshold is set', async () => {
// Pre-scaling the main upload would cause the server-returned
// attachment.filename to carry `-scaled`, which would then leak
// into every sub-size name. Threshold scaling must happen as a
// sideload so the original is uploaded with its un-suffixed
// basename.
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
bigImageSizeThreshold: 2560,
} );
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );

const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];

await unlock( registry.dispatch( uploadStore ) ).prepareItem(
item.id
);

const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];

expect( updatedItem.operations ).not.toEqual(
expect.arrayContaining( [ OperationType.ResizeCrop ] )
);
expect( updatedItem.operations ).toEqual(
expect.arrayContaining( [
OperationType.Upload,
OperationType.ThumbnailGeneration,
] )
);
} );
} );
} );
Loading