From 836b6cbc5c3c87fdc88eadafb93f1e18988b78d1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 6 May 2026 11:22:32 -0700 Subject: [PATCH 1/3] Upload Media: stop propagating `-scaled` to sub-size filenames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an image exceeded `big_image_size_threshold`, every sub-size inherited the `-scaled` suffix (e.g. `IMG_2300-scaled-150x150.jpg`) because `prepareItem()` queued a `ResizeCrop` step before `Upload`, making the main upload itself the `-scaled` file. The server-returned `attachment.filename` then carried `-scaled` and was used as the basename for every thumbnail and the redundant scaled sideload — producing duplicates like `-scaled-scaled.jpg` once collisions kicked in. Move threshold scaling to the existing scaled-sideload path in `generateThumbnails()`, matching the HEIC flow and core's `wp_create_image_subsizes()` behavior: the original is uploaded unchanged, sub-sizes are derived from its un-suffixed basename, and a single `-scaled` file is sideloaded with `image_size: 'scaled'`. Also defensively strip a stray `-scaled` from `attachment.filename` before deriving sub-size names, via a new `stripScaledSuffix()` util. Adds unit tests covering the helper, the thumbnail/scaled sideload naming, and the prepareItem operation list. Fixes #78037 --- packages/upload-media/CHANGELOG.md | 4 + .../upload-media/src/store/private-actions.ts | 63 ++++---- .../upload-media/src/store/test/actions.ts | 142 ++++++++++++++++++ .../src/test/strip-scaled-suffix.ts | 19 +++ packages/upload-media/src/utils.ts | 21 +++ 5 files changed, 220 insertions(+), 29 deletions(-) create mode 100644 packages/upload-media/src/test/strip-scaled-suffix.ts diff --git a/packages/upload-media/CHANGELOG.md b/packages/upload-media/CHANGELOG.md index 2a61f113196cdf..ee4f16e466d5ee 100644 --- a/packages/upload-media/CHANGELOG.md +++ b/packages/upload-media/CHANGELOG.md @@ -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 diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 3befa57f685e27..948b04e4663e1e 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -13,7 +13,12 @@ type WPDataRegistry = ReturnType< typeof createRegistry >; /** * Internal dependencies */ -import { cloneFile, convertBlobToFile, renameFile } from '../utils'; +import { + cloneFile, + convertBlobToFile, + renameFile, + stripScaledSuffix, +} from '../utils'; import { canvasConvertToJpeg } from '../canvas-utils'; import { isClientSideMediaSupported } from '../feature-detection'; import { CLIENT_SIDE_SUPPORTED_MIME_TYPES, HEIC_MIME_TYPES } from './constants'; @@ -665,30 +670,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, @@ -1148,8 +1144,15 @@ export function generateThumbnails( id: QueueItemId ) { attachment.missing_image_sizes as string[]; const thumbnailSource = item.sourceFile; - const file = attachment.filename - ? renameFile( thumbnailSource, attachment.filename ) + // Strip any `-scaled` suffix from the attachment filename so + // sub-size names are derived from the original basename. This + // matches WordPress core's wp_create_image_subsizes() naming, + // where only the scaled-down full-size copy carries `-scaled`. + const subSizeFilename = attachment.filename + ? stripScaledSuffix( attachment.filename ) + : null; + const file = subSizeFilename + ? renameFile( thumbnailSource, subSizeFilename ) : thumbnailSource; const batchId = uuidv4(); @@ -1247,9 +1250,11 @@ export function generateThumbnails( id: QueueItemId ) { bitmap.close(); if ( needsScaling ) { - // Rename sourceFile to match the server attachment filename. - const sourceForScaled = attachment.filename - ? renameFile( thumbnailSource, attachment.filename ) + // Use the un-suffixed filename so vipsResizeImage adds + // a single `-scaled` suffix to produce e.g. + // `IMG_2300-scaled.jpg` (rather than `-scaled-scaled`). + const sourceForScaled = subSizeFilename + ? renameFile( thumbnailSource, subSizeFilename ) : thumbnailSource; // Add scaling to queue. diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index 3627ce5e03a234..589a077b522737 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -1085,5 +1085,147 @@ describe( 'actions', () => { // Exactly at threshold means no scaling (condition is > not >=). expect( scaledItems ).toHaveLength( 0 ); } ); + + // Regression coverage for the `-scaled` filename bug: + // when the server returned an attachment whose filename already + // carried `-scaled` (because a prior step had scaled the main file), + // every sub-size inherited the suffix — producing names like + // `IMG_2300-scaled-150x150.jpg`. Sub-sizes must be derived from + // the un-suffixed basename instead. + it( 'should strip -scaled from attachment.filename when naming thumbnail sideloads', async () => { + mockCreateImageBitmap( 4000, 3000 ); + + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + bigImageSizeThreshold: 2560, + allImageSizes: { + thumbnail: { width: 150, height: 150 }, + medium: { width: 300, height: 300 }, + }, + } ); + + const item = await setupItemForThumbnailGeneration( { + attachment: { filename: 'IMG_2300-scaled.jpg' }, + } ); + await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( + item.id + ); + + const allItems = unlock( + registry.select( uploadStore ) + ).getAllItems(); + + const thumbnailItems = allItems.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( 'IMG_2300.jpg' ); + } + } ); + + it( 'should strip -scaled from attachment.filename when naming the scaled sideload', async () => { + mockCreateImageBitmap( 4000, 3000 ); + + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + bigImageSizeThreshold: 2560, + allImageSizes: { + thumbnail: { width: 150, height: 150 }, + }, + } ); + + const item = await setupItemForThumbnailGeneration( { + attachment: { + filename: 'IMG_2300-scaled.jpg', + missing_image_sizes: [ 'thumbnail' ], + }, + } ); + await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( + item.id + ); + + const allItems = unlock( + registry.select( uploadStore ) + ).getAllItems(); + + const scaledItems = allItems.filter( + ( i ) => i.additionalData?.image_size === 'scaled' + ); + expect( scaledItems ).toHaveLength( 1 ); + // vipsResizeImage adds the single `-scaled` suffix when running + // the ResizeCrop operation; the file enters the queue under the + // un-suffixed basename so we don't end up with `-scaled-scaled`. + expect( scaledItems[ 0 ].file.name ).toBe( 'IMG_2300.jpg' ); + } ); + + it( 'should preserve un-suffixed attachment.filename for thumbnails', async () => { + 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: 'photo.jpg' }, + } ); + await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( + item.id + ); + + const allItems = unlock( + registry.select( uploadStore ) + ).getAllItems(); + + const sideloads = allItems.filter( + ( i ) => i.parentId === item.id + ); + expect( sideloads.length ).toBeGreaterThan( 0 ); + for ( const sideload of sideloads ) { + expect( sideload.file.name ).toBe( 'photo.jpg' ); + } + } ); + } ); + + 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, + ] ) + ); + } ); } ); } ); diff --git a/packages/upload-media/src/test/strip-scaled-suffix.ts b/packages/upload-media/src/test/strip-scaled-suffix.ts new file mode 100644 index 00000000000000..93b5d2f11ff282 --- /dev/null +++ b/packages/upload-media/src/test/strip-scaled-suffix.ts @@ -0,0 +1,19 @@ +/** + * Internal dependencies + */ +import { stripScaledSuffix } from '../utils'; + +describe( 'stripScaledSuffix', () => { + it.each( [ + [ 'IMG_2300-scaled.jpg', 'IMG_2300.jpg' ], + [ 'photo-scaled.jpeg', 'photo.jpeg' ], + [ 'my.image-scaled.png', 'my.image.png' ], + [ 'IMG_2300.jpg', 'IMG_2300.jpg' ], + [ 'thumbnail-150x150.jpg', 'thumbnail-150x150.jpg' ], + [ 'name-scaled-1.jpg', 'name-scaled-1.jpg' ], + [ 'scaled.jpg', 'scaled.jpg' ], + [ '', '' ], + ] )( 'for file name %s returns %s', ( input, expected ) => { + expect( stripScaledSuffix( input ) ).toBe( expected ); + } ); +} ); diff --git a/packages/upload-media/src/utils.ts b/packages/upload-media/src/utils.ts index b326f52be41716..7612e716792452 100644 --- a/packages/upload-media/src/utils.ts +++ b/packages/upload-media/src/utils.ts @@ -106,3 +106,24 @@ export function getFileBasename( name: string ): string { export function getFileNameFromUrl( url: string ) { return getFilename( url ) || _x( 'unnamed', 'file name' ); } + +/** + * Strips a trailing `-scaled` suffix from a file's basename. + * + * Used when deriving sub-size filenames from an attachment whose main + * file has been scaled down by `wp_create_image_subsizes()`. Sub-sizes + * inherit the un-suffixed basename so they end up named like + * `IMG_2300-150x150.jpg` rather than `IMG_2300-scaled-150x150.jpg`. + * + * @param name File name including extension. + * @return File name with `-scaled` removed from the basename, or the + * input unchanged if no `-scaled` suffix is present. + */ +export function stripScaledSuffix( name: string ): string { + const basename = getFileBasename( name ); + if ( ! basename.endsWith( '-scaled' ) ) { + return name; + } + const stripped = basename.slice( 0, -'-scaled'.length ); + return name.replace( basename, stripped ); +} From eca40fcda1bd3fe0337b88cd76dbe5e624f9bc6f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 09:25:41 -0700 Subject: [PATCH 2/3] Upload Media: trust attachment.filename instead of stripping -scaled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The defensive stripScaledSuffix() utility removes a trailing '-scaled' from any attachment.filename, but that incorrectly mangles legitimately named files like foo-scaled.jpg. With the prepareItem() fix already in place, the server is told generate_sub_sizes: false (which disables big_image_size_threshold), so attachment.filename is never -scaled in the normal flow — the strip is unnecessary defense in depth that introduces a real regression. Use the server-returned filename verbatim. Add a regression test that covers the foo-scaled.jpg case. --- .../upload-media/src/store/private-actions.ts | 26 +++------ .../upload-media/src/store/test/actions.ts | 53 ++++--------------- .../src/test/strip-scaled-suffix.ts | 19 ------- packages/upload-media/src/utils.ts | 21 -------- 4 files changed, 15 insertions(+), 104 deletions(-) delete mode 100644 packages/upload-media/src/test/strip-scaled-suffix.ts diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 948b04e4663e1e..846fbcfe3cfb84 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -13,12 +13,7 @@ type WPDataRegistry = ReturnType< typeof createRegistry >; /** * Internal dependencies */ -import { - cloneFile, - convertBlobToFile, - renameFile, - stripScaledSuffix, -} from '../utils'; +import { cloneFile, convertBlobToFile, renameFile } from '../utils'; import { canvasConvertToJpeg } from '../canvas-utils'; import { isClientSideMediaSupported } from '../feature-detection'; import { CLIENT_SIDE_SUPPORTED_MIME_TYPES, HEIC_MIME_TYPES } from './constants'; @@ -1144,15 +1139,8 @@ export function generateThumbnails( id: QueueItemId ) { attachment.missing_image_sizes as string[]; const thumbnailSource = item.sourceFile; - // Strip any `-scaled` suffix from the attachment filename so - // sub-size names are derived from the original basename. This - // matches WordPress core's wp_create_image_subsizes() naming, - // where only the scaled-down full-size copy carries `-scaled`. - const subSizeFilename = attachment.filename - ? stripScaledSuffix( attachment.filename ) - : null; - const file = subSizeFilename - ? renameFile( thumbnailSource, subSizeFilename ) + const file = attachment.filename + ? renameFile( thumbnailSource, attachment.filename ) : thumbnailSource; const batchId = uuidv4(); @@ -1250,11 +1238,9 @@ export function generateThumbnails( id: QueueItemId ) { bitmap.close(); if ( needsScaling ) { - // Use the un-suffixed filename so vipsResizeImage adds - // a single `-scaled` suffix to produce e.g. - // `IMG_2300-scaled.jpg` (rather than `-scaled-scaled`). - const sourceForScaled = subSizeFilename - ? renameFile( thumbnailSource, subSizeFilename ) + // Rename sourceFile to match the server attachment filename. + const sourceForScaled = attachment.filename + ? renameFile( thumbnailSource, attachment.filename ) : thumbnailSource; // Add scaling to queue. diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index 589a077b522737..246862069b9e91 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -1086,13 +1086,7 @@ describe( 'actions', () => { expect( scaledItems ).toHaveLength( 0 ); } ); - // Regression coverage for the `-scaled` filename bug: - // when the server returned an attachment whose filename already - // carried `-scaled` (because a prior step had scaled the main file), - // every sub-size inherited the suffix — producing names like - // `IMG_2300-scaled-150x150.jpg`. Sub-sizes must be derived from - // the un-suffixed basename instead. - it( 'should strip -scaled from attachment.filename when naming thumbnail sideloads', async () => { + it( 'should derive sub-size names from attachment.filename', async () => { mockCreateImageBitmap( 4000, 3000 ); unlock( registry.dispatch( uploadStore ) ).updateSettings( { @@ -1104,7 +1098,7 @@ describe( 'actions', () => { } ); const item = await setupItemForThumbnailGeneration( { - attachment: { filename: 'IMG_2300-scaled.jpg' }, + attachment: { filename: 'IMG_2300.jpg' }, } ); await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( item.id @@ -1125,8 +1119,11 @@ describe( 'actions', () => { } } ); - it( 'should strip -scaled from attachment.filename when naming the scaled sideload', async () => { - mockCreateImageBitmap( 4000, 3000 ); + // Regression coverage: a user-supplied filename that legitimately + // ends in `-scaled` (e.g., `foo-scaled.jpg`) must be passed through + // unchanged. Sub-sizes use the server-returned filename verbatim. + it( 'should preserve a `-scaled` suffix that is part of the original filename', async () => { + mockCreateImageBitmap( 800, 600 ); unlock( registry.dispatch( uploadStore ) ).updateSettings( { bigImageSizeThreshold: 2560, @@ -1137,7 +1134,7 @@ describe( 'actions', () => { const item = await setupItemForThumbnailGeneration( { attachment: { - filename: 'IMG_2300-scaled.jpg', + filename: 'foo-scaled.jpg', missing_image_sizes: [ 'thumbnail' ], }, } ); @@ -1149,44 +1146,12 @@ describe( 'actions', () => { registry.select( uploadStore ) ).getAllItems(); - const scaledItems = allItems.filter( - ( i ) => i.additionalData?.image_size === 'scaled' - ); - expect( scaledItems ).toHaveLength( 1 ); - // vipsResizeImage adds the single `-scaled` suffix when running - // the ResizeCrop operation; the file enters the queue under the - // un-suffixed basename so we don't end up with `-scaled-scaled`. - expect( scaledItems[ 0 ].file.name ).toBe( 'IMG_2300.jpg' ); - } ); - - it( 'should preserve un-suffixed attachment.filename for thumbnails', async () => { - 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: 'photo.jpg' }, - } ); - await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( - item.id - ); - - const allItems = unlock( - registry.select( uploadStore ) - ).getAllItems(); - const sideloads = allItems.filter( ( i ) => i.parentId === item.id ); expect( sideloads.length ).toBeGreaterThan( 0 ); for ( const sideload of sideloads ) { - expect( sideload.file.name ).toBe( 'photo.jpg' ); + expect( sideload.file.name ).toBe( 'foo-scaled.jpg' ); } } ); } ); diff --git a/packages/upload-media/src/test/strip-scaled-suffix.ts b/packages/upload-media/src/test/strip-scaled-suffix.ts deleted file mode 100644 index 93b5d2f11ff282..00000000000000 --- a/packages/upload-media/src/test/strip-scaled-suffix.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Internal dependencies - */ -import { stripScaledSuffix } from '../utils'; - -describe( 'stripScaledSuffix', () => { - it.each( [ - [ 'IMG_2300-scaled.jpg', 'IMG_2300.jpg' ], - [ 'photo-scaled.jpeg', 'photo.jpeg' ], - [ 'my.image-scaled.png', 'my.image.png' ], - [ 'IMG_2300.jpg', 'IMG_2300.jpg' ], - [ 'thumbnail-150x150.jpg', 'thumbnail-150x150.jpg' ], - [ 'name-scaled-1.jpg', 'name-scaled-1.jpg' ], - [ 'scaled.jpg', 'scaled.jpg' ], - [ '', '' ], - ] )( 'for file name %s returns %s', ( input, expected ) => { - expect( stripScaledSuffix( input ) ).toBe( expected ); - } ); -} ); diff --git a/packages/upload-media/src/utils.ts b/packages/upload-media/src/utils.ts index 7612e716792452..b326f52be41716 100644 --- a/packages/upload-media/src/utils.ts +++ b/packages/upload-media/src/utils.ts @@ -106,24 +106,3 @@ export function getFileBasename( name: string ): string { export function getFileNameFromUrl( url: string ) { return getFilename( url ) || _x( 'unnamed', 'file name' ); } - -/** - * Strips a trailing `-scaled` suffix from a file's basename. - * - * Used when deriving sub-size filenames from an attachment whose main - * file has been scaled down by `wp_create_image_subsizes()`. Sub-sizes - * inherit the un-suffixed basename so they end up named like - * `IMG_2300-150x150.jpg` rather than `IMG_2300-scaled-150x150.jpg`. - * - * @param name File name including extension. - * @return File name with `-scaled` removed from the basename, or the - * input unchanged if no `-scaled` suffix is present. - */ -export function stripScaledSuffix( name: string ): string { - const basename = getFileBasename( name ); - if ( ! basename.endsWith( '-scaled' ) ) { - return name; - } - const stripped = basename.slice( 0, -'-scaled'.length ); - return name.replace( basename, stripped ); -} From 60737292a2bcf5cc739bb618af297d4f1d926f60 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 10:12:43 -0700 Subject: [PATCH 3/3] Upload Media: expand sub-size naming coverage with it.each Replace the two ad-hoc sub-size naming tests with parameterized tables covering the cases swissspidy raised about filenames that already contain '-scaled', plus other edge cases: - foo-scaled.jpg (legitimate trailing -scaled suffix) - scaled.jpg (literal basename) - my-scaled-image.jpg (-scaled mid-name) - IMG_2300-1.jpg (server conflict-resolution suffix) - IMG-scaled-2.jpg (-scaled mid-name with conflict suffix) - image.with.dots.jpg (multi-dot) - FOO-SCALED.JPG (mixed case) - photo.jpeg (alternate extension) Both the thumbnail and the scaled-sideload paths are exercised. 198 tests pass (up from 186). --- .../upload-media/src/store/test/actions.ts | 117 +++++++++++------- 1 file changed, 71 insertions(+), 46 deletions(-) diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index 246862069b9e91..40e9dcb20df436 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -1086,8 +1086,26 @@ describe( 'actions', () => { expect( scaledItems ).toHaveLength( 0 ); } ); - it( 'should derive sub-size names from attachment.filename', async () => { - mockCreateImageBitmap( 4000, 3000 ); + // 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, @@ -1098,62 +1116,69 @@ describe( 'actions', () => { } ); const item = await setupItemForThumbnailGeneration( { - attachment: { filename: 'IMG_2300.jpg' }, + attachment: { filename }, } ); await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( item.id ); - const allItems = unlock( - registry.select( uploadStore ) - ).getAllItems(); - - const thumbnailItems = allItems.filter( - ( i ) => - i.additionalData?.image_size === 'thumbnail' || - i.additionalData?.image_size === 'medium' - ); + 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( 'IMG_2300.jpg' ); + expect( sideload.file.name ).toBe( filename ); } } ); - // Regression coverage: a user-supplied filename that legitimately - // ends in `-scaled` (e.g., `foo-scaled.jpg`) must be passed through - // unchanged. Sub-sizes use the server-returned filename verbatim. - it( 'should preserve a `-scaled` suffix that is part of the original filename', async () => { - mockCreateImageBitmap( 800, 600 ); - - unlock( registry.dispatch( uploadStore ) ).updateSettings( { - bigImageSizeThreshold: 2560, - allImageSizes: { - thumbnail: { width: 150, height: 150 }, - }, - } ); - - const item = await setupItemForThumbnailGeneration( { - attachment: { - filename: 'foo-scaled.jpg', - missing_image_sizes: [ 'thumbnail' ], - }, - } ); - await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( - item.id - ); - - const allItems = unlock( - registry.select( uploadStore ) - ).getAllItems(); + 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 sideloads = allItems.filter( - ( i ) => i.parentId === item.id - ); - expect( sideloads.length ).toBeGreaterThan( 0 ); - for ( const sideload of sideloads ) { - expect( sideload.file.name ).toBe( 'foo-scaled.jpg' ); + 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', () => {