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..3a2657bed4d6b5 100644 --- a/packages/vips/src/index.ts +++ b/packages/vips/src/index.ts @@ -186,6 +186,133 @@ 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 { + // Clone so we don't mutate the caller's config. + // If resize.height is zero, calculate from aspect ratio. + const target: ImageSizeCrop = { + ...resize, + height: + resize.height || ( originalHeight / originalWidth ) * resize.width, + }; + + const thumbnailOptions: ThumbnailOptions = { + size: 'down', + height: target.height, + }; + + let resizeWidth = target.width; + + if ( ! target.crop ) { + return createThumbnail( resizeWidth, thumbnailOptions ); + } + + if ( true === target.crop ) { + thumbnailOptions.crop = smartCrop ? 'attention' : 'centre'; + return createThumbnail( resizeWidth, thumbnailOptions ); + } + + // Positional crop: first resize, then crop to exact dimensions. + if ( originalWidth < originalHeight ) { + resizeWidth = + target.width >= target.height + ? target.width + : ( originalWidth / originalHeight ) * target.height; + thumbnailOptions.height = + target.width >= target.height + ? ( originalHeight / originalWidth ) * resizeWidth + : target.height; + } else { + resizeWidth = + target.width >= target.height + ? ( originalWidth / originalHeight ) * target.height + : target.width; + thumbnailOptions.height = + target.width >= target.height + ? target.height + : ( originalHeight / originalWidth ) * resizeWidth; + } + + const image = createThumbnail( resizeWidth, thumbnailOptions ); + + let left = 0; + 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' === 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 `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 ); + const cropWidth = Math.min( image.width, target.width ); + const cropHeight = Math.min( image.height, target.height ); + + return image.crop( left, top, cropWidth, cropHeight ); +} + +/** + * 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 +344,6 @@ export async function resizeImage( try { const vips = await getVips(); - const thumbnailOptions: ThumbnailOptions = { - size: 'down', - }; let strOptions = ''; const loadOptions: LoadOptions< typeof type > = {}; @@ -228,7 +352,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,113 +368,147 @@ export async function resizeImage( const { width, pageHeight } = image; - // If resize.height is zero. - resize.height = resize.height || ( pageHeight / width ) * resize.width; + 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; + } + ); - let resizeWidth = resize.width; - thumbnailOptions.height = resize.height; + const saveOptions = buildSaveOptions( type, quality ); + const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); - if ( ! resize.crop ) { - image = vips.Image.thumbnailBuffer( - buffer, - resizeWidth, - thumbnailOptions - ); + const result = { + buffer: outBuffer.buffer, + width: image.width, + height: image.pageHeight, + originalWidth: width, + originalHeight: pageHeight, + }; - image.onProgress = onProgress; - } else if ( true === resize.crop ) { - thumbnailOptions.crop = smartCrop ? 'attention' : 'centre'; + // Only call after `image` is no longer being used. + cleanup?.(); - image = vips.Image.thumbnailBuffer( - buffer, - resizeWidth, - thumbnailOptions - ); + return result; + } finally { + inProgressOperations.delete( id ); + } +} - 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; - } +/** + * Configuration for a single resize operation within a batch. + */ +interface BatchResizeConfig { + resize: ImageSizeCrop; + quality: number; +} - image = vips.Image.thumbnailBuffer( - buffer, - resizeWidth, - thumbnailOptions - ); +/** + * Result from a single resize operation within a batch. + */ +interface BatchResizeResult { + buffer: ArrayBuffer | ArrayBufferLike; + width: number; + height: number; + originalWidth: number; + originalHeight: number; +} - image.onProgress = onProgress; +/** + * 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 ]; - 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; - } + inProgressOperations.add( id ); + + try { + const vips = await getVips(); + + // Do not load animation frames for batch resize — copyMemory() + // would materialize all frames and use excessive memory. + const loadOptions: LoadOptions< typeof inputType > = {}; - 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; + const sourceImage = vips.Image.newFromBuffer( buffer, '', loadOptions ); + + sourceImage.onProgress = () => { + if ( ! inProgressOperations.has( id ) ) { + sourceImage.kill = true; } + }; - // 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 ); + const { width: originalWidth, pageHeight: originalHeight } = + sourceImage; - image = image.crop( left, top, resize.width, resize.height ); + // 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(); - image.onProgress = onProgress; - } + const results: BatchResizeResult[] = []; - const saveOptions: SaveOptions< typeof type > = { - // Strip metadata except ICC color profiles, - // matching WordPress core's behavior. - keep: 'icc', - }; + for ( const config of resizes ) { + // Check cancellation between thumbnails. + if ( ! inProgressOperations.has( id ) ) { + break; + } - if ( supportsQuality( type ) ) { - saveOptions.Q = quality * 100; - } + const image = applyResizeAndCrop( + config.resize, + originalWidth, + originalHeight, + smartCrop, + ( resizeWidth, thumbnailOptions ) => + memImage.thumbnailImage( resizeWidth, thumbnailOptions ) + ); - const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); + const saveOptions = buildSaveOptions( outputType, config.quality ); + const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); - const result = { - buffer: outBuffer.buffer, - width: image.width, - height: image.pageHeight, - originalWidth: width, - originalHeight: pageHeight, - }; + results.push( { + buffer: outBuffer.buffer, + width: image.width, + height: image.pageHeight, + originalWidth, + originalHeight, + } ); + } - // Only call after `image` is no longer being used. + // Only call after all images are no longer being used. cleanup?.(); - return result; + return results; } finally { inProgressOperations.delete( id ); } @@ -486,6 +643,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, };