From f6a8daf3f3ab1914d310482714a7b5d19769c8fa Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 16 Mar 2026 11:27:44 -0700 Subject: [PATCH 1/8] Make client-side supported MIME types configurable The CLIENT_SIDE_SUPPORTED_MIME_TYPES constant is hardcoded, preventing plugins from adding support for additional image formats via client-side processing. This adds a clientSideSupportedMimeTypes setting that flows from a PHP filter through the REST API and settings pipeline, falling back to the existing constant when not set. This follows the same pattern used by imageOutputFormats. --- lib/media/load.php | 28 +++++++++++++++---- .../provider/use-media-upload-settings.js | 1 + packages/core-data/src/entities.js | 1 + .../provider/use-block-editor-settings.js | 2 ++ .../upload-media/src/store/private-actions.ts | 7 +++-- packages/upload-media/src/store/types.ts | 3 ++ 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/lib/media/load.php b/lib/media/load.php index 15e47a1b007151..9a4302672f7104 100644 --- a/lib/media/load.php +++ b/lib/media/load.php @@ -86,13 +86,29 @@ function gutenberg_media_processing_filter_rest_index( WP_REST_Response $respons /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ $gif_interlaced = (bool) apply_filters( 'image_save_progressive', false, 'image/gif' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + /** + * Filters the MIME types supported by client-side media processing. + * + * Allows plugins to add or remove MIME types from the list of formats + * that can be processed client-side using WebAssembly-based vips. + * + * @since 21.8.0 + * + * @param string[] $mime_types Array of MIME type strings. + */ + $client_side_supported_mime_types = apply_filters( + 'client_side_supported_mime_types', + array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif' ) + ); + if ( current_user_can( 'upload_files' ) ) { - $response->data['image_sizes'] = gutenberg_get_all_image_sizes(); - $response->data['image_size_threshold'] = $image_size_threshold; - $response->data['image_output_formats'] = (object) $default_image_output_formats; - $response->data['jpeg_interlaced'] = $jpeg_interlaced; - $response->data['png_interlaced'] = $png_interlaced; - $response->data['gif_interlaced'] = $gif_interlaced; + $response->data['image_sizes'] = gutenberg_get_all_image_sizes(); + $response->data['image_size_threshold'] = $image_size_threshold; + $response->data['image_output_formats'] = (object) $default_image_output_formats; + $response->data['jpeg_interlaced'] = $jpeg_interlaced; + $response->data['png_interlaced'] = $png_interlaced; + $response->data['gif_interlaced'] = $gif_interlaced; + $response->data['client_side_supported_mime_types'] = $client_side_supported_mime_types; } return $response; 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 7c00c145d27a72..0c3bbdad18aa99 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 @@ -19,6 +19,7 @@ function useMediaUploadSettings( settings = {} ) { allowedMimeTypes: settings.allowedMimeTypes, allImageSizes: settings.allImageSizes, bigImageSizeThreshold: settings.bigImageSizeThreshold, + clientSideSupportedMimeTypes: settings.clientSideSupportedMimeTypes, } ), [ settings ] ); diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 75cc7fc34faa40..b9104891a92665 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -54,6 +54,7 @@ export const rootEntitiesConfig = [ 'jpeg_interlaced', 'png_interlaced', 'gif_interlaced', + 'client_side_supported_mime_types', '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 997469ba81f54a..58b46f93fa7f71 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -183,6 +183,8 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { return { allImageSizes: baseData?.image_sizes, bigImageSizeThreshold: baseData?.image_size_threshold, + clientSideSupportedMimeTypes: + baseData?.client_side_supported_mime_types, allowRightClickOverrides: get( 'core', 'allowRightClickOverrides' diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index fa22d3f61e3df4..3bdfc28e06c229 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -711,9 +711,10 @@ export function prepareItem( id: QueueItemId ) { const settings = select.getSettings(); const isImage = file.type.startsWith( 'image/' ); - const isVipsSupported = CLIENT_SIDE_SUPPORTED_MIME_TYPES.includes( - file.type - ); + const supportedMimeTypes = + settings.clientSideSupportedMimeTypes ?? + CLIENT_SIDE_SUPPORTED_MIME_TYPES; + const isVipsSupported = supportedMimeTypes.includes( file.type ); // For images that can be processed by vips, check if we need to scale down based on threshold. if ( isImage && isVipsSupported ) { diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 579ad27d5c7e9e..10b5df6c379b2b 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -172,6 +172,9 @@ export interface Settings { bigImageSizeThreshold?: number; // Map of source MIME types to output MIME types for transcoding. imageOutputFormats?: Record< string, string >; + // MIME types supported by client-side media processing. + // Overrides the default CLIENT_SIDE_SUPPORTED_MIME_TYPES constant. + clientSideSupportedMimeTypes?: readonly string[]; // Whether to use progressive/interlaced encoding for JPEG. jpegInterlaced?: boolean; // Whether to use interlaced encoding for PNG. From 7c6415315d479c684912f2ccc95711bbab5a5937 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 16 Mar 2026 11:40:14 -0700 Subject: [PATCH 2/8] Add PHP tests for client_side_supported_mime_types Cover the new configurable MIME types setting in the REST index: verify it is absent for unprivileged users, present with correct defaults for admins, and filterable via the client_side_supported_mime_types hook. --- phpunit/media/media-processing-test.php | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/phpunit/media/media-processing-test.php b/phpunit/media/media-processing-test.php index a697d27897dbb9..1ffb47b7682d76 100644 --- a/phpunit/media/media-processing-test.php +++ b/phpunit/media/media-processing-test.php @@ -121,6 +121,7 @@ public function test_get_rest_index_should_return_additional_settings() { $this->assertArrayNotHasKey( 'png_interlaced', $data ); $this->assertArrayNotHasKey( 'gif_interlaced', $data ); $this->assertArrayNotHasKey( 'image_sizes', $data ); + $this->assertArrayNotHasKey( 'client_side_supported_mime_types', $data ); } /** @@ -141,6 +142,36 @@ public function test_get_rest_index_should_return_additional_settings_can_upload $this->assertArrayHasKey( 'png_interlaced', $data ); $this->assertArrayHasKey( 'gif_interlaced', $data ); $this->assertArrayHasKey( 'image_sizes', $data ); + $this->assertArrayHasKey( 'client_side_supported_mime_types', $data ); + $this->assertSame( + array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif' ), + $data['client_side_supported_mime_types'] + ); + } + + /** + * @covers ::gutenberg_media_processing_filter_rest_index + */ + public function test_client_side_supported_mime_types_filter() { + wp_set_current_user( self::$admin_id ); + + $filter_callback = static function () { + return array( 'image/jpeg', 'image/png' ); + }; + add_filter( 'client_side_supported_mime_types', $filter_callback ); + + $server = new WP_REST_Server(); + + $request = new WP_REST_Request( 'GET', '/' ); + $index = $server->dispatch( $request ); + $data = $index->get_data(); + + remove_filter( 'client_side_supported_mime_types', $filter_callback ); + + $this->assertSame( + array( 'image/jpeg', 'image/png' ), + $data['client_side_supported_mime_types'] + ); } /** From d87c113553ccf43c400eaaee543e0201ab55924e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 16 Mar 2026 11:41:43 -0700 Subject: [PATCH 3/8] Add JS tests for clientSideSupportedMimeTypes setting Verify that prepareItem respects a custom MIME types list from settings, can add normally-unsupported types to client-side processing, and falls back to the default constant when the setting is not provided. --- .../upload-media/src/store/test/actions.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index d9f3072a1648a6..7fcc4115f2313f 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -324,6 +324,113 @@ describe( 'actions', () => { true ); } ); + + it( 'should use custom clientSideSupportedMimeTypes when set', async () => { + // Exclude AVIF from supported types. + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + clientSideSupportedMimeTypes: [ 'image/jpeg', 'image/png' ], + } ); + + const avifFile = new File( [ 'avif' ], 'photo.avif', { + lastModified: 1234567891, + type: 'image/avif', + } ); + + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: avifFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + await unlock( registry.dispatch( uploadStore ) ).prepareItem( + item.id + ); + + const updatedItem = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + // AVIF is excluded from custom list, so no client-side processing. + expect( updatedItem.operations ).toEqual( + expect.arrayContaining( [ OperationType.Upload ] ) + ); + expect( updatedItem.operations ).not.toEqual( + expect.arrayContaining( [ OperationType.ThumbnailGeneration ] ) + ); + expect( updatedItem.additionalData.generate_sub_sizes ).toBe( + true + ); + } ); + + it( 'should support normally-unsupported types via clientSideSupportedMimeTypes', async () => { + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + clientSideSupportedMimeTypes: [ 'image/jpeg', 'image/bmp' ], + } ); + + const bmpFile = new File( [ 'bmp' ], 'photo.bmp', { + lastModified: 1234567891, + type: 'image/bmp', + } ); + + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: bmpFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + await unlock( registry.dispatch( uploadStore ) ).prepareItem( + item.id + ); + + const updatedItem = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + // BMP is in custom list, so client-side processing applies. + expect( updatedItem.operations ).toEqual( + expect.arrayContaining( [ + OperationType.Upload, + OperationType.ThumbnailGeneration, + ] ) + ); + expect( updatedItem.additionalData.generate_sub_sizes ).toBe( + false + ); + } ); + + it( 'should use default constant when clientSideSupportedMimeTypes is not set', async () => { + // No custom setting — defaults should apply. + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + await unlock( registry.dispatch( uploadStore ) ).prepareItem( + item.id + ); + + const updatedItem = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + // JPEG is in default list, so client-side processing applies. + expect( updatedItem.operations ).toEqual( + expect.arrayContaining( [ + OperationType.Upload, + OperationType.ThumbnailGeneration, + ] ) + ); + expect( updatedItem.additionalData.generate_sub_sizes ).toBe( + false + ); + } ); } ); describe( 'cancelItem', () => { From 1c2252afa765b5ed7cd15efbf5662af9ccd28056 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 16 Mar 2026 11:44:08 -0700 Subject: [PATCH 4/8] Fix PHP equals sign alignment in load.php --- lib/media/load.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/media/load.php b/lib/media/load.php index 9a4302672f7104..cfe9dc82d4c8bc 100644 --- a/lib/media/load.php +++ b/lib/media/load.php @@ -102,13 +102,13 @@ function gutenberg_media_processing_filter_rest_index( WP_REST_Response $respons ); if ( current_user_can( 'upload_files' ) ) { - $response->data['image_sizes'] = gutenberg_get_all_image_sizes(); - $response->data['image_size_threshold'] = $image_size_threshold; - $response->data['image_output_formats'] = (object) $default_image_output_formats; - $response->data['jpeg_interlaced'] = $jpeg_interlaced; - $response->data['png_interlaced'] = $png_interlaced; - $response->data['gif_interlaced'] = $gif_interlaced; - $response->data['client_side_supported_mime_types'] = $client_side_supported_mime_types; + $response->data['image_sizes'] = gutenberg_get_all_image_sizes(); + $response->data['image_size_threshold'] = $image_size_threshold; + $response->data['image_output_formats'] = (object) $default_image_output_formats; + $response->data['jpeg_interlaced'] = $jpeg_interlaced; + $response->data['png_interlaced'] = $png_interlaced; + $response->data['gif_interlaced'] = $gif_interlaced; + $response->data['client_side_supported_mime_types'] = $client_side_supported_mime_types; } return $response; From 66023e1be326a16d5685301153479be5c26bc722 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 16 Mar 2026 14:36:38 -0700 Subject: [PATCH 5/8] Add replace_file parameter to sideload endpoint Plugins that convert files client-side before upload need to swap the main attached file afterward (e.g. uploading a JPEG for processing then replacing it with the original HEIC). The new replace_file boolean parameter updates the attached file, MIME type, and metadata, and deletes the old file from disk. --- ...-gutenberg-rest-attachments-controller.php | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index d2032ca271fa46..464f4d9dc0e7ca 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -45,12 +45,17 @@ public function register_routes(): void { 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ), 'type' => 'integer', ), - 'image_size' => array( + 'image_size' => array( 'description' => __( 'Image size.', 'gutenberg' ), 'type' => 'string', 'enum' => $valid_image_sizes, 'required' => true, ), + 'replace_file' => array( + 'description' => __( 'Whether to replace the main attached file and delete the old one.', 'gutenberg' ), + 'type' => 'boolean', + 'default' => false, + ), ), ), 'allow_batch' => $this->allow_batch, @@ -408,7 +413,32 @@ public function sideload_item( WP_REST_Request $request ) { $metadata = array(); } - if ( 'original' === $image_size ) { + if ( ! empty( $request['replace_file'] ) ) { + // Replace the main attached file with the sideloaded one. + $old_path = get_attached_file( $attachment_id ); + + update_attached_file( $attachment_id, $path ); + + $metadata['file'] = _wp_relative_upload_path( $path ); + + // Store the new file as original_image if subsizes exist. + if ( ! empty( $metadata['sizes'] ) ) { + $metadata['original_image'] = wp_basename( $path ); + } + + // Update the post MIME type to match the new file. + wp_update_post( + array( + 'ID' => $attachment_id, + 'post_mime_type' => $type, + ) + ); + + // Delete the old main file if it differs from the new one. + if ( $old_path && $old_path !== $path && file_exists( $old_path ) ) { + wp_delete_file( $old_path ); + } + } elseif ( 'original' === $image_size ) { $metadata['original_image'] = wp_basename( $path ); } elseif ( 'scaled' === $image_size ) { // The current attached file is the original; record it as original_image. From e30688ad7b82652940d804c9d1f4ab0661264103 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 16 Mar 2026 14:45:16 -0700 Subject: [PATCH 6/8] Add new MIME types field to preload paths The preload root fields must exactly match the fields requested in entities.js. Without this, the site editor makes an extra fetch for the root endpoint because the preloaded URL doesn't match the requested URL. --- lib/compat/wordpress-7.0/preload.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/compat/wordpress-7.0/preload.php b/lib/compat/wordpress-7.0/preload.php index 0c650114cb2a1a..5b1d0fb14e813c 100644 --- a/lib/compat/wordpress-7.0/preload.php +++ b/lib/compat/wordpress-7.0/preload.php @@ -44,7 +44,7 @@ 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'; + $root_fields = 'description,gmt_offset,home,image_sizes,image_size_threshold,image_output_formats,jpeg_interlaced,png_interlaced,gif_interlaced,client_side_supported_mime_types,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=' ) ) { From 70f1017cb1b6af7a6ead7eb6b52a37676372c200 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 16 Mar 2026 14:51:18 -0700 Subject: [PATCH 7/8] Fix array arrow alignment in REST controller Resolves PHPCS warning about double arrow alignment in the sideload item endpoint args array. --- lib/media/class-gutenberg-rest-attachments-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index 464f4d9dc0e7ca..fb45b4ac7b73d5 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -41,7 +41,7 @@ public function register_routes(): void { 'callback' => array( $this, 'sideload_item' ), 'permission_callback' => array( $this, 'sideload_item_permissions_check' ), 'args' => array( - 'id' => array( + 'id' => array( 'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ), 'type' => 'integer', ), From 7b03bc8fe4e14691d8896a3ce557aff537bb0aa0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 17 Mar 2026 09:19:39 -0700 Subject: [PATCH 8/8] Add backport changelog for core PR #11277 Links Gutenberg PR #76549 to the corresponding wordpress-develop backport PR. --- backport-changelog/7.0/11277.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 backport-changelog/7.0/11277.md diff --git a/backport-changelog/7.0/11277.md b/backport-changelog/7.0/11277.md new file mode 100644 index 00000000000000..9ecc1be43a3381 --- /dev/null +++ b/backport-changelog/7.0/11277.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/11277 + +* https://github.com/WordPress/gutenberg/pull/76549