@wordpress/upload-media
Version:
Core media upload logic.
342 lines (316 loc) • 9.64 kB
text/typescript
/**
* Internal dependencies
*/
import { ImageFile } from '../../image-file';
import { getFileBasename } from '../../utils';
import type { ImageSizeCrop, QueueItemId } from '../types';
/**
* Cached dynamic import promise for @wordpress/vips/worker.
*
* The module contains ~10MB of inlined WASM code. By using a dynamic import,
* the WASM is only loaded when vips functions are actually called at image
* processing time, rather than at module parse time.
*
* The promise is cached so the module is only resolved once.
*/
let vipsModulePromise:
| Promise< typeof import('@wordpress/vips/worker') >
| undefined;
/**
* The resolved module reference, available synchronously after the first
* load completes. Used by terminateVipsWorker() and vipsCancelOperations().
*/
let vipsModule: typeof import('@wordpress/vips/worker') | undefined;
/**
* Lazily loads and caches the @wordpress/vips/worker module.
*
* @return The vips worker module.
*/
function loadVipsModule(): Promise< typeof import('@wordpress/vips/worker') > {
if ( ! vipsModulePromise ) {
vipsModulePromise = import( '@wordpress/vips/worker' ).then(
( mod ) => {
vipsModule = mod;
return mod;
}
);
}
return vipsModulePromise;
}
/**
* Converts an image to a different format using vips in a web worker.
*
* @param id Queue item ID.
* @param file File object.
* @param type Output mime type.
* @param quality Desired quality (0-1).
* @param interlaced Whether to use interlaced/progressive mode.
* @return Converted file.
*/
export async function vipsConvertImageFormat(
id: QueueItemId,
file: File,
type:
| 'image/jpeg'
| 'image/png'
| 'image/webp'
| 'image/avif'
| 'image/gif',
quality: number,
interlaced?: boolean
) {
const { vipsConvertImageFormat: convertImageFormat } =
await loadVipsModule();
const buffer = await convertImageFormat(
id,
await file.arrayBuffer(),
file.type,
type,
quality,
interlaced
);
const ext = type.split( '/' )[ 1 ];
const fileName = `${ getFileBasename( file.name ) }.${ ext }`;
return new File( [ new Blob( [ buffer as ArrayBuffer ] ) ], fileName, {
type,
} );
}
/**
* Compresses an image using vips in a web worker.
*
* @param id Queue item ID.
* @param file File object.
* @param quality Desired quality (0-1).
* @param interlaced Whether to use interlaced/progressive mode.
* @return Compressed file.
*/
export async function vipsCompressImage(
id: QueueItemId,
file: File,
quality: number,
interlaced?: boolean
) {
const { vipsCompressImage: compressImage } = await loadVipsModule();
const buffer = await compressImage(
id,
await file.arrayBuffer(),
file.type,
quality,
interlaced
);
return new File(
[ new Blob( [ buffer as ArrayBuffer ], { type: file.type } ) ],
file.name,
{ type: file.type }
);
}
/**
* Checks whether an image has transparency using vips in a web worker.
*
* @param url Image URL.
* @return Whether the image has transparency.
*/
export async function vipsHasTransparency( url: string ) {
const { vipsHasTransparency: hasTransparency } = await loadVipsModule();
const response = await fetch( url );
if ( ! response.ok ) {
throw new Error( `Failed to fetch image: ${ response.status }` );
}
return hasTransparency( await response.arrayBuffer() );
}
/**
* Probes a JPEG buffer for UltraHDR (ISO 21496-1 gain map) support using vips
* in a web worker.
*
* @param buffer Image buffer to probe.
* @return UltraHDR info if the buffer is a valid UltraHDR JPEG, otherwise null.
*/
export async function vipsGetUltraHdrInfo( buffer: ArrayBuffer ) {
const { vipsGetUltraHdrInfo: getUltraHdrInfo } = await loadVipsModule();
return getUltraHdrInfo( buffer );
}
/**
* Resizes an image using vips in a web worker.
*
* UltraHDR JPEGs are auto-detected by libvips and their gain map is
* preserved through the resize.
*
* @param id Queue item ID.
* @param file File object.
* @param resize Resize options (width, height, crop).
* @param smartCrop Whether to use smart cropping (saliency-aware).
* @param addSuffix Whether to add dimension suffix to filename.
* @param signal Optional abort signal to cancel the operation.
* @param scaledSuffix Whether to add '-scaled' suffix instead of dimensions (for big image threshold).
* @param quality Desired quality (0-1). Defaults to 0.82.
* @return Resized ImageFile with dimension metadata.
*/
export async function vipsResizeImage(
id: QueueItemId,
file: File,
resize: ImageSizeCrop,
smartCrop: boolean,
addSuffix: boolean,
signal?: AbortSignal,
scaledSuffix?: boolean,
quality?: number
) {
if ( signal?.aborted ) {
throw new Error( 'Operation aborted' );
}
const { vipsResizeImage: resizeImage } = await loadVipsModule();
const { buffer, width, height, originalWidth, originalHeight } =
await resizeImage(
id,
await file.arrayBuffer(),
file.type,
resize,
smartCrop,
quality
);
let fileName = file.name;
const wasResized = originalWidth > width || originalHeight > height;
if ( wasResized ) {
const basename = getFileBasename( file.name );
if ( scaledSuffix ) {
// Add '-scaled' suffix for big image threshold resizing.
// This matches WordPress core's behavior in wp_create_image_subsizes().
fileName = file.name.replace( basename, `${ basename }-scaled` );
} else if ( addSuffix ) {
// Add dimension suffix for thumbnails.
fileName = file.name.replace(
basename,
`${ basename }-${ width }x${ height }`
);
}
}
const resultFile = new ImageFile(
new File(
[ new Blob( [ buffer as ArrayBuffer ], { type: file.type } ) ],
fileName,
{
type: file.type,
}
),
width,
height,
originalWidth,
originalHeight
);
return resultFile;
}
/**
* Rotates an image based on EXIF orientation using vips in a web worker.
*
* This applies the correct rotation/flip transformation based on the EXIF
* orientation value (1-8), and adds a '-rotated' suffix to the filename.
* This matches WordPress core's behavior when rotating images based on EXIF.
*
* @param id Queue item ID.
* @param file File object.
* @param orientation EXIF orientation value (1-8).
* @param signal Optional abort signal to cancel the operation.
* @return Rotated ImageFile with updated dimensions.
*/
export async function vipsRotateImage(
id: QueueItemId,
file: File,
orientation: number,
signal?: AbortSignal
) {
if ( signal?.aborted ) {
throw new Error( 'Operation aborted' );
}
// If orientation is 1 (normal), no rotation needed.
if ( orientation === 1 ) {
return file;
}
const { vipsRotateImage: rotateImage } = await loadVipsModule();
const { buffer, width, height } = await rotateImage(
id,
await file.arrayBuffer(),
file.type,
orientation
);
// Add '-rotated' suffix to filename, matching WordPress core behavior.
const basename = getFileBasename( file.name );
const fileName = file.name.replace( basename, `${ basename }-rotated` );
const resultFile = new ImageFile(
new File(
[ new Blob( [ buffer as ArrayBuffer ], { type: file.type } ) ],
fileName,
{
type: file.type,
}
),
width,
height
);
return resultFile;
}
/**
* Cancels all ongoing image operations for the given item.
*
* If the vips module has not been loaded yet, there can be no active
* operations to cancel.
*
* @param id Queue item ID to cancel operations for.
* @return Whether any operation was cancelled.
*/
export async function vipsCancelOperations( id: QueueItemId ) {
if ( ! vipsModule ) {
return false;
}
return vipsModule.vipsCancelOperations( id );
}
/**
* Terminates the vips worker if it has been loaded.
*
* If the vips module has not been loaded yet (i.e., no image processing
* has occurred), this is a no-op since there is no worker to terminate.
*
* The worker itself is recreated lazily by `getWorkerAPI()` inside
* `@wordpress/vips/worker` on the next vips call — the module reference
* cached here can keep pointing at the same module since re-importing
* returns the same instance from the JS module cache.
*/
export function terminateVipsWorker(): void {
if ( vipsModule ) {
vipsModule.terminateVipsWorker();
}
}
/**
* Tracks the number of completed vips image processing operations across
* both the success path (`finishOperation`) and the failure path
* (`cancelItem`). Used to periodically recycle the WASM worker to reclaim
* memory, since WASM linear memory can only grow and never shrink.
*/
let completedVipsOperations = 0;
/**
* Maximum number of vips operations before recycling the worker.
* Each operation can consume 50-100MB+ of WASM memory for large images.
*/
const MAX_VIPS_OPS_BEFORE_RECYCLE = 50;
/**
* Records that a vips operation has completed and recycles the worker if
* the threshold has been reached and no other vips operations are in
* flight. Call this from both success and failure paths so that a burst
* of failures can't bypass the recycle budget.
*
* @param activeImageProcessingCount Number of vips operations currently
* in flight. Recycling is deferred while
* any are running so an in-flight worker
* isn't killed mid-operation.
*/
export function maybeRecycleVipsWorker(
activeImageProcessingCount: number
): void {
completedVipsOperations++;
if (
completedVipsOperations >= MAX_VIPS_OPS_BEFORE_RECYCLE &&
activeImageProcessingCount === 0
) {
terminateVipsWorker();
completedVipsOperations = 0;
}
}