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',