diff --git a/.eslintignore b/.eslintignore
index 06cf44220498f7..b4427be4094c5d 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -7,5 +7,6 @@ node_modules
packages/block-serialization-spec-parser/parser.js
packages/icons/src/library/*.tsx
packages/react-native-editor/bundle
+packages/vips/src/worker-code.ts
vendor
!.*.js
diff --git a/lib/client-assets.php b/lib/client-assets.php
index a665307cbf83c4..cfef81bd63deb0 100644
--- a/lib/client-assets.php
+++ b/lib/client-assets.php
@@ -439,6 +439,20 @@ function gutenberg_enqueue_latex_to_mathml_loader() {
wp_enqueue_script_module( '@wordpress/latex-to-mathml/loader' );
}
+/**
+ * Enqueue the vips loader script module in the block editor.
+ *
+ * This registers @wordpress/vips/worker as a dynamic dependency in the import map,
+ * enabling on-demand loading of the ~3.8MB WASM-based image processing module
+ * when client-side media processing is triggered via @wordpress/upload-media.
+ *
+ * @see packages/vips/src/loader.ts
+ */
+add_action( 'enqueue_block_editor_assets', 'gutenberg_enqueue_vips_loader' );
+function gutenberg_enqueue_vips_loader() {
+ wp_enqueue_script_module( '@wordpress/vips/loader' );
+}
+
add_action( 'admin_enqueue_scripts', 'gutenberg_enqueue_core_abilities' );
function gutenberg_enqueue_core_abilities() {
wp_enqueue_script_module( '@wordpress/core-abilities' );
diff --git a/lib/compat/wordpress-7.0/preload.php b/lib/compat/wordpress-7.0/preload.php
index 0a046c52376ebd..0c650114cb2a1a 100644
--- a/lib/compat/wordpress-7.0/preload.php
+++ b/lib/compat/wordpress-7.0/preload.php
@@ -26,3 +26,34 @@ static function ( $path ) {
return $paths;
}
add_filter( 'block_editor_rest_api_preload_paths', 'gutenberg_block_editor_preload_paths_6_9', 10, 2 );
+
+/**
+ * Filters the block editor preload paths to include media processing fields.
+ *
+ * The packages/core-data/src/entities.js file requests additional fields
+ * (image_sizes, image_size_threshold) on the root endpoint that are not
+ * included in WordPress Core's default preload paths. This filter ensures
+ * the preloaded URL matches exactly what the JavaScript requests.
+ *
+ * @since 20.1.0
+ *
+ * @param array $paths REST API paths to preload.
+ * @return array Filtered preload paths.
+ */
+function gutenberg_block_editor_preload_paths_root_fields( $paths ) {
+ // Complete list of fields expected by packages/core-data/src/entities.js.
+ // This must match exactly for preloading to work (same fields, same order).
+ // @see packages/core-data/src/entities.js rootEntitiesConfig.__unstableBase
+ $root_fields = 'description,gmt_offset,home,image_sizes,image_size_threshold,image_output_formats,jpeg_interlaced,png_interlaced,gif_interlaced,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front';
+
+ foreach ( $paths as $key => $path ) {
+ if ( is_string( $path ) && str_starts_with( $path, '/?_fields=' ) ) {
+ // Replace with the complete fields list to ensure exact match.
+ $paths[ $key ] = '/?_fields=' . $root_fields;
+ break;
+ }
+ }
+
+ return $paths;
+}
+add_filter( 'block_editor_rest_api_preload_paths', 'gutenberg_block_editor_preload_paths_root_fields' );
diff --git a/lib/experimental/media/class-gutenberg-rest-attachments-controller.php b/lib/experimental/media/class-gutenberg-rest-attachments-controller.php
index 71bf7b7a958351..4defe09bfff681 100644
--- a/lib/experimental/media/class-gutenberg-rest-attachments-controller.php
+++ b/lib/experimental/media/class-gutenberg-rest-attachments-controller.php
@@ -78,10 +78,31 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE
return $args;
}
+ /**
+ * Retrieves the attachment's schema, conforming to JSON Schema.
+ *
+ * Adds exif_orientation field to the schema.
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ $schema = parent::get_item_schema();
+
+ $schema['properties']['exif_orientation'] = array(
+ 'description' => __( 'EXIF orientation value from the original image. Values 1-8 follow the EXIF specification. A value other than 1 indicates the image needs rotation.', 'gutenberg' ),
+ 'type' => 'integer',
+ 'context' => array( 'edit' ),
+ 'readonly' => true,
+ );
+
+ return $schema;
+ }
+
/**
* Prepares a single attachment output for response.
*
* Ensures 'missing_image_sizes' is set for PDFs and not just images.
+ * Adds 'exif_orientation' for images that need client-side rotation.
*
* @param WP_Post $item Attachment object.
* @param WP_REST_Request $request Request object.
@@ -92,10 +113,27 @@ public function prepare_item_for_response( $item, $request ): WP_REST_Response {
$data = $response->get_data();
- // Handle missing image sizes for PDFs.
-
$fields = $this->get_fields_for_response( $request );
+ // Add EXIF orientation for images.
+ if ( rest_is_field_included( 'exif_orientation', $fields ) ) {
+ if ( wp_attachment_is_image( $item ) ) {
+ $metadata = wp_get_attachment_metadata( $item->ID, true );
+
+ // Get the EXIF orientation from the image metadata.
+ // This is stored by wp_read_image_metadata() during upload.
+ $orientation = 1; // Default: no rotation needed.
+ if (
+ is_array( $metadata ) &&
+ isset( $metadata['image_meta']['orientation'] )
+ ) {
+ $orientation = (int) $metadata['image_meta']['orientation'];
+ }
+
+ $data['exif_orientation'] = $orientation;
+ }
+ }
+
if (
rest_is_field_included( 'missing_image_sizes', $fields ) &&
empty( $data['missing_image_sizes'] )
@@ -158,7 +196,9 @@ public function create_item( $request ) {
if ( ! $request['generate_sub_sizes'] ) {
add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
-
+ // Disable server-side EXIF rotation so the client can handle it.
+ // This preserves the original orientation value in the metadata.
+ add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
}
if ( ! $request['convert_format'] ) {
@@ -169,6 +209,7 @@ public function create_item( $request ) {
remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
+ remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
return $response;
diff --git a/package-lock.json b/package-lock.json
index 9f944e4cc331ae..4a0619a20e41a4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,6 +38,7 @@
"@types/uuid": "8.3.1",
"@vitejs/plugin-react": "5.1.2",
"@wordpress/build": "file:./packages/wp-build",
+ "@wordpress/vips": "1.0.0-prerelease",
"ajv": "8.17.1",
"babel-jest": "29.7.0",
"babel-loader": "9.2.1",
@@ -26343,12 +26344,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/clean-git-ref": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz",
- "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==",
- "license": "Apache-2.0"
- },
"node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@@ -54814,15 +54809,6 @@
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
- "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",
@@ -57885,6 +57871,7 @@
"@wordpress/reusable-blocks": "file:../reusable-blocks",
"@wordpress/rich-text": "file:../rich-text",
"@wordpress/server-side-render": "file:../server-side-render",
+ "@wordpress/upload-media": "file:../upload-media",
"@wordpress/url": "file:../url",
"@wordpress/viewport": "file:../viewport",
"@wordpress/wordcount": "file:../wordcount",
@@ -62172,6 +62159,7 @@
"@wordpress/preferences": "file:../preferences",
"@wordpress/private-apis": "file:../private-apis",
"@wordpress/url": "file:../url",
+ "@wordpress/vips": "1.0.0-prerelease",
"uuid": "^9.0.1"
},
"engines": {
@@ -62237,13 +62225,22 @@
"license": "GPL-2.0-or-later",
"dependencies": {
"@wordpress/worker-threads": "file:../worker-threads",
- "wasm-vips": "^0.0.10"
+ "wasm-vips": "^0.0.16"
},
"engines": {
"node": ">=18.12.0",
"npm": ">=8.19.2"
}
},
+ "packages/vips/node_modules/wasm-vips": {
+ "version": "0.0.16",
+ "resolved": "https://registry.npmjs.org/wasm-vips/-/wasm-vips-0.0.16.tgz",
+ "integrity": "sha512-4/bEq8noAFt7DX3VT+Vt5AgNtnnOLwvmrDbduWfiv9AV+VYkbUU4f9Dam9e6khRqPinyClFHCqiwATTTJEiGwA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.4.0"
+ }
+ },
"packages/warning": {
"name": "@wordpress/warning",
"version": "3.39.0",
diff --git a/package.json b/package.json
index 1cc5d9fcfdffab..bda8b33e0854c4 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
"@types/uuid": "8.3.1",
"@vitejs/plugin-react": "5.1.2",
"@wordpress/build": "file:./packages/wp-build",
+ "@wordpress/vips": "1.0.0-prerelease",
"ajv": "8.17.1",
"babel-jest": "29.7.0",
"babel-loader": "9.2.1",
diff --git a/packages/block-editor/src/components/provider/use-media-upload-settings.js b/packages/block-editor/src/components/provider/use-media-upload-settings.js
index 40390a77e746ef..7c00c145d27a72 100644
--- a/packages/block-editor/src/components/provider/use-media-upload-settings.js
+++ b/packages/block-editor/src/components/provider/use-media-upload-settings.js
@@ -17,6 +17,8 @@ function useMediaUploadSettings( settings = {} ) {
mediaSideload: settings.mediaSideload,
maxUploadFileSize: settings.maxUploadFileSize,
allowedMimeTypes: settings.allowedMimeTypes,
+ allImageSizes: settings.allImageSizes,
+ bigImageSizeThreshold: settings.bigImageSizeThreshold,
} ),
[ settings ]
);
diff --git a/packages/block-library/package.json b/packages/block-library/package.json
index 0bec3c4541f314..18fd8f810eac08 100644
--- a/packages/block-library/package.json
+++ b/packages/block-library/package.json
@@ -126,6 +126,7 @@
"@wordpress/reusable-blocks": "file:../reusable-blocks",
"@wordpress/rich-text": "file:../rich-text",
"@wordpress/server-side-render": "file:../server-side-render",
+ "@wordpress/upload-media": "file:../upload-media",
"@wordpress/url": "file:../url",
"@wordpress/viewport": "file:../viewport",
"@wordpress/wordcount": "file:../wordcount",
diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js
index ecf5a281e222dd..45e4ce558d6e83 100644
--- a/packages/block-library/src/image/edit.js
+++ b/packages/block-library/src/image/edit.js
@@ -25,6 +25,7 @@ import { image as icon, plugins as pluginsIcon } from '@wordpress/icons';
import { store as noticesStore } from '@wordpress/notices';
import { useResizeObserver } from '@wordpress/compose';
import { getProtocol, prependHTTPS } from '@wordpress/url';
+import { store as uploadStore } from '@wordpress/upload-media';
/**
* Internal dependencies
@@ -344,6 +345,17 @@ export function ImageEdit( {
const isExternal = isExternalImage( id, url );
const src = isExternal ? url : undefined;
+
+ const isSideloading = useSelect(
+ ( select ) => {
+ if ( ! window.__experimentalMediaProcessing || ! id ) {
+ return false;
+ }
+ return select( uploadStore ).isUploadingById( id );
+ },
+ [ id ]
+ );
+
const mediaPreview = !! url && (
onSelectImage( undefined ) }
- isUploading={ !! temporaryURL }
+ isUploading={
+ !! temporaryURL || isSideloading
+ }
emptyLabel={ __( 'Add image' ) }
/>
@@ -1065,7 +1068,7 @@ export default function Image( {
...shadowProps.style,
} }
/>
- { temporaryURL && }
+ { ( temporaryURL || isSideloading ) && }
>
);
diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json
index b58dad52bda69f..62d742bff364bf 100644
--- a/packages/block-library/tsconfig.json
+++ b/packages/block-library/tsconfig.json
@@ -32,6 +32,7 @@
{ "path": "../keycodes" },
{ "path": "../primitives" },
{ "path": "../rich-text" },
+ { "path": "../upload-media" },
{ "path": "../url" },
{ "path": "../wordcount" }
],
diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js
index 901b3c34826e83..7b474491548b5c 100644
--- a/packages/core-data/src/entities.js
+++ b/packages/core-data/src/entities.js
@@ -42,11 +42,17 @@ export const rootEntitiesConfig = [
baseURL: '/',
baseURLParams: {
// Please also change the preload path when changing this.
- // @see lib/compat/wordpress-6.8/preload.php
+ // @see lib/compat/wordpress-7.0/preload.php
_fields: [
'description',
'gmt_offset',
'home',
+ 'image_sizes',
+ 'image_size_threshold',
+ 'image_output_formats',
+ 'jpeg_interlaced',
+ 'png_interlaced',
+ 'gif_interlaced',
'name',
'site_icon',
'site_icon_url',
diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js
index b946ff80d52c81..38f46c0dd56266 100644
--- a/packages/editor/src/components/provider/use-block-editor-settings.js
+++ b/packages/editor/src/components/provider/use-block-editor-settings.js
@@ -45,6 +45,7 @@ const BLOCK_EDITOR_SETTINGS = [
'__experimentalDiscussionSettings',
'__experimentalFeatures',
'__experimentalGlobalStylesBaseStyles',
+ 'allImageSizes',
'alignWide',
'blockInspectorTabs',
'maxUploadFileSize',
@@ -116,6 +117,8 @@ const {
function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
const isLargeViewport = useViewportMatch( 'medium' );
const {
+ allImageSizes,
+ bigImageSizeThreshold,
allowRightClickOverrides,
blockTypes,
focusMode,
@@ -154,6 +157,9 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
? getEntityRecord( 'root', 'site' )
: undefined;
+ // Fetch image sizes from REST API index for client-side media processing.
+ const baseData = getEntityRecord( 'root', '__unstableBase' );
+
function getSectionRootBlock() {
if ( renderingMode === 'template-locked' ) {
return getBlocksByName( 'core/post-content' )?.[ 0 ] ?? '';
@@ -168,6 +174,8 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
}
return {
+ allImageSizes: baseData?.image_sizes,
+ bigImageSizeThreshold: baseData?.image_size_threshold,
allowRightClickOverrides: get(
'core',
'allowRightClickOverrides'
@@ -328,6 +336,8 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
),
[ globalStylesDataKey ]: globalStylesData,
[ globalStylesLinksDataKey ]: globalStylesLinksData,
+ allImageSizes,
+ bigImageSizeThreshold,
allowedBlockTypes,
allowRightClickOverrides,
focusMode: focusMode && ! forceDisableFocusMode,
@@ -431,6 +441,8 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
editMediaEntity,
wrappedOnNavigateToEntityRecord,
deviceType,
+ allImageSizes,
+ bigImageSizeThreshold,
isNavigationOverlayContext,
] );
}
diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json
index c75f27323a1b5a..706b1734a53bc1 100644
--- a/packages/upload-media/package.json
+++ b/packages/upload-media/package.json
@@ -58,6 +58,7 @@
"@wordpress/preferences": "file:../preferences",
"@wordpress/private-apis": "file:../private-apis",
"@wordpress/url": "file:../url",
+ "@wordpress/vips": "1.0.0-prerelease",
"uuid": "^9.0.1"
},
"peerDependencies": {
diff --git a/packages/upload-media/src/store/actions.ts b/packages/upload-media/src/store/actions.ts
index 3df010aaac4b83..ae6b69f425062d 100644
--- a/packages/upload-media/src/store/actions.ts
+++ b/packages/upload-media/src/store/actions.ts
@@ -31,6 +31,7 @@ import type {
removeItem,
revokeBlobUrls,
} from './private-actions';
+import { vipsCancelOperations } from './utils';
import { validateMimeType } from '../validate-mime-type';
import { validateMimeTypeForUser } from '../validate-mime-type-for-user';
import { validateFileSize } from '../validate-file-size';
@@ -159,6 +160,9 @@ export function cancelItem( id: QueueItemId, error: Error, silent = false ) {
item.abortController?.abort();
+ // Cancel any ongoing vips operations for this item.
+ await vipsCancelOperations( id );
+
if ( ! silent ) {
const { onError } = item;
onError?.( error ?? new Error( 'Upload cancelled' ) );
diff --git a/packages/upload-media/src/store/constants.ts b/packages/upload-media/src/store/constants.ts
index 65e25220779a30..c326fa5bc5a676 100644
--- a/packages/upload-media/src/store/constants.ts
+++ b/packages/upload-media/src/store/constants.ts
@@ -4,3 +4,13 @@ export const STORE_NAME = 'core/upload-media';
* Default maximum number of concurrent uploads.
*/
export const DEFAULT_MAX_CONCURRENT_UPLOADS = 5;
+
+/**
+ * Default maximum number of concurrent image processing operations.
+ *
+ * Image processing (VIPS WASM) is significantly more memory-intensive
+ * than network uploads. Each operation can consume 50-100MB+ of memory
+ * for large images. A lower limit prevents out-of-memory crashes when
+ * uploading many images at once.
+ */
+export const DEFAULT_MAX_CONCURRENT_IMAGE_PROCESSING = 2;
diff --git a/packages/upload-media/src/store/index.ts b/packages/upload-media/src/store/index.ts
index c74f59ea7a7cf3..dbf2aa40212273 100644
--- a/packages/upload-media/src/store/index.ts
+++ b/packages/upload-media/src/store/index.ts
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
-import { createReduxStore, register } from '@wordpress/data';
+import { createReduxStore, register, select } from '@wordpress/data';
/**
* Internal dependencies
@@ -36,7 +36,11 @@ export const store = createReduxStore( STORE_NAME, {
actions,
} );
-register( store );
+// The upload-media package is bundled into multiple packages (block-editor, editor).
+// Guard against duplicate registration when both bundles are loaded on the same page.
+if ( ! select( store ) ) {
+ register( store );
+}
// @ts-ignore
unlock( store ).registerPrivateActions( privateActions );
// @ts-ignore
diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts
index b1b7d45b4b35bd..1bf8a1fb2ce20a 100644
--- a/packages/upload-media/src/store/private-actions.ts
+++ b/packages/upload-media/src/store/private-actions.ts
@@ -14,8 +14,10 @@ type WPDataRegistry = ReturnType< typeof createRegistry >;
/**
* Internal dependencies
*/
-import { cloneFile, convertBlobToFile } from '../utils';
+import { cloneFile, convertBlobToFile, renameFile } from '../utils';
import { StubFile } from '../stub-file';
+import { UploadError } from '../upload-error';
+import { vipsResizeImage, vipsRotateImage, terminateVipsWorker } from './utils';
import type {
AddAction,
AdditionalData,
@@ -27,6 +29,7 @@ import type {
OnErrorHandler,
OnSuccessHandler,
Operation,
+ OperationArgs,
OperationFinishAction,
OperationStartAction,
PauseItemAction,
@@ -36,6 +39,7 @@ import type {
ResumeItemAction,
ResumeQueueAction,
RevokeBlobUrlsAction,
+ SideloadAdditionalData,
Settings,
State,
UpdateProgressAction,
@@ -47,13 +51,18 @@ import type { cancelItem } from './actions';
type ActionCreators = {
cancelItem: typeof cancelItem;
addItem: typeof addItem;
+ addSideloadItem: typeof addSideloadItem;
removeItem: typeof removeItem;
pauseItem: typeof pauseItem;
- resumeItem: typeof resumeItem;
+ resumeItemByPostId: typeof resumeItemByPostId;
prepareItem: typeof prepareItem;
processItem: typeof processItem;
finishOperation: typeof finishOperation;
uploadItem: typeof uploadItem;
+ sideloadItem: typeof sideloadItem;
+ resizeCropItem: typeof resizeCropItem;
+ rotateItem: typeof rotateItem;
+ generateThumbnails: typeof generateThumbnails;
updateItemProgress: typeof updateItemProgress;
revokeBlobUrls: typeof revokeBlobUrls;
< T = Record< string, unknown > >( args: T ): void;
@@ -74,6 +83,32 @@ type ThunkArgs = {
registry: WPDataRegistry;
};
+/**
+ * Determines if an upload should be paused to avoid race conditions.
+ *
+ * When sideloading thumbnails, we need to pause uploads if another
+ * upload to the same post is already in progress.
+ *
+ * @param item Queue item to check.
+ * @param operation Current operation type.
+ * @param select Store selectors.
+ * @return Whether the upload should be paused.
+ */
+function shouldPauseForSideload(
+ item: QueueItem,
+ operation: OperationType | undefined,
+ select: Selectors
+): boolean {
+ if (
+ operation !== OperationType.Upload ||
+ ! item.parentId ||
+ ! item.additionalData.post
+ ) {
+ return false;
+ }
+ return select.isUploadingToPost( item.additionalData.post as number );
+}
+
interface AddItemArgs {
// It should always be a File, but some consumers might still pass Blobs only.
file: File | Blob;
@@ -150,6 +185,7 @@ export function addItem( {
},
additionalData: {
convert_format: false,
+ generate_sub_sizes: false,
...additionalData,
},
onChange,
@@ -169,6 +205,62 @@ export function addItem( {
};
}
+interface AddSideloadItemArgs {
+ file: File;
+ onChange?: OnChangeHandler;
+ additionalData?: AdditionalData;
+ operations?: Operation[];
+ batchId?: BatchId;
+ parentId?: QueueItemId;
+}
+
+/**
+ * Adds a new item to the upload queue for sideloading.
+ *
+ * This is typically a client-side generated thumbnail.
+ *
+ * @param $0
+ * @param $0.file File
+ * @param [$0.batchId] Batch ID.
+ * @param [$0.parentId] Parent ID.
+ * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available.
+ * @param [$0.additionalData] Additional data to include in the request.
+ * @param [$0.operations] List of operations to perform. Defaults to automatically determined list, based on the file.
+ */
+export function addSideloadItem( {
+ file,
+ onChange,
+ additionalData,
+ operations,
+ batchId,
+ parentId,
+}: AddSideloadItemArgs ) {
+ return ( { dispatch }: ThunkArgs ) => {
+ const itemId = uuidv4();
+ dispatch< AddAction >( {
+ type: Type.Add,
+ item: {
+ id: itemId,
+ batchId,
+ status: ItemStatus.Processing,
+ sourceFile: cloneFile( file ),
+ file,
+ onChange,
+ additionalData: {
+ ...additionalData,
+ },
+ parentId,
+ operations: Array.isArray( operations )
+ ? operations
+ : [ OperationType.Prepare ],
+ abortController: new AbortController(),
+ },
+ } );
+
+ dispatch.processItem( itemId );
+ };
+}
+
/**
* Processes a single item in the queue.
*
@@ -182,14 +274,36 @@ export function processItem( id: QueueItemId ) {
return;
}
- const item = select.getItem( id ) as QueueItem;
+ const item = select.getItem( id );
+ if ( ! item ) {
+ return;
+ }
- const { attachment, onChange, onSuccess, onBatchSuccess, batchId } =
- item;
+ const {
+ attachment,
+ onChange,
+ onSuccess,
+ onBatchSuccess,
+ batchId,
+ parentId,
+ } = item;
const operation = Array.isArray( item.operations?.[ 0 ] )
? item.operations[ 0 ][ 0 ]
: item.operations?.[ 0 ];
+ const operationArgs = Array.isArray( item.operations?.[ 0 ] )
+ ? item.operations[ 0 ][ 1 ]
+ : undefined;
+
+ // If we're sideloading a thumbnail, pause upload to avoid race conditions.
+ // It will be resumed after the previous upload finishes.
+ if ( shouldPauseForSideload( item, operation, select ) ) {
+ dispatch< PauseItemAction >( {
+ type: Type.PauseItem,
+ id,
+ } );
+ return;
+ }
/*
* If the next operation is an upload, check concurrency limit.
@@ -204,6 +318,23 @@ export function processItem( id: QueueItemId ) {
}
}
+ /*
+ * If the next operation is image processing (resize/crop/rotate),
+ * check the image processing concurrency limit.
+ * If at capacity, the item remains queued and will be processed
+ * when another image processing operation completes.
+ */
+ if (
+ operation === OperationType.ResizeCrop ||
+ operation === OperationType.Rotate
+ ) {
+ const settings = select.getSettings();
+ const activeCount = select.getActiveImageProcessingCount();
+ if ( activeCount >= settings.maxConcurrentImageProcessing ) {
+ return;
+ }
+ }
+
if ( attachment ) {
onChange?.( [ attachment ] );
}
@@ -215,15 +346,42 @@ export function processItem( id: QueueItemId ) {
*/
if ( ! operation ) {
- if ( attachment ) {
- onSuccess?.( [ attachment ] );
+ if (
+ parentId ||
+ ( ! parentId && ! select.hasPendingItemsByParentId( id ) )
+ ) {
+ if ( attachment ) {
+ onSuccess?.( [ attachment ] );
+ }
+
+ dispatch.removeItem( id );
+ dispatch.revokeBlobUrls( id );
+
+ if ( batchId && select.isBatchUploaded( batchId ) ) {
+ onBatchSuccess?.();
+ }
}
- dispatch.removeItem( id );
- dispatch.revokeBlobUrls( id );
+ // All other side-loaded items have been removed, so remove the parent too.
+ if ( parentId && batchId && select.isBatchUploaded( batchId ) ) {
+ const parentItem = select.getItem( parentId ) as QueueItem;
+ if ( ! parentItem ) {
+ return;
+ }
+
+ if ( attachment ) {
+ parentItem.onSuccess?.( [ attachment ] );
+ }
+
+ dispatch.removeItem( parentId );
+ dispatch.revokeBlobUrls( parentId );
- if ( batchId && select.isBatchUploaded( batchId ) ) {
- onBatchSuccess?.();
+ if (
+ parentItem.batchId &&
+ select.isBatchUploaded( parentItem.batchId )
+ ) {
+ parentItem.onBatchSuccess?.();
+ }
}
/*
@@ -234,11 +392,6 @@ export function processItem( id: QueueItemId ) {
return;
}
- if ( ! operation ) {
- // This shouldn't really happen.
- return;
- }
-
dispatch< OperationStartAction >( {
type: Type.OperationStart,
id,
@@ -250,8 +403,30 @@ export function processItem( id: QueueItemId ) {
dispatch.prepareItem( item.id );
break;
+ case OperationType.ResizeCrop:
+ dispatch.resizeCropItem(
+ item.id,
+ operationArgs as OperationArgs[ OperationType.ResizeCrop ]
+ );
+ break;
+
+ case OperationType.Rotate:
+ dispatch.rotateItem(
+ item.id,
+ operationArgs as OperationArgs[ OperationType.Rotate ]
+ );
+ break;
+
case OperationType.Upload:
- dispatch.uploadItem( id );
+ if ( item.parentId ) {
+ dispatch.sideloadItem( id );
+ } else {
+ dispatch.uploadItem( id );
+ }
+ break;
+
+ case OperationType.ThumbnailGeneration:
+ dispatch.generateThumbnails( id );
break;
}
};
@@ -303,23 +478,24 @@ export function pauseItem( id: QueueItemId ) {
}
/**
- * Resumes a specific paused item in the queue.
+ * Resumes processing for a given post/attachment ID.
*
- * @param id Item ID.
+ * This function looks up paused uploads by post ID and resumes them.
+ * It's typically called after a sideload completes to resume paused
+ * thumbnail uploads.
+ *
+ * @param postOrAttachmentId Post or attachment ID.
*/
-export function resumeItem( id: QueueItemId ) {
+export function resumeItemByPostId( postOrAttachmentId: number ) {
return async ( { select, dispatch }: ThunkArgs ) => {
- const item = select.getItem( id );
- if ( ! item || item.status !== ItemStatus.Paused ) {
- return;
+ const item = select.getPausedUploadForPost( postOrAttachmentId );
+ if ( item ) {
+ dispatch< ResumeItemAction >( {
+ type: Type.ResumeItem,
+ id: item.id,
+ } );
+ dispatch.processItem( item.id );
}
-
- dispatch< ResumeItemAction >( {
- type: Type.ResumeItem,
- id,
- } );
-
- dispatch.processItem( id );
};
}
@@ -339,6 +515,14 @@ export function removeItem( id: QueueItemId ) {
type: Type.Remove,
id,
} );
+
+ /*
+ * If the queue is now empty, terminate the VIPS worker to free
+ * WASM memory. The worker will be lazily re-created if needed.
+ */
+ if ( select.getAllItems().length === 0 ) {
+ terminateVipsWorker();
+ }
};
}
@@ -374,6 +558,21 @@ export function finishOperation(
dispatch.processItem( pendingItem.id );
}
}
+
+ /*
+ * If an image processing operation just finished, there may be items
+ * waiting in the queue due to the image processing concurrency limit.
+ * Trigger processing for them.
+ */
+ if (
+ previousOperation === OperationType.ResizeCrop ||
+ previousOperation === OperationType.Rotate
+ ) {
+ const pendingItems = select.getPendingImageProcessing();
+ for ( const pendingItem of pendingItems ) {
+ dispatch.processItem( pendingItem.id );
+ }
+ }
};
}
@@ -392,8 +591,44 @@ export function finishOperation(
* @param id Item ID.
*/
export function prepareItem( id: QueueItemId ) {
- return async ( { dispatch }: ThunkArgs ) => {
- const operations: Operation[] = [ OperationType.Upload ];
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ const item = select.getItem( id );
+ if ( ! item ) {
+ return;
+ }
+ const { file } = item;
+
+ const operations: Operation[] = [];
+
+ const isImage = file.type.startsWith( 'image/' );
+
+ // For images, check if we need to scale down based on threshold.
+ if ( isImage ) {
+ const bigImageSizeThreshold =
+ select.getSettings().bigImageSizeThreshold;
+
+ // If a threshold is set, add a resize operation to scale down large images.
+ // This matches WordPress core's behavior in wp_create_image_subsizes().
+ if ( bigImageSizeThreshold ) {
+ operations.push( [
+ OperationType.ResizeCrop,
+ {
+ resize: {
+ width: bigImageSizeThreshold,
+ height: bigImageSizeThreshold,
+ },
+ isThresholdResize: true,
+ },
+ ] );
+ }
+
+ operations.push(
+ OperationType.Upload,
+ OperationType.ThumbnailGeneration
+ );
+ } else {
+ operations.push( OperationType.Upload );
+ }
dispatch< AddOperationsAction >( {
type: Type.AddOperations,
@@ -412,7 +647,10 @@ export function prepareItem( id: QueueItemId ) {
*/
export function uploadItem( id: QueueItemId ) {
return async ( { select, dispatch }: ThunkArgs ) => {
- const item = select.getItem( id ) as QueueItem;
+ const item = select.getItem( id );
+ if ( ! item ) {
+ return;
+ }
select.getSettings().mediaUpload( {
filesList: [ item.file ],
@@ -437,6 +675,296 @@ export function uploadItem( id: QueueItemId ) {
};
}
+/**
+ * Sideloads an item to the server.
+ *
+ * @param id Item ID.
+ */
+export function sideloadItem( id: QueueItemId ) {
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ const item = select.getItem( id );
+ if ( ! item ) {
+ return;
+ }
+
+ const { post, ...additionalData } =
+ item.additionalData as SideloadAdditionalData;
+
+ const mediaSideload = select.getSettings().mediaSideload;
+ if ( ! mediaSideload ) {
+ // If sideloading is not supported, skip this operation.
+ dispatch.finishOperation( id, {} );
+ return;
+ }
+
+ mediaSideload( {
+ file: item.file,
+ attachmentId: post as number,
+ additionalData,
+ signal: item.abortController?.signal,
+ onFileChange: ( [ attachment ] ) => {
+ dispatch.finishOperation( id, { attachment } );
+ dispatch.resumeItemByPostId( post as number );
+ },
+ onError: ( error ) => {
+ dispatch.cancelItem( id, error );
+ dispatch.resumeItemByPostId( post as number );
+ },
+ } );
+ };
+}
+
+type ResizeCropItemArgs = OperationArgs[ OperationType.ResizeCrop ];
+
+/**
+ * Resizes and crops an existing image item.
+ *
+ * @param id Item ID.
+ * @param [args] Additional arguments for the operation.
+ */
+export function resizeCropItem( id: QueueItemId, args?: ResizeCropItemArgs ) {
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ const item = select.getItem( id );
+ if ( ! item ) {
+ return;
+ }
+
+ if ( ! args?.resize ) {
+ dispatch.finishOperation( id, {
+ file: item.file,
+ } );
+ return;
+ }
+
+ // Add dimension suffix for sub-sizes (thumbnails).
+ const addSuffix = Boolean( item.parentId );
+ // Add '-scaled' suffix for big image threshold resizing.
+ const scaledSuffix = Boolean( args.isThresholdResize );
+
+ try {
+ const file = await vipsResizeImage(
+ item.id,
+ item.file,
+ args.resize,
+ false, // smartCrop
+ addSuffix,
+ item.abortController?.signal,
+ scaledSuffix
+ );
+
+ const blobUrl = createBlobURL( file );
+ dispatch< CacheBlobUrlAction >( {
+ type: Type.CacheBlobUrl,
+ id,
+ blobUrl,
+ } );
+
+ dispatch.finishOperation( id, {
+ file,
+ attachment: {
+ url: blobUrl,
+ },
+ } );
+ } catch ( error ) {
+ dispatch.cancelItem(
+ id,
+ new UploadError( {
+ code: 'IMAGE_TRANSCODING_ERROR',
+ message: 'File could not be uploaded',
+ file: item.file,
+ cause: error instanceof Error ? error : undefined,
+ } )
+ );
+ }
+ };
+}
+
+type RotateItemArgs = OperationArgs[ OperationType.Rotate ];
+
+/**
+ * Rotates an image based on EXIF orientation.
+ *
+ * This is used for images that need rotation but don't need resizing
+ * (i.e., smaller than the big image size threshold).
+ * Matches WordPress core's behavior of creating a '-rotated' version.
+ *
+ * @param id Item ID.
+ * @param [args] Rotation arguments including EXIF orientation value.
+ */
+export function rotateItem( id: QueueItemId, args?: RotateItemArgs ) {
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ const item = select.getItem( id );
+ if ( ! item ) {
+ return;
+ }
+
+ // If no orientation provided or orientation is 1 (normal), skip rotation.
+ if ( ! args?.orientation || args.orientation === 1 ) {
+ dispatch.finishOperation( id, {
+ file: item.file,
+ } );
+ return;
+ }
+
+ try {
+ const file = await vipsRotateImage(
+ item.id,
+ item.file,
+ args.orientation,
+ item.abortController?.signal
+ );
+
+ const blobUrl = createBlobURL( file );
+ dispatch< CacheBlobUrlAction >( {
+ type: Type.CacheBlobUrl,
+ id,
+ blobUrl,
+ } );
+
+ dispatch.finishOperation( id, {
+ file,
+ attachment: {
+ url: blobUrl,
+ },
+ } );
+ } catch ( error ) {
+ dispatch.cancelItem(
+ id,
+ new UploadError( {
+ code: 'IMAGE_ROTATION_ERROR',
+ message: 'Image could not be rotated',
+ file: item.file,
+ cause: error instanceof Error ? error : undefined,
+ } )
+ );
+ }
+ };
+}
+
+/**
+ * Adds thumbnail versions to the queue for sideloading.
+ *
+ * Also handles image rotation for images that need EXIF-based rotation
+ * but weren't scaled down (and thus weren't auto-rotated by vips).
+ *
+ * @param id Item ID.
+ */
+export function generateThumbnails( id: QueueItemId ) {
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ const item = select.getItem( id );
+ if ( ! item ) {
+ return;
+ }
+
+ if ( ! item.attachment ) {
+ dispatch.finishOperation( id, {} );
+ return;
+ }
+ const attachment = item.attachment;
+
+ // Check if image needs rotation.
+ // If exif_orientation is not 1, the image needs rotation.
+ // Images that were scaled (bigImageSizeThreshold) are already rotated by vips.
+ const needsRotation =
+ attachment.exif_orientation &&
+ attachment.exif_orientation !== 1 &&
+ ! item.file.name.includes( '-scaled' );
+
+ // If rotation is needed for a non-scaled image, sideload the rotated version.
+ // This matches WordPress core's behavior of creating a -rotated version.
+ if ( needsRotation && attachment.id ) {
+ try {
+ const rotatedFile = await vipsRotateImage(
+ item.id,
+ item.sourceFile,
+ attachment.exif_orientation as number,
+ item.abortController?.signal
+ );
+
+ // Sideload the rotated file as the "original" to set original_image metadata.
+ // The server will store this in $metadata['original_image'].
+ dispatch.addSideloadItem( {
+ file: rotatedFile,
+ batchId: uuidv4(),
+ parentId: item.id,
+ additionalData: {
+ post: attachment.id,
+ image_size: 'original',
+ convert_format: false,
+ },
+ operations: [ OperationType.Upload ],
+ } );
+ } catch {
+ // If rotation fails, continue with thumbnail generation.
+ // Thumbnails will still be rotated correctly by vips.
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'Failed to rotate image, continuing with thumbnails'
+ );
+ }
+ }
+
+ // Client-side thumbnail generation for images.
+ if (
+ ! item.parentId &&
+ attachment.missing_image_sizes &&
+ attachment.missing_image_sizes.length > 0
+ ) {
+ // Use sourceFile for thumbnail generation to preserve quality.
+ // WordPress core generates thumbnails from the original (unscaled) image.
+ // Vips will auto-rotate based on EXIF orientation during thumbnail generation.
+ const file = attachment.media_filename
+ ? renameFile( item.sourceFile, attachment.media_filename )
+ : item.sourceFile;
+ const batchId = uuidv4();
+
+ const allImageSizes = select.getSettings().allImageSizes || {};
+
+ for ( const name of attachment.missing_image_sizes ) {
+ const imageSize = allImageSizes[ name ];
+ if ( ! imageSize ) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `Image size "${ name }" not found in configuration`
+ );
+ continue;
+ }
+
+ dispatch.addSideloadItem( {
+ file,
+ onChange: ( [ updatedAttachment ] ) => {
+ // If the sub-size is still being generated, there is no need
+ // to invoke the callback below. It would just override
+ // the main image in the editor with the sub-size.
+ if ( isBlobURL( updatedAttachment.url ) ) {
+ return;
+ }
+
+ // This might be confusing, but the idea is to update the original
+ // image item in the editor with the new one with the added sub-size.
+ item.onChange?.( [ updatedAttachment ] );
+ },
+ batchId,
+ parentId: item.id,
+ additionalData: {
+ // Sideloading does not use the parent post ID but the
+ // attachment ID as the image sizes need to be added to it.
+ post: attachment.id,
+ image_size: name,
+ convert_format: false,
+ },
+ operations: [
+ [ OperationType.ResizeCrop, { resize: imageSize } ],
+ OperationType.Upload,
+ ],
+ } );
+ }
+ }
+
+ dispatch.finishOperation( id, {} );
+ };
+}
+
/**
* Revokes all blob URLs for a given item, freeing up memory.
*
diff --git a/packages/upload-media/src/store/private-selectors.ts b/packages/upload-media/src/store/private-selectors.ts
index 07003b4f672254..20a91bab629ac9 100644
--- a/packages/upload-media/src/store/private-selectors.ts
+++ b/packages/upload-media/src/store/private-selectors.ts
@@ -144,6 +144,46 @@ export function getPendingUploads( state: State ): QueueItem[] {
} );
}
+/**
+ * Returns the number of items currently performing image processing operations.
+ *
+ * This counts items whose current operation is ResizeCrop or Rotate,
+ * used to enforce the image processing concurrency limit.
+ *
+ * @param state Upload state.
+ *
+ * @return Number of items currently processing images.
+ */
+export function getActiveImageProcessingCount( state: State ): number {
+ return state.queue.filter(
+ ( item ) =>
+ item.currentOperation === OperationType.ResizeCrop ||
+ item.currentOperation === OperationType.Rotate
+ ).length;
+}
+
+/**
+ * Returns items waiting for image processing (next operation is ResizeCrop
+ * or Rotate but not yet started).
+ *
+ * @param state Upload state.
+ *
+ * @return Items pending image processing.
+ */
+export function getPendingImageProcessing( state: State ): QueueItem[] {
+ return state.queue.filter( ( item ) => {
+ const nextOperation = Array.isArray( item.operations?.[ 0 ] )
+ ? item.operations[ 0 ][ 0 ]
+ : item.operations?.[ 0 ];
+ return (
+ ( nextOperation === OperationType.ResizeCrop ||
+ nextOperation === OperationType.Rotate ) &&
+ item.currentOperation !== OperationType.ResizeCrop &&
+ item.currentOperation !== OperationType.Rotate
+ );
+ } );
+}
+
/**
* Returns items that failed with an error.
*
@@ -155,6 +195,21 @@ export function getFailedItems( state: State ): QueueItem[] {
return state.queue.filter( ( item ) => item.error !== undefined );
}
+/**
+ * Returns true if any child items with the given parentId exist in the queue.
+ *
+ * @param state Upload state.
+ * @param parentId Parent item ID.
+ *
+ * @return Whether any child items with the given parentId exist in the queue.
+ */
+export function hasPendingItemsByParentId(
+ state: State,
+ parentId: QueueItemId
+): boolean {
+ return state.queue.some( ( item ) => item.parentId === parentId );
+}
+
/**
* Returns the progress of a specific item.
*
diff --git a/packages/upload-media/src/store/reducer.ts b/packages/upload-media/src/store/reducer.ts
index 962e5e051d248e..b51759282d6ea5 100644
--- a/packages/upload-media/src/store/reducer.ts
+++ b/packages/upload-media/src/store/reducer.ts
@@ -23,7 +23,10 @@ import {
type UpdateProgressAction,
type UpdateSettingsAction,
} from './types';
-import { DEFAULT_MAX_CONCURRENT_UPLOADS } from './constants';
+import {
+ DEFAULT_MAX_CONCURRENT_UPLOADS,
+ DEFAULT_MAX_CONCURRENT_IMAGE_PROCESSING,
+} from './constants';
const noop = () => {};
@@ -34,6 +37,7 @@ const DEFAULT_STATE: State = {
settings: {
mediaUpload: noop,
maxConcurrentUploads: DEFAULT_MAX_CONCURRENT_UPLOADS,
+ maxConcurrentImageProcessing: DEFAULT_MAX_CONCURRENT_IMAGE_PROCESSING,
},
};
diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts
index adb38ab27128e3..9c9302afde0d37 100644
--- a/packages/upload-media/src/store/test/actions.ts
+++ b/packages/upload-media/src/store/test/actions.ts
@@ -19,6 +19,15 @@ jest.mock( '@wordpress/blob', () => ( {
revokeBlobURL: jest.fn(),
} ) );
+jest.mock( '../utils', () => ( {
+ vipsCancelOperations: jest.fn( () => Promise.resolve( true ) ),
+ vipsResizeImage: jest.fn(),
+ terminateVipsWorker: jest.fn(),
+} ) );
+
+// Import the mocked module to access the mock function.
+import { vipsCancelOperations } from '../utils';
+
function createRegistryWithStores() {
// Create a registry and register used stores.
const registry = createRegistry();
@@ -109,4 +118,136 @@ describe( 'actions', () => {
);
} );
} );
+
+ describe( 'addSideloadItem', () => {
+ it( 'adds a sideload item with parent ID', () => {
+ // Add parent item first.
+ unlock( registry.dispatch( uploadStore ) ).addItem( {
+ file: jpegFile,
+ } );
+ const parentItem = unlock(
+ registry.select( uploadStore )
+ ).getAllItems()[ 0 ];
+
+ unlock( registry.dispatch( uploadStore ) ).addSideloadItem( {
+ file: jpegFile,
+ parentId: parentItem.id,
+ additionalData: { post: 123, image_size: 'thumbnail' },
+ } );
+
+ const items = unlock(
+ registry.select( uploadStore )
+ ).getAllItems();
+ expect( items ).toHaveLength( 2 );
+ expect( items[ 1 ].parentId ).toBe( parentItem.id );
+ expect( items[ 1 ].additionalData ).toEqual(
+ expect.objectContaining( {
+ post: 123,
+ image_size: 'thumbnail',
+ } )
+ );
+ } );
+
+ it( 'adds a sideload item with custom operations', () => {
+ unlock( registry.dispatch( uploadStore ) ).addSideloadItem( {
+ file: jpegFile,
+ additionalData: { post: 456, image_size: 'medium' },
+ } );
+
+ const items = unlock(
+ registry.select( uploadStore )
+ ).getAllItems();
+ expect( items ).toHaveLength( 1 );
+ expect( items[ 0 ].status ).toBe( ItemStatus.Processing );
+ } );
+ } );
+
+ describe( 'cancelItem', () => {
+ beforeEach( () => {
+ ( vipsCancelOperations as jest.Mock ).mockClear();
+ } );
+
+ it( 'calls vipsCancelOperations when cancelling', async () => {
+ // Suppress console.error that fires when there's no onError callback.
+ const consoleErrorSpy = jest
+ .spyOn( console, 'error' )
+ .mockImplementation( () => {} );
+
+ unlock( registry.dispatch( uploadStore ) ).addItem( {
+ file: jpegFile,
+ } );
+ const item = unlock(
+ registry.select( uploadStore )
+ ).getAllItems()[ 0 ];
+
+ await registry
+ .dispatch( uploadStore )
+ .cancelItem( item.id, new Error( 'User cancelled' ) );
+
+ expect( vipsCancelOperations ).toHaveBeenCalledWith( item.id );
+ expect( consoleErrorSpy ).toHaveBeenCalled();
+
+ consoleErrorSpy.mockRestore();
+ } );
+
+ it( 'removes item from queue after cancelling', async () => {
+ // Suppress console.error that fires when there's no onError callback.
+ const consoleErrorSpy = jest
+ .spyOn( console, 'error' )
+ .mockImplementation( () => {} );
+
+ unlock( registry.dispatch( uploadStore ) ).addItem( {
+ file: jpegFile,
+ } );
+ const item = unlock(
+ registry.select( uploadStore )
+ ).getAllItems()[ 0 ];
+
+ await registry
+ .dispatch( uploadStore )
+ .cancelItem( item.id, new Error( 'User cancelled' ) );
+
+ expect(
+ unlock( registry.select( uploadStore ) ).getAllItems()
+ ).toHaveLength( 0 );
+
+ consoleErrorSpy.mockRestore();
+ } );
+
+ it( 'calls onError callback when not silent', async () => {
+ const onError = jest.fn();
+ unlock( registry.dispatch( uploadStore ) ).addItem( {
+ file: jpegFile,
+ onError,
+ } );
+ const item = unlock(
+ registry.select( uploadStore )
+ ).getAllItems()[ 0 ];
+
+ await registry
+ .dispatch( uploadStore )
+ .cancelItem( item.id, new Error( 'Test error' ) );
+
+ expect( onError ).toHaveBeenCalledWith(
+ expect.objectContaining( { message: 'Test error' } )
+ );
+ } );
+
+ it( 'does not call onError when silent', async () => {
+ const onError = jest.fn();
+ unlock( registry.dispatch( uploadStore ) ).addItem( {
+ file: jpegFile,
+ onError,
+ } );
+ const item = unlock(
+ registry.select( uploadStore )
+ ).getAllItems()[ 0 ];
+
+ await registry
+ .dispatch( uploadStore )
+ .cancelItem( item.id, new Error( 'Test error' ), true );
+
+ expect( onError ).not.toHaveBeenCalled();
+ } );
+ } );
} );
diff --git a/packages/upload-media/src/store/test/selectors.ts b/packages/upload-media/src/store/test/selectors.ts
index aebd775883e616..67c11445c9559a 100644
--- a/packages/upload-media/src/store/test/selectors.ts
+++ b/packages/upload-media/src/store/test/selectors.ts
@@ -9,9 +9,12 @@ import {
} from '../selectors';
import {
getActiveUploadCount,
+ getActiveImageProcessingCount,
getFailedItems,
getItemProgress,
getPendingUploads,
+ getPendingImageProcessing,
+ hasPendingItemsByParentId,
} from '../private-selectors';
import {
ItemStatus,
@@ -145,6 +148,61 @@ describe( 'selectors', () => {
} );
} );
+ describe( 'getActiveImageProcessingCount', () => {
+ it( 'should return the count of items currently doing image processing', () => {
+ const state: State = {
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ currentOperation: OperationType.ResizeCrop,
+ },
+ {
+ id: '2',
+ status: ItemStatus.Processing,
+ currentOperation: OperationType.Upload,
+ },
+ {
+ id: '3',
+ status: ItemStatus.Processing,
+ currentOperation: OperationType.Rotate,
+ },
+ {
+ id: '4',
+ status: ItemStatus.Processing,
+ currentOperation: OperationType.Prepare,
+ },
+ ] as QueueItem[],
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ };
+
+ expect( getActiveImageProcessingCount( state ) ).toBe( 2 );
+ } );
+
+ it( 'should return 0 when no image processing is active', () => {
+ const state: State = {
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ currentOperation: OperationType.Upload,
+ },
+ ] as QueueItem[],
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ };
+
+ expect( getActiveImageProcessingCount( state ) ).toBe( 0 );
+ } );
+ } );
+
describe( 'getPendingUploads', () => {
it( 'should return items waiting for upload', () => {
const state: State = {
@@ -175,6 +233,86 @@ describe( 'selectors', () => {
} );
} );
+ describe( 'getPendingImageProcessing', () => {
+ it( 'should return items waiting for image processing', () => {
+ const state: State = {
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ operations: [
+ [
+ OperationType.ResizeCrop,
+ {
+ resize: {
+ width: 150,
+ height: 150,
+ },
+ },
+ ],
+ ],
+ currentOperation: undefined,
+ },
+ {
+ id: '2',
+ status: ItemStatus.Processing,
+ operations: [
+ [
+ OperationType.ResizeCrop,
+ {
+ resize: {
+ width: 300,
+ height: 300,
+ },
+ },
+ ],
+ ],
+ currentOperation: OperationType.ResizeCrop,
+ },
+ {
+ id: '3',
+ status: ItemStatus.Processing,
+ operations: [ OperationType.Upload ],
+ currentOperation: undefined,
+ },
+ ] as QueueItem[],
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ };
+
+ const pending = getPendingImageProcessing( state );
+ expect( pending ).toHaveLength( 1 );
+ expect( pending[ 0 ].id ).toBe( '1' );
+ } );
+
+ it( 'should include items pending Rotate operations', () => {
+ const state: State = {
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ operations: [
+ [ OperationType.Rotate, { orientation: 6 } ],
+ ],
+ currentOperation: undefined,
+ },
+ ] as QueueItem[],
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ };
+
+ const pending = getPendingImageProcessing( state );
+ expect( pending ).toHaveLength( 1 );
+ expect( pending[ 0 ].id ).toBe( '1' );
+ } );
+ } );
+
describe( 'getFailedItems', () => {
it( 'should return items with errors', () => {
const state: State = {
@@ -235,4 +373,54 @@ describe( 'selectors', () => {
expect( getItemProgress( state, '999' ) ).toBeUndefined();
} );
} );
+
+ describe( 'hasPendingItemsByParentId', () => {
+ it( 'should return true if there are items with matching parent ID', () => {
+ const state: State = {
+ queue: [
+ {
+ id: '1',
+ parentId: 'parent-1',
+ status: ItemStatus.Processing,
+ },
+ {
+ id: '2',
+ status: ItemStatus.Processing,
+ },
+ ] as QueueItem[],
+ queueStatus: 'paused',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ };
+
+ expect( hasPendingItemsByParentId( state, 'parent-1' ) ).toBe(
+ true
+ );
+ expect( hasPendingItemsByParentId( state, 'parent-2' ) ).toBe(
+ false
+ );
+ } );
+
+ it( 'should return false if no items have a parent ID', () => {
+ const state: State = {
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ },
+ ] as QueueItem[],
+ queueStatus: 'paused',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ };
+
+ expect( hasPendingItemsByParentId( state, 'parent-1' ) ).toBe(
+ false
+ );
+ } );
+ } );
} );
diff --git a/packages/upload-media/src/store/test/vips.ts b/packages/upload-media/src/store/test/vips.ts
new file mode 100644
index 00000000000000..0ad3a593fdcbc2
--- /dev/null
+++ b/packages/upload-media/src/store/test/vips.ts
@@ -0,0 +1,329 @@
+/**
+ * External dependencies
+ */
+
+// Mock the vips worker module.
+// The mock functions must be declared inside the factory to avoid hoisting issues.
+jest.mock( '@wordpress/vips/worker', () => ( {
+ vipsConvertImageFormat: jest.fn(),
+ vipsCompressImage: jest.fn(),
+ vipsHasTransparency: jest.fn(),
+ vipsResizeImage: jest.fn(),
+ vipsRotateImage: jest.fn(),
+ vipsCancelOperations: jest.fn(),
+} ) );
+
+// Import the mocked module to get access to the mock functions.
+import * as vipsWorker from '@wordpress/vips/worker';
+
+/**
+ * Internal dependencies
+ */
+import { ImageFile } from '../../image-file';
+import type { ImageSizeCrop } from '../types';
+
+// Import after mock is set up.
+import {
+ vipsConvertImageFormat,
+ vipsCompressImage,
+ vipsHasTransparency,
+ vipsResizeImage,
+ vipsRotateImage,
+ vipsCancelOperations,
+} from '../utils/vips';
+
+// Cast to jest.Mock for type safety.
+const mockConvertImageFormat = vipsWorker.vipsConvertImageFormat as jest.Mock;
+const mockCompressImage = vipsWorker.vipsCompressImage as jest.Mock;
+const mockHasTransparency = vipsWorker.vipsHasTransparency as jest.Mock;
+const mockResizeImage = vipsWorker.vipsResizeImage as jest.Mock;
+const mockRotateImage = vipsWorker.vipsRotateImage as jest.Mock;
+const mockCancelOperations = vipsWorker.vipsCancelOperations as jest.Mock;
+
+const jpegFile = new File( [ 'test-content' ], 'test.jpg', {
+ type: 'image/jpeg',
+ lastModified: 1234567890,
+} );
+
+const pngFile = new File( [ 'test-content' ], 'image.png', {
+ type: 'image/png',
+ lastModified: 1234567890,
+} );
+
+describe( 'vips utilities', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ describe( 'vipsConvertImageFormat', () => {
+ it( 'converts image and returns new File with correct extension', async () => {
+ mockConvertImageFormat.mockResolvedValue( new ArrayBuffer( 10 ) );
+
+ const result = await vipsConvertImageFormat(
+ 'item-1',
+ jpegFile,
+ 'image/webp',
+ 0.8
+ );
+
+ expect( result.name ).toBe( 'test.webp' );
+ expect( result.type ).toBe( 'image/webp' );
+ expect( mockConvertImageFormat ).toHaveBeenCalledTimes( 1 );
+ expect( mockConvertImageFormat.mock.calls[ 0 ][ 0 ] ).toBe(
+ 'item-1'
+ );
+ expect( mockConvertImageFormat.mock.calls[ 0 ][ 2 ] ).toBe(
+ 'image/jpeg'
+ );
+ expect( mockConvertImageFormat.mock.calls[ 0 ][ 3 ] ).toBe(
+ 'image/webp'
+ );
+ expect( mockConvertImageFormat.mock.calls[ 0 ][ 4 ] ).toBe( 0.8 );
+ } );
+
+ it( 'converts PNG to AVIF with interlacing', async () => {
+ mockConvertImageFormat.mockResolvedValue( new ArrayBuffer( 5 ) );
+
+ const result = await vipsConvertImageFormat(
+ 'item-2',
+ pngFile,
+ 'image/avif',
+ 0.9,
+ true
+ );
+
+ expect( result.name ).toBe( 'image.avif' );
+ expect( result.type ).toBe( 'image/avif' );
+ expect( mockConvertImageFormat.mock.calls[ 0 ][ 5 ] ).toBe( true );
+ } );
+ } );
+
+ describe( 'vipsCompressImage', () => {
+ it( 'compresses image preserving filename and type', async () => {
+ mockCompressImage.mockResolvedValue( new ArrayBuffer( 5 ) );
+
+ const result = await vipsCompressImage( 'item-1', jpegFile, 0.8 );
+
+ expect( result.name ).toBe( 'test.jpg' );
+ expect( result.type ).toBe( 'image/jpeg' );
+ expect( mockCompressImage ).toHaveBeenCalledTimes( 1 );
+ expect( mockCompressImage.mock.calls[ 0 ][ 0 ] ).toBe( 'item-1' );
+ expect( mockCompressImage.mock.calls[ 0 ][ 2 ] ).toBe(
+ 'image/jpeg'
+ );
+ expect( mockCompressImage.mock.calls[ 0 ][ 3 ] ).toBe( 0.8 );
+ } );
+
+ it( 'compresses image with interlacing option', async () => {
+ mockCompressImage.mockResolvedValue( new ArrayBuffer( 5 ) );
+
+ const result = await vipsCompressImage(
+ 'item-2',
+ pngFile,
+ 0.7,
+ true
+ );
+
+ expect( result.name ).toBe( 'image.png' );
+ expect( mockCompressImage.mock.calls[ 0 ][ 4 ] ).toBe( true );
+ } );
+ } );
+
+ describe( 'vipsHasTransparency', () => {
+ let mockFetch: jest.Mock;
+ let originalFetch: typeof window.fetch;
+
+ beforeEach( () => {
+ originalFetch = window.fetch;
+ mockFetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ arrayBuffer: () => Promise.resolve( new ArrayBuffer( 0 ) ),
+ } as Response );
+ window.fetch = mockFetch;
+ } );
+
+ afterEach( () => {
+ window.fetch = originalFetch;
+ } );
+
+ it( 'returns true when image has transparency', async () => {
+ mockHasTransparency.mockResolvedValue( true );
+
+ const result = await vipsHasTransparency( 'blob:test-url' );
+
+ expect( result ).toBe( true );
+ expect( mockFetch ).toHaveBeenCalledWith( 'blob:test-url' );
+ expect( mockHasTransparency ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'returns false when image has no transparency', async () => {
+ mockHasTransparency.mockResolvedValue( false );
+
+ const result = await vipsHasTransparency(
+ 'https://example.com/img'
+ );
+
+ expect( result ).toBe( false );
+ } );
+ } );
+
+ describe( 'vipsResizeImage', () => {
+ it( 'resizes image and returns ImageFile with dimensions and suffix', async () => {
+ mockResizeImage.mockResolvedValue( {
+ buffer: new ArrayBuffer( 10 ),
+ width: 150,
+ height: 150,
+ originalWidth: 300,
+ originalHeight: 300,
+ } );
+
+ const resize: ImageSizeCrop = { width: 150, height: 150 };
+ const result = await vipsResizeImage(
+ 'item-1',
+ jpegFile,
+ resize,
+ false,
+ true
+ );
+
+ expect( result ).toBeInstanceOf( ImageFile );
+ // ImageFile extends File, so name/type are direct properties.
+ expect( result.name ).toBe( 'test-150x150.jpg' );
+ expect( result.type ).toBe( 'image/jpeg' );
+ expect( result.width ).toBe( 150 );
+ expect( result.height ).toBe( 150 );
+ expect( result.originalWidth ).toBe( 300 );
+ expect( result.originalHeight ).toBe( 300 );
+ } );
+
+ it( 'does not add suffix when dimensions unchanged', async () => {
+ mockResizeImage.mockResolvedValue( {
+ buffer: new ArrayBuffer( 10 ),
+ width: 300,
+ height: 300,
+ originalWidth: 300,
+ originalHeight: 300,
+ } );
+
+ const resize: ImageSizeCrop = { width: 300, height: 300 };
+ const result = await vipsResizeImage(
+ 'item-1',
+ jpegFile,
+ resize,
+ false,
+ true
+ );
+
+ expect( result.name ).toBe( 'test.jpg' );
+ } );
+
+ it( 'does not add suffix when addSuffix is false', async () => {
+ mockResizeImage.mockResolvedValue( {
+ buffer: new ArrayBuffer( 10 ),
+ width: 150,
+ height: 150,
+ originalWidth: 300,
+ originalHeight: 300,
+ } );
+
+ const resize: ImageSizeCrop = { width: 150, height: 150 };
+ const result = await vipsResizeImage(
+ 'item-1',
+ jpegFile,
+ resize,
+ false,
+ false
+ );
+
+ expect( result.name ).toBe( 'test.jpg' );
+ } );
+
+ it( 'passes smart crop parameter to worker', async () => {
+ mockResizeImage.mockResolvedValue( {
+ buffer: new ArrayBuffer( 10 ),
+ width: 100,
+ height: 100,
+ originalWidth: 200,
+ originalHeight: 200,
+ } );
+
+ const resize: ImageSizeCrop = {
+ width: 100,
+ height: 100,
+ crop: true,
+ };
+ await vipsResizeImage( 'item-1', jpegFile, resize, true, true );
+
+ expect( mockResizeImage ).toHaveBeenCalledTimes( 1 );
+ expect( mockResizeImage.mock.calls[ 0 ][ 0 ] ).toBe( 'item-1' );
+ expect( mockResizeImage.mock.calls[ 0 ][ 2 ] ).toBe( 'image/jpeg' );
+ expect( mockResizeImage.mock.calls[ 0 ][ 3 ] ).toEqual( resize );
+ expect( mockResizeImage.mock.calls[ 0 ][ 4 ] ).toBe( true );
+ } );
+ } );
+
+ describe( 'vipsRotateImage', () => {
+ it( 'rotates image and returns ImageFile with -rotated suffix', async () => {
+ mockRotateImage.mockResolvedValue( {
+ buffer: new ArrayBuffer( 10 ),
+ width: 200,
+ height: 300,
+ } );
+
+ const result = await vipsRotateImage( 'item-1', jpegFile, 6 );
+
+ expect( result ).toBeInstanceOf( ImageFile );
+ expect( result.name ).toBe( 'test-rotated.jpg' );
+ expect( result.type ).toBe( 'image/jpeg' );
+ expect( result.width ).toBe( 200 );
+ expect( result.height ).toBe( 300 );
+
+ expect( mockRotateImage ).toHaveBeenCalledTimes( 1 );
+ expect( mockRotateImage.mock.calls[ 0 ][ 0 ] ).toBe( 'item-1' );
+ expect( mockRotateImage.mock.calls[ 0 ][ 2 ] ).toBe( 'image/jpeg' );
+ expect( mockRotateImage.mock.calls[ 0 ][ 3 ] ).toBe( 6 );
+ } );
+
+ it( 'returns original file when orientation is 1 (no rotation needed)', async () => {
+ const result = await vipsRotateImage( 'item-1', jpegFile, 1 );
+
+ expect( result ).toBe( jpegFile );
+ expect( mockRotateImage ).not.toHaveBeenCalled();
+ } );
+
+ it( 'handles different EXIF orientation values', async () => {
+ mockRotateImage.mockResolvedValue( {
+ buffer: new ArrayBuffer( 10 ),
+ width: 300,
+ height: 200,
+ } );
+
+ // Test orientation 3 (180° rotation)
+ await vipsRotateImage( 'item-1', jpegFile, 3 );
+ expect( mockRotateImage.mock.calls[ 0 ][ 3 ] ).toBe( 3 );
+
+ // Test orientation 8 (90° CCW rotation)
+ await vipsRotateImage( 'item-2', jpegFile, 8 );
+ expect( mockRotateImage.mock.calls[ 1 ][ 3 ] ).toBe( 8 );
+ } );
+ } );
+
+ describe( 'vipsCancelOperations', () => {
+ it( 'calls worker cancelOperations with item ID', async () => {
+ mockCancelOperations.mockResolvedValue( true );
+
+ const result = await vipsCancelOperations( 'item-123' );
+
+ expect( mockCancelOperations ).toHaveBeenCalledWith( 'item-123' );
+ expect( result ).toBe( true );
+ } );
+
+ it( 'returns false when no operations were cancelled', async () => {
+ mockCancelOperations.mockResolvedValue( false );
+
+ const result = await vipsCancelOperations( 'item-456' );
+
+ expect( result ).toBe( false );
+ } );
+ } );
+} );
diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts
index 444d02069d75bc..4563c23b71f708 100644
--- a/packages/upload-media/src/store/types.ts
+++ b/packages/upload-media/src/store/types.ts
@@ -25,6 +25,7 @@ export interface QueueItem {
sourceUrl?: string;
sourceAttachmentId?: number;
abortController?: AbortController;
+ parentId?: QueueItemId;
}
export interface State {
@@ -129,18 +130,49 @@ interface UploadMediaArgs {
signal?: AbortSignal;
}
+/**
+ * Arguments for sideloading a file to an existing attachment.
+ *
+ * Sideloading adds additional image sizes (thumbnails) to an already
+ * uploaded attachment without creating a new attachment.
+ */
+export interface SideloadMediaArgs {
+ /** File to sideload (typically a resized version of the original). */
+ file: File;
+ /** Attachment ID to add the sideloaded file to. */
+ attachmentId: number;
+ /** Additional data to include in the request. */
+ additionalData?: AdditionalData;
+ /** Function called when an error happens. */
+ onError?: OnErrorHandler;
+ /** Function called when the file or a temporary representation is available. */
+ onFileChange?: OnChangeHandler;
+ /** Abort signal to cancel the sideload operation. */
+ signal?: AbortSignal;
+}
+
export interface Settings {
+ // Registered image sizes from the server.
+ allImageSizes?: Record< string, ImageSizeCrop >;
// Function for uploading files to the server.
mediaUpload: ( args: UploadMediaArgs ) => void;
+ // Function for sideloading files to existing attachments.
+ mediaSideload?: ( args: SideloadMediaArgs ) => void;
// List of allowed mime types and file extensions.
allowedMimeTypes?: Record< string, string > | null;
// Maximum upload file size.
maxUploadFileSize?: number;
// Maximum number of concurrent uploads.
maxConcurrentUploads: number;
+ // Maximum number of concurrent image processing operations (resize, crop, rotate).
+ maxConcurrentImageProcessing: number;
+ // Big image size threshold in pixels.
+ // Images larger than this will be scaled down.
+ // Default is 2560 (matching WordPress core).
+ bigImageSizeThreshold?: number;
}
-// Must match the Attachment type from the media-utils package.
+// Matches the Attachment type from the media-utils package.
export interface Attachment {
id: number;
alt: string;
@@ -153,7 +185,24 @@ export interface Attachment {
mime_type: string;
featured_media?: number;
missing_image_sizes?: string[];
+ media_filename?: string;
poster?: string;
+ /**
+ * EXIF orientation value from the original image.
+ * Values 1-8 follow the EXIF specification.
+ * A value other than 1 indicates the image needs rotation.
+ *
+ * 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
+ */
+ exif_orientation?: number;
}
export type OnChangeHandler = ( attachments: Partial< Attachment >[] ) => void;
@@ -172,9 +221,49 @@ export enum ItemStatus {
export enum OperationType {
Prepare = 'PREPARE',
Upload = 'UPLOAD',
+ ResizeCrop = 'RESIZE_CROP',
+ Rotate = 'ROTATE',
+ ThumbnailGeneration = 'THUMBNAIL_GENERATION',
+}
+
+/**
+ * Defines the dimensions and cropping behavior for an image size.
+ */
+export interface ImageSizeCrop {
+ /** Target width in pixels. */
+ width: number;
+ /** Target height in pixels. */
+ height: number;
+ /**
+ * Crop behavior.
+ * - `true` for hard crop centered.
+ * - Positional array like `['left', 'top']` for specific crop anchor.
+ * - `false` or undefined for soft proportional resize.
+ */
+ crop?:
+ | boolean
+ | [ 'left' | 'center' | 'right', 'top' | 'center' | 'bottom' ];
+ /** Size name identifier (e.g., 'thumbnail', 'medium'). */
+ name?: string;
}
-export interface OperationArgs {}
+export interface OperationArgs {
+ [ OperationType.ResizeCrop ]: {
+ resize: ImageSizeCrop;
+ /**
+ * Whether this resize is for the big image size threshold.
+ * If true, uses '-scaled' suffix instead of dimension suffix.
+ */
+ isThresholdResize?: boolean;
+ };
+ [ OperationType.Rotate ]: {
+ /**
+ * EXIF orientation value (1-8) indicating the required rotation.
+ * Used to apply the correct rotation/flip transformation.
+ */
+ orientation: number;
+ };
+}
type OperationWithArgs< T extends keyof OperationArgs = keyof OperationArgs > =
[ T, OperationArgs[ T ] ];
@@ -183,4 +272,17 @@ export type Operation = OperationType | OperationWithArgs;
export type AdditionalData = Record< string, unknown >;
+/**
+ * Additional data specific to sideload operations.
+ *
+ * This extends the base AdditionalData with fields required for
+ * sideloading image sizes to an existing attachment.
+ */
+export interface SideloadAdditionalData extends AdditionalData {
+ /** The attachment ID to add the image size to. */
+ post: number;
+ /** The name of the image size being generated (e.g., 'thumbnail', 'medium'). */
+ image_size: string;
+}
+
export type ImageFormat = 'jpeg' | 'webp' | 'avif' | 'png' | 'gif';
diff --git a/packages/upload-media/src/store/utils/index.ts b/packages/upload-media/src/store/utils/index.ts
new file mode 100644
index 00000000000000..1dc46e90afc0e9
--- /dev/null
+++ b/packages/upload-media/src/store/utils/index.ts
@@ -0,0 +1,9 @@
+export {
+ vipsConvertImageFormat,
+ vipsCompressImage,
+ vipsHasTransparency,
+ vipsResizeImage,
+ vipsRotateImage,
+ vipsCancelOperations,
+ terminateVipsWorker,
+} from './vips';
diff --git a/packages/upload-media/src/store/utils/vips.ts b/packages/upload-media/src/store/utils/vips.ts
new file mode 100644
index 00000000000000..e28833cc16b99e
--- /dev/null
+++ b/packages/upload-media/src/store/utils/vips.ts
@@ -0,0 +1,285 @@
+/**
+ * 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 ) {
+ try {
+ 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() );
+ } catch ( error ) {
+ // eslint-disable-next-line no-console
+ console.error( 'Error checking transparency:', error );
+ return false; // Safe fallback
+ }
+}
+
+/**
+ * Resizes an image using vips in a web worker.
+ *
+ * @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).
+ * @return Resized ImageFile with dimension metadata.
+ */
+export async function vipsResizeImage(
+ id: QueueItemId,
+ file: File,
+ resize: ImageSizeCrop,
+ smartCrop: boolean,
+ addSuffix: boolean,
+ signal?: AbortSignal,
+ scaledSuffix?: boolean
+) {
+ 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
+ );
+
+ 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.
+ */
+export function terminateVipsWorker(): void {
+ if ( vipsModule ) {
+ vipsModule.terminateVipsWorker();
+ }
+}
diff --git a/packages/upload-media/tsconfig.json b/packages/upload-media/tsconfig.json
index f78b74537d78fc..20ebdb1f6791a0 100644
--- a/packages/upload-media/tsconfig.json
+++ b/packages/upload-media/tsconfig.json
@@ -13,6 +13,7 @@
{ "path": "../i18n" },
{ "path": "../preferences" },
{ "path": "../private-apis" },
- { "path": "../url" }
+ { "path": "../url" },
+ { "path": "../vips" }
]
}
diff --git a/packages/vips/README.md b/packages/vips/README.md
index 5f3bde6b521d1b..875890e2207148 100644
--- a/packages/vips/README.md
+++ b/packages/vips/README.md
@@ -85,4 +85,109 @@ _Returns_
- `Promise< { buffer: ArrayBuffer | ArrayBufferLike; width: number; height: number; originalWidth: number; originalHeight: number; } >`: Processed file data plus the old and new dimensions.
+### rotateImage
+
+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
+
+_Parameters_
+
+- _id_ `ItemId`: Item ID.
+- _buffer_ `ArrayBuffer`: Original file buffer.
+- _type_ `string`: Mime type.
+- _orientation_ `number`: EXIF orientation value (1-8).
+
+_Returns_
+
+- `Promise< { buffer: ArrayBuffer | ArrayBufferLike; width: number; height: number; } >`: Rotated file data plus the new dimensions.
+
+### vipsCancelOperations
+
+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.
+
+### vipsCompressImage
+
+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 | ArrayBufferLike >`: Compressed file data.
+
+### vipsConvertImageFormat
+
+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.
+
+### vipsHasTransparency
+
+Determines whether an image has an alpha channel.
+
+_Parameters_
+
+- _buffer_ `ArrayBuffer`: Original file object.
+
+_Returns_
+
+- `Promise< boolean >`: Whether the image has an alpha channel.
+
+### vipsResizeImage
+
+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 | ArrayBufferLike; width: number; height: number; originalWidth: number; originalHeight: number; } >`: Processed file data plus the old and new dimensions.
+
+### vipsRotateImage
+
+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
+
+_Parameters_
+
+- _id_ `ItemId`: Item ID.
+- _buffer_ `ArrayBuffer`: Original file buffer.
+- _type_ `string`: Mime type.
+- _orientation_ `number`: EXIF orientation value (1-8).
+
+_Returns_
+
+- `Promise< { buffer: ArrayBuffer | ArrayBufferLike; width: number; height: number; } >`: Rotated file data plus the new dimensions.
+
diff --git a/packages/vips/package.json b/packages/vips/package.json
index 66dcb8183de8d5..84f0177d53989b 100644
--- a/packages/vips/package.json
+++ b/packages/vips/package.json
@@ -42,13 +42,22 @@
},
"./package.json": "./package.json"
},
- "types": "build-types",
"wpWorkers": {
- "./worker": "./src/worker.ts"
+ "./worker": {
+ "entry": "./src/worker.ts",
+ "resolve": {
+ "vips-es6.js": "vips.js"
+ }
+ }
},
+ "wpScriptModuleExports": {
+ "./worker": "./build-module/vips-worker.mjs",
+ "./loader": "./build-module/loader.mjs"
+ },
+ "types": "build-types",
"dependencies": {
"@wordpress/worker-threads": "file:../worker-threads",
- "wasm-vips": "^0.0.10"
+ "wasm-vips": "^0.0.16"
},
"publishConfig": {
"access": "public"
diff --git a/packages/vips/src/index.ts b/packages/vips/src/index.ts
index 812cc19081038c..6266f50d317bf0 100644
--- a/packages/vips/src/index.ts
+++ b/packages/vips/src/index.ts
@@ -41,6 +41,9 @@ async function getVips(): Promise< typeof Vips > {
}
vipsInstance = await Vips( {
+ // Only load JXL module, skip HEIF due to trademark issues.
+ // wasm-vips defaults to ["vips-jxl.wasm", "vips-heif.wasm"].
+ dynamicLibraries: [ 'vips-jxl.wasm' ],
locateFile: ( fileName: string ) => {
// WASM files are inlined as base64 data URLs at build time,
// eliminating the need for separate file downloads and avoiding
@@ -50,7 +53,6 @@ async function getVips(): Promise< typeof Vips > {
} else if ( fileName.endsWith( 'vips-jxl.wasm' ) ) {
return VipsJxlModule;
}
-
return fileName;
},
preRun: ( module: EmscriptenModule ) => {
@@ -108,46 +110,54 @@ export async function convertImageFormat(
inProgressOperations.add( id );
- let strOptions = '';
- const loadOptions: LoadOptions< typeof inputType > = {};
+ try {
+ 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;
- }
+ // 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 );
+ 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;
- }
- };
+ // 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 > = {};
+ const saveOptions: SaveOptions< typeof outputType > = {};
- if ( supportsQuality( outputType ) ) {
- saveOptions.Q = quality * 100;
- }
+ if ( supportsQuality( outputType ) ) {
+ saveOptions.Q = quality * 100;
+ }
- if ( interlaced && supportsInterlace( outputType ) ) {
- saveOptions.interlace = interlaced;
- }
+ if ( interlaced && supportsInterlace( outputType ) ) {
+ saveOptions.interlace = interlaced;
+ }
- // See https://github.com/swissspidy/media-experiments/issues/324.
- if ( 'image/avif' === outputType ) {
- saveOptions.effort = 2;
- }
+ // 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;
+ const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions );
+ const result = outBuffer.buffer;
- cleanup?.();
+ cleanup?.();
- return result;
+ return result;
+ } finally {
+ inProgressOperations.delete( id );
+ }
}
/**
@@ -198,134 +208,243 @@ export async function resizeImage(
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;
+ try {
+ 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;
}
- };
- let image = vips.Image.newFromBuffer( buffer, strOptions, loadOptions );
+ // TODO: Report progress, see https://github.com/swissspidy/media-experiments/issues/327.
+ const onProgress = () => {
+ if ( ! inProgressOperations.has( id ) ) {
+ image.kill = true;
+ }
+ };
- image.onProgress = onProgress;
+ let image = vips.Image.newFromBuffer( buffer, strOptions, loadOptions );
- const { width, pageHeight } = image;
+ image.onProgress = onProgress;
- // If resize.height is zero.
- resize.height = resize.height || ( pageHeight / width ) * resize.width;
+ const { width, pageHeight } = image;
- let resizeWidth = resize.width;
- thumbnailOptions.height = resize.height;
+ // If resize.height is zero.
+ resize.height = resize.height || ( pageHeight / width ) * resize.width;
- if ( ! resize.crop ) {
- image = vips.Image.thumbnailBuffer(
- buffer,
- resizeWidth,
- thumbnailOptions
- );
+ let resizeWidth = resize.width;
+ thumbnailOptions.height = resize.height;
- image.onProgress = onProgress;
- } else if ( true === resize.crop ) {
- thumbnailOptions.crop = smartCrop ? 'attention' : 'centre';
+ if ( ! resize.crop ) {
+ image = vips.Image.thumbnailBuffer(
+ buffer,
+ resizeWidth,
+ thumbnailOptions
+ );
- image = vips.Image.thumbnailBuffer(
- buffer,
- resizeWidth,
- thumbnailOptions
- );
+ image.onProgress = onProgress;
+ } else if ( true === resize.crop ) {
+ thumbnailOptions.crop = smartCrop ? 'attention' : 'centre';
- 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;
+ image = vips.Image.thumbnailBuffer(
+ buffer,
+ resizeWidth,
+ thumbnailOptions
+ );
+
+ image.onProgress = onProgress;
} else {
- resizeWidth =
- resize.width >= resize.height
- ? ( width / pageHeight ) * resize.height
- : resize.width;
- thumbnailOptions.height =
- resize.width >= resize.height
- ? resize.height
- : ( pageHeight / width ) * resizeWidth;
- }
+ // 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 = vips.Image.thumbnailBuffer(
+ buffer,
+ resizeWidth,
+ thumbnailOptions
+ );
- image.onProgress = onProgress;
+ 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 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;
+ 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;
}
- // 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 );
+ // TODO: Allow passing quality?
+ const saveOptions: SaveOptions< typeof type > = {};
+ const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions );
- image = image.crop( left, top, resize.width, resize.height );
+ const result = {
+ buffer: outBuffer.buffer,
+ width: image.width,
+ height: image.pageHeight,
+ originalWidth: width,
+ originalHeight: pageHeight,
+ };
- image.onProgress = onProgress;
+ // Only call after `image` is no longer being used.
+ cleanup?.();
+
+ return result;
+ } finally {
+ inProgressOperations.delete( id );
}
+}
+
+/**
+ * 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 ];
- // TODO: Allow passing quality?
- const saveOptions: SaveOptions< typeof type > = {};
- const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions );
+ inProgressOperations.add( id );
- const result = {
- buffer: outBuffer.buffer,
- width: image.width,
- height: image.pageHeight,
- originalWidth: width,
- originalHeight: pageHeight,
- };
+ try {
+ const vips = await getVips();
- // Only call after `image` is no longer being used.
- cleanup?.();
+ 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 );
- return result;
+ 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:
+ // Rotated 90° CCW and flipped horizontally
+ image = image.rot270().flipHor();
+ break;
+ case 6:
+ // Rotated 90° CW
+ image = image.rot90();
+ break;
+ case 7:
+ // Rotated 90° CW and flipped horizontally
+ image = image.rot90().flipHor();
+ break;
+ case 8:
+ // Rotated 90° CCW
+ image = image.rot270();
+ break;
+ // case 1 and default: no transformation needed
+ }
+
+ 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 );
+ }
}
/**
@@ -345,3 +464,14 @@ export async function hasTransparency(
return hasAlpha;
}
+
+// 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,
+ cancelOperations as vipsCancelOperations,
+};
diff --git a/packages/vips/src/loader.ts b/packages/vips/src/loader.ts
new file mode 100644
index 00000000000000..f00370db9ba675
--- /dev/null
+++ b/packages/vips/src/loader.ts
@@ -0,0 +1,18 @@
+/**
+ * Loader for the @wordpress/vips/worker module.
+ *
+ * This tiny module exists so that WordPress can discover @wordpress/vips/worker
+ * as a dynamic module dependency and include it in the import map. Without this,
+ * the dynamic import() call in @wordpress/upload-media's IIFE bundle cannot
+ * resolve the module URL at runtime.
+ *
+ * The loader is enqueued on block editor pages via wp_enqueue_script_module()
+ * in lib/client-assets.php. The heavy vips/worker module (~3.8MB of inlined WASM)
+ * is only fetched when image processing is actually triggered.
+ *
+ * @see packages/upload-media/src/store/utils/vips.ts — the consumer
+ * @see packages/latex-to-mathml/src/loader.ts — the reference pattern
+ */
+export default function loader() {
+ return import( '@wordpress/vips/worker' );
+}
diff --git a/packages/vips/src/vips-worker.ts b/packages/vips/src/vips-worker.ts
index 559466e572c61d..e11a40ac4b961c 100644
--- a/packages/vips/src/vips-worker.ts
+++ b/packages/vips/src/vips-worker.ts
@@ -43,7 +43,7 @@ function getWorkerAPI(): Remote< WorkerAPI > {
type: 'application/javascript',
} );
workerBlobUrl = URL.createObjectURL( blob );
- worker = new Worker( workerBlobUrl );
+ worker = new Worker( workerBlobUrl, { type: 'module' } );
workerAPI = wrap< WorkerAPI >( worker );
}
return workerAPI;
@@ -140,6 +140,29 @@ export async function vipsHasTransparency(
return api.hasTransparency( buffer );
}
+/**
+ * Rotates an image based on EXIF orientation using vips in a worker.
+ *
+ * @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 vipsRotateImage(
+ id: ItemId,
+ buffer: ArrayBuffer,
+ type: string,
+ orientation: number
+): Promise< {
+ buffer: ArrayBuffer | ArrayBufferLike;
+ width: number;
+ height: number;
+} > {
+ const api = getWorkerAPI();
+ return api.rotateImage( id, buffer, type, orientation );
+}
+
/**
* Cancels all ongoing image operations for a given item ID.
*
diff --git a/packages/vips/src/worker.ts b/packages/vips/src/worker.ts
index 15bf5f28a159c3..2fad366825604f 100644
--- a/packages/vips/src/worker.ts
+++ b/packages/vips/src/worker.ts
@@ -19,6 +19,7 @@ import {
convertImageFormat,
compressImage,
resizeImage,
+ rotateImage,
hasTransparency,
} from './index';
@@ -30,6 +31,7 @@ const api = {
convertImageFormat,
compressImage,
resizeImage,
+ rotateImage,
hasTransparency,
};
diff --git a/packages/wp-build/README.md b/packages/wp-build/README.md
index 544e5686b38e6b..cf05da43cbdeb2 100644
--- a/packages/wp-build/README.md
+++ b/packages/wp-build/README.md
@@ -132,6 +132,42 @@ Files to copy with optional PHP transformations:
}
```
+### `wpWorkers`
+
+Worker bundle definitions for packages that need self-contained Web Worker files.
+Workers are bundled with all dependencies included and can be loaded via Blob URLs.
+
+**String shorthand** — entry path only:
+
+```json
+{
+ "wpWorkers": {
+ "./worker": "./src/worker.ts"
+ }
+}
+```
+
+**Object format** — entry path with module resolve redirects:
+
+```json
+{
+ "wpWorkers": {
+ "./worker": {
+ "entry": "./src/worker.ts",
+ "resolve": {
+ "vips-es6.js": "vips.js"
+ }
+ }
+ }
+}
+```
+
+The `resolve` map redirects module loads during bundling. Keys are filename
+patterns to match; values are replacement filenames in the same directory.
+This is useful when a dependency's ES module entry point uses `import.meta.url`,
+which fails in Blob URL Worker contexts. By redirecting to an alternative
+entry point (e.g., a CommonJS version), the issue is avoided.
+
## Root Configuration
Configure your root `package.json` with a `wpPlugin` object to control global namespace and externalization behavior:
diff --git a/packages/wp-build/lib/worker-build.mjs b/packages/wp-build/lib/worker-build.mjs
index 3efff5db246e95..0303df34de664f 100644
--- a/packages/wp-build/lib/worker-build.mjs
+++ b/packages/wp-build/lib/worker-build.mjs
@@ -14,6 +14,92 @@ import { readFile, writeFile, access } from 'fs/promises';
import path from 'path';
import esbuild from 'esbuild';
+/**
+ * Creates an esbuild plugin that redirects module loads based on filename patterns.
+ *
+ * This is useful when bundling workers for Blob URL contexts where certain
+ * ES module entry points use `import.meta.url` (which resolves to an invalid
+ * `blob:` URL at runtime). By redirecting to an alternative entry point
+ * (e.g., a CommonJS version), the issue is avoided.
+ *
+ * Packages declare redirects in their `wpWorkers` config:
+ *
+ * "wpWorkers": {
+ * "./worker": {
+ * "entry": "./src/worker.ts",
+ * "resolve": {
+ * "vips-es6.js": "vips.js"
+ * }
+ * }
+ * }
+ *
+ * @param {Object} resolveMap An object mapping source filenames to target
+ * filenames. When esbuild loads a file whose path
+ * ends with a source key, the plugin rewrites it
+ * to re-export from the corresponding target file
+ * in the same directory.
+ * @return {Object} An esbuild plugin.
+ */
+function createModuleRedirectPlugin( resolveMap ) {
+ // Build a single regex that matches any of the source filenames.
+ const escapedKeys = Object.keys( resolveMap ).map( ( key ) =>
+ key.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' )
+ );
+ const pattern = new RegExp( `(${ escapedKeys.join( '|' ) })$` );
+
+ return {
+ name: 'module-redirect',
+ setup( build ) {
+ build.onLoad( { filter: pattern }, ( args ) => {
+ // Find which key matched.
+ const matchedKey = Object.keys( resolveMap ).find( ( key ) =>
+ args.path.endsWith( key )
+ );
+ const targetPath = args.path.replace(
+ matchedKey,
+ resolveMap[ matchedKey ]
+ );
+ return {
+ contents: `export { default } from ${ JSON.stringify(
+ targetPath
+ ) };`,
+ loader: 'js',
+ };
+ } );
+ },
+ };
+}
+
+/**
+ * Extracts the entry path from a wpWorkers config value.
+ *
+ * Supports both the string shorthand and the object format:
+ * - String: "./src/worker.ts"
+ * - Object: { "entry": "./src/worker.ts", "resolve": { ... } }
+ *
+ * @param {string|Object} workerConfig The worker configuration value.
+ * @return {string} The entry file path.
+ */
+function getWorkerEntryPath( workerConfig ) {
+ if ( typeof workerConfig === 'string' ) {
+ return workerConfig;
+ }
+ return workerConfig.entry;
+}
+
+/**
+ * Extracts the resolve map from a wpWorkers config value, if present.
+ *
+ * @param {string|Object} workerConfig The worker configuration value.
+ * @return {Object|undefined} The resolve map, or undefined if not configured.
+ */
+function getWorkerResolveMap( workerConfig ) {
+ if ( typeof workerConfig === 'string' ) {
+ return undefined;
+ }
+ return workerConfig.resolve;
+}
+
/**
* Generate placeholder worker-code.ts for packages with wpWorkers.
*
@@ -78,7 +164,9 @@ export async function buildWorkers(
? Object.entries( packageJson.wpWorkers )
: [];
- for ( const [ outputName, entryPath ] of workerEntries ) {
+ for ( const [ outputName, workerConfig ] of workerEntries ) {
+ const entryPath = getWorkerEntryPath( workerConfig );
+ const resolveMap = getWorkerResolveMap( workerConfig );
const workerEntryPoint = path.join( packageDir, entryPath );
const workerOutputName = outputName.replace( /^\.\//, '' );
@@ -98,7 +186,12 @@ export async function buildWorkers(
sourcemap: true,
// Bundle everything - workers need to be self-contained.
external: [],
- plugins: [ wasmInlinePlugin ],
+ plugins: [
+ wasmInlinePlugin,
+ ...( resolveMap
+ ? [ createModuleRedirectPlugin( resolveMap ) ]
+ : [] ),
+ ],
define: {
'process.env.NODE_ENV': JSON.stringify(
process.env.NODE_ENV || 'production'
diff --git a/packages/wp-build/templates/page-wp-admin.php.template b/packages/wp-build/templates/page-wp-admin.php.template
index 558d57f7b79cd7..970954d34330af 100644
--- a/packages/wp-build/templates/page-wp-admin.php.template
+++ b/packages/wp-build/templates/page-wp-admin.php.template
@@ -96,8 +96,9 @@ if ( ! function_exists( '{{PREFIX}}_{{PAGE_SLUG_UNDERSCORE}}_wp_admin_preload_da
*/
function {{PREFIX}}_{{PAGE_SLUG_UNDERSCORE}}_wp_admin_preload_data() {
// Define paths to preload - same for all pages
+ // Please also change packages/core-data/src/entities.js when changing this.
$preload_paths = array(
- '/?_fields=description,gmt_offset,home,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
+ '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,image_output_formats,jpeg_interlaced,png_interlaced,gif_interlaced,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
array( '/wp/v2/settings', 'OPTIONS' ),
);
diff --git a/packages/wp-build/templates/page.php.template b/packages/wp-build/templates/page.php.template
index 2231885cd700f5..af675f7f2e101a 100644
--- a/packages/wp-build/templates/page.php.template
+++ b/packages/wp-build/templates/page.php.template
@@ -97,8 +97,9 @@ if ( ! function_exists( '{{PREFIX}}_{{PAGE_SLUG_UNDERSCORE}}_preload_data' ) ) {
*/
function {{PREFIX}}_{{PAGE_SLUG_UNDERSCORE}}_preload_data() {
// Define paths to preload - same for all pages
+ // Please also change packages/core-data/src/entities.js when changing this.
$preload_paths = array(
- '/?_fields=description,gmt_offset,home,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
+ '/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,image_output_formats,jpeg_interlaced,png_interlaced,gif_interlaced,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
array( '/wp/v2/settings', 'OPTIONS' ),
);
diff --git a/storybook/main.ts b/storybook/main.ts
index 1a88868d57d0b5..9568efc2a8828b 100644
--- a/storybook/main.ts
+++ b/storybook/main.ts
@@ -96,6 +96,62 @@ const config: StorybookConfig = {
} );
},
},
+ // Stub the vips and wasm-vips packages for Storybook since they use WASM modules that Vite can't handle.
+ {
+ name: 'stub-vips',
+ enforce: 'pre',
+ resolveId( id: string ) {
+ // Stub @wordpress/vips imports.
+ if (
+ id === '@wordpress/vips' ||
+ id.startsWith( '@wordpress/vips/' )
+ ) {
+ return '\0virtual:vips-stub';
+ }
+ // Stub wasm-vips imports.
+ if (
+ id === 'wasm-vips' ||
+ id.startsWith( 'wasm-vips/' )
+ ) {
+ return '\0virtual:wasm-vips-stub';
+ }
+ // Stub WASM file imports.
+ if ( id.endsWith( '.wasm' ) ) {
+ return '\0virtual:wasm-stub';
+ }
+ return null;
+ },
+ load( id: string ) {
+ if ( id === '\0virtual:vips-stub' ) {
+ // Return a stub module with no-op exports for Storybook.
+ return `
+ export const setLocation = () => {};
+ export const cancelOperations = async () => false;
+ export const convertImageFormat = async () => new ArrayBuffer(0);
+ export const compressImage = async () => new ArrayBuffer(0);
+ export const resizeImage = async () => ({ buffer: new ArrayBuffer(0), width: 0, height: 0, originalWidth: 0, originalHeight: 0 });
+ export const rotateImage = async () => ({ buffer: new ArrayBuffer(0), width: 0, height: 0 });
+ export const hasTransparency = async () => false;
+ export const vipsConvertImageFormat = convertImageFormat;
+ export const vipsCompressImage = compressImage;
+ export const vipsResizeImage = resizeImage;
+ export const vipsRotateImage = rotateImage;
+ export const vipsHasTransparency = hasTransparency;
+ export const vipsCancelOperations = cancelOperations;
+ export const terminateVipsWorker = () => {};
+ `;
+ }
+ if ( id === '\0virtual:wasm-vips-stub' ) {
+ // Return a stub for wasm-vips default export.
+ return `export default () => Promise.resolve({});`;
+ }
+ if ( id === '\0virtual:wasm-stub' ) {
+ // Return empty string for WASM files.
+ return `export default '';`;
+ }
+ return null;
+ },
+ },
],
build: {
/**
diff --git a/test/unit/config/vips-worker-code-stub.js b/test/unit/config/vips-worker-code-stub.js
new file mode 100644
index 00000000000000..c737b632aac215
--- /dev/null
+++ b/test/unit/config/vips-worker-code-stub.js
@@ -0,0 +1,28 @@
+/**
+ * Stub for the @wordpress/vips/worker module.
+ *
+ * The real vips-worker.ts imports from worker-code.ts, which is auto-generated
+ * during the full build process and is gitignored. Since unit tests don't run
+ * a full build, we provide this stub with mock implementations.
+ *
+ * Tests that need to customize the mock behavior can use jest.mock() in their
+ * test files to override these defaults.
+ */
+
+const vipsConvertImageFormat = jest.fn();
+const vipsCompressImage = jest.fn();
+const vipsHasTransparency = jest.fn();
+const vipsResizeImage = jest.fn();
+const vipsRotateImage = jest.fn();
+const vipsCancelOperations = jest.fn();
+const terminateVipsWorker = jest.fn();
+
+module.exports = {
+ vipsConvertImageFormat,
+ vipsCompressImage,
+ vipsHasTransparency,
+ vipsResizeImage,
+ vipsRotateImage,
+ vipsCancelOperations,
+ terminateVipsWorker,
+};
diff --git a/test/unit/jest.config.js b/test/unit/jest.config.js
index 3ea5aa3cc83b88..da23dc791722ee 100644
--- a/test/unit/jest.config.js
+++ b/test/unit/jest.config.js
@@ -14,9 +14,12 @@ process.env.TZ = 'UTC';
module.exports = {
rootDir: '../../',
moduleNameMapper: {
+ // Mock @wordpress/vips/worker before the general pattern so it doesn't try to load the real file.
+ // The worker-code.ts file is auto-generated during full builds and is gitignored.
+ '@wordpress/vips/worker':
+ '/test/unit/config/vips-worker-code-stub.js',
[ `@wordpress\\/(${ transpiledPackageNames.join( '|' ) })$` ]:
'packages/$1/src',
- '@wordpress/vips/worker': '/packages/vips/src/vips-worker.ts',
'@wordpress/theme/design-tokens.js':
'/packages/theme/src/prebuilt/js/design-tokens.mjs',
'.+\\.wasm$': '/test/unit/config/wasm-stub.js',