Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.30.0 (2026-04-29)

### Enhancement
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