Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/vips/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- Add optional `stripMeta` and `maxBitdepth` parameters to `resizeImage`, `compressImage`, and `convertImageFormat`, so callers can keep all image metadata and cap the output bit depth, mirroring WordPress core's `image_strip_meta` and `image_max_bit_depth` filters ([#80216](https://github.com/WordPress/gutenberg/issues/80216)).

### Bug Fixes

- `convertImageFormat`: Only decode all animation frames when the output format supports animation. Converting a long animated GIF to a still format (e.g. a JPEG poster) previously decoded every frame as one vertical strip, which exceeded the JPEG encoder's 65,500 px dimension limit and produced a film-strip image instead of a single frame ([#80259](https://github.com/WordPress/gutenberg/issues/80259)).

## 2.4.0 (2026-07-14)

## 2.3.0 (2026-07-01)
Expand Down
14 changes: 12 additions & 2 deletions packages/vips/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,18 @@ export async function convertImageFormat(
let strOptions = '';
const loadOptions: LoadOptions< typeof inputType > = {};

// To ensure all frames are loaded in case the image is animated.
if ( supportsAnimation( inputType ) ) {
/*
* To ensure all frames are loaded in case the image is animated and
* the output format can represent them. A still output (e.g. a JPEG
* poster for a GIF) only needs the first frame; loading all frames
* would decode them as one vertical strip whose height easily
* exceeds encoder dimension limits for long animations.
* See https://github.com/WordPress/gutenberg/issues/80259.
*/
if (
supportsAnimation( inputType ) &&
supportsAnimation( outputType )
) {
strOptions = '[n=-1]';
( loadOptions as LoadOptions< typeof inputType > ).n = -1;
}
Expand Down
100 changes: 100 additions & 0 deletions packages/vips/src/test/convert-image-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Internal dependencies
*/
import { convertImageFormat, compressImage } from '../';

const mockNewFromBuffer = jest.fn( () => new MockImage() );
const mockWriteToBuffer = jest.fn( () => ( {
buffer: '',
} ) );

class MockImage {
width = 100;
height = 100;
pageHeight = 100;
writeToBuffer = mockWriteToBuffer;
getInt = jest.fn( () => 0 );
}

class MockVipsImage {
static newFromBuffer = mockNewFromBuffer;
}

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

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

it( 'loads only the first frame when converting a GIF to JPEG', async () => {
/*
* Regression test for https://github.com/WordPress/gutenberg/issues/80259.
* Loading an animated GIF with n=-1 produces a vertical strip image
* `frames × height` pixels tall. A JPEG cannot be animated, and
* libjpeg's 65,500 px dimension limit makes saving such a strip fail
* for long GIFs, so a still output must only decode the first frame.
*/
const gifFile = new File( [ '<BLOB>' ], 'example.gif', {
lastModified: 1234567891,
type: 'image/gif',
} );
const buffer = await gifFile.arrayBuffer();

await convertImageFormat( 'itemId', buffer, 'image/gif', 'image/jpeg' );

expect( mockNewFromBuffer ).toHaveBeenCalledWith( buffer, '', {} );
} );

it( 'loads all frames when converting a GIF to WebP', async () => {
const gifFile = new File( [ '<BLOB>' ], 'example.gif', {
lastModified: 1234567891,
type: 'image/gif',
} );
const buffer = await gifFile.arrayBuffer();

await convertImageFormat( 'itemId', buffer, 'image/gif', 'image/webp' );

expect( mockNewFromBuffer ).toHaveBeenCalledWith( buffer, '[n=-1]', {
n: -1,
} );
} );

it( 'loads all frames when compressing a GIF in place', async () => {
const gifFile = new File( [ '<BLOB>' ], 'example.gif', {
lastModified: 1234567891,
type: 'image/gif',
} );
const buffer = await gifFile.arrayBuffer();

await compressImage( 'itemId', buffer, 'image/gif' );

expect( mockNewFromBuffer ).toHaveBeenCalledWith( buffer, '[n=-1]', {
n: -1,
} );
} );

it( 'does not pass animation load options for still images', async () => {
const jpegFile = new File( [ '<BLOB>' ], 'example.jpg', {
lastModified: 1234567891,
type: 'image/jpeg',
} );
const buffer = await jpegFile.arrayBuffer();

await convertImageFormat(
'itemId',
buffer,
'image/jpeg',
'image/webp'
);

expect( mockNewFromBuffer ).toHaveBeenCalledWith( buffer, '', {} );
} );
} );
4 changes: 4 additions & 0 deletions packages/worker-threads/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Bug Fixes

- `expose`: Normalize thrown values that are not `Error` instances (or have an empty message), such as `WebAssembly.Exception`, so they reach the main thread as promise rejections instead of being mistaken for a successful `undefined` result ([#80259](https://github.com/WordPress/gutenberg/issues/80259)).

## 1.11.0 (2026-07-14)

## 1.10.0 (2026-07-01)
Expand Down
184 changes: 184 additions & 0 deletions packages/worker-threads/src/test/error-propagation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* Tests for error propagation across the worker RPC boundary.
*
* The RPC reply only carries an error *message* string. A worker method that
* throws a non-`Error` value (e.g. a `WebAssembly.Exception` from a wasm
* module) or an `Error` with an empty message would otherwise produce a reply
* that the main thread treats as a successful `undefined` result instead of
* a rejection. See https://github.com/WordPress/gutenberg/issues/80259.
*/

// Store the original self.
const originalSelf = globalThis.self;

// Mock self for worker context.
let mockPostMessage: jest.Mock;
let messageListeners: Array< ( event: MessageEvent ) => void > = [];

function setupMockSelf() {
mockPostMessage = jest.fn();
messageListeners = [];

// Override self with addEventListener pattern (matching comctx usage).
const mockSelf = {
postMessage: mockPostMessage,
addEventListener: (
type: string,
handler: ( event: MessageEvent ) => void
) => {
if ( type === 'message' ) {
messageListeners.push( handler );
}
},
removeEventListener: (
type: string,
handler: ( event: MessageEvent ) => void
) => {
if ( type === 'message' ) {
messageListeners = messageListeners.filter(
( h ) => h !== handler
);
}
},
};

Object.defineProperty( globalThis, 'self', {
value: mockSelf,
writable: true,
configurable: true,
} );
}

function restoreSelf() {
Object.defineProperty( globalThis, 'self', {
value: originalSelf,
writable: true,
configurable: true,
} );
messageListeners = [];
}

/**
* Sends an RPC "apply" message for the given method to the exposed worker
* API and returns the reply message posted back by the worker.
*
* @param method Method name to call.
* @return The reply message.
*/
async function callExposedMethod(
method: string
): Promise< { id: string; data?: unknown; error?: string } > {
const messageId = `test-${ method }`;
const applyMessage = {
type: 'apply',
sender: 'injector',
id: messageId,
path: [ method ],
args: [],
callbackIds: [],
meta: {},
namespace: '__wordpress_worker__',
timeStamp: 1234567891,
};

for ( const listener of [ ...messageListeners ] ) {
listener( { data: applyMessage } as MessageEvent );
}

// Wait for the async handler to post the reply.
for ( let i = 0; i < 10; i++ ) {
await new Promise( ( resolve ) => {
setTimeout( resolve, 0 );
} );
const reply = mockPostMessage.mock.calls
.map( ( call ) => call[ 0 ] )
.find( ( message ) => message?.id === messageId );
if ( reply ) {
return reply;
}
}

throw new Error( `No reply received for method "${ method }"` );
}

describe( 'worker-thread error propagation', () => {
beforeEach( () => {
setupMockSelf();
jest.resetModules();
} );

afterEach( () => {
restoreSelf();
} );

it( 'propagates the message of a thrown Error', async () => {
const { expose } = await import( '../worker-thread' );
expose( {
fails: async () => {
throw new Error( 'boom' );
},
} );

const reply = await callExposedMethod( 'fails' );

expect( reply.error ).toBe( 'boom' );
} );

it( 'rejects with a non-empty message when a non-Error value is thrown', async () => {
const { expose } = await import( '../worker-thread' );

// Mimics a WebAssembly.Exception: an object that is not an Error
// and has no message property.
class FakeWasmException {}

expose( {
fails: async () => {
throw new FakeWasmException();
},
} );

const reply = await callExposedMethod( 'fails' );

expect( typeof reply.error ).toBe( 'string' );
expect( reply.error ).not.toHaveLength( 0 );
} );

it( 'rejects with a non-empty message when an Error with an empty message is thrown', async () => {
const { expose } = await import( '../worker-thread' );
expose( {
fails: async () => {
throw new Error();
},
} );

const reply = await callExposedMethod( 'fails' );

expect( typeof reply.error ).toBe( 'string' );
expect( reply.error ).not.toHaveLength( 0 );
} );

it( 'rejects when a string is thrown', async () => {
const { expose } = await import( '../worker-thread' );
expose( {
fails: async () => {
throw 'string failure';
},
} );

const reply = await callExposedMethod( 'fails' );

expect( reply.error ).toContain( 'string failure' );
} );

it( 'does not affect successful results', async () => {
const { expose } = await import( '../worker-thread' );
expose( {
succeeds: async () => 'result',
} );

const reply = await callExposedMethod( 'succeeds' );

expect( reply.error ).toBeUndefined();
expect( reply.data ).toBe( 'result' );
} );
} );
Loading
Loading