From 53e35477648ca311bf8d47734d1224c457920e6f Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 27 Aug 2024 18:47:08 +0200 Subject: [PATCH] Add new private `vips` package --- package-lock.json | 37 +++ package.json | 1 + packages/vips/.npmrc | 1 + packages/vips/CHANGELOG.md | 7 + packages/vips/README.md | 98 +++++++ packages/vips/package.json | 40 +++ packages/vips/src/index.ts | 374 +++++++++++++++++++++++++ packages/vips/src/test/resize-image.ts | 223 +++++++++++++++ packages/vips/src/types.ts | 171 +++++++++++ packages/vips/src/utils.ts | 66 +++++ packages/vips/tsconfig.json | 9 + test/unit/config/global-mocks.js | 4 + test/unit/config/wasm-stub.js | 7 + test/unit/jest.config.js | 1 + tools/webpack/packages.js | 14 + tools/webpack/shared.js | 7 + tsconfig.json | 1 + 17 files changed, 1061 insertions(+) create mode 100644 packages/vips/.npmrc create mode 100644 packages/vips/CHANGELOG.md create mode 100644 packages/vips/README.md create mode 100644 packages/vips/package.json create mode 100644 packages/vips/src/index.ts create mode 100644 packages/vips/src/test/resize-image.ts create mode 100644 packages/vips/src/types.ts create mode 100644 packages/vips/src/utils.ts create mode 100644 packages/vips/tsconfig.json create mode 100644 test/unit/config/wasm-stub.js diff --git a/package-lock.json b/package-lock.json index 6bae0fa5859941..19e2e18df0ec5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,7 @@ "@wordpress/undo-manager": "file:packages/undo-manager", "@wordpress/url": "file:packages/url", "@wordpress/viewport": "file:packages/viewport", + "@wordpress/vips": "file:packages/vips", "@wordpress/warning": "file:packages/warning", "@wordpress/widgets": "file:packages/widgets", "@wordpress/wordcount": "file:packages/wordcount", @@ -17231,6 +17232,10 @@ "resolved": "packages/viewport", "link": true }, + "node_modules/@wordpress/vips": { + "resolved": "packages/vips", + "link": true + }, "node_modules/@wordpress/warning": { "resolved": "packages/warning", "link": true @@ -49676,6 +49681,15 @@ "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==" }, + "node_modules/wasm-vips": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/wasm-vips/-/wasm-vips-0.0.10.tgz", + "integrity": "sha512-ZDIRjxTm03iRRXM9WwvVh8MH+mKHUBedJTC/MYd/cVGZxShnYcEd0BB7gSGDpxgkzcPSYUnN1HQJipDBhxVHTg==", + "license": "MIT", + "engines": { + "node": ">=16.4.0" + } + }, "node_modules/watchpack": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz", @@ -55107,6 +55121,18 @@ "react": "^18.0.0" } }, + "packages/vips": { + "name": "@wordpress/vips", + "version": "1.0.0-prerelease", + "license": "GPL-2.0-or-later", + "dependencies": { + "wasm-vips": "^0.0.10" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "packages/warning": { "name": "@wordpress/warning", "version": "3.6.0", @@ -69104,6 +69130,12 @@ "@wordpress/element": "file:../element" } }, + "@wordpress/vips": { + "version": "file:packages/vips", + "requires": { + "wasm-vips": "^0.0.10" + } + }, "@wordpress/warning": { "version": "file:packages/warning" }, @@ -93847,6 +93879,11 @@ "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==" }, + "wasm-vips": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/wasm-vips/-/wasm-vips-0.0.10.tgz", + "integrity": "sha512-ZDIRjxTm03iRRXM9WwvVh8MH+mKHUBedJTC/MYd/cVGZxShnYcEd0BB7gSGDpxgkzcPSYUnN1HQJipDBhxVHTg==" + }, "watchpack": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz", diff --git a/package.json b/package.json index 7349e60eb4c210..b83b5e3a0f19e4 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@wordpress/undo-manager": "file:packages/undo-manager", "@wordpress/url": "file:packages/url", "@wordpress/viewport": "file:packages/viewport", + "@wordpress/vips": "file:packages/vips", "@wordpress/warning": "file:packages/warning", "@wordpress/widgets": "file:packages/widgets", "@wordpress/wordcount": "file:packages/wordcount", diff --git a/packages/vips/.npmrc b/packages/vips/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/vips/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/vips/CHANGELOG.md b/packages/vips/CHANGELOG.md new file mode 100644 index 00000000000000..691cb6760320f4 --- /dev/null +++ b/packages/vips/CHANGELOG.md @@ -0,0 +1,7 @@ + + +## Unreleased + +### New Features + +- Initial public release. diff --git a/packages/vips/README.md b/packages/vips/README.md new file mode 100644 index 00000000000000..d7bf6001b51027 --- /dev/null +++ b/packages/vips/README.md @@ -0,0 +1,98 @@ +# `@wordpress/vips` + +Helper package to interact with [`wasm-vips`](https://github.com/kleisauke/wasm-vips). + +## Installation + +Install the module + +```bash +npm install @wordpress/vips --save +``` + +## API Reference + + + +### cancelOperations + +Cancels all ongoing image operations for a given item ID. + +The onProgress callbacks check for an IDs existence in this list, killing the process if it's absent. + +_Parameters_ + +- _id_ `ItemId`: Item ID. + +_Returns_ + +- boolean Whether any operation was cancelled. + +### compressImage + +Compresses an existing image using vips. + +_Parameters_ + +- _id_ `ItemId`: Item ID. +- _buffer_ `ArrayBuffer`: Original file buffer. +- _type_ `string`: Mime type. +- _quality_ Desired quality. +- _interlaced_ Whether to use interlaced/progressive mode. Only used if the outputType supports it. + +_Returns_ + +- `Promise< ArrayBuffer >`: Compressed file data. + +### convertImageFormat + +Converts an image to a different format using vips. + +_Parameters_ + +- _id_ `ItemId`: Item ID. +- _buffer_ `ArrayBuffer`: Original file buffer. +- _inputType_ `string`: Input mime type. +- _outputType_ `string`: Output mime type. +- _quality_ Desired quality. +- _interlaced_ Whether to use interlaced/progressive mode. Only used if the outputType supports it. + +### hasTransparency + +Determines whether an image has an alpha channel. + +_Parameters_ + +- _buffer_ `ArrayBuffer`: Original file object. + +_Returns_ + +- `Promise< boolean >`: Whether the image has an alpha channel. + +### resizeImage + +Resizes an image using vips. + +_Parameters_ + +- _id_ `ItemId`: Item ID. +- _buffer_ `ArrayBuffer`: Original file buffer. +- _type_ `string`: Mime type. +- _resize_ `ImageSizeCrop`: Resize options. +- _smartCrop_ Whether to use smart cropping (i.e. saliency-aware). + +_Returns_ + +- `Promise< { buffer: ArrayBuffer; width: number; height: number; originalWidth: number; originalHeight: number; } >`: Processed file data plus the old and new dimensions. + +### setLocation + +Dynamically sets the location / public path to use for loading the WASM files. + +This is required when loading this module in an inline worker, where globals such as **webpack_public_path** are not available. + +_Parameters_ + +- _newLocation_ `string`: Location, typically a base URL such as ". + + diff --git a/packages/vips/package.json b/packages/vips/package.json new file mode 100644 index 00000000000000..b4435fbb3d1343 --- /dev/null +++ b/packages/vips/package.json @@ -0,0 +1,40 @@ +{ + "name": "@wordpress/vips", + "version": "1.0.0-prerelease", + "private": true, + "description": "Utils for working with libvips.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "media", + "libvips" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/vips/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/vips" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "types": "build-types", + "exports": { + ".": { + "default": "./src/index.ts" + } + }, + "dependencies": { + "wasm-vips": "^0.0.10" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/vips/src/index.ts b/packages/vips/src/index.ts new file mode 100644 index 00000000000000..8e8cac09fc9790 --- /dev/null +++ b/packages/vips/src/index.ts @@ -0,0 +1,374 @@ +/** + * External dependencies + */ +import Vips from 'wasm-vips'; + +// @ts-expect-error +// eslint-disable-next-line import/no-unresolved +import VipsModule from 'wasm-vips/vips.wasm'; + +// @ts-expect-error +// eslint-disable-next-line import/no-unresolved +import VipsHeifModule from 'wasm-vips/vips-heif.wasm'; + +// @ts-expect-error +// eslint-disable-next-line import/no-unresolved +import VipsJxlModule from 'wasm-vips/vips-jxl.wasm'; + +/** + * Internal dependencies + */ +import type { + ItemId, + ImageSizeCrop, + LoadOptions, + SaveOptions, + ThumbnailOptions, +} from './types'; +import { + isFileTypeSupported, + supportsAnimation, + supportsInterlace, + supportsQuality, +} from './utils'; + +type EmscriptenModule = { + setAutoDeleteLater: ( autoDelete: boolean ) => void; + setDelayFunction: ( fn: ( fn: () => void ) => void ) => void; +}; + +let location = ''; + +/** + * Dynamically sets the location / public path to use for loading the WASM files. + * + * This is required when loading this module in an inline worker, + * where globals such as __webpack_public_path__ are not available. + * + * @param newLocation Location, typically a base URL such as "https://example.com/path/to/js/...". + */ +export function setLocation( newLocation: string ) { + location = newLocation; +} + +let cleanup: () => void; + +let vipsInstance: typeof Vips; + +/** + * Instantiates and returns a new vips instance. + * + * Reuses any existing instance. + */ +async function getVips(): Promise< typeof Vips > { + if ( vipsInstance ) { + return vipsInstance; + } + + vipsInstance = await Vips( { + locateFile: ( fileName: string ) => { + if ( fileName.endsWith( 'vips.wasm' ) ) { + fileName = VipsModule; + } else if ( fileName.endsWith( 'vips-heif.wasm' ) ) { + fileName = VipsHeifModule; + } else if ( fileName.endsWith( 'vips-jxl.wasm' ) ) { + fileName = VipsJxlModule; + } + + return location + fileName; + }, + preRun: ( module: EmscriptenModule ) => { + // https://github.com/kleisauke/wasm-vips/issues/13#issuecomment-1073246828 + module.setAutoDeleteLater( true ); + module.setDelayFunction( ( fn: () => void ) => { + cleanup = fn; + } ); + }, + } ); + + return vipsInstance; +} + +/** + * Holds a list of ongoing operations for a given ID. + * + * This way, operations can be cancelled mid-progress. + */ +const inProgressOperations = new Set< ItemId >(); + +/** + * Cancels all ongoing image operations for a given item ID. + * + * The onProgress callbacks check for an IDs existence in this list, + * killing the process if it's absence. + * + * @param id Item ID. + * @return boolean Whether any operation was cancelled. + */ +export async function cancelOperations( id: ItemId ) { + return inProgressOperations.delete( id ); +} + +/** + * Converts an image to a different format using vips. + * + * @param id Item ID. + * @param buffer Original file buffer. + * @param inputType Input mime type. + * @param outputType Output mime type. + * @param quality Desired quality. + * @param interlaced Whether to use interlaced/progressive mode. + * Only used if the outputType supports it. + */ +export async function convertImageFormat( + id: ItemId, + buffer: ArrayBuffer, + inputType: string, + outputType: string, + quality = 0.82, + interlaced = false +): Promise< ArrayBuffer > { + const ext = outputType.split( '/' )[ 1 ]; + + inProgressOperations.add( id ); + + let strOptions = ''; + const loadOptions: LoadOptions< typeof inputType > = {}; + + // To ensure all frames are loaded in case the image is animated. + if ( supportsAnimation( inputType ) ) { + strOptions = '[n=-1]'; + ( loadOptions as LoadOptions< typeof inputType > ).n = -1; + } + + const vips = await getVips(); + const image = vips.Image.newFromBuffer( buffer, strOptions, loadOptions ); + + // TODO: Report progress, see https://github.com/swissspidy/media-experiments/issues/327. + image.onProgress = () => { + if ( ! inProgressOperations.has( id ) ) { + image.kill = true; + } + }; + + const saveOptions: SaveOptions< typeof outputType > = {}; + + if ( supportsQuality( outputType ) ) { + saveOptions.Q = quality * 100; + } + + if ( interlaced && supportsInterlace( outputType ) ) { + saveOptions.interlace = interlaced; + } + + // See https://github.com/swissspidy/media-experiments/issues/324. + if ( 'image/avif' === outputType ) { + saveOptions.effort = 2; + } + + const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); + const result = outBuffer.buffer; + + cleanup?.(); + + return result; +} + +/** + * Compresses an existing image using vips. + * + * @param id Item ID. + * @param buffer Original file buffer. + * @param type Mime type. + * @param quality Desired quality. + * @param interlaced Whether to use interlaced/progressive mode. + * Only used if the outputType supports it. + * @return Compressed file data. + */ +export async function compressImage( + id: ItemId, + buffer: ArrayBuffer, + type: string, + quality = 0.82, + interlaced = false +): Promise< ArrayBuffer > { + if ( ! isFileTypeSupported( type ) ) { + throw new Error( 'Unsupported file type' ); + } + return convertImageFormat( id, buffer, type, type, quality, interlaced ); +} + +/** + * Resizes an image using vips. + * + * @param id Item ID. + * @param buffer Original file buffer. + * @param type Mime type. + * @param resize Resize options. + * @param smartCrop Whether to use smart cropping (i.e. saliency-aware). + * @return Processed file data plus the old and new dimensions. + */ +export async function resizeImage( + id: ItemId, + buffer: ArrayBuffer, + type: string, + resize: ImageSizeCrop, + smartCrop = false +): Promise< { + buffer: ArrayBuffer; + width: number; + height: number; + originalWidth: number; + originalHeight: number; +} > { + const ext = type.split( '/' )[ 1 ]; + + inProgressOperations.add( id ); + + const vips = await getVips(); + const thumbnailOptions: ThumbnailOptions = { + size: 'down', + }; + + let strOptions = ''; + const loadOptions: LoadOptions< typeof type > = {}; + + // To ensure all frames are loaded in case the image is animated. + // 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; + } + + // TODO: Report progress, see https://github.com/swissspidy/media-experiments/issues/327. + const onProgress = () => { + if ( ! inProgressOperations.has( id ) ) { + image.kill = true; + } + }; + + let image = vips.Image.newFromBuffer( buffer, strOptions, loadOptions ); + + image.onProgress = onProgress; + + 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; + } + + // 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; + } + + // TODO: Allow passing quality? + const saveOptions: SaveOptions< typeof type > = {}; + const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); + + const result = { + buffer: outBuffer.buffer, + width: image.width, + height: image.pageHeight, + originalWidth: width, + originalHeight: pageHeight, + }; + + // Only call after `image` is no longer being used. + cleanup?.(); + + return result; +} + +/** + * Determines whether an image has an alpha channel. + * + * @param buffer Original file object. + * @return Whether the image has an alpha channel. + */ +export async function hasTransparency( + buffer: ArrayBuffer +): Promise< boolean > { + const vips = await getVips(); + const image = vips.Image.newFromBuffer( buffer ); + const hasAlpha = image.hasAlpha(); + + cleanup?.(); + + return hasAlpha; +} diff --git a/packages/vips/src/test/resize-image.ts b/packages/vips/src/test/resize-image.ts new file mode 100644 index 00000000000000..1f1155d388d42c --- /dev/null +++ b/packages/vips/src/test/resize-image.ts @@ -0,0 +1,223 @@ +/** + * Internal dependencies + */ +import { resizeImage } from '../'; +import type { ImageSizeCrop } from '../types'; + +const mockThumbnailBuffer = jest.fn( () => new MockImage() ); +const mockCrop = jest.fn( () => new MockImage() ); +const mockNewFromBuffer = jest.fn( () => new MockImage() ); + +class MockImage { + width = 100; + height = 100; + pageHeight = 100; + crop = mockCrop; + writeToBuffer = jest.fn( () => ( { + buffer: '', + } ) ); +} + +class MockVipsImage { + static thumbnailBuffer = mockThumbnailBuffer; + static newFromBuffer = mockNewFromBuffer; +} + +jest.mock( 'wasm-vips', () => + jest.fn( () => ( { + Image: MockVipsImage, + } ) ) +); + +describe( 'resizeImage', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'resizes without crop', async () => { + const jpegFile = new File( [ '' ], 'example.jpg', { + lastModified: 1234567891, + type: 'image/jpeg', + } ); + const buffer = await jpegFile.arrayBuffer(); + + await resizeImage( 'itemId', buffer, 'image/jpeg', { + width: 100, + height: 100, + } ); + + expect( mockThumbnailBuffer ).toHaveBeenCalledWith( buffer, 100, { + height: 100, + size: 'down', + } ); + expect( mockCrop ).not.toHaveBeenCalled(); + } ); + + it( 'resizes without crop and zero height', async () => { + const jpegFile = new File( [], 'example.jpg', { + lastModified: 1234567891, + type: 'image/jpeg', + } ); + const buffer = await jpegFile.arrayBuffer(); + + await resizeImage( 'itemId', buffer, 'image/jpeg', { + width: 100, + height: 0, + } ); + + expect( mockThumbnailBuffer ).toHaveBeenCalledWith( buffer, 100, { + size: 'down', + height: 100, + } ); + expect( mockCrop ).not.toHaveBeenCalled(); + } ); + + it( 'resizes with center crop', async () => { + const jpegFile = new File( [ '' ], 'example.jpg', { + lastModified: 1234567891, + type: 'image/jpeg', + } ); + const buffer = await jpegFile.arrayBuffer(); + + await resizeImage( 'itemId', buffer, 'image/jpeg', { + width: 100, + height: 100, + crop: true, + } ); + + expect( mockThumbnailBuffer ).toHaveBeenCalledWith( buffer, 100, { + height: 100, + crop: 'centre', + size: 'down', + } ); + expect( mockCrop ).not.toHaveBeenCalled(); + } ); + + it( 'resizes with center crop and zero height', async () => { + const jpegFile = new File( [ '' ], 'example.jpg', { + lastModified: 1234567891, + type: 'image/jpeg', + } ); + const buffer = await jpegFile.arrayBuffer(); + + await resizeImage( 'itemId', buffer, 'image/jpeg', { + width: 100, + height: 0, + crop: true, + } ); + + expect( mockThumbnailBuffer ).toHaveBeenCalledWith( buffer, 100, { + crop: 'centre', + height: 100, + size: 'down', + } ); + expect( mockCrop ).not.toHaveBeenCalled(); + } ); + + it( 'resizes without crop and attention strategy', async () => { + const jpegFile = new File( [ '' ], 'example.jpg', { + lastModified: 1234567891, + type: 'image/jpeg', + } ); + const buffer = await jpegFile.arrayBuffer(); + + await resizeImage( + 'itemId', + buffer, + 'image/jpeg', + { + width: 100, + height: 100, + }, + true + ); + + expect( mockThumbnailBuffer ).toHaveBeenCalledWith( buffer, 100, { + height: 100, + size: 'down', + } ); + expect( mockCrop ).not.toHaveBeenCalled(); + } ); + + it( 'resizes with center crop and attention strategy', async () => { + const jpegFile = new File( [ '' ], 'example.jpg', { + lastModified: 1234567891, + type: 'image/jpeg', + } ); + const buffer = await jpegFile.arrayBuffer(); + + await resizeImage( + 'itemId', + buffer, + 'image/jpeg', + { + width: 100, + height: 100, + crop: true, + }, + true + ); + + expect( mockThumbnailBuffer ).toHaveBeenCalledWith( buffer, 100, { + height: 100, + crop: 'attention', + size: 'down', + } ); + expect( mockCrop ).not.toHaveBeenCalled(); + } ); + + it.each< [ ImageSizeCrop[ 'crop' ], [ number, number, number, number ] ] >( + [ + [ + [ 'left', 'top' ], + [ 0, 0, 25, 25 ], + ], + [ + [ 'center', 'top' ], + [ 37.5, 0, 25, 25 ], + ], + [ + [ 'right', 'top' ], + [ 75, 0, 25, 25 ], + ], + [ + [ 'left', 'center' ], + [ 0, 37.5, 25, 25 ], + ], + [ + [ 'center', 'center' ], + [ 37.5, 37.5, 25, 25 ], + ], + [ + [ 'right', 'center' ], + [ 75, 37.5, 25, 25 ], + ], + [ + [ 'left', 'bottom' ], + [ 0, 75, 25, 25 ], + ], + [ + [ 'center', 'bottom' ], + [ 37.5, 75, 25, 25 ], + ], + [ + [ 'right', 'bottom' ], + [ 75, 75, 25, 25 ], + ], + ] + )( 'resizes with %s param and crops %s', async ( crop, expected ) => { + const jpegFile = new File( [ '' ], 'example.jpg', { + lastModified: 1234567891, + type: 'image/jpeg', + } ); + const buffer = await jpegFile.arrayBuffer(); + + await resizeImage( 'itemId', buffer, 'image/jpeg', { + width: 25, + height: 25, + crop, + } ); + + expect( mockCrop ).toHaveBeenCalledWith( ...expected ); + } ); +} ); diff --git a/packages/vips/src/types.ts b/packages/vips/src/types.ts new file mode 100644 index 00000000000000..7cf03723730595 --- /dev/null +++ b/packages/vips/src/types.ts @@ -0,0 +1,171 @@ +export type ItemId = string; + +// Same type as in @mextp/upload-media +// TODO: Move to shared package? +export type ImageSizeCrop = { + width: number; + height: number; + crop?: + | boolean + | [ 'left' | 'center' | 'right', 'top' | 'center' | 'bottom' ]; +}; + +/** + * none: Do nothing. Same as low. + * centre: Just take the centre. + * entropy: Use an entropy measure + * attention: Look for features likely to draw human attention. + * low: Position the crop towards the low coordinate. Same as none. + * high: Position the crop towards the high coordinate. + * all: Everything is interesting. + */ +type Interesting = + | 'none' + | 'centre' + | 'entropy' + | 'attention' + | 'low' + | 'high' + | 'all'; + +/** + * none: Don't attach metadata. + * exif: Keep Exif metadata. + * xmp: Keep XMP metadata. + * iptc: Keep IPTC metadata. + * icc: Keep ICC metadata. + * other: Keep other metadata (e.g. PNG comments and some TIFF tags). + * all: Keep all metadata. + */ +type ForeignKeep = 'none' | 'exif' | 'xmp' | 'iptc' | 'icc' | 'other' | 'all'; + +/** + * The rendering intent.'absolute' is best for + * scientific work, 'relative' is usually best for + * accurate communication with other imaging libraries. + * + * perceptual: Perceptual rendering intent. + * relative: Relative colorimetric rendering intent. + * saturation: Saturation rendering intent. + * absolute: Absolute colorimetric rendering intent. + */ +type Intent = 'perceptual' | 'relative' | 'saturation' | 'absolute'; + +/** + * How sensitive loaders are to errors, from never stop (very insensitive), to + * stop on the smallest warning (very sensitive). + * + * Each one implies the ones before it, so 'error' implies + * 'truncated'. + * + * none: Never stop. + * truncated: Stop on image truncated, nothing else. + * error: Stop on serious error or truncation. + * warning: Stop on anything, even warnings. + */ +type FailOn = 'none' | 'truncated' | 'error' | 'warning'; + +/** + * The type of access an operation has to supply. See vips_tilecache() + * and #VipsForeign. + * + * random: means requests can come in any order. + * + * sequential: means requests will be top-to-bottom, but with some + * amount of buffering behind the read point for small non-local accesses. + */ +type Access = 'random' | 'sequential' | 'sequential-unbuffered'; + +export type LoadOptions< T extends string > = { + /** + * Number of pages to load, -1 for all. + */ + n?: T extends 'image/gif' + ? number + : T extends 'image/webp' + ? number + : never; + /** + * Required access pattern for this file. + */ + access?: Access; + /** + * Error level to fail on. + */ + fail_on?: FailOn; + /** + * Don't use a cached result for this operation. + */ + revalidate?: boolean; +}; + +export type SaveOptions< T extends string > = { + /** + * Quality factor. + */ + Q?: T extends 'image/gif' ? never : number; + /** + * Which metadata to retain. + */ + keep?: ForeignKeep; + /** + * Generate an interlaced (progressive) JPEG/PNG/GIF. + * Do not provide for any other type! + */ + interlace?: boolean; + /** + * Enable lossless compression (for WebP). + * Do not provide for any other type! + */ + lossless?: T extends 'image/gif' ? never : boolean; + /** + * CPU effort / encoding speed. + * + * While supported by other encoders as well, + * it is most relevant for AVIF, as it is slow by default. + */ + effort?: number; +}; + +export type ThumbnailOptions = { + /** + * Options that are passed on to the underlying loader. + */ + option_string?: string; + /** + * Size to this height. + */ + height?: number; + /** + * Only upsize, only downsize, or both. + */ + size?: 'both' | 'up' | 'down' | 'force'; + /** + * Don't use orientation tags to rotate image upright. + */ + no_rotate?: boolean; + /** + * Reduce to fill target rectangle, then crop. + */ + crop?: Interesting; + /** + * Reduce in linear light. + */ + linear?: boolean; + /** + * Fallback import profile. + */ + import_profile?: string; + /** + * Fallback export profile. + */ + export_profile?: string; + /** + * Rendering intent. + */ + intent?: Intent; + /** + * Error level to fail on. + */ + fail_on?: FailOn; +}; diff --git a/packages/vips/src/utils.ts b/packages/vips/src/utils.ts new file mode 100644 index 00000000000000..e8c9a62b0d7aa2 --- /dev/null +++ b/packages/vips/src/utils.ts @@ -0,0 +1,66 @@ +/** + * Determines whether a given file type is supported by vips. + * + * @param type Mime type. + * @return Whether the file type is supported. + */ +export function isFileTypeSupported( + type: string +): type is + | 'image/jpeg' + | 'image/png' + | 'image/webp' + | 'image/avif' + | 'image/gif' { + return [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/avif', + 'image/gif', + ].includes( type ); +} + +/** + * Determines whether a given file type supports a quality setting, + * + * @todo Make this smarter. + * + * @param type Mime type. + * @return Whether the file supports a quality setting. + */ +export function supportsQuality( + type: string +): type is 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif' { + return [ 'image/jpeg', 'image/png', 'image/webp', 'image/avif' ].includes( + type + ); +} + +/** + * Determines whether a given file type supports animation, + * + * @todo Make this smarter. + * + * @param type Mime type. + * @return Whether the file supports animation. + */ +export function supportsAnimation( + type: string +): type is 'image/webp' | 'image/gif' { + return [ 'image/webp', 'image/gif' ].includes( type ); +} + +/** + * Determines whether a given file type supports interlaced/progressive output. + * + * @todo Make this smarter. + * + * @param type Mime type. + * @return Whether the file supports interlaced/progressive output. + */ +export function supportsInterlace( + type: string +): type is 'image/jpeg' | 'image/gif' | 'image/png' { + return [ 'image/jpeg', 'image/gif', 'image/png' ].includes( type ); +} diff --git a/packages/vips/tsconfig.json b/packages/vips/tsconfig.json new file mode 100644 index 00000000000000..6e33d8ff82d47e --- /dev/null +++ b/packages/vips/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types" + }, + "include": [ "src/**/*" ] +} diff --git a/test/unit/config/global-mocks.js b/test/unit/config/global-mocks.js index 4c5e5f8f8cd117..84111eab2d82b2 100644 --- a/test/unit/config/global-mocks.js +++ b/test/unit/config/global-mocks.js @@ -2,6 +2,7 @@ * External dependencies */ import { TextDecoder, TextEncoder } from 'node:util'; +import { Blob as BlobPolyfill, File as FilePolyfill } from 'node:buffer'; jest.mock( '@wordpress/compose', () => { return { @@ -44,3 +45,6 @@ if ( ! global.TextDecoder ) { if ( ! global.TextEncoder ) { global.TextEncoder = TextEncoder; } + +global.Blob = BlobPolyfill; +global.File = FilePolyfill; diff --git a/test/unit/config/wasm-stub.js b/test/unit/config/wasm-stub.js new file mode 100644 index 00000000000000..d3c290680efe79 --- /dev/null +++ b/test/unit/config/wasm-stub.js @@ -0,0 +1,7 @@ +module.exports = { + process() { + return { + code: 'module.exports = ""', + }; + }, +}; diff --git a/test/unit/jest.config.js b/test/unit/jest.config.js index 30f94485396432..a9fd60b41327b5 100644 --- a/test/unit/jest.config.js +++ b/test/unit/jest.config.js @@ -13,6 +13,7 @@ module.exports = { moduleNameMapper: { [ `@wordpress\\/(${ transpiledPackageNames.join( '|' ) })$` ]: 'packages/$1/src', + '.+\\.wasm$': '/test/unit/config/wasm-stub.js', }, preset: '@wordpress/jest-preset-default', setupFiles: [ diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index 055665c2feb1c8..22475f4610d2cf 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -140,6 +140,20 @@ module.exports = { return `webpack://${ info.namespace }/${ info.resourcePath }`; }, }, + module: { + rules: [ + ...baseConfig.module.rules, + { + test: /\.wasm$/, + type: 'asset/resource', + generator: { + // FIXME: Do not hardcode path. + filename: './build/vips/[name].wasm', + publicPath: '', + }, + }, + ], + }, performance: { hints: false, // disable warnings about package sizes }, diff --git a/tools/webpack/shared.js b/tools/webpack/shared.js index f30d3a830f3eb1..bad55ca53cd9d1 100644 --- a/tools/webpack/shared.js +++ b/tools/webpack/shared.js @@ -48,6 +48,13 @@ const baseConfig = { }, ], }, + resolve: { + // Ensure "require" has a higher priority when matching export conditions. + // Needed for wasm-vips which is used by the @wordpress/vips package. + // https://webpack.js.org/configuration/resolve/#resolveconditionnames + // https://github.com/kleisauke/wasm-vips/issues/50#issuecomment-1664118137 + conditionNames: [ 'require', 'import' ], + }, watchOptions: { ignored: [ '**/node_modules', diff --git a/tsconfig.json b/tsconfig.json index cf986ddbee72bf..1d86ac443838b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -51,6 +51,7 @@ { "path": "packages/token-list" }, { "path": "packages/undo-manager" }, { "path": "packages/url" }, + { "path": "packages/vips" }, { "path": "packages/warning" }, { "path": "packages/wordcount" } ],