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
1 change: 1 addition & 0 deletions packages/vips/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Enhancements

- Bump `wasm-vips` to `^0.0.18`, adding native decoding of 10- and 12-bit (high bit depth) AVIF images ([#79179](https://github.com/WordPress/gutenberg/pull/79179)).
- Preserve the bit depth of high-bit-depth (10/12-bit) AVIF images through `resizeImage`, `compressImage`, and `convertImageFormat` so HDR sub-sizes are no longer flattened to 8-bit.

## 2.1.1 (2026-06-16)

Expand Down
132 changes: 128 additions & 4 deletions packages/vips/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,15 @@ export async function convertImageFormat(
// See https://github.com/swissspidy/media-experiments/issues/324.
if ( 'image/avif' === outputType ) {
saveOptions.effort = 2;

// Preserve the source bit depth so high-bit-depth (10/12-bit) HDR
// images stay high-bit-depth when compressed or converted to AVIF
// instead of being flattened to 8-bit. Unlike resizing, this path
// keeps the decoded 16-bit image, so no extra handling is needed.
const sourceBitdepth = getSourceBitdepth( image );
if ( sourceBitdepth > 8 ) {
saveOptions.bitdepth = sourceBitdepth;
}
}

const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions );
Expand Down Expand Up @@ -373,16 +382,43 @@ function applyResizeAndCrop<
return result;
}

/**
* Reads the source bit depth of a decoded HEIF/AVIF image.
*
* High-bit-depth (10/12-bit) AVIF/HEIF images decode into a 16-bit `ushort`
* container and expose a `heif-bitdepth` metadata field. Standard 8-bit images
* report 8 or omit the field. Used to keep HDR sub-sizes at their original bit
* depth instead of silently flattening them to 8-bit.
*
* @param image Decoded vips image.
* @return Source bit depth (typically 8, 10, or 12).
*/
function getSourceBitdepth< T extends { getInt: ( name: string ) => number } >(
image: T
): number {
try {
const bitdepth = image.getInt( 'heif-bitdepth' );
if ( bitdepth > 8 ) {
return bitdepth;
}
} catch {
// Field absent: standard (8-bit) image.
}
return 8;
}

/**
* Builds save options for writing an image to a buffer.
*
* @param type Output mime type.
* @param quality Desired quality (0-1).
* @param type Output mime type.
* @param quality Desired quality (0-1).
* @param bitdepth Source bit depth; values above 8 are preserved for AVIF.
* @return Save options object.
*/
function buildSaveOptions(
type: string,
quality: number
quality: number,
bitdepth = 8
): SaveOptions< typeof type > {
const saveOptions: SaveOptions< typeof type > = {
// Strip metadata except ICC color profiles or gainmaps,
Expand All @@ -397,11 +433,78 @@ function buildSaveOptions(
// See https://github.com/swissspidy/media-experiments/issues/324.
if ( 'image/avif' === type ) {
saveOptions.effort = 2;

// Preserve the source bit depth so high-bit-depth (10/12-bit) HDR
// images are not flattened to 8-bit on output.
if ( bitdepth > 8 ) {
saveOptions.bitdepth = bitdepth;
}
}

return saveOptions;
}

/**
* Resizes a decoded high-bit-depth image while preserving its 16-bit samples.
*
* libvips `thumbnail` performs a colour-managed export that flattens samples to
* 8-bit sRGB, which would silently turn HDR sub-sizes into 8-bit. `resize` and
* `crop` keep the full 16-bit precision, so this mirrors `thumbnail`'s geometry
* (shrink-to-fit, or fill-then-centre-crop when a crop is requested) using those
* operations instead. Attention/smart cropping is unavailable here and falls
* back to a centre crop. `size: 'down'` semantics are preserved: images are
* never enlarged.
*
* @param image Decoded 16-bit source image.
* @param targetWidth Target width in pixels.
* @param options Thumbnail options (target height and optional crop).
* @return The resized (and optionally cropped) 16-bit image.
*/
function resizeHighBitDepth<
Comment thread
adamsilverstein marked this conversation as resolved.
T extends {
width: number;
pageHeight: number;
resize: ( scale: number ) => T;
crop: ( left: number, top: number, width: number, height: number ) => T;
},
>( image: T, targetWidth: number, options: ThumbnailOptions ): T {
const targetHeight = options.height ?? targetWidth;

if ( options.crop ) {
// Fill the target box, then centre-crop to the exact dimensions.
const scale = Math.min(
1,
Math.max(
targetWidth / image.width,
targetHeight / image.pageHeight
)
);
const resized = image.resize( scale );
const cropWidth = Math.min( resized.width, Math.round( targetWidth ) );
const cropHeight = Math.min(
resized.pageHeight,
Math.round( targetHeight )
);
const left = Math.max(
0,
Math.round( ( resized.width - cropWidth ) / 2 )
);
const top = Math.max(
0,
Math.round( ( resized.pageHeight - cropHeight ) / 2 )
);
return resized.crop( left, top, cropWidth, cropHeight );
}

// Shrink to fit within the target box, preserving the aspect ratio.
const scale = Math.min(
1,
targetWidth / image.width,
targetHeight / image.pageHeight
);
return image.resize( scale );
}

/**
* Resizes an image using vips.
*
Expand Down Expand Up @@ -462,12 +565,33 @@ export async function resizeImage(

const { width, pageHeight } = image;

// Detect high-bit-depth (10/12-bit) AVIF sources. `thumbnail` would
// flatten these to 8-bit sRGB, so they are resized directly from the
// decoded 16-bit image, which keeps full precision. Animated images
// keep the streaming `thumbnail` path (multi-page resize/crop is not
// handled here, and HDR is a still-image concern).
const sourceBitdepth =
'image/avif' === type && ! strOptions
? getSourceBitdepth( image )
: 8;
const isHighBitDepth = sourceBitdepth > 8;
const sourceImage = image;

image = applyResizeAndCrop(
resize,
width,
pageHeight,
smartCrop,
( resizeWidth, thumbnailOptions ) => {
if ( isHighBitDepth ) {
const resized = resizeHighBitDepth(
sourceImage,
resizeWidth,
thumbnailOptions
);
resized.onProgress = onProgress;
return resized;
}
if ( strOptions ) {
thumbnailOptions.option_string = strOptions;
}
Expand All @@ -481,7 +605,7 @@ export async function resizeImage(
}
);

const saveOptions = buildSaveOptions( type, quality );
const saveOptions = buildSaveOptions( type, quality, sourceBitdepth );
const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions );

const result = {
Expand Down
85 changes: 85 additions & 0 deletions packages/vips/src/test/highbitdepth-avif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,89 @@ describe( 'wasm-vips high-bit-depth AVIF decoding', () => {

image.delete();
} );

/*
* Output (sub-size) bit depth.
*
* Decoding alone (above) does not prove that generated sub-sizes stay
* high-bit-depth. The resize pipeline must avoid `thumbnail` (which flattens
* to 8-bit sRGB) and write the matching bit depth on save. These tests
* exercise the same vips operations `resizeImage` uses for high-bit-depth
* sources. They cannot import `resizeImage` directly: that module inlines
* the WASM binaries at build time, so it only runs through the package build,
* not the Node test environment. The mocked `resize-image.ts` unit test
* verifies that `resizeImage` actually wires these operations together.
*/
describe( 'resize preserves bit depth', () => {
it.each( [
[ '10-bit', 'highbitdepth-10bit.avif', 10 ],
[ '12-bit', 'highbitdepth-12bit.avif', 12 ],
] )(
'keeps a %s AVIF high-bit-depth through resize and re-encode',
( _label, file, depth ) => {
const buffer = readFileSync( join( FIXTURES, file ) );

// `thumbnail` flattens samples to 8-bit sRGB...
const flattened = vips.Image.thumbnailBuffer( buffer, 32, {
size: 'down',
height: 32,
} );
expect( flattened.format ).toBe( 'uchar' );
flattened.delete();

// ...while resizing the decoded 16-bit image keeps full precision.
const resized =
vips.Image.newFromBuffer( buffer ).resize( 0.5 );
expect( resized.width ).toBe( 32 );
expect( resized.format ).toBe( 'ushort' );
// More than 256 tonal levels means genuine high-bit-depth data,
// not 8-bit values stretched into a 16-bit container.
expect( resized.max() ).toBeGreaterThan( 255 );

// Re-encoding with the matching bit depth yields a high-bit-depth AVIF.
const out = resized.writeToBuffer( '.avif', {
keep: 'icc|gainmap',
Q: 82,
effort: 2,
bitdepth: depth,
} );
const reloaded = vips.Image.newFromBuffer( out );
expect( reloaded.format ).toBe( 'ushort' );
expect( reloaded.getInt( 'heif-bitdepth' ) ).toBe( depth );

reloaded.delete();
resized.delete();
}
);

it( 'requires the explicit bit depth to keep a 10-bit source at 10-bit', () => {
// Regression guard for passing the source bit depth on save.
// heifsave defaults any 16-bit image to 12-bit, so a 10-bit source
// would be inflated to 12-bit (larger, not "similar") unless the
// original depth is written explicitly.
const buffer = readFileSync(
join( FIXTURES, 'highbitdepth-10bit.avif' )
);
const resized = vips.Image.newFromBuffer( buffer ).resize( 0.5 );

const saveOptions = { keep: 'icc|gainmap', Q: 82, effort: 2 };

const defaultOut = resized.writeToBuffer( '.avif', saveOptions );
const defaultReloaded = vips.Image.newFromBuffer( defaultOut );
// Default inflates the 10-bit source to 12-bit.
expect( defaultReloaded.getInt( 'heif-bitdepth' ) ).toBe( 12 );

const preservedOut = resized.writeToBuffer( '.avif', {
...saveOptions,
bitdepth: 10,
} );
const preservedReloaded = vips.Image.newFromBuffer( preservedOut );
// Explicit bit depth keeps it at the source's 10-bit.
expect( preservedReloaded.getInt( 'heif-bitdepth' ) ).toBe( 10 );

preservedReloaded.delete();
defaultReloaded.delete();
resized.delete();
} );
} );
} );
84 changes: 81 additions & 3 deletions packages/vips/src/test/resize-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,26 @@ import type { ImageSizeCrop } from '../types';

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

// Controls the `heif-bitdepth` value reported by the mocked source image so
// tests can exercise both the standard (8-bit) and high-bit-depth code paths.
let mockBitdepth = 8;

class MockImage {
width = 100;
height = 100;
pageHeight = 100;
crop = mockCrop;
writeToBuffer = jest.fn( () => ( {
buffer: '',
} ) );
resize = mockResize;
writeToBuffer = mockWriteToBuffer;
getInt = jest.fn( ( name: string ) =>
'heif-bitdepth' === name ? mockBitdepth : 0
);
}

class MockVipsImage {
Expand All @@ -35,6 +45,7 @@ jest.mock( 'wasm-vips', () =>
describe( 'resizeImage', () => {
afterEach( () => {
jest.clearAllMocks();
mockBitdepth = 8;
} );

it( 'resizes without crop', async () => {
Expand Down Expand Up @@ -223,4 +234,71 @@ describe( 'resizeImage', () => {

expect( mockCrop ).toHaveBeenCalledWith( ...expected );
} );

describe( 'high-bit-depth AVIF', () => {
it( 'preserves bit depth when resizing a 10-bit AVIF without crop', async () => {
mockBitdepth = 10;
const avifFile = new File( [ '<BLOB>' ], 'example.avif', {
type: 'image/avif',
} );
const buffer = await avifFile.arrayBuffer();

await resizeImage( 'itemId', buffer, 'image/avif', {
width: 50,
height: 50,
} );

// Uses the precision-preserving resize path, not `thumbnail`
// (which would flatten the samples to 8-bit sRGB).
expect( mockResize ).toHaveBeenCalled();
expect( mockThumbnailBuffer ).not.toHaveBeenCalled();
// Writes the source bit depth so the sub-size stays 10-bit.
expect( mockWriteToBuffer ).toHaveBeenCalledWith(
'.avif',
expect.objectContaining( { bitdepth: 10 } )
);
} );

it( 'centre-crops a 12-bit AVIF while preserving bit depth', async () => {
mockBitdepth = 12;
const avifFile = new File( [ '<BLOB>' ], 'example.avif', {
type: 'image/avif',
} );
const buffer = await avifFile.arrayBuffer();

await resizeImage( 'itemId', buffer, 'image/avif', {
width: 50,
height: 50,
crop: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be worth adding a test case to cover crop being passed an array of ['right', 'top'], etc? Or are we only concerned about centre-weighted crops for this one?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mainly we are testing color depth after cropping, I wouldn't expect changing the cropping to impact the outcome, but we can add at least one cropped variant in the tests to make sure.

} );

expect( mockResize ).toHaveBeenCalled();
expect( mockCrop ).toHaveBeenCalled();
expect( mockThumbnailBuffer ).not.toHaveBeenCalled();
expect( mockWriteToBuffer ).toHaveBeenCalledWith(
'.avif',
expect.objectContaining( { bitdepth: 12 } )
);
} );

it( 'uses the standard thumbnail path for an 8-bit AVIF', async () => {
mockBitdepth = 8;
const avifFile = new File( [ '<BLOB>' ], 'example.avif', {
type: 'image/avif',
} );
const buffer = await avifFile.arrayBuffer();

await resizeImage( 'itemId', buffer, 'image/avif', {
width: 50,
height: 50,
} );

expect( mockThumbnailBuffer ).toHaveBeenCalled();
expect( mockResize ).not.toHaveBeenCalled();
expect( mockWriteToBuffer ).toHaveBeenCalledWith(
'.avif',
expect.not.objectContaining( { bitdepth: expect.anything() } )
);
} );
} );
} );
Loading
Loading