From 22cf12b93e9698813af1ce606ee68b90468d70cf Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 1 Apr 2026 15:29:45 -0700 Subject: [PATCH 1/4] Use image.copyMemory() for batch thumbnail generation Decode the source image once via copyMemory() and use thumbnailImage() for each sub-size, instead of re-decoding the source buffer for every thumbnail. This approach was suggested by the wasm-vips maintainer and avoids both re-decoding overhead and the need for intermediate format conversions. The new batchResizeImage() function in the vips package materializes the decoded image in WASM memory, then generates all thumbnails from that single in-memory copy. Results are written directly to the target output format, eliminating separate transcode steps. Falls back to per-thumbnail processing if the batch operation fails. --- .../upload-media/src/store/private-actions.ts | 184 +++++++++++----- .../upload-media/src/store/test/actions.ts | 19 ++ .../src/store/test/private-actions.js | 142 +++++++++++- .../upload-media/src/store/utils/index.ts | 80 +++++++ packages/vips/README.md | 38 ++++ packages/vips/src/index.ts | 204 +++++++++++++++++- packages/vips/src/vips-worker.ts | 44 ++++ packages/vips/src/worker.ts | 2 + 8 files changed, 657 insertions(+), 56 deletions(-) diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index b06af25f2d6d43..667c6c0ffda5bc 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -19,6 +19,7 @@ import { StubFile } from '../stub-file'; import { UploadError } from '../upload-error'; import { vipsResizeImage, + vipsBatchResizeImage, vipsRotateImage, vipsConvertImageFormat, vipsHasTransparency, @@ -1167,6 +1168,35 @@ export function generateThumbnails( id: QueueItemId ) { ); } + // Determine the actual output type for thumbnails. + // If transcoding is configured, use that format; otherwise + // use the source format. + const thumbnailOutputType = thumbnailTranscodeOperation + ? `image/${ thumbnailTranscodeOperation[ 1 ].outputFormat }` + : sourceType; + + const quality = thumbnailTranscodeOperation + ? thumbnailTranscodeOperation[ 1 ].outputQuality ?? + DEFAULT_OUTPUT_QUALITY + : DEFAULT_OUTPUT_QUALITY; + + // Collect all resize configs for batch processing. + const batchConfigs: Array< { + name: string; + resize: { + width: number; + height: number; + crop?: + | boolean + | [ + 'left' | 'center' | 'right', + 'top' | 'center' | 'bottom', + ]; + }; + quality: number; + scaledSuffix?: boolean; + } > = []; + for ( const name of sizesToGenerate ) { const imageSize = allImageSizes[ name ]; if ( ! imageSize ) { @@ -1177,65 +1207,122 @@ export function generateThumbnails( id: QueueItemId ) { continue; } - // Build operations list for this thumbnail. - const thumbnailOperations: Operation[] = [ - [ OperationType.ResizeCrop, { resize: imageSize } ], - ]; - - // Add transcoding if format conversion is configured and - // the transparency check passed. - if ( thumbnailTranscodeOperation ) { - thumbnailOperations.push( thumbnailTranscodeOperation ); - } - - thumbnailOperations.push( OperationType.Upload ); - - dispatch.addSideloadItem( { - file, - onChange: ( [ updatedAttachment ] ) => { - // If the sub-size is still being generated, there is no need - // to invoke the callback below. It would just override - // the main image in the editor with the sub-size. - if ( isBlobURL( updatedAttachment.url ) ) { - return; - } - - // This might be confusing, but the idea is to update the original - // image item in the editor with the new one with the added sub-size. - item.onChange?.( [ updatedAttachment ] ); - }, - batchId, - parentId: item.id, - additionalData: { - // 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, - convert_format: false, - }, - operations: thumbnailOperations, + batchConfigs.push( { + name, + resize: imageSize, + quality, } ); } - // Create and sideload the scaled version. + // Check if a scaled version is needed. const { bigImageSizeThreshold } = settings; + let needsScaling = false; if ( bigImageSizeThreshold && attachment.id ) { - // Check if the image actually exceeds the threshold. - // Only create a scaled version for images larger than the threshold, - // matching WordPress core's wp_create_image_subsizes() behavior. const bitmap = await createImageBitmap( item.sourceFile ); - const needsScaling = + needsScaling = bitmap.width > bigImageSizeThreshold || bitmap.height > bigImageSizeThreshold; bitmap.close(); if ( needsScaling ) { - // Rename sourceFile to match the server attachment filename. - const sourceForScaled = attachment.filename - ? renameFile( item.sourceFile, attachment.filename ) - : item.sourceFile; + batchConfigs.push( { + name: 'scaled', + resize: { + width: bigImageSizeThreshold, + height: bigImageSizeThreshold, + }, + quality, + scaledSuffix: true, + } ); + } + } + + // Batch resize: decode source once via copyMemory(), + // generate all sub-sizes with thumbnailImage(), and + // write each directly to the output format. + // Falls back to per-thumbnail processing on failure. + let batchResults: Awaited< + ReturnType< typeof vipsBatchResizeImage > + > | null = null; + + if ( batchConfigs.length > 0 ) { + try { + batchResults = await vipsBatchResizeImage( + item.id, + file, + thumbnailOutputType, + batchConfigs, + false + ); + } catch { + // eslint-disable-next-line no-console + console.warn( + 'Batch resize failed, falling back to per-thumbnail processing' + ); + } + } + + if ( batchResults ) { + // Batch succeeded — enqueue upload-only sideloads. + for ( const result of batchResults ) { + dispatch.addSideloadItem( { + file: result.file, + onChange: ( [ updatedAttachment ] ) => { + if ( isBlobURL( updatedAttachment.url ) ) { + return; + } + item.onChange?.( [ updatedAttachment ] ); + }, + batchId, + parentId: item.id, + additionalData: { + post: attachment.id, + image_size: result.name, + convert_format: false, + }, + operations: [ OperationType.Upload ], + } ); + } + } else { + // Fallback: per-thumbnail processing (original approach + // without batch resize). + for ( const name of sizesToGenerate ) { + const imageSize = allImageSizes[ name ]; + if ( ! imageSize ) { + continue; + } + + const thumbnailOperations: Operation[] = [ + [ OperationType.ResizeCrop, { resize: imageSize } ], + ]; + + if ( thumbnailTranscodeOperation ) { + thumbnailOperations.push( thumbnailTranscodeOperation ); + } + + thumbnailOperations.push( OperationType.Upload ); + + dispatch.addSideloadItem( { + file, + onChange: ( [ updatedAttachment ] ) => { + if ( isBlobURL( updatedAttachment.url ) ) { + return; + } + item.onChange?.( [ updatedAttachment ] ); + }, + batchId, + parentId: item.id, + additionalData: { + post: attachment.id, + image_size: name, + convert_format: false, + }, + operations: thumbnailOperations, + } ); + } - // Add scaling to queue. + // Fallback scaled version. + if ( needsScaling && bigImageSizeThreshold && attachment.id ) { const scaledOperations: Operation[] = [ [ OperationType.ResizeCrop, @@ -1249,7 +1336,6 @@ export function generateThumbnails( id: QueueItemId ) { ], ]; - // Add transcoding if format conversion is configured. if ( thumbnailTranscodeOperation ) { scaledOperations.push( thumbnailTranscodeOperation ); } @@ -1257,7 +1343,7 @@ export function generateThumbnails( id: QueueItemId ) { scaledOperations.push( OperationType.Upload ); dispatch.addSideloadItem( { - file: sourceForScaled, + file, onChange: ( [ updatedAttachment ] ) => { if ( isBlobURL( updatedAttachment.url ) ) { return; diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index 20524bf85ef83f..5246902db2bd5d 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -27,6 +27,25 @@ jest.mock( '../utils', () => ( { } ) ) ), + vipsBatchResizeImage: jest.fn( ( _id, file, outputType, configs ) => + Promise.resolve( + configs.map( + ( c: { + name: string; + resize: { width: number; height: number }; + } ) => ( { + name: c.name, + file: new File( + [ 'batch-resized' ], + `example-${ c.resize.width }x${ c.resize.height }.${ + outputType.split( '/' )[ 1 ] + }`, + { type: outputType } + ), + } ) + ) + ) + ), vipsRotateImage: jest.fn(), vipsHasTransparency: jest.fn( () => Promise.resolve( false ) ), vipsConvertImageFormat: jest.fn(), diff --git a/packages/upload-media/src/store/test/private-actions.js b/packages/upload-media/src/store/test/private-actions.js index 5da1dace106aea..40f6ba40c3ac3e 100644 --- a/packages/upload-media/src/store/test/private-actions.js +++ b/packages/upload-media/src/store/test/private-actions.js @@ -6,19 +6,38 @@ import { createBlobURL, revokeBlobURL } from '@wordpress/blob'; /** * Internal dependencies */ -import { getTranscodeImageOperation, finalizeItem } from '../private-actions'; +import { + getTranscodeImageOperation, + finalizeItem, + generateThumbnails, +} from '../private-actions'; import { OperationType } from '../types'; -import { vipsHasTransparency } from '../utils'; +import { vipsHasTransparency, vipsBatchResizeImage } from '../utils'; // Mock @wordpress/blob jest.mock( '@wordpress/blob', () => ( { createBlobURL: jest.fn( () => 'blob:mock-url' ), revokeBlobURL: jest.fn(), + isBlobURL: jest.fn( () => false ), } ) ); // Mock vips utilities jest.mock( '../utils', () => ( { vipsHasTransparency: jest.fn(), + vipsBatchResizeImage: jest.fn( ( _id, _file, outputType, configs ) => + Promise.resolve( + configs.map( ( c ) => ( { + name: c.name, + file: new File( + [ 'batch-resized' ], + `converted-${ c.resize.width }x${ c.resize.height }.${ + outputType.split( '/' )[ 1 ] + }`, + { type: outputType } + ), + } ) ) + ) + ), } ) ); describe( 'private actions', () => { @@ -252,6 +271,125 @@ describe( 'private actions', () => { } ); } ); + describe( 'generateThumbnails - batch resize with copyMemory()', () => { + let addSideloadItem; + let finishOperation; + let dispatch; + + function makeSelect( { + sourceType = 'image/avif', + outputMimeType = 'image/jpeg', + filename = 'photo.avif', + } = {} ) { + const sourceFile = new File( [ 'test' ], filename, { + type: sourceType, + } ); + return { + getItem: () => ( { + id: 'item-1', + file: sourceFile, + sourceFile, + attachment: { + id: 42, + filename, + missing_image_sizes: [ 'thumbnail', 'medium' ], + }, + } ), + getSettings: () => ( { + allImageSizes: { + thumbnail: { width: 150, height: 150, crop: true }, + medium: { width: 300, height: 300, crop: false }, + }, + imageOutputFormats: outputMimeType + ? { [ sourceType ]: outputMimeType } + : {}, + jpegInterlaced: false, + pngInterlaced: false, + gifInterlaced: false, + } ), + }; + } + + beforeEach( () => { + jest.clearAllMocks(); + addSideloadItem = jest.fn(); + finishOperation = jest.fn(); + dispatch = { addSideloadItem, finishOperation }; + } ); + + it( 'should use batch resize with output format when transcoding', async () => { + const select = makeSelect( { + sourceType: 'image/avif', + outputMimeType: 'image/jpeg', + } ); + + const thunk = generateThumbnails( 'item-1' ); + await thunk( { select, dispatch } ); + + expect( vipsBatchResizeImage ).toHaveBeenCalledWith( + 'item-1', + expect.any( File ), + 'image/jpeg', + expect.arrayContaining( [ + expect.objectContaining( { name: 'thumbnail' } ), + expect.objectContaining( { name: 'medium' } ), + ] ), + false + ); + // Sideload items should only have Upload operation. + const thumbnailCall = addSideloadItem.mock.calls.find( + ( call ) => call[ 0 ].additionalData?.image_size === 'thumbnail' + ); + expect( thumbnailCall ).toBeDefined(); + expect( thumbnailCall[ 0 ].operations ).toEqual( [ + OperationType.Upload, + ] ); + } ); + + it( 'should use source format for batch resize when no transcoding', async () => { + const select = makeSelect( { + sourceType: 'image/jpeg', + outputMimeType: undefined, + filename: 'photo.jpg', + } ); + + const thunk = generateThumbnails( 'item-1' ); + await thunk( { select, dispatch } ); + + expect( vipsBatchResizeImage ).toHaveBeenCalledWith( + 'item-1', + expect.any( File ), + 'image/jpeg', + expect.any( Array ), + false + ); + } ); + + it( 'should fall back to per-thumbnail processing when batch resize fails', async () => { + vipsBatchResizeImage.mockRejectedValueOnce( new Error( 'OOM' ) ); + const select = makeSelect( { + sourceType: 'image/avif', + outputMimeType: 'image/jpeg', + } ); + + const thunk = generateThumbnails( 'item-1' ); + await thunk( { select, dispatch } ); + + expect( console ).toHaveWarned(); + + // Should still create sideload items with ResizeCrop + TranscodeImage operations. + const thumbnailCall = addSideloadItem.mock.calls.find( + ( call ) => call[ 0 ].additionalData?.image_size === 'thumbnail' + ); + expect( thumbnailCall ).toBeDefined(); + expect( thumbnailCall[ 0 ].operations ).toEqual( + expect.arrayContaining( [ + expect.arrayContaining( [ OperationType.ResizeCrop ] ), + ] ) + ); + } ); + } ); + describe( 'finalizeItem', () => { it( 'should call mediaFinalize with the attachment ID', async () => { const mediaFinalize = jest.fn().mockResolvedValue( undefined ); diff --git a/packages/upload-media/src/store/utils/index.ts b/packages/upload-media/src/store/utils/index.ts index 6109c85781211d..c97c9c6ce0a8bf 100644 --- a/packages/upload-media/src/store/utils/index.ts +++ b/packages/upload-media/src/store/utils/index.ts @@ -198,6 +198,86 @@ export async function vipsResizeImage( return resultFile; } +/** + * Resizes an image into multiple sizes in a single pass using a web worker. + * + * Decodes the source once and uses copyMemory() + thumbnailImage() + * to avoid re-decoding for each sub-size. Writes each result directly + * to the specified output format. + * + * @param id Queue item ID. + * @param file Source file object. + * @param outputType Output mime type for all results (may differ from source). + * @param configs Array of { name, resize, quality } for each sub-size. + * @param smartCrop Whether to use smart cropping. + * @return Array of { name, file } results with proper filenames. + */ +export async function vipsBatchResizeImage( + id: QueueItemId, + file: File, + outputType: string, + configs: Array< { + name: string; + resize: ImageSizeCrop; + quality: number; + scaledSuffix?: boolean; + } >, + smartCrop = false +) { + const { vipsBatchResizeImage: batchResize } = await loadVipsModule(); + + const resizes = configs.map( ( c ) => ( { + resize: { ...c.resize }, + quality: c.quality, + } ) ); + + const results = await batchResize( + id, + await file.arrayBuffer(), + file.type, + outputType, + resizes, + smartCrop + ); + + const ext = outputType.split( '/' )[ 1 ]; + const sourceBasename = getFileBasename( file.name ); + + return results.map( ( result, i ) => { + const config = configs[ i ]; + const { width, height, originalWidth, originalHeight } = result; + const wasResized = originalWidth > width || originalHeight > height; + + let fileName = `${ sourceBasename }.${ ext }`; + if ( wasResized ) { + if ( config.scaledSuffix ) { + fileName = `${ sourceBasename }-scaled.${ ext }`; + } else { + fileName = `${ sourceBasename }-${ width }x${ height }.${ ext }`; + } + } + + return { + name: config.name, + file: new ImageFile( + new File( + [ + new Blob( [ result.buffer as ArrayBuffer ], { + type: outputType, + } ), + ], + fileName, + { type: outputType } + ), + width, + height, + originalWidth, + originalHeight + ), + }; + } ); +} + /** * Rotates an image based on EXIF orientation using vips in a web worker. * diff --git a/packages/vips/README.md b/packages/vips/README.md index e5fc1146909014..3aee99f699bb54 100644 --- a/packages/vips/README.md +++ b/packages/vips/README.md @@ -14,6 +14,25 @@ npm install @wordpress/vips --save +### batchResizeImage + +Resizes an image into multiple sizes in a single pass using copyMemory(). + +Decodes the source image once, materializes it in WASM memory via copyMemory(), then uses thumbnailImage() for each sub-size. This avoids re-decoding the source for every thumbnail. + +_Parameters_ + +- _id_ `ItemId`: Item ID. +- _buffer_ `ArrayBuffer`: Original file buffer. +- _inputType_ `string`: Input mime type. +- _outputType_ `string`: Output mime type for all results. +- _resizes_ `BatchResizeConfig[]`: Array of resize configurations. +- _smartCrop_ Whether to use smart cropping (i.e. saliency-aware). + +_Returns_ + +- `Promise< BatchResizeResult[] >`: Array of processed results, one per resize config. + ### cancelOperations Cancels all ongoing image operations for a given item ID. @@ -103,6 +122,25 @@ _Returns_ - `Promise< { buffer: ArrayBuffer | ArrayBufferLike; width: number; height: number; } >`: Rotated file data plus the new dimensions. +### vipsBatchResizeImage + +Resizes an image into multiple sizes in a single pass using copyMemory(). + +Decodes the source image once, materializes it in WASM memory via copyMemory(), then uses thumbnailImage() for each sub-size. This avoids re-decoding the source for every thumbnail. + +_Parameters_ + +- _id_ `ItemId`: Item ID. +- _buffer_ `ArrayBuffer`: Original file buffer. +- _inputType_ `string`: Input mime type. +- _outputType_ `string`: Output mime type for all results. +- _resizes_ `BatchResizeConfig[]`: Array of resize configurations. +- _smartCrop_ Whether to use smart cropping (i.e. saliency-aware). + +_Returns_ + +- `Promise< BatchResizeResult[] >`: Array of processed results, one per resize config. + ### vipsCancelOperations Cancels all ongoing image operations for a given item ID. diff --git a/packages/vips/src/index.ts b/packages/vips/src/index.ts index 9ea5d86d01dc4c..b2cbe945568e73 100644 --- a/packages/vips/src/index.ts +++ b/packages/vips/src/index.ts @@ -28,7 +28,7 @@ interface EmscriptenModule { let cleanup: () => void; -let vipsPromise: Promise< typeof Vips > | undefined; +let vipsInstance: typeof Vips; /** * Instantiates and returns a new vips instance. @@ -36,11 +36,11 @@ let vipsPromise: Promise< typeof Vips > | undefined; * Reuses any existing instance. */ async function getVips(): Promise< typeof Vips > { - if ( vipsPromise ) { - return await vipsPromise; + if ( vipsInstance ) { + return vipsInstance; } - vipsPromise = Vips( { + vipsInstance = await Vips( { // Load HEIF dynamic module for HEIF/HEIC and AVIF format support. // JXL is omitted as WordPress Core does not currently support it. // It can be re-added when Core adds JXL support. @@ -65,7 +65,7 @@ async function getVips(): Promise< typeof Vips > { }, } ); - return await vipsPromise; + return vipsInstance; } /** @@ -357,6 +357,199 @@ export async function resizeImage( } } +/** + * Configuration for a single resize operation within a batch. + */ +interface BatchResizeConfig { + resize: ImageSizeCrop; + quality: number; +} + +/** + * Result from a single resize operation within a batch. + */ +interface BatchResizeResult { + buffer: ArrayBuffer | ArrayBufferLike; + width: number; + height: number; + originalWidth: number; + originalHeight: number; +} + +/** + * Resizes an image into multiple sizes in a single pass using copyMemory(). + * + * Decodes the source image once, materializes it in WASM memory via + * copyMemory(), then uses thumbnailImage() for each sub-size. This avoids + * re-decoding the source for every thumbnail. + * + * @param id Item ID. + * @param buffer Original file buffer. + * @param inputType Input mime type. + * @param outputType Output mime type for all results. + * @param resizes Array of resize configurations. + * @param smartCrop Whether to use smart cropping (i.e. saliency-aware). + * @return Array of processed results, one per resize config. + */ +export async function batchResizeImage( + id: ItemId, + buffer: ArrayBuffer, + inputType: string, + outputType: string, + resizes: BatchResizeConfig[], + smartCrop = false +): Promise< BatchResizeResult[] > { + const ext = outputType.split( '/' )[ 1 ]; + + inProgressOperations.add( id ); + + try { + const vips = await getVips(); + + const strOptions = ''; + const loadOptions: LoadOptions< typeof inputType > = {}; + + // Do not load animation frames for batch resize — copyMemory() + // would materialize all frames and use excessive memory. + + const sourceImage = vips.Image.newFromBuffer( + buffer, + strOptions, + loadOptions + ); + + sourceImage.onProgress = () => { + if ( ! inProgressOperations.has( id ) ) { + sourceImage.kill = true; + } + }; + + const { width: originalWidth, pageHeight: originalHeight } = + sourceImage; + + // Materialize the decoded image in WASM memory. + // This renders the full pipeline once so thumbnailImage() calls + // do not re-decode the source. + const memImage = sourceImage.copyMemory(); + + const results: BatchResizeResult[] = []; + + for ( const config of resizes ) { + // Check cancellation between thumbnails. + if ( ! inProgressOperations.has( id ) ) { + break; + } + + const { resize } = config; + + // If resize.height is zero, calculate from aspect ratio. + resize.height = + resize.height || + ( originalHeight / originalWidth ) * resize.width; + + const thumbnailOptions: ThumbnailOptions = { + size: 'down', + height: resize.height, + }; + + let resizeWidth = resize.width; + let image; + + if ( ! resize.crop ) { + image = memImage.thumbnailImage( + resizeWidth, + thumbnailOptions + ); + } else if ( true === resize.crop ) { + thumbnailOptions.crop = smartCrop ? 'attention' : 'centre'; + + image = memImage.thumbnailImage( + resizeWidth, + thumbnailOptions + ); + } else { + // First resize, then do the cropping. + if ( originalWidth < originalHeight ) { + resizeWidth = + resize.width >= resize.height + ? resize.width + : ( originalWidth / originalHeight ) * + resize.height; + thumbnailOptions.height = + resize.width >= resize.height + ? ( originalHeight / originalWidth ) * resizeWidth + : resize.height; + } else { + resizeWidth = + resize.width >= resize.height + ? ( originalWidth / originalHeight ) * resize.height + : resize.width; + thumbnailOptions.height = + resize.width >= resize.height + ? resize.height + : ( originalHeight / originalWidth ) * resizeWidth; + } + + image = memImage.thumbnailImage( + resizeWidth, + thumbnailOptions + ); + + let left = 0; + if ( 'center' === resize.crop[ 0 ] ) { + left = ( image.width - resize.width ) / 2; + } else if ( 'right' === resize.crop[ 0 ] ) { + left = image.width - resize.width; + } + + let top = 0; + if ( 'center' === resize.crop[ 1 ] ) { + top = ( image.height - resize.height ) / 2; + } else if ( 'bottom' === resize.crop[ 1 ] ) { + top = image.height - resize.height; + } + + left = Math.max( 0, left ); + top = Math.max( 0, top ); + resize.width = Math.min( image.width, resize.width ); + resize.height = Math.min( image.height, resize.height ); + + image = image.crop( left, top, resize.width, resize.height ); + } + + const saveOptions: SaveOptions< typeof outputType > = { + keep: 'icc', + }; + + if ( supportsQuality( outputType ) ) { + saveOptions.Q = config.quality * 100; + } + + // Use faster AVIF encoding for sub-sizes. + if ( 'image/avif' === outputType ) { + saveOptions.effort = 2; + } + + const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); + + results.push( { + buffer: outBuffer.buffer, + width: image.width, + height: image.pageHeight, + originalWidth, + originalHeight, + } ); + } + + // Only call after all images are no longer being used. + cleanup?.(); + + return results; + } finally { + inProgressOperations.delete( id ); + } +} + /** * Rotates an image based on EXIF orientation value. * @@ -486,6 +679,7 @@ export { convertImageFormat as vipsConvertImageFormat, compressImage as vipsCompressImage, resizeImage as vipsResizeImage, + batchResizeImage as vipsBatchResizeImage, rotateImage as vipsRotateImage, hasTransparency as vipsHasTransparency, cancelOperations as vipsCancelOperations, diff --git a/packages/vips/src/vips-worker.ts b/packages/vips/src/vips-worker.ts index 17e7bd50add466..86655c5db55123 100644 --- a/packages/vips/src/vips-worker.ts +++ b/packages/vips/src/vips-worker.ts @@ -129,6 +129,50 @@ export async function vipsResizeImage( return api.resizeImage( id, buffer, type, resize, smartCrop, quality ); } +/** + * Resizes an image into multiple sizes in a single pass using a worker. + * + * Decodes the source once and uses copyMemory() + thumbnailImage() + * to avoid re-decoding for each sub-size. + * + * @param id Item ID. + * @param buffer Original file buffer. + * @param inputType Input mime type. + * @param outputType Output mime type for all results. + * @param resizes Array of resize configurations. + * @param smartCrop Whether to use smart cropping. + * @return Array of processed results. + */ +export async function vipsBatchResizeImage( + id: ItemId, + buffer: ArrayBuffer, + inputType: string, + outputType: string, + resizes: Array< { + resize: ImageSizeCrop; + quality: number; + } >, + smartCrop = false +): Promise< + Array< { + buffer: ArrayBuffer | ArrayBufferLike; + width: number; + height: number; + originalWidth: number; + originalHeight: number; + } > +> { + const api = getWorkerAPI(); + return api.batchResizeImage( + id, + buffer, + inputType, + outputType, + resizes, + smartCrop + ); +} + /** * Determines whether an image has an alpha channel using vips in a worker. * diff --git a/packages/vips/src/worker.ts b/packages/vips/src/worker.ts index 2fad366825604f..fcd7bf9c5ff2a0 100644 --- a/packages/vips/src/worker.ts +++ b/packages/vips/src/worker.ts @@ -19,6 +19,7 @@ import { convertImageFormat, compressImage, resizeImage, + batchResizeImage, rotateImage, hasTransparency, } from './index'; @@ -31,6 +32,7 @@ const api = { convertImageFormat, compressImage, resizeImage, + batchResizeImage, rotateImage, hasTransparency, }; From d74e1bd1405aabde3a7b580e2d5b45e15138817c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 2 Apr 2026 08:59:07 -0700 Subject: [PATCH 2/4] Restore vipsPromise singleton pattern in getVips() Fix unintended revert of #76780 during rebase. The promise-based approach ensures only a single Vips instance is ever created, even when multiple callers race on initialization. --- packages/vips/src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vips/src/index.ts b/packages/vips/src/index.ts index b2cbe945568e73..93964ae846f155 100644 --- a/packages/vips/src/index.ts +++ b/packages/vips/src/index.ts @@ -28,7 +28,7 @@ interface EmscriptenModule { let cleanup: () => void; -let vipsInstance: typeof Vips; +let vipsPromise: Promise< typeof Vips > | undefined; /** * Instantiates and returns a new vips instance. @@ -36,11 +36,11 @@ let vipsInstance: typeof Vips; * Reuses any existing instance. */ async function getVips(): Promise< typeof Vips > { - if ( vipsInstance ) { - return vipsInstance; + if ( vipsPromise ) { + return await vipsPromise; } - vipsInstance = await Vips( { + vipsPromise = Vips( { // Load HEIF dynamic module for HEIF/HEIC and AVIF format support. // JXL is omitted as WordPress Core does not currently support it. // It can be re-added when Core adds JXL support. @@ -65,7 +65,7 @@ async function getVips(): Promise< typeof Vips > { }, } ); - return vipsInstance; + return await vipsPromise; } /** From df1537e98839e3a3db896152459b4480a538f849 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 2 Apr 2026 09:02:09 -0700 Subject: [PATCH 3/4] Extract shared resize/crop logic into common helpers Add applyResizeAndCrop() to consolidate the duplicated dimension calculation and crop logic between resizeImage and batchResizeImage. Add buildSaveOptions() to unify save option construction, which also fixes the missing AVIF effort=2 setting in resizeImage. --- packages/vips/src/index.ts | 334 +++++++++++++++++-------------------- 1 file changed, 150 insertions(+), 184 deletions(-) diff --git a/packages/vips/src/index.ts b/packages/vips/src/index.ts index 93964ae846f155..37364597616bcd 100644 --- a/packages/vips/src/index.ts +++ b/packages/vips/src/index.ts @@ -186,6 +186,129 @@ export async function compressImage( return convertImageFormat( id, buffer, type, type, quality, interlaced ); } +/** + * Applies resize and optional crop logic to produce a thumbnail. + * + * Handles three crop modes: no crop (simple downscale), boolean `true` + * (center/attention crop), and positional crop (e.g. ['center', 'top']). + * + * @param resize Resize options including target dimensions and crop mode. + * @param originalWidth Width of the source image. + * @param originalHeight Height (pageHeight) of the source image. + * @param smartCrop Whether to use saliency-aware cropping. + * @param createThumbnail Callback that creates a thumbnail at the given width/options. + * @return The resized (and optionally cropped) image. + */ +function applyResizeAndCrop< + T extends { + width: number; + height: number; + crop: ( ...args: number[] ) => T; + }, +>( + resize: ImageSizeCrop, + originalWidth: number, + originalHeight: number, + smartCrop: boolean, + createThumbnail: ( width: number, options: ThumbnailOptions ) => T +): T { + // If resize.height is zero, calculate from aspect ratio. + resize.height = + resize.height || ( originalHeight / originalWidth ) * resize.width; + + const thumbnailOptions: ThumbnailOptions = { + size: 'down', + height: resize.height, + }; + + let resizeWidth = resize.width; + + if ( ! resize.crop ) { + return createThumbnail( resizeWidth, thumbnailOptions ); + } + + if ( true === resize.crop ) { + thumbnailOptions.crop = smartCrop ? 'attention' : 'centre'; + return createThumbnail( resizeWidth, thumbnailOptions ); + } + + // Positional crop: first resize, then crop to exact dimensions. + if ( originalWidth < originalHeight ) { + resizeWidth = + resize.width >= resize.height + ? resize.width + : ( originalWidth / originalHeight ) * resize.height; + thumbnailOptions.height = + resize.width >= resize.height + ? ( originalHeight / originalWidth ) * resizeWidth + : resize.height; + } else { + resizeWidth = + resize.width >= resize.height + ? ( originalWidth / originalHeight ) * resize.height + : resize.width; + thumbnailOptions.height = + resize.width >= resize.height + ? resize.height + : ( originalHeight / originalWidth ) * resizeWidth; + } + + const image = createThumbnail( resizeWidth, thumbnailOptions ); + + let left = 0; + if ( 'center' === resize.crop[ 0 ] ) { + left = ( image.width - resize.width ) / 2; + } else if ( 'right' === resize.crop[ 0 ] ) { + left = image.width - resize.width; + } + + let top = 0; + if ( 'center' === resize.crop[ 1 ] ) { + top = ( image.height - resize.height ) / 2; + } else if ( 'bottom' === resize.crop[ 1 ] ) { + top = image.height - resize.height; + } + + // Address rounding errors where `left` or `top` become negative integers + // and `resize.width` / `resize.height` are bigger than the actual dimensions. + // Downside: one side could be 1px smaller than the requested size. + left = Math.max( 0, left ); + top = Math.max( 0, top ); + resize.width = Math.min( image.width, resize.width ); + resize.height = Math.min( image.height, resize.height ); + + return image.crop( left, top, resize.width, resize.height ); +} + +/** + * Builds save options for writing an image to a buffer. + * + * @param type Output mime type. + * @param quality Desired quality (0-1). + * @return Save options object. + */ +function buildSaveOptions( + type: string, + quality: number +): SaveOptions< typeof type > { + const saveOptions: SaveOptions< typeof type > = { + // Strip metadata except ICC color profiles, + // matching WordPress core's behavior. + keep: 'icc', + }; + + if ( supportsQuality( type ) ) { + saveOptions.Q = quality * 100; + } + + // See https://github.com/swissspidy/media-experiments/issues/324. + if ( 'image/avif' === type ) { + saveOptions.effort = 2; + } + + return saveOptions; +} + /** * Resizes an image using vips. * @@ -217,9 +340,6 @@ export async function resizeImage( try { const vips = await getVips(); - const thumbnailOptions: ThumbnailOptions = { - size: 'down', - }; let strOptions = ''; const loadOptions: LoadOptions< typeof type > = {}; @@ -228,7 +348,6 @@ export async function resizeImage( // But only if we're not cropping. if ( supportsAnimation( type ) && ! resize.crop ) { strOptions = '[n=-1]'; - thumbnailOptions.option_string = strOptions; ( loadOptions as LoadOptions< typeof type > ).n = -1; } @@ -245,99 +364,26 @@ export async function resizeImage( const { width, pageHeight } = image; - // If resize.height is zero. - resize.height = resize.height || ( pageHeight / width ) * resize.width; - - let resizeWidth = resize.width; - thumbnailOptions.height = resize.height; - - if ( ! resize.crop ) { - image = vips.Image.thumbnailBuffer( - buffer, - resizeWidth, - thumbnailOptions - ); - - image.onProgress = onProgress; - } else if ( true === resize.crop ) { - thumbnailOptions.crop = smartCrop ? 'attention' : 'centre'; - - image = vips.Image.thumbnailBuffer( - buffer, - resizeWidth, - thumbnailOptions - ); - - image.onProgress = onProgress; - } else { - // First resize, then do the cropping. - // This allows operating on the second bitmap with the correct dimensions. - - if ( width < pageHeight ) { - resizeWidth = - resize.width >= resize.height - ? resize.width - : ( width / pageHeight ) * resize.height; - thumbnailOptions.height = - resize.width >= resize.height - ? ( pageHeight / width ) * resizeWidth - : resize.height; - } else { - resizeWidth = - resize.width >= resize.height - ? ( width / pageHeight ) * resize.height - : resize.width; - thumbnailOptions.height = - resize.width >= resize.height - ? resize.height - : ( pageHeight / width ) * resizeWidth; - } - - image = vips.Image.thumbnailBuffer( - buffer, - resizeWidth, - thumbnailOptions - ); - - image.onProgress = onProgress; - - let left = 0; - if ( 'center' === resize.crop[ 0 ] ) { - left = ( image.width - resize.width ) / 2; - } else if ( 'right' === resize.crop[ 0 ] ) { - left = image.width - resize.width; - } - - let top = 0; - if ( 'center' === resize.crop[ 1 ] ) { - top = ( image.height - resize.height ) / 2; - } else if ( 'bottom' === resize.crop[ 1 ] ) { - top = image.height - resize.height; + image = applyResizeAndCrop( + resize, + width, + pageHeight, + smartCrop, + ( resizeWidth, thumbnailOptions ) => { + if ( strOptions ) { + thumbnailOptions.option_string = strOptions; + } + const thumb = vips.Image.thumbnailBuffer( + buffer, + resizeWidth, + thumbnailOptions + ); + thumb.onProgress = onProgress; + return thumb; } + ); - // Address rounding errors where `left` or `top` become negative integers - // and `resize.width` / `resize.height` are bigger than the actual dimensions. - // Downside: one side could be 1px smaller than the requested size. - left = Math.max( 0, left ); - top = Math.max( 0, top ); - resize.width = Math.min( image.width, resize.width ); - resize.height = Math.min( image.height, resize.height ); - - image = image.crop( left, top, resize.width, resize.height ); - - image.onProgress = onProgress; - } - - const saveOptions: SaveOptions< typeof type > = { - // Strip metadata except ICC color profiles, - // matching WordPress core's behavior. - keep: 'icc', - }; - - if ( supportsQuality( type ) ) { - saveOptions.Q = quality * 100; - } - + const saveOptions = buildSaveOptions( type, quality ); const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); const result = { @@ -440,96 +486,16 @@ export async function batchResizeImage( break; } - const { resize } = config; - - // If resize.height is zero, calculate from aspect ratio. - resize.height = - resize.height || - ( originalHeight / originalWidth ) * resize.width; - - const thumbnailOptions: ThumbnailOptions = { - size: 'down', - height: resize.height, - }; - - let resizeWidth = resize.width; - let image; - - if ( ! resize.crop ) { - image = memImage.thumbnailImage( - resizeWidth, - thumbnailOptions - ); - } else if ( true === resize.crop ) { - thumbnailOptions.crop = smartCrop ? 'attention' : 'centre'; - - image = memImage.thumbnailImage( - resizeWidth, - thumbnailOptions - ); - } else { - // First resize, then do the cropping. - if ( originalWidth < originalHeight ) { - resizeWidth = - resize.width >= resize.height - ? resize.width - : ( originalWidth / originalHeight ) * - resize.height; - thumbnailOptions.height = - resize.width >= resize.height - ? ( originalHeight / originalWidth ) * resizeWidth - : resize.height; - } else { - resizeWidth = - resize.width >= resize.height - ? ( originalWidth / originalHeight ) * resize.height - : resize.width; - thumbnailOptions.height = - resize.width >= resize.height - ? resize.height - : ( originalHeight / originalWidth ) * resizeWidth; - } - - image = memImage.thumbnailImage( - resizeWidth, - thumbnailOptions - ); - - let left = 0; - if ( 'center' === resize.crop[ 0 ] ) { - left = ( image.width - resize.width ) / 2; - } else if ( 'right' === resize.crop[ 0 ] ) { - left = image.width - resize.width; - } - - let top = 0; - if ( 'center' === resize.crop[ 1 ] ) { - top = ( image.height - resize.height ) / 2; - } else if ( 'bottom' === resize.crop[ 1 ] ) { - top = image.height - resize.height; - } - - left = Math.max( 0, left ); - top = Math.max( 0, top ); - resize.width = Math.min( image.width, resize.width ); - resize.height = Math.min( image.height, resize.height ); - - image = image.crop( left, top, resize.width, resize.height ); - } - - const saveOptions: SaveOptions< typeof outputType > = { - keep: 'icc', - }; - - if ( supportsQuality( outputType ) ) { - saveOptions.Q = config.quality * 100; - } - - // Use faster AVIF encoding for sub-sizes. - if ( 'image/avif' === outputType ) { - saveOptions.effort = 2; - } + const image = applyResizeAndCrop( + config.resize, + originalWidth, + originalHeight, + smartCrop, + ( resizeWidth, thumbnailOptions ) => + memImage.thumbnailImage( resizeWidth, thumbnailOptions ) + ); + const saveOptions = buildSaveOptions( outputType, config.quality ); const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); results.push( { From 0120477fe6842d0b3d9527f2d37e19e393bf83e6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Apr 2026 18:24:58 -0600 Subject: [PATCH 4/4] Address review nits in applyResizeAndCrop and batchResizeImage Clone the resize config into a local target object rather than mutating the caller's ImageSizeCrop parameter, and drop an unused strOptions constant in batchResizeImage. --- packages/vips/src/index.ts | 70 ++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/packages/vips/src/index.ts b/packages/vips/src/index.ts index 37364597616bcd..3a2657bed4d6b5 100644 --- a/packages/vips/src/index.ts +++ b/packages/vips/src/index.ts @@ -212,22 +212,26 @@ function applyResizeAndCrop< smartCrop: boolean, createThumbnail: ( width: number, options: ThumbnailOptions ) => T ): T { + // Clone so we don't mutate the caller's config. // If resize.height is zero, calculate from aspect ratio. - resize.height = - resize.height || ( originalHeight / originalWidth ) * resize.width; + const target: ImageSizeCrop = { + ...resize, + height: + resize.height || ( originalHeight / originalWidth ) * resize.width, + }; const thumbnailOptions: ThumbnailOptions = { size: 'down', - height: resize.height, + height: target.height, }; - let resizeWidth = resize.width; + let resizeWidth = target.width; - if ( ! resize.crop ) { + if ( ! target.crop ) { return createThumbnail( resizeWidth, thumbnailOptions ); } - if ( true === resize.crop ) { + if ( true === target.crop ) { thumbnailOptions.crop = smartCrop ? 'attention' : 'centre'; return createThumbnail( resizeWidth, thumbnailOptions ); } @@ -235,49 +239,49 @@ function applyResizeAndCrop< // Positional crop: first resize, then crop to exact dimensions. if ( originalWidth < originalHeight ) { resizeWidth = - resize.width >= resize.height - ? resize.width - : ( originalWidth / originalHeight ) * resize.height; + target.width >= target.height + ? target.width + : ( originalWidth / originalHeight ) * target.height; thumbnailOptions.height = - resize.width >= resize.height + target.width >= target.height ? ( originalHeight / originalWidth ) * resizeWidth - : resize.height; + : target.height; } else { resizeWidth = - resize.width >= resize.height - ? ( originalWidth / originalHeight ) * resize.height - : resize.width; + target.width >= target.height + ? ( originalWidth / originalHeight ) * target.height + : target.width; thumbnailOptions.height = - resize.width >= resize.height - ? resize.height + target.width >= target.height + ? target.height : ( originalHeight / originalWidth ) * resizeWidth; } const image = createThumbnail( resizeWidth, thumbnailOptions ); let left = 0; - if ( 'center' === resize.crop[ 0 ] ) { - left = ( image.width - resize.width ) / 2; - } else if ( 'right' === resize.crop[ 0 ] ) { - left = image.width - resize.width; + if ( 'center' === target.crop[ 0 ] ) { + left = ( image.width - target.width ) / 2; + } else if ( 'right' === target.crop[ 0 ] ) { + left = image.width - target.width; } let top = 0; - if ( 'center' === resize.crop[ 1 ] ) { - top = ( image.height - resize.height ) / 2; - } else if ( 'bottom' === resize.crop[ 1 ] ) { - top = image.height - resize.height; + if ( 'center' === target.crop[ 1 ] ) { + top = ( image.height - target.height ) / 2; + } else if ( 'bottom' === target.crop[ 1 ] ) { + top = image.height - target.height; } // Address rounding errors where `left` or `top` become negative integers - // and `resize.width` / `resize.height` are bigger than the actual dimensions. + // and `target.width` / `target.height` are bigger than the actual dimensions. // Downside: one side could be 1px smaller than the requested size. left = Math.max( 0, left ); top = Math.max( 0, top ); - resize.width = Math.min( image.width, resize.width ); - resize.height = Math.min( image.height, resize.height ); + const cropWidth = Math.min( image.width, target.width ); + const cropHeight = Math.min( image.height, target.height ); - return image.crop( left, top, resize.width, resize.height ); + return image.crop( left, top, cropWidth, cropHeight ); } /** @@ -452,17 +456,11 @@ export async function batchResizeImage( try { const vips = await getVips(); - const strOptions = ''; - const loadOptions: LoadOptions< typeof inputType > = {}; - // Do not load animation frames for batch resize — copyMemory() // would materialize all frames and use excessive memory. + const loadOptions: LoadOptions< typeof inputType > = {}; - const sourceImage = vips.Image.newFromBuffer( - buffer, - strOptions, - loadOptions - ); + const sourceImage = vips.Image.newFromBuffer( buffer, '', loadOptions ); sourceImage.onProgress = () => { if ( ! inProgressOperations.has( id ) ) {