Skip to content

Conversation

@adamsilverstein
Copy link
Member

@adamsilverstein adamsilverstein commented Jan 13, 2026

What?

Closes #74355

Depends on #74352

Add client-side thumbnail generation for uploaded images in the @wordpress/upload-media package. When an image is uploaded, this PR enables the browser to generate sub-sized images (thumbnails) using the vips library in a web worker, then sideloads them to the server.

Why?

By generating thumbnails client-side using WASM (via the @wordpress/vips package), we can:

  • Take advantage of the latest media libraries, leapfrogging server limitations
  • Reduce server load during image uploads
  • Speed up the upload process by parallelizing thumbnail generation

How?

This PR introduces several key changes to the @wordpress/upload-media package:

  1. Vips utility wrappers (packages/upload-media/src/store/utils/vips.ts):
    Thin wrappers around @wordpress/vips/worker functions that handle File/Blob conversions:

    • vipsResizeImage: Resizes images to specified dimensions with optional cropping and dimension suffix
    • vipsConvertImageFormat: Converts images between formats (JPEG, PNG, WebP, AVIF, GIF)
    • vipsCompressImage: Compresses images with quality control
    • vipsHasTransparency: Checks if an image has transparency (with error handling fallback)
    • vipsRotateImage: Rotates images based on EXIF orientation values
    • vipsCancelOperations: Cancels ongoing vips operations for a given queue item
  2. New operation types (OperationType enum):

    • ResizeCrop: Resizes and crops images to specific dimensions via resizeCropItem action
    • ThumbnailGeneration: Orchestrates creating multiple thumbnail sizes via generateThumbnails action
    • Rotate: Handles EXIF-based image rotation via rotateItem action
  3. Big image size threshold scaling:

    • Passes bigImageSizeThreshold setting from REST API (default 2560px) through block editor settings
    • Automatically scales down images exceeding the threshold before upload
    • Adds -scaled suffix to scaled images, matching WordPress core's wp_create_image_subsizes() behavior
    • Thumbnails are generated from the original high-resolution image (not the scaled version) for better quality
  4. Automatic image rotation:

    • Mirrors WordPress core's server-side EXIF rotation behavior on the client
    • Supports all 8 EXIF orientation values (rotations and flips)
    • Returns exif_orientation from REST API when generate_sub_sizes=false
    • Images scaled via threshold are auto-rotated by vips during scaling
    • Small images needing rotation only get a -rotated version sideloaded
    • All thumbnails are auto-rotated during generation
  5. Sideloading support:

    • addSideloadItem action: Queues a file for sideloading (typically a generated thumbnail)
    • sideloadItem action: Uploads generated thumbnails to existing attachments via mediaSideload
    • SideloadMediaArgs interface: Defines the contract for sideload operations
    • SideloadAdditionalData interface: Includes post (attachment ID) and image_size name
  6. Image size configuration:

    • Added allImageSizes to Settings type: Maps size names to { width, height, crop } definitions
    • Image sizes are passed through block editor settings from the REST API
  7. Queue management improvements:

    • isUploadingByParentId selector: Tracks child uploads to prevent premature parent removal
    • isUploadingToPost selector: Detects concurrent uploads to the same attachment
    • getPausedUploadForPost selector: Finds paused items for a given post/attachment
    • shouldPauseForSideload helper: Pauses uploads to avoid race conditions when sideloading
    • resumeItemByPostId action: Resumes paused uploads after sideload completes
    • Support for missing_image_sizes from attachment response to determine which sizes need generation

Testing Instructions

Prerequisites / Setup

  1. Enable the Client Side Media experiment

    • Go to Gutenberg → Experiments in wp-admin
    • Check "Enable Client Side Media"
    • Save changes
  2. Prepare test images (for comprehensive testing):

    • A large image (>2560px in either dimension) for threshold testing
    • A standard image (<2560px) for basic upload testing
    • An image with EXIF orientation data (portrait photo from phone)
    • A small image (<500px) for edge case testing
    • Images in different formats: JPEG, PNG, WebP, AVIF, PNG
    • Images in various sizes (small to large)
    • Images with transparency (PNG, WebP, AVIF)
    • HDR images (AVIF. WebP)
    • Note HEIC AND UltraHDR are not supported yet
  3. Open browser DevTools → Network tab to observe upload behavior


Feature 1: Basic Client-Side Thumbnail Generation

What it does: When an image is uploaded, the browser generates sub-sized images (thumbnails) using the vips library, then sideloads them to the server.

Testing steps:

  1. Create or open a post/page in the block editor
  2. Insert an Image block and upload a standard-sized image (under 2560px)
  3. In the Network tab, observe:
    • The main image uploads first (POST to /wp/v2/media)
    • Additional requests are made to sideload thumbnails (look for requests with image_size parameter)
    • Multiple thumbnail sizes are uploaded (thumbnail, medium, medium_large, large, etc.)
  4. Open the Media Library and select the uploaded image
  5. Verify:
    • When turning off experiment and uploading same image, resulting files and meta data are the same
    • All expected image sizes appear in the attachment details
    • Thumbnail dimensions match your site's configured sizes (Settings → Media)
    • Custom sizes from themes/plugins are also generated

Feature 2: Big Image Size Threshold Scaling

What it does: Images larger than 2560px (configurable via big_image_size_threshold filter) are automatically scaled down before upload, with a -scaled suffix added. Note one bug: currently the original image is not uploaded when a scaled image is created, see #75320.

Testing steps:

  1. Upload an image larger than 2560px in either dimension (e.g., 4000x3000)
  2. In the Network tab, observe:
    • The uploaded file has -scaled in its filename
    • The scaled image dimensions do not exceed 2560px
  3. In the Media Library, verify:
    • The main image shows the scaled dimensions
    • The "original_image" metadata is preserved (visible in attachment JSON via REST API: /wp-json/wp/v2/media/{id})
    • Thumbnails are generated with correct proportions
  4. Use the big_image_size_threshold filter to adjust the threshold and test again

Edge cases to test:

  • Image exactly 2560px wide → should NOT be scaled
  • Image 2561px wide → should be scaled to 2560px
  • Portrait image 2000x3000 → should be scaled (height exceeds threshold)

Feature 3: Automatic Image Rotation (EXIF)

What it does: Images with EXIF orientation data are automatically rotated to display correctly, matching WordPress core's server-side behavior.

Testing steps:

  1. Upload a photo taken in portrait mode (or any image with EXIF orientation ≠ 1)
    • Tip: Photos from smartphones often have EXIF orientation data
  2. Verify:
    • The image displays correctly oriented in the editor (not sideways/upside-down)
    • The image displays correctly on the frontend after publishing
    • Behavior matches server side handling with experiment disabled

For images smaller than threshold that need rotation:

  1. Upload a small image (<2560px) with EXIF rotation data
  2. Verify:
    • A -rotated suffix version is created and sideloaded
    • The rotated version is used as the main image

Edge case:

  • Image with EXIF orientation = 1 (normal) → no rotation suffix added

Feature 4: Upload Cancellation

What it does: Canceling an upload mid-process properly cleans up all related operations including thumbnail generation.

Testing steps:

  1. Start uploading a large image
  2. Quickly click the cancel button (X) before upload completes
  3. Verify:
    • The upload stops immediately
    • No partial uploads remain in the Media Library
    • The Image block returns to the placeholder state
    • No errors appear in the browser console
    • Subsequent uploads work normally

Feature 5: Custom Image Sizes (Theme/Plugin)

What it does: Custom image sizes registered by themes or plugins are also generated client-side.

Testing steps:

  1. Add a custom image size to your theme's functions.php:
    add_image_size( 'custom-test-size', 400, 300, true ); // cropped
    add_image_size( 'custom-proportional', 600, 0, false ); // proportional
  2. Upload a new image
  3. Verify:
    • Both custom sizes appear in the Media Library attachment
    • Cropped size (400x300) maintains exact dimensions
    • Proportional size (600x?) maintains aspect ratio
    • Behavior matches server side handling with experiment disabled

Feature 6: Concurrent Upload Handling

What it does: Multiple images can be uploaded simultaneously without conflicts.

Testing steps:

  1. Insert a Gallery block
  2. Select multiple images (3-5) to upload at once
  3. Verify:
    • All images upload successfully
    • Each image gets its own set of thumbnails
    • No errors or race conditions occur
    • Progress indicators (spinner for now) update correctly for each image

Error Handling (Optional)

Network failure during sideload:

  1. Start uploading an image
  2. Use DevTools to throttle/block network during thumbnail sideload
  3. Verify:
    • Main image still exists in Media Library
    • Missing thumbnails can be regenerated via "Regenerate Thumbnails" plugin or similar (a future refinement will automate this)

Notes for Reviewers

  • All thumbnail generation happens in a Web Worker using WASM (vips)
  • Network tab will show multiple POST requests per image upload
  • Server load should be reduced compared to server-side thumbnail generation
  • Test on slower connections to observe the parallelized upload behavior

Technical Notes

Dependencies added:

New/Updated Types:

  • ImageSizeCrop: Defines width, height, and crop settings for image sizes (supports positional crop anchors)
  • SideloadAdditionalData: Additional data for sideload requests (attachment ID, image size name)
  • SideloadMediaArgs: Arguments for the mediaSideload function
  • Settings.allImageSizes: Registry of available image sizes from the server
  • Settings.bigImageSizeThreshold: Threshold for automatic image scaling (default 2560)

Test coverage:

  • Unit tests for vips utility functions (including rotation)
  • Unit tests for isUploadingByParentId selector
  • Integration tests for sideloading and cancellation flows
  • E2E tests for big image size threshold scaling

@github-actions github-actions bot added [Package] Editor /packages/editor [Package] Block editor /packages/block-editor labels Jan 13, 2026
@adamsilverstein adamsilverstein added [Type] Enhancement A suggestion for improvement. [Feature] Client Side Media Media processing in the browser with WASM and removed [Package] Editor /packages/editor [Package] Block editor /packages/block-editor labels Jan 13, 2026
@github-actions github-actions bot added [Package] Editor /packages/editor [Package] Block editor /packages/block-editor [Package] Core data /packages/core-data labels Jan 13, 2026
@github-project-automation github-project-automation bot moved this to 🔎 Needs Review in WordPress 7.0 Editor Tasks Jan 20, 2026
@github-actions github-actions bot added [Package] Element /packages/element [Package] Date /packages/date [Package] Data /packages/data [Package] Server Side Render /packages/server-side-render [Package] A11y /packages/a11y [Package] Autop /packages/autop [Package] DOM ready /packages/dom-ready [Package] Hooks /packages/hooks [Package] i18n /packages/i18n [Package] is-shallow-equal /packages/is-shallow-equal [Package] Url /packages/url [Package] Word count /packages/wordcount [Package] Deprecated packages/deprecated [Package] Blob /packages/blob [Package] Compose /packages/compose [Package] API fetch /packages/api-fetch labels Jan 20, 2026
@adamsilverstein
Copy link
Member Author

Hmm, there should be some good test images in core unit tests. I will attach those here for testing. Its a little hard to test because you may also be getting auto rotation when viewing the images. The response from the initial upload should indicate the image needs rotation at a minimum.

Test images here: https://github.com/WordPress/wordpress-develop/tree/trunk/tests/phpunit/data/images

The best way to test autorotation is to try turning it off to see how that impacts uploads:

add_filter( 'wp_image_maybe_exif_rotate', '__return_false' );

I am double checking that this works as expected with client side media.

@adamsilverstein
Copy link
Member Author

add_filter( 'wp_image_maybe_exif_rotate', '__return_false' );

I am double checking that this works as expected with client side media.

This didn't work as I expected even with client side media disabled. I will loop back to confirming this later, lets focus on the other functionality for now.

@adamsilverstein
Copy link
Member Author

When I went to test I noticed that the sideload isn't happening. I think we need to pass in generate_sub_sizes: false in order to force the client-side sideloading to occur. I've left a comment, but once I added that in, I could get the sideloading working.

Thanks @andrewserong - enabled in 4a4b9ca

@adamsilverstein
Copy link
Member Author

I was using images from my phone and the examples from https://github.com/recurser/exif-orientation-examples. I verified the metadata of the images, and uploaded a rotated image, but can't see any -rotated suffix. I also checked uploads folders.

I missed this earlier, these should be good for testing. testing uploads in the media library should give the expected (baseline) behavior we want to match.

adamsilverstein and others added 8 commits February 6, 2026 11:42
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 <[email protected]>
Co-Authored-By: Happy <[email protected]>
Part of the image processing concurrency limiting work to
prevent OOM crashes during bulk image uploads.
Sets the default maxConcurrentImageProcessing value in the
upload-media store's DEFAULT_STATE so the concurrency limit
is active out of the box.
These selectors mirror the existing upload concurrency
selectors and will be used to enforce limits on concurrent
VIPS/WASM image processing operations.
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.
Without this, items gated by the concurrency limit would
never be retried. Mirrors existing pattern where finished
uploads trigger pending upload items.
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.
Covers getActiveImageProcessingCount and
getPendingImageProcessing, verifying they correctly
identify ResizeCrop and Rotate operations.
@adamsilverstein
Copy link
Member Author

A couple of issues I ran into: I'm not sure if it's an OOM or memory leak issue, but I noticed if I upload lots of images at once (in this case 17) to create a gallery, sometimes it simply fails and Chrome crashes altogether (it seemed to crash before it got to the sideload stage for me):

@andrewserong -

I was able to reproduce this readily by dragging more than a few images into the editor. The code was trying to process them all at once which doesn't make sense. I reduced this to 2 concurrent operations so the next operation starts as the last completes (5cc1bda, 46f4640, 3a43770, 86ece0c). This commit makes sure vips gets cleaned up after use: ed2b000

After these changes I was no longer able to reproduce the out of memory error.

The Storybook vips stub and Jest test mock were not updated
when terminateVipsWorker was introduced, causing CI failures.
@adamsilverstein
Copy link
Member Author

adamsilverstein commented Feb 6, 2026

regarding the scaled attribute. this can be tested with a large image or by changing the threshold with a filter (big_image_size_threshold).

I tested and found the -scaled image was created at the correct size, however the original image was not uploaded in this case which feels like a bug because it does not match the server behavior. I'm going to look into this a little more and maybe open a separate issue to fix it.

server side handling (uploaded in media library):
scaled

client side media handling (same image):
client side scaled

sizes match

image

@adamsilverstein
Copy link
Member Author

adamsilverstein commented Feb 6, 2026

I tested and found the -scaled image was created at the correct size, however the original image was not uploaded in this case which feels like a bug because it does not match the server behavior. I'm going to look into this a little more and maybe open a separate issue to fix it.

Created: #75320

I also noted this bug in the testing instructions

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.
@github-actions github-actions bot added the [Package] Block library /packages/block-library label Feb 6, 2026
@adamsilverstein
Copy link
Member Author

When it works, I noticed that the sideloading occurs after the main image upload completes. To a user, this looks like the uploading has finished even though sideloading images is still happening in the background. In this state, I could imagine someone either closing the window (or publishing) and potentially getting into a state where they've left the post even though the smaller image sizes haven't been generated yet:

I am working on more robust progress indicators in #74363 - in the meantime, a smaller change here would me to only show the "complete" state when all sideloads have completed. I will work on that.

@andrewserong addressed this in ccd49b3-

@adamsilverstein
Copy link
Member Author

@andrewserong & @ramonjd - I addresses all your feedback and this is ready for another review. Let me know if you have any questions or if I missed anything. I would love to land this soon so I can rebase the remaining PRs which nearly all rely on the changes in this PR. We can address any remaining bugs you find in a separate issue.

Things to note

  • so far changes are only active ONLY when the client side media experiment is enabled
  • some features are still missing - for example transcoding from one format to another.

Smaller PRs depending on this one

Comment on lines +901 to +903
console.warn(
'Failed to rotate image, continuing with thumbnails'
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker, just curious about whether we should one day capture error details for user feedback.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, especially if there is some action users can. take to fix the issue. I am working on progress reporting and error handling in follow up PRs.

@ramonjd
Copy link
Member

ramonjd commented Feb 9, 2026

Thanks for all the updates.

It's generally testing well for me so far. I went through test description, but ran out of time for a deep dive. It's working pretty well though. 🚀

Quickly click the cancel button (X) before upload completes

I can't find this in the UI. I tried my other glasses, but that didn't help. 😆 Am I missing it somewhere or it is a future UI enhancement?

regarding the scaled attribute. this can be tested with a large image or by changing the threshold with a filter (big_image_size_threshold).

Thanks, I played around with the filter and the resulting scaled images comply with the returned value. 👍🏻

A -rotated suffix version is created and sideloaded

I just wanted to confirm that this works as expected when uploading rotated images in the media library, but not the editor, where the resulting image name is something like test-image-upside-down-4.jpg. Is that right?

Screenshot 2026-02-09 at 11 53 26 am

@andrewserong
Copy link
Contributor

This is definitely testing better for me (I've only done smoke testing so far, but the issues I found the other day appear to be resolved)! Overall I'm supportive of merging sooner than later and iterating as we can. The main blocker I can see is that while this is guarded behind an experiment, it adds an enormous amount to the JS packages according to the automated comment on the PR: #74566 (comment) (3.81MB to the block-editor minified JS, and 3.82MB to the block-library minified JS).

Unfortunately, I think we'll need to address this before merge, or it'll affect the size of these bundles even when the experiment isn't active.

Is this because the upload-media package is still bundled? We're looking at unbundling it in #74951. So, one approach we could take is to try landing that PR first, and then rebase this one on trunk, and see if the minified JS sizes are still so large? What do you think?

Comment on lines +1 to +12
/**
* External dependencies
*/
import {
vipsConvertImageFormat as convertImageFormat,
vipsCompressImage as compressImage,
vipsHasTransparency as hasTransparency,
vipsResizeImage as resizeImage,
vipsRotateImage as rotateImage,
vipsCancelOperations as cancelOperations,
terminateVipsWorker,
} from '@wordpress/vips/worker';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take this suggestion with a necessary grain of salt as I know next to nothing about optimising loading for the WASM, but when I asked Claude about the large bundle sizes, it flagged these lines as static imports that suddenly add to the bundle size.

Not sure if or how possible it might be, but is there a way to lazy load @wordpress/vips/worker when needed during a function call, so that simply importing upload-media doesn't add it all at once?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure, but worth investigating further.

@adamsilverstein
Copy link
Member Author

Thanks for all the updates.

Thanks for the continued testing and reviews.

It's generally testing well for me so far. I went through test description, but ran out of time for a deep dive. It's working pretty well though. 🚀

Quickly click the cancel button (X) before upload completes

I can't find this in the UI. I tried my other glasses, but that didn't help. 😆 Am I missing it somewhere or it is a future UI enhancement?

This is missing, removing from the testing instructions!

regarding the scaled attribute. this can be tested with a large image or by changing the threshold with a filter (big_image_size_threshold).

Thanks, I played around with the filter and the resulting scaled images comply with the returned value. 👍🏻

Excellent, thanks for testing!

A -rotated suffix version is created and sideloaded

I just wanted to confirm that this works as expected when uploading rotated images in the media library, but not the editor, where the resulting image name is something like test-image-upside-down-4.jpg. Is that right?

yes exactly, it wasn't working in my testing (even on the server), but I must have been doing something wrong.

I will work on fixing this, in the br or in a follow up bug issue.

@adamsilverstein
Copy link
Member Author

This is definitely testing better for me (I've only done smoke testing so far, but the issues I found the other day appear to be resolved)! Overall I'm supportive of merging sooner than later and iterating as we can.

Excellent... same!

The main blocker I can see is that while this is guarded behind an experiment, it adds an enormous amount to the JS packages according to the automated comment on the PR: #74566 (comment) (3.81MB to the block-editor minified JS, and 3.82MB to the block-library minified JS).

Unfortunately, I think we'll need to address this before merge, or it'll affect the size of these bundles even when the experiment isn't active.

Is this because the upload-media package is still bundled? We're looking at unbundling it in #74951. So, one approach we could take is to try landing that PR first, and then rebase this one on trunk, and see if the minified JS sizes are still so large? What do you think?

Yes, lets give that a try! I'll get that merged first then rebase this branch.

@adamsilverstein
Copy link
Member Author

I merged #74951 into trunk and then trunk into this branch. In my local build this seems to have fixed the bundle size. I assume the CI will run and update the PR warning. Claude also suggested lazy loading @wordpress/vips which I am investigating now. Makes sense to only load vips when we need it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Client Side Media Media processing in the browser with WASM No Core Sync Required Indicates that any changes do not need to be synced to WordPress Core [Package] Block editor /packages/block-editor [Package] Block library /packages/block-library [Package] Core data /packages/core-data [Package] Editor /packages/editor [Status] In Progress Tracking issues with work in progress [Type] Enhancement A suggestion for improvement.

Projects

Status: 🔎 Needs Review

Development

Successfully merging this pull request may close these issues.

Generate all sub-sized image on the client

3 participants