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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/vips/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ 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.

Cancellation semantics: atomic. If `cancelOperations()` is invoked mid-batch, this function throws `OperationCancelledError` and discards any thumbnails generated so far. Callers never observe a partially filled result array — either every requested size is returned, or the call rejects with no usable output.

_Parameters_

- _id_ `ItemId`: Item ID.
Expand Down Expand Up @@ -88,6 +90,16 @@ _Returns_

- `Promise< boolean >`: Whether the image has an alpha channel.

### OperationCancelledError

Thrown when an image operation is cancelled mid-flight.

Lets callers distinguish user-cancellation from genuine processing failures so they can suppress error reporting for cancelled work.

_Type_

- `OperationCancelledError`

### resizeImage

Resizes an image using vips.
Expand Down Expand Up @@ -128,6 +140,8 @@ 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.

Cancellation semantics: atomic. If `cancelOperations()` is invoked mid-batch, this function throws `OperationCancelledError` and discards any thumbnails generated so far. Callers never observe a partially filled result array — either every requested size is returned, or the call rejects with no usable output.

_Parameters_

- _id_ `ItemId`: Item ID.
Expand Down Expand Up @@ -196,6 +210,16 @@ _Returns_

- `Promise< boolean >`: Whether the image has an alpha channel.

### VipsOperationCancelledError

Thrown when an image operation is cancelled mid-flight.

Lets callers distinguish user-cancellation from genuine processing failures so they can suppress error reporting for cancelled work.

_Type_

- `OperationCancelledError`

### vipsResizeImage

Resizes an image using vips.
Expand Down
45 changes: 34 additions & 11 deletions packages/vips/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ export async function cancelOperations( id: ItemId ) {
return inProgressOperations.delete( id );
}

/**
* Thrown when an image operation is cancelled mid-flight.
*
* Lets callers distinguish user-cancellation from genuine processing
* failures so they can suppress error reporting for cancelled work.
*/
export class OperationCancelledError extends Error {
constructor( message = 'Image processing was cancelled' ) {
super( message );
this.name = 'OperationCancelledError';
}
}

/**
* Converts an image to a different format using vips.
*
Expand Down Expand Up @@ -433,13 +446,21 @@ interface BatchResizeResult {
* 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).
* Cancellation semantics: atomic. If `cancelOperations()` is invoked
* mid-batch, this function throws `OperationCancelledError` and discards
* any thumbnails generated so far. Callers never observe a partially
* filled result array — either every requested size is returned, or the
* call rejects with no usable output.
*
* @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.
* @throws {OperationCancelledError} When the operation is cancelled
* before all sizes are produced.
*/
export async function batchResizeImage(
id: ItemId,
Expand Down Expand Up @@ -479,9 +500,10 @@ export async function batchResizeImage(
const results: BatchResizeResult[] = [];

for ( const config of resizes ) {
// Check cancellation between thumbnails.
// Atomic cancellation: discard partial results so the caller
// never has to reconcile a half-finished batch.
if ( ! inProgressOperations.has( id ) ) {
break;
throw new OperationCancelledError();
}
Comment thread
adamsilverstein marked this conversation as resolved.

const image = applyResizeAndCrop(
Expand All @@ -505,11 +527,11 @@ export async function batchResizeImage(
} );
}

// Only call after all images are no longer being used.
cleanup?.();

return results;
} finally {
// Run after all images are out of scope, regardless of whether
// the loop completed, was cancelled, or threw.
cleanup?.();
inProgressOperations.delete( id );
}
}
Expand Down Expand Up @@ -647,4 +669,5 @@ export {
rotateImage as vipsRotateImage,
hasTransparency as vipsHasTransparency,
cancelOperations as vipsCancelOperations,
OperationCancelledError as VipsOperationCancelledError,
};
115 changes: 115 additions & 0 deletions packages/vips/src/test/batch-resize-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Internal dependencies
*/
import {
batchResizeImage,
cancelOperations,
OperationCancelledError,
} from '../';

const mockThumbnailImage = jest.fn( () => new MockImage() );
const mockCopyMemory = jest.fn( () => new MockImage() );
const mockNewFromBuffer = jest.fn( () => new MockImage() );

class MockImage {
width = 100;
height = 100;
pageHeight = 100;
kill = false;
onProgress: ( () => void ) | undefined;
thumbnailImage = mockThumbnailImage;
copyMemory = mockCopyMemory;
writeToBuffer = jest.fn( () => ( {
buffer: new ArrayBuffer( 0 ),
} ) );
}

class MockVipsImage {
static newFromBuffer = mockNewFromBuffer;
}

jest.mock( 'wasm-vips', () =>
jest.fn( () => ( {
Image: MockVipsImage,
} ) )
);

const buildBuffer = async () => {
const file = new File( [ '<BLOB>' ], 'example.jpg', {
lastModified: 1234567891,
type: 'image/jpeg',
} );
return file.arrayBuffer();
};

describe( 'batchResizeImage', () => {
afterEach( () => {
jest.clearAllMocks();
} );

it( 'returns one result per requested size when not cancelled', async () => {
const buffer = await buildBuffer();

const results = await batchResizeImage(
'itemId',
buffer,
'image/jpeg',
'image/jpeg',
[
{ resize: { width: 100, height: 100 }, quality: 0.82 },
{ resize: { width: 50, height: 50 }, quality: 0.82 },
{ resize: { width: 25, height: 25 }, quality: 0.82 },
]
);

expect( results ).toHaveLength( 3 );
expect( mockThumbnailImage ).toHaveBeenCalledTimes( 3 );
} );

it( 'throws OperationCancelledError and discards partial results when cancelled mid-batch', async () => {
const buffer = await buildBuffer();

// Cancel after the first thumbnail is produced. Each call to
// thumbnailImage() advances one iteration; on the second call we
// trigger cancellation so the loop check at the top of the third
// iteration aborts the batch.
let calls = 0;
mockThumbnailImage.mockImplementation( () => {
calls += 1;
if ( calls === 2 ) {
cancelOperations( 'itemId' );
}
return new MockImage();
} );

await expect(
batchResizeImage( 'itemId', buffer, 'image/jpeg', 'image/jpeg', [
{ resize: { width: 100, height: 100 }, quality: 0.82 },
{ resize: { width: 50, height: 50 }, quality: 0.82 },
{ resize: { width: 25, height: 25 }, quality: 0.82 },
] )
).rejects.toBeInstanceOf( OperationCancelledError );

// Two iterations executed before cancellation aborted the third.
expect( mockThumbnailImage ).toHaveBeenCalledTimes( 2 );
} );

it( 'throws when cancelled before the first thumbnail', async () => {
const buffer = await buildBuffer();

// Pre-cancel: the loop's first iteration check should fire.
mockNewFromBuffer.mockImplementationOnce( () => {
cancelOperations( 'itemId' );
return new MockImage();
} );

await expect(
batchResizeImage( 'itemId', buffer, 'image/jpeg', 'image/jpeg', [
{ resize: { width: 100, height: 100 }, quality: 0.82 },
{ resize: { width: 50, height: 50 }, quality: 0.82 },
] )
).rejects.toBeInstanceOf( OperationCancelledError );

expect( mockThumbnailImage ).not.toHaveBeenCalled();
} );
} );
Loading