/
githubmirror
/
gutenberg
Обзор
Документация
Войти
/
githubmirror
/
gutenberg
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
trunk
packages/vips/src/index.ts
898 строк
26 KB
Marco Ciampini
ESLint: Remove legacy import suppressions (#81338)
07 авг 2026, 15:52
Не верифицирован
07 авг 2026, 15:52
8181ec4
Код
Авторство
О чём код?
import Vips from 'wasm-vips'; // @ts-expect-error - WASM files are inlined as Uint8Array at build time. import VipsModule from 'wasm-vips/vips.wasm'; // @ts-expect-error - WASM files are inlined as Uint8Array at build time. import VipsHeifModule from 'wasm-vips/vips-heif.wasm'; import type { ItemId, ImageSizeCrop, LoadOptions, SaveOptions, ThumbnailOptions, ConvertImageOptions, ResizeImageOptions, } from './types'; import { supportsAnimation, supportsInterlace, supportsQuality } from './utils'; interface EmscriptenModule { setAutoDeleteLater: ( autoDelete: boolean ) => void; setDelayFunction: ( fn: ( fn: () => void ) => void ) => void; } let cleanup: () => void; let vipsPromise: Promise< typeof Vips > | undefined; /** * Caches Blob URLs created for inlined WASM binaries. * * The WASM binaries are inlined as `Uint8Array` values at build time. wasm-vips * loads them (including the HEIF dynamic library) by fetching a URL, so the * bytes are wrapped in a Blob URL the first time each is requested. */ const wasmUrls = new WeakMap< Uint8Array< ArrayBuffer >, string >(); /** * Returns a Blob URL for an inlined WASM binary, creating it on first use. * * @param bytes The inlined WASM binary. * @return A Blob URL pointing at the binary. */ function getWasmUrl( bytes: Uint8Array< ArrayBuffer > ): string { let url = wasmUrls.get( bytes ); if ( ! url ) { url = URL.createObjectURL( new Blob( [ bytes ], { type: 'application/wasm' } ) ); wasmUrls.set( bytes, url ); } return url; } /** * Instantiates and returns a new vips instance. * * Reuses any existing instance. */ async function getVips(): Promise< typeof Vips > { if ( vipsPromise ) { return await vipsPromise; } vipsPromise = Vips( { // Load HEIF dynamic module for HEIF/HEIC and AVIF format support. // JXL is omitted as WordPress Core does not currently support it. // It can be re-added when Core adds JXL support. dynamicLibraries: [ 'vips-heif.wasm' ], locateFile: ( fileName: string ) => { // WASM files are inlined as a Uint8Array at build time and exposed // here as Blob URLs. This eliminates the need for separate file // downloads and avoids issues with hosts not serving WASM files // with correct MIME types, while keeping the inlined bytes // compressible (see the build-time binary encoding). if ( fileName.endsWith( 'vips.wasm' ) ) { return getWasmUrl( VipsModule ); } else if ( fileName.endsWith( 'vips-heif.wasm' ) ) { return getWasmUrl( VipsHeifModule ); } return fileName; }, preRun: ( module: EmscriptenModule ) => { // https://github.com/kleisauke/wasm-vips/issues/13#issuecomment-1073246828 module.setAutoDeleteLater( true ); module.setDelayFunction( ( fn: () => void ) => { cleanup = fn; } ); }, // Redirect wasm-vips internal stdout/stderr to prevent console errors // (e.g. AVIF codec warnings that are not actionable for users). // Set globalThis.__vipsDebug to a function to capture this output during development. print: ( text: string ) => { ( globalThis as any ).__vipsDebug?.( text ); }, printErr: ( text: string ) => { ( globalThis as any ).__vipsDebug?.( text ); }, } ); const vipsInstance = await vipsPromise; // Disable the operation cache to prevent out-of-memory crashes // during repeated image processing. libvips caches results from // previous operations which accumulates WASM memory over time. // See https://github.com/WordPress/gutenberg/issues/76706 vipsInstance.Cache.max( 0 ); 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 absent. * * @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 options Conversion options. */ export async function convertImageFormat( id: ItemId, buffer: ArrayBuffer, inputType: string, outputType: string, options: ConvertImageOptions = {} ): Promise< ArrayBuffer | ArrayBufferLike > { const { quality = 0.82, interlaced = false, stripMeta = true, maxBitdepth = 16, } = options; const ext = outputType.split( '/' )[ 1 ]; inProgressOperations.add( id ); try { let strOptions = ''; const loadOptions: LoadOptions< typeof 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; } 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 > = { // Strip metadata except ICC color profiles, // matching WordPress core's behavior. The `image_strip_meta` // filter can disable stripping entirely. keep: stripMeta ? 'icc' : 'all', }; 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; // 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. // The `image_max_bit_depth` filter can cap the output depth; the // depth is set explicitly whenever the source is high-bit-depth, // since heifsave would otherwise default to 12-bit for 16-bit // pixel data. const sourceBitdepth = getSourceBitdepth( image ); if ( sourceBitdepth > 8 ) { saveOptions.bitdepth = resolveSaveBitdepth( sourceBitdepth, maxBitdepth ); } } const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); const result = outBuffer.buffer; cleanup?.(); return result; } finally { inProgressOperations.delete( id ); } } /** * Compresses an existing image using vips. * * @param id Item ID. * @param buffer Original file buffer. * @param type Mime type. * @param options Compression options. * @return Compressed file data. */ export async function compressImage( id: ItemId, buffer: ArrayBuffer, type: string, options: ConvertImageOptions = {} ): Promise< ArrayBuffer | ArrayBufferLike > { return convertImageFormat( id, buffer, type, type, options ); } /** * Applies resize and optional crop logic to produce a thumbnail. * * Handles three crop modes: no crop (simple downscale), boolean `true` * (center/attention crop), and positional crop (e.g. ['center', 'top']). * * @param resize Resize options including target dimensions and crop mode. * @param originalWidth Width of the source image. * @param originalHeight Height (pageHeight) of the source image. * @param smartCrop Whether to use saliency-aware cropping. * @param createThumbnail Callback that creates a thumbnail at the given width/options. * @return The resized (and optionally cropped) image. */ function applyResizeAndCrop< T extends { width: number; height: number; crop: ( ...args: number[] ) => T; // Optional UltraHDR support: present on Vips.Image instances when the // source has an embedded gain map. gainmap?: T; copy?: () => T; setImage?: ( name: string, value: T ) => void; }, >( resize: ImageSizeCrop, originalWidth: number, originalHeight: number, smartCrop: boolean, createThumbnail: ( width: number, options: ThumbnailOptions ) => T ): T { // Clone so we don't mutate the caller's config. // If resize.height is zero, calculate from aspect ratio. const target: ImageSizeCrop = { ...resize, height: resize.height || ( originalHeight / originalWidth ) * resize.width, }; const thumbnailOptions: ThumbnailOptions = { size: 'down', height: target.height, }; let resizeWidth = target.width; if ( ! target.crop ) { return createThumbnail( resizeWidth, thumbnailOptions ); } if ( true === target.crop ) { thumbnailOptions.crop = smartCrop ? 'attention' : 'centre'; return createThumbnail( resizeWidth, thumbnailOptions ); } // Positional crop: first resize, then crop to exact dimensions. if ( originalWidth < originalHeight ) { resizeWidth = target.width >= target.height ? target.width : ( originalWidth / originalHeight ) * target.height; thumbnailOptions.height = target.width >= target.height ? ( originalHeight / originalWidth ) * resizeWidth : target.height; } else { resizeWidth = target.width >= target.height ? ( originalWidth / originalHeight ) * target.height : target.width; thumbnailOptions.height = target.width >= target.height ? target.height : ( originalHeight / originalWidth ) * resizeWidth; } const image = createThumbnail( resizeWidth, thumbnailOptions ); let left = 0; if ( 'center' === target.crop[ 0 ] ) { left = ( image.width - target.width ) / 2; } else if ( 'right' === target.crop[ 0 ] ) { left = image.width - target.width; } let top = 0; if ( 'center' === target.crop[ 1 ] ) { top = ( image.height - target.height ) / 2; } else if ( 'bottom' === target.crop[ 1 ] ) { top = image.height - target.height; } // Address rounding errors where `left` or `top` become negative integers // and `target.width` / `target.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 ); const cropWidth = Math.min( image.width, target.width ); const cropHeight = Math.min( image.height, target.height ); const cropped = image.crop( left, top, cropWidth, cropHeight ); // For UltraHDR sources, also crop the attached gain map. The gain map // can be smaller than the main image, so we scale the crop coordinates // to its resolution. See: // https://www.libvips.org/API/current/uhdr.html#a-la-carte-processing const gainmap = image.gainmap; const copy = cropped.copy; const setImage = cropped.setImage; if ( ! gainmap || ! copy || ! setImage ) { return cropped; } // Scale the crop rect to the gain map's resolution. `crop` expects integer // pixel coordinates, so round here rather than relying on an implicit // float-to-int conversion, and clamp to the gain map bounds so the rect // never extends past its edges. const hscale = gainmap.width / image.width; const vscale = gainmap.height / image.height; const gainmapLeft = Math.round( left * hscale ); const gainmapTop = Math.round( top * vscale ); const gainmapWidth = Math.min( Math.round( cropWidth * hscale ), gainmap.width - gainmapLeft ); const gainmapHeight = Math.min( Math.round( cropHeight * vscale ), gainmap.height - gainmapTop ); const newGainmap = gainmap.crop( gainmapLeft, gainmapTop, gainmapWidth, gainmapHeight ); // setImage mutates, so produce a unique copy first. const result = copy.call( cropped ); setImage.call( result, 'gainmap', newGainmap ); 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; } /** * Resolves the effective AVIF save bit depth from the source depth and the * `image_max_bit_depth` cap, snapping down to the nearest depth supported by * heifsave (8, 10, or 12) since the filter may return any integer. * * @param sourceBitdepth Source bit depth (8, 10, or 12). * @param maxBitdepth Maximum bit depth from the `image_max_bit_depth` filter. * @return The bit depth to save at. */ function resolveSaveBitdepth( sourceBitdepth: number, maxBitdepth: number ): number { const target = Math.min( sourceBitdepth, maxBitdepth ); if ( target >= 12 ) { return 12; } if ( target >= 10 ) { return 10; } return 8; } /** * Builds save options for writing an image to a buffer. * * @param type Output mime type. * @param quality Desired quality (0-1). * @param bitdepth Save bit depth; values above 8 are preserved for AVIF. * @param stripMeta Whether to strip metadata (except color profiles), * from the `image_strip_meta` filter. * @return Save options object. */ function buildSaveOptions( type: string, quality: number, bitdepth = 8, stripMeta = true ): SaveOptions< typeof type > { const saveOptions: SaveOptions< typeof type > = { // Strip metadata except ICC color profiles or gainmaps, // matching WordPress core's behavior. The `image_strip_meta` // filter can disable stripping entirely. keep: stripMeta ? 'icc|gainmap' : 'all', }; if ( supportsQuality( type ) ) { saveOptions.Q = quality * 100; } // 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< 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. * * UltraHDR JPEGs are auto-detected and preserved: libvips's `uhdrload*` * has higher priority than `jpegload*`, so `newFromBuffer`/`thumbnailBuffer` * decode the gain map alongside the base image, and `jpegsave*` delegates * to `uhdrsave*` on output when a gain map is attached. * * Sub-sizes of animated images are generated from the first frame only, * matching WordPress core's server-side behavior: both GD and Imagick * flatten animated images when resizing, and `wp_calculate_image_srcset()` * prevents flattened sub-sizes and the animated full-size image from mixing * in a srcset. Loading all frames (`[n=-1]`) would re-encode a full animated * GIF per sub-size, which takes tens of seconds for long animations and can * produce sub-sizes larger than the original file. * See https://github.com/WordPress/gutenberg/issues/80266. * * @param id Item ID. * @param buffer Original file buffer. * @param type Mime type. * @param resize Resize options. * @param options Additional resize options. * @return Processed file data plus the old and new dimensions. */ export async function resizeImage( id: ItemId, buffer: ArrayBuffer, type: string, resize: ImageSizeCrop, options: ResizeImageOptions = {} ): Promise< { buffer: ArrayBuffer | ArrayBufferLike; width: number; height: number; originalWidth: number; originalHeight: number; } > { const { smartCrop = false, quality = 0.82, stripMeta = true, maxBitdepth = 16, } = options; const ext = type.split( '/' )[ 1 ]; inProgressOperations.add( id ); try { const vips = await getVips(); // 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 ); image.onProgress = onProgress; 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. const sourceBitdepth = 'image/avif' === type ? getSourceBitdepth( image ) : 8; // The `image_max_bit_depth` filter can cap the output depth. When the // cap flattens the image to 8-bit anyway, the regular colour-managed // `thumbnail` path is used, matching standard-depth sources. const saveBitdepth = sourceBitdepth > 8 ? resolveSaveBitdepth( sourceBitdepth, maxBitdepth ) : 8; const isHighBitDepth = saveBitdepth > 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; } const thumb = vips.Image.thumbnailBuffer( buffer, resizeWidth, thumbnailOptions ); thumb.onProgress = onProgress; return thumb; } ); const saveOptions = buildSaveOptions( type, quality, saveBitdepth, stripMeta ); 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; } finally { inProgressOperations.delete( id ); } } /** * Information returned by getUltraHdrInfo() for a successfully probed * UltraHDR JPEG. */ interface UltraHdrInfo { width: number; height: number; /** HDR headroom in stops (log2 of the linear capacity). */ hdrCapacity: number; } /** * Probes a JPEG to determine whether it is an UltraHDR image with an embedded * gain map. * * Returns dimensions and HDR headroom on success, or `null` if the buffer is * not a valid UltraHDR JPEG (no gain map, decode failure, or unsupported * format). * * @param buffer Image buffer. * @return UltraHDR info, or null when the buffer is not UltraHDR. */ export async function getUltraHdrInfo( buffer: ArrayBuffer ): Promise< UltraHdrInfo | null > { try { const vips = await getVips(); const image = vips.Image.uhdrloadBuffer( buffer ); if ( ! image.gainmap ) { cleanup?.(); return null; } // `gainmap-hdr-capacity-max` is libultrahdr's linear-scale max capacity. // Convert to log2 stops so the value stored in attachment metadata // represents HDR headroom in stops. let hdrCapacityLinear = 1; try { hdrCapacityLinear = image.getDouble( 'gainmap-hdr-capacity-max' ); } catch { // Field may be missing; fall back to no headroom. } const hdrCapacity = hdrCapacityLinear > 0 ? Math.log2( hdrCapacityLinear ) : 0; const info: UltraHdrInfo = { width: image.width, height: image.pageHeight, hdrCapacity, }; cleanup?.(); return info; } catch { // Not an UltraHDR image (or libultrahdr decoder unavailable). cleanup?.(); return null; } } /** * Rotates an image based on EXIF orientation value. * * EXIF orientation values: * 1 = Normal (no rotation needed) * 2 = Flipped horizontally * 3 = Rotated 180° * 4 = Flipped vertically * 5 = Rotated 90° CCW and flipped horizontally * 6 = Rotated 90° CW * 7 = Rotated 90° CW and flipped horizontally * 8 = Rotated 90° CCW * * @param id Item ID. * @param buffer Original file buffer. * @param type Mime type. * @param orientation EXIF orientation value (1-8). * @return Rotated file data plus the new dimensions. */ export async function rotateImage( id: ItemId, buffer: ArrayBuffer, type: string, orientation: number ): Promise< { buffer: ArrayBuffer | ArrayBufferLike; width: number; height: number; } > { const ext = type.split( '/' )[ 1 ]; inProgressOperations.add( id ); try { const vips = await getVips(); let strOptions = ''; const loadOptions: LoadOptions< typeof type > = {}; // To ensure all frames are loaded in case the image is animated. if ( supportsAnimation( type ) ) { strOptions = '[n=-1]'; ( loadOptions as LoadOptions< typeof type > ).n = -1; } let image = vips.Image.newFromBuffer( buffer, strOptions, loadOptions ); image.onProgress = () => { if ( ! inProgressOperations.has( id ) ) { image.kill = true; } }; // Apply transformation based on EXIF orientation. // See: https://exiftool.org/TagNames/EXIF.html#:~:text=0x0112,Orientation switch ( orientation ) { case 2: // Flipped horizontally image = image.flipHor(); break; case 3: // Rotated 180° image = image.rot180(); break; case 4: // Flipped vertically image = image.flipVer(); break; case 5: // Mirrored horizontally and rotated 270° CW (transpose). // The mirror is applied before the rotation, matching the // EXIF spec; the operand order matters for orientations 5/7. image = image.flipHor().rot270(); break; case 6: // Rotated 90° CW image = image.rot90(); break; case 7: // Mirrored horizontally and rotated 90° CW (transverse). // The mirror is applied before the rotation, matching the // EXIF spec; the operand order matters for orientations 5/7. image = image.flipHor().rot90(); break; case 8: // Rotated 90° CCW image = image.rot270(); break; // case 1 and default: no transformation needed } // The pixels have now been physically rotated, so strip the EXIF // orientation tag (which `newFromBuffer` copies through from the // source) to keep the output self-consistent. Otherwise a later // consumer that auto-rotates from EXIF could apply the rotation a // second time. image.remove( 'orientation' ); const saveOptions: SaveOptions< typeof type > = {}; const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); const result = { buffer: outBuffer.buffer, width: image.width, height: image.pageHeight, }; // Only call after `image` is no longer being used. cleanup?.(); return result; } finally { inProgressOperations.delete( id ); } } /** * Determines whether an image has visible transparency. * * Channel presence alone is not enough: PNG encoders often retain an alpha * channel even when every pixel is fully opaque, and animated GIFs declare * a transparent color index for disposal-method frame compositing without * ever rendering a visibly transparent pixel. This check loads the first * frame (any transparency there is visible — there is no previous frame to * inherit from) and samples the alpha channel for an actually-transparent * pixel. * * @param buffer Original file object. * @return Whether any pixel in the image is partially or fully transparent. */ export async function hasTransparency( buffer: ArrayBuffer ): Promise< boolean > { const vips = await getVips(); const image = vips.Image.newFromBuffer( buffer ); if ( ! image.hasAlpha() ) { cleanup?.(); return false; } // `min()` on the alpha band is one read of a single channel of frame 0. // For uchar (GIF/8-bit PNG) the opaque value is 255; 16-bit PNGs (rare // inputs here) use 65535. Anything lower means at least one pixel is // transparent. const alpha = image.extractBand( image.bands - 1 ); const minAlpha = alpha.min(); const opaqueValue = image.format === 'ushort' ? 65535 : 255; cleanup?.(); return minAlpha < opaqueValue; } // Re-export with vips prefix for worker module compatibility. // The worker loader expects these prefixed names. export { convertImageFormat as vipsConvertImageFormat, compressImage as vipsCompressImage, resizeImage as vipsResizeImage, rotateImage as vipsRotateImage, hasTransparency as vipsHasTransparency, getUltraHdrInfo as vipsGetUltraHdrInfo, cancelOperations as vipsCancelOperations, };