Skip to content

Commit 2453663

Browse files
adamsilversteinclaudehappy-otter
authored
Create sub-sized images (#74566)
* feat: add sideloading functionality and image resizing operations to upload media types * Add getImageSizes and getImageSize selectors * add imageSizes support to media upload settings and block editor settings * add dependency for @wordpress/vips in upload-media * add vips image processing utilities for format conversion, compression, transparency check and resizing * Add the shopify/web-worker dependency * export vips utility functions for image processing * Add thumbnail generation and sideload actions * linter fixes * Ensure parentItem exists before using * Add tests for sideloading and canceling image uploads * rename imageSizes -> allImageSizes to avoid conflict * cleanup * Simplify loading of all image sizes * update to new vips utils * update package lock * refactor vips tests to use updated mock functions * Add Error Handling in generateThumbnails and hasTransparency * verify item exists before using * ensure item available * Improve docs and add abort signal * remove async keyword, not needed (?) * Add bigImageSizeThreshold setting to upload-media store Pass the big image size threshold (default 2560) from the REST API through the settings chain to the upload-media store. This enables client-side scaling of large images before upload, matching WordPress core's behavior. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Implement big image size threshold scaling When an image exceeds the big image size threshold (default 2560px), scale it down before upload with a '-scaled' suffix. This matches WordPress core's behavior in wp_create_image_subsizes(). - Add isThresholdResize flag to ResizeCrop operation args - Update vipsResizeImage to support '-scaled' suffix - Update resizeCropItem to pass through threshold flag - prepareItem now adds ResizeCrop operation for threshold scaling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add E2E tests for big image size threshold Add tests to verify that: - Images larger than the threshold (default 2560) are scaled down - The -scaled suffix is added to scaled images - Images smaller than the threshold are not modified Tests skip when cross-origin isolation is not enabled, as the vips library requires SharedArrayBuffer. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Generate thumbnails from original image for better quality Use sourceFile instead of file when generating thumbnails to match WordPress core behavior. This ensures thumbnails are created from the original high-resolution image rather than the scaled version. Also adds E2E test assertions to verify original_image metadata is preserved and thumbnails are generated with expected dimensions. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Implement automatic image rotation matching server handling This adds client-side EXIF orientation handling that mirrors WordPress core's server-side rotation behavior: - Add exif_orientation field to REST API response for images - Disable server-side EXIF rotation when generate_sub_sizes=false so client can handle rotation using the returned orientation value - Add rotateImage function to vips package supporting all 8 EXIF orientation values (rotations and flips) - Add vipsRotateImage wrapper in upload-media store utils - Add Rotate operation type and rotateItem action handler - Update generateThumbnails to sideload rotated version for images that need rotation but weren't scaled (matching -rotated suffix) - Fix Jest config to resolve @wordpress/vips/worker module for tests - Add tests for vipsRotateImage function The implementation ensures: - Images scaled via bigImageSizeThreshold are auto-rotated by vips - Small images needing rotation get a -rotated version sideloaded - Thumbnails are auto-rotated by vips during generation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix build and type issues for image rotation support - Add type assertion for exif_orientation parameter - Export vipsRotateImage from utils for external use - Add ./worker export mapping to vips package - Mark @wordpress/vips and wasm-vips as always external in build to handle WASM imports Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix CI failures for create subsized images PR - Change imports from @wordpress/vips/worker to @wordpress/vips (the /worker subpath is not available in trunk and both export the same module) - Add missing vips reference to upload-media tsconfig.json - Remove unnecessary ./worker export from vips package.json - Remove jest config mapping for vips/worker - Remove E2E test that relies on vips library functionality - Fix prettier formatting in wordpress-externals-plugin.mjs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Exclude vips package from Storybook build The vips package uses WASM modules which Vite cannot handle directly during the Storybook build. Exclude it from optimization and mark it as external for SSR to prevent build failures. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Stub vips package for Storybook build Add a Vite plugin that stubs the @wordpress/vips module with no-op functions for Storybook. This prevents the WASM module loading error during build since Vite cannot handle WASM imports directly without additional plugins. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Extend vips stub to cover wasm-vips and WASM files Add enforce: 'pre' to run the plugin before others, and extend stubbing to cover wasm-vips package and all .wasm file imports. This ensures all WASM-related imports are stubbed before Vite tries to process them. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * restore package lock from trunk * update packages from trunk * restore build file from trunk * restore vips dependency * restore vips dependency 2 * add vips dependency * update readme * try: avoid extra preload * Fix preload path to include image_sizes and image_size_threshold The preload test was failing because entities.js requests the root endpoint with image_sizes and image_size_threshold fields, but WordPress Core's site-editor.php preloads without these fields. The previous fix added a filter to lib/experimental/media/load.php, but this file is only loaded when the experimental media processing feature is enabled, causing the filter to not run in most cases. Move the preload filter to lib/compat/wordpress-7.0/preload.php which is always loaded. Also update the wp-build templates to include these fields for the experimental site-editor-v2 page. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix phpcs * Fix WASM loading error by using module worker type The ES6 version of wasm-vips uses import.meta for path resolution, which requires the worker to be created as a module worker. Classic script workers (the default) don't support import.meta, causing the WASM module to fail loading with an undefined error. By adding { type: 'module' } to the Worker constructor, import.meta becomes available, and the ES6 version's locateFile callback works correctly with our base64 data URL approach. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix wasm-vips loading in Blob URL Worker context Add esbuild plugin to redirect wasm-vips ES module (vips-es6.js) to CommonJS version (vips.js) which doesn't use import.meta.url that fails in Blob URL Worker context. Also: - Upgrade wasm-vips from ^0.0.10 to ^0.0.16 - Add vips-heif.wasm inline import and locateFile handler Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Remove HEIF support from wasm-vips due to trademark issues - Remove vips-heif.wasm import - Add dynamicLibraries config to only load JXL module - Remove HEIF handler from locateFile callback HEIC/HEIF input support is removed, but JPEG, PNG, WebP, GIF, AVIF output and JXL support remain unaffected. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add media processing fields directly to template preload paths Move media processing fields (image_output_formats, jpeg_interlaced, png_interlaced, gif_interlaced) from a filter in load.php to the template files directly. This simplifies the code path and ensures the preload paths match exactly with entities.js. - Add fields to page.php.template and page-wp-admin.php.template - Remove gutenberg_media_processing_preload_paths filter from load.php - Update @see references in entities.js Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix CI failures: preload fields mismatch and Prettier formatting - Add missing fields to preload.php to match entities.js exactly: image_output_formats, jpeg_interlaced, png_interlaced, gif_interlaced - Fix Prettier formatting in worker-build.mjs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * clean stray comment * remove unintended changes * Fix memory leak in vips by ensuring operation cleanup Wrap vips operations in try/finally blocks to guarantee inProgressOperations.delete(id) is always called, even when errors occur during image processing. * Rename isUploadingByParentId to hasPendingItemsByParentId The selector checks for existence of items in the queue, not their upload status. The new name better reflects its purpose. * Fix test cleanup for mocked window.fetch Properly restore the original fetch function in afterEach to prevent test pollution. * Clean up unused variable and update vips package reference Remove unused $mime_type variable in REST attachments controller, clarify comments in types, and use local file reference for the @wordpress/vips package during development. * Convert filesList to array before passing to addItems FileList objects from input elements are not true arrays. Using Array.from() ensures compatibility with store actions that expect array methods to be available. * Guard against duplicate upload-media store registration The upload-media package is bundled into multiple packages (block-editor, editor). This prevents errors when both bundles are loaded on the same page by checking if the store is already registered before calling register(). * restore loading vips prerelease directly * add rotate image functionality * update package lock * correct vips load * Add Jest stub for vips worker module The worker-code.ts file is auto-generated during full builds and is gitignored. Since unit tests don't run a full build, provide a stub with mock implementations to prevent test failures. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix vips test to mock correct module path The test was mocking @wordpress/vips but the actual code imports from @wordpress/vips/worker, causing mock functions to not be used. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Skip server side sub size generation * Add image processing concurrency constant Limit concurrent VIPS/WASM image processing operations to prevent out-of-memory crashes when uploading many images at once. Each operation can consume 50-100MB+ of memory for large images. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering> * Add maxConcurrentImageProcessing to Settings type Part of the image processing concurrency limiting work to prevent OOM crashes during bulk image uploads. * Wire image processing concurrency into reducer Sets the default maxConcurrentImageProcessing value in the upload-media store's DEFAULT_STATE so the concurrency limit is active out of the box. * Add image processing concurrency selectors These selectors mirror the existing upload concurrency selectors and will be used to enforce limits on concurrent VIPS/WASM image processing operations. * Gate image processing on concurrency limit Without this gate, all images start VIPS/WASM processing simultaneously when uploaded in bulk, consuming 50-100MB+ each and crashing the browser. Now at most 2 run at once. * Trigger queued image processing when slot opens Without this, items gated by the concurrency limit would never be retried. Mirrors existing pattern where finished uploads trigger pending upload items. * Terminate VIPS worker when upload queue empties After bulk image processing, the WASM worker can hold hundreds of MB. Terminating it when the queue is empty frees that memory. The worker is lazily re-created on next use. * Add tests for image processing selectors Covers getActiveImageProcessingCount and getPendingImageProcessing, verifying they correctly identify ResizeCrop and Rotate operations. * Add missing terminateVipsWorker to stub and mock The Storybook vips stub and Jest test mock were not updated when terminateVipsWorker was introduced, causing CI failures. * Show spinner during sub-size image sideloading When client-side media processing is enabled, the spinner and dimmed overlay now persist while sub-sized images are being generated and uploaded, preventing users from closing the window or publishing with missing image sizes. * Exclude generated worker-code.ts from ESLint The vips package generates a 10MB+ worker-code.ts file during builds that crashes ESLint's text-table formatter. The file is already in .gitignore. * Update package-lock.json for upload-media dep * Lazy-load vips worker module on first use The @wordpress/vips/worker module contains ~10MB of inlined WASM code. Loading it eagerly at module parse time adds unnecessary overhead when no image processing occurs. This defers the import to the first actual vips function call and caches the module for subsequent use. * Add upload-media reference to block-library tsconfig The block-library package depends on @wordpress/upload-media but the tsconfig reference was missing, causing the lint:tsconfig CI check to fail. * Unbundle vips from upload-media as a script module The upload-media IIFE bundle was 3.81MB because vips (with inlined WASM) was bundled inline. Adding wpScriptModuleExports to the vips package lets the build system externalize it, producing a separate on-demand script module. Upload-media drops to ~19KB; vips loads only when image processing runs. * Make wasm-vips esbuild plugin generic via wpWorkers.resolve The hardcoded createWasmVipsCommonJsPlugin in wp-build contained package-specific knowledge about wasm-vips. This moves the resolve config into the vips package's wpWorkers declaration and replaces the plugin with a generic createModuleRedirectPlugin that reads from the new config. Also documents the wpWorkers config format. * Use ImageSizeCrop type for allImageSizes setting The inline type had crop typed as just boolean, but WordPress's add_image_size() also accepts a positional array like ['left', 'top']. Reuse the existing ImageSizeCrop interface which already has the correct type. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Happy <yesreply@happy.engineering>
1 parent 21f985c commit 2453663

40 files changed

Lines changed: 2495 additions & 206 deletions

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ node_modules
77
packages/block-serialization-spec-parser/parser.js
88
packages/icons/src/library/*.tsx
99
packages/react-native-editor/bundle
10+
packages/vips/src/worker-code.ts
1011
vendor
1112
!.*.js

lib/client-assets.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,20 @@ function gutenberg_enqueue_latex_to_mathml_loader() {
439439
wp_enqueue_script_module( '@wordpress/latex-to-mathml/loader' );
440440
}
441441

442+
/**
443+
* Enqueue the vips loader script module in the block editor.
444+
*
445+
* This registers @wordpress/vips/worker as a dynamic dependency in the import map,
446+
* enabling on-demand loading of the ~3.8MB WASM-based image processing module
447+
* when client-side media processing is triggered via @wordpress/upload-media.
448+
*
449+
* @see packages/vips/src/loader.ts
450+
*/
451+
add_action( 'enqueue_block_editor_assets', 'gutenberg_enqueue_vips_loader' );
452+
function gutenberg_enqueue_vips_loader() {
453+
wp_enqueue_script_module( '@wordpress/vips/loader' );
454+
}
455+
442456
add_action( 'admin_enqueue_scripts', 'gutenberg_enqueue_core_abilities' );
443457
function gutenberg_enqueue_core_abilities() {
444458
wp_enqueue_script_module( '@wordpress/core-abilities' );

lib/compat/wordpress-7.0/preload.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,34 @@ static function ( $path ) {
2626
return $paths;
2727
}
2828
add_filter( 'block_editor_rest_api_preload_paths', 'gutenberg_block_editor_preload_paths_6_9', 10, 2 );
29+
30+
/**
31+
* Filters the block editor preload paths to include media processing fields.
32+
*
33+
* The packages/core-data/src/entities.js file requests additional fields
34+
* (image_sizes, image_size_threshold) on the root endpoint that are not
35+
* included in WordPress Core's default preload paths. This filter ensures
36+
* the preloaded URL matches exactly what the JavaScript requests.
37+
*
38+
* @since 20.1.0
39+
*
40+
* @param array $paths REST API paths to preload.
41+
* @return array Filtered preload paths.
42+
*/
43+
function gutenberg_block_editor_preload_paths_root_fields( $paths ) {
44+
// Complete list of fields expected by packages/core-data/src/entities.js.
45+
// This must match exactly for preloading to work (same fields, same order).
46+
// @see packages/core-data/src/entities.js rootEntitiesConfig.__unstableBase
47+
$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';
48+
49+
foreach ( $paths as $key => $path ) {
50+
if ( is_string( $path ) && str_starts_with( $path, '/?_fields=' ) ) {
51+
// Replace with the complete fields list to ensure exact match.
52+
$paths[ $key ] = '/?_fields=' . $root_fields;
53+
break;
54+
}
55+
}
56+
57+
return $paths;
58+
}
59+
add_filter( 'block_editor_rest_api_preload_paths', 'gutenberg_block_editor_preload_paths_root_fields' );

lib/experimental/media/class-gutenberg-rest-attachments-controller.php

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,31 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE
7878
return $args;
7979
}
8080

81+
/**
82+
* Retrieves the attachment's schema, conforming to JSON Schema.
83+
*
84+
* Adds exif_orientation field to the schema.
85+
*
86+
* @return array Item schema data.
87+
*/
88+
public function get_item_schema() {
89+
$schema = parent::get_item_schema();
90+
91+
$schema['properties']['exif_orientation'] = array(
92+
'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' ),
93+
'type' => 'integer',
94+
'context' => array( 'edit' ),
95+
'readonly' => true,
96+
);
97+
98+
return $schema;
99+
}
100+
81101
/**
82102
* Prepares a single attachment output for response.
83103
*
84104
* Ensures 'missing_image_sizes' is set for PDFs and not just images.
105+
* Adds 'exif_orientation' for images that need client-side rotation.
85106
*
86107
* @param WP_Post $item Attachment object.
87108
* @param WP_REST_Request $request Request object.
@@ -92,10 +113,27 @@ public function prepare_item_for_response( $item, $request ): WP_REST_Response {
92113

93114
$data = $response->get_data();
94115

95-
// Handle missing image sizes for PDFs.
96-
97116
$fields = $this->get_fields_for_response( $request );
98117

118+
// Add EXIF orientation for images.
119+
if ( rest_is_field_included( 'exif_orientation', $fields ) ) {
120+
if ( wp_attachment_is_image( $item ) ) {
121+
$metadata = wp_get_attachment_metadata( $item->ID, true );
122+
123+
// Get the EXIF orientation from the image metadata.
124+
// This is stored by wp_read_image_metadata() during upload.
125+
$orientation = 1; // Default: no rotation needed.
126+
if (
127+
is_array( $metadata ) &&
128+
isset( $metadata['image_meta']['orientation'] )
129+
) {
130+
$orientation = (int) $metadata['image_meta']['orientation'];
131+
}
132+
133+
$data['exif_orientation'] = $orientation;
134+
}
135+
}
136+
99137
if (
100138
rest_is_field_included( 'missing_image_sizes', $fields ) &&
101139
empty( $data['missing_image_sizes'] )
@@ -158,7 +196,9 @@ public function create_item( $request ) {
158196
if ( ! $request['generate_sub_sizes'] ) {
159197
add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
160198
add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
161-
199+
// Disable server-side EXIF rotation so the client can handle it.
200+
// This preserves the original orientation value in the metadata.
201+
add_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
162202
}
163203

164204
if ( ! $request['convert_format'] ) {
@@ -169,6 +209,7 @@ public function create_item( $request ) {
169209

170210
remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
171211
remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
212+
remove_filter( 'wp_image_maybe_exif_rotate', '__return_false', 100 );
172213
remove_filter( 'image_editor_output_format', '__return_empty_array', 100 );
173214

174215
return $response;

package-lock.json

Lines changed: 13 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"@types/uuid": "8.3.1",
6161
"@vitejs/plugin-react": "5.1.2",
6262
"@wordpress/build": "file:./packages/wp-build",
63+
"@wordpress/vips": "1.0.0-prerelease",
6364
"ajv": "8.17.1",
6465
"babel-jest": "29.7.0",
6566
"babel-loader": "9.2.1",

packages/block-editor/src/components/provider/use-media-upload-settings.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ function useMediaUploadSettings( settings = {} ) {
1717
mediaSideload: settings.mediaSideload,
1818
maxUploadFileSize: settings.maxUploadFileSize,
1919
allowedMimeTypes: settings.allowedMimeTypes,
20+
allImageSizes: settings.allImageSizes,
21+
bigImageSizeThreshold: settings.bigImageSizeThreshold,
2022
} ),
2123
[ settings ]
2224
);

packages/block-library/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
"@wordpress/reusable-blocks": "file:../reusable-blocks",
127127
"@wordpress/rich-text": "file:../rich-text",
128128
"@wordpress/server-side-render": "file:../server-side-render",
129+
"@wordpress/upload-media": "file:../upload-media",
129130
"@wordpress/url": "file:../url",
130131
"@wordpress/viewport": "file:../viewport",
131132
"@wordpress/wordcount": "file:../wordcount",

packages/block-library/src/image/edit.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { image as icon, plugins as pluginsIcon } from '@wordpress/icons';
2525
import { store as noticesStore } from '@wordpress/notices';
2626
import { useResizeObserver } from '@wordpress/compose';
2727
import { getProtocol, prependHTTPS } from '@wordpress/url';
28+
import { store as uploadStore } from '@wordpress/upload-media';
2829

2930
/**
3031
* Internal dependencies
@@ -344,6 +345,17 @@ export function ImageEdit( {
344345

345346
const isExternal = isExternalImage( id, url );
346347
const src = isExternal ? url : undefined;
348+
349+
const isSideloading = useSelect(
350+
( select ) => {
351+
if ( ! window.__experimentalMediaProcessing || ! id ) {
352+
return false;
353+
}
354+
return select( uploadStore ).isUploadingById( id );
355+
},
356+
[ id ]
357+
);
358+
347359
const mediaPreview = !! url && (
348360
<img
349361
alt={ __( 'Edit image' ) }
@@ -357,7 +369,7 @@ export function ImageEdit( {
357369
const shadowProps = getShadowClassesAndStyles( attributes );
358370

359371
const classes = clsx( className, {
360-
'is-transient': !! temporaryURL,
372+
'is-transient': !! temporaryURL || isSideloading,
361373
'is-resized': !! width || !! height,
362374
[ `size-${ sizeSlug }` ]: sizeSlug,
363375
'has-custom-border':
@@ -448,6 +460,7 @@ export function ImageEdit( {
448460
<figure { ...blockProps }>
449461
<Image
450462
temporaryURL={ temporaryURL }
463+
isSideloading={ isSideloading }
451464
attributes={ attributes }
452465
setAttributes={ setAttributes }
453466
isSingleSelected={ isSingleSelected }

packages/block-library/src/image/image.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ function ContentOnlyControls( {
258258

259259
export default function Image( {
260260
temporaryURL,
261+
isSideloading,
261262
attributes,
262263
setAttributes,
263264
isSingleSelected,
@@ -874,7 +875,9 @@ export default function Image( {
874875
onSelectURL={ onSelectURL }
875876
onError={ onUploadError }
876877
onReset={ () => onSelectImage( undefined ) }
877-
isUploading={ !! temporaryURL }
878+
isUploading={
879+
!! temporaryURL || isSideloading
880+
}
878881
emptyLabel={ __( 'Add image' ) }
879882
/>
880883
</ToolsPanelItem>
@@ -1065,7 +1068,7 @@ export default function Image( {
10651068
...shadowProps.style,
10661069
} }
10671070
/>
1068-
{ temporaryURL && <Spinner /> }
1071+
{ ( temporaryURL || isSideloading ) && <Spinner /> }
10691072
</>
10701073
);
10711074

0 commit comments

Comments
 (0)