From 3ddfba49795758d38d82f895f9add5f4e3e12805 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 4 Feb 2026 14:34:02 -0800 Subject: [PATCH 01/12] add preload patchs for client side media --- src/wp-admin/edit-form-blocks.php | 6 ++++++ src/wp-admin/site-editor.php | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/wp-admin/edit-form-blocks.php b/src/wp-admin/edit-form-blocks.php index d0f2000fdce17..2237fc69ce293 100644 --- a/src/wp-admin/edit-form-blocks.php +++ b/src/wp-admin/edit-form-blocks.php @@ -92,6 +92,12 @@ static function ( $classes ) { '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/src/wp-admin/site-editor.php b/src/wp-admin/site-editor.php index 1c8e8b525459b..9a8268c3392d7 100644 --- a/src/wp-admin/site-editor.php +++ b/src/wp-admin/site-editor.php @@ -218,6 +218,12 @@ static function ( $classes ) { 'description', 'gmt_offset', 'home', + 'image_sizes', + 'image_size_threshold', + 'image_output_formats', + 'jpeg_interlaced', + 'png_interlaced', + 'gif_interlaced', 'name', 'site_icon', 'site_icon_url', From 86938a40a1df0cee43c0c4f42e1699763fb01c2d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 4 Feb 2026 14:56:24 -0800 Subject: [PATCH 02/12] REST API: Add media processing settings to index endpoint --- .../rest-api/class-wp-rest-server.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index dbf605523d2dc..b7418c41ea811 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -1368,6 +1368,38 @@ public function get_index( $request ) { 'routes' => $this->get_data_for_routes( $this->get_routes(), $request['context'] ), ); + // Add media processing settings for users who can upload files. + if ( current_user_can( 'upload_files' ) ) { + // Image sizes with normalized data. + $sizes = wp_get_registered_image_subsizes(); + foreach ( $sizes as $name => &$size ) { + $size['height'] = (int) $size['height']; + $size['width'] = (int) $size['width']; + $size['name'] = $name; + } + unset( $size ); + $available['image_sizes'] = $sizes; + + /** This filter is documented in wp-admin/includes/image.php */ + $available['image_size_threshold'] = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); + + // Image output formats. + $input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' ); + $output_formats = array(); + foreach ( $input_formats as $mime_type ) { + /** This filter is documented in wp-includes/class-wp-image-editor.php */ + $output_formats = apply_filters( 'image_editor_output_format', $output_formats, '', $mime_type ); + } + $available['image_output_formats'] = (object) $output_formats; + + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + $available['jpeg_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/jpeg' ); + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + $available['png_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/png' ); + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + $available['gif_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/gif' ); + } + $response = new WP_REST_Response( $available ); $fields = $request['_fields'] ?? ''; From 21c02291757a252f5919eb2c3d57eb1ca3c88a3e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 4 Feb 2026 15:28:07 -0800 Subject: [PATCH 03/12] REST API: Add filename and filesize fields to attachments Add filename and filesize REST fields to the attachments endpoint for client-side media processing. The filename returns the original attachment file name, and filesize returns the file size in bytes. --- .../class-wp-rest-attachments-controller.php | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index f824b0c9e2cab..491c77cececd1 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -990,6 +990,14 @@ public function prepare_item_for_response( $item, $request ) { $data['missing_image_sizes'] = array_keys( wp_get_missing_image_subsizes( $post->ID ) ); } + if ( in_array( 'filename', $fields, true ) ) { + $data['filename'] = $this->get_attachment_filename( $post->ID ); + } + + if ( in_array( 'filesize', $fields, true ) ) { + $data['filesize'] = $this->get_attachment_filesize( $post->ID ); + } + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->filter_response_by_context( $data, $context ); @@ -1159,6 +1167,20 @@ public function get_item_schema() { 'readonly' => true, ); + $schema['properties']['filename'] = array( + 'description' => __( 'Original attachment file name.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ); + + $schema['properties']['filesize'] = array( + 'description' => __( 'Attachment file size in bytes.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ); + unset( $schema['properties']['password'] ); $this->schema = $schema; @@ -1724,4 +1746,53 @@ protected function get_edit_media_item_args() { return $args; } + + /** + * Gets the attachment's original file name. + * + * @since 6.9.0 + * + * @param int $attachment_id Attachment ID. + * @return string|null Attachment file name, or null if not found. + */ + protected function get_attachment_filename( $attachment_id ) { + $path = wp_get_original_image_path( $attachment_id ); + + if ( $path ) { + return wp_basename( $path ); + } + + $path = get_attached_file( $attachment_id ); + + if ( $path ) { + return wp_basename( $path ); + } + + return null; + } + + /** + * Gets the attachment's file size in bytes. + * + * @since 6.9.0 + * + * @param int $attachment_id Attachment ID. + * @return int|null Attachment file size in bytes, or null if not available. + */ + protected function get_attachment_filesize( $attachment_id ) { + $meta = wp_get_attachment_metadata( $attachment_id ); + + if ( isset( $meta['filesize'] ) ) { + return $meta['filesize']; + } + + $original_path = wp_get_original_image_path( $attachment_id ); + $attached_file = $original_path ? $original_path : get_attached_file( $attachment_id ); + + if ( is_string( $attached_file ) && file_exists( $attached_file ) ) { + return wp_filesize( $attached_file ); + } + + return null; + } } From b912c2f5ad55db6096351d5bc575e7e7ad9d22d6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 4 Feb 2026 15:28:33 -0800 Subject: [PATCH 04/12] REST API: Add exif_orientation field to attachments Add exif_orientation field to the attachments REST endpoint for client-side EXIF rotation handling. Values 1-8 follow the EXIF specification, where 1 means no rotation is needed. --- .../class-wp-rest-attachments-controller.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 491c77cececd1..376ca901b12e7 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -998,6 +998,22 @@ public function prepare_item_for_response( $item, $request ) { $data['filesize'] = $this->get_attachment_filesize( $post->ID ); } + if ( in_array( 'exif_orientation', $fields, true ) && wp_attachment_is_image( $post ) ) { + $metadata = wp_get_attachment_metadata( $post->ID, true ); + + // Default to 1 (no rotation needed) if orientation not set. + $orientation = 1; + + if ( + is_array( $metadata ) && + isset( $metadata['image_meta']['orientation'] ) + ) { + $orientation = (int) $metadata['image_meta']['orientation']; + } + + $data['exif_orientation'] = $orientation; + } + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->filter_response_by_context( $data, $context ); @@ -1181,6 +1197,13 @@ public function get_item_schema() { 'readonly' => true, ); + $schema['properties']['exif_orientation'] = array( + 'description' => __( 'EXIF orientation value. Values 1-8 follow the EXIF specification, where 1 means no rotation needed.' ), + 'type' => 'integer', + 'context' => array( 'edit' ), + 'readonly' => true, + ); + unset( $schema['properties']['password'] ); $this->schema = $schema; From b223e0373ec4c6554a7af9a130b27b2b065e965c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 4 Feb 2026 15:29:26 -0800 Subject: [PATCH 05/12] REST API: Add generate_sub_sizes and convert_format params Add generate_sub_sizes and convert_format parameters to the attachments POST endpoint for client-side media processing control. When generate_sub_sizes is false, server-side sub-size generation and EXIF rotation are disabled. When convert_format is false, automatic image format conversion is disabled. --- .../class-wp-rest-attachments-controller.php | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 376ca901b12e7..f65132e6756c1 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -65,6 +65,36 @@ public function register_routes() { ); } + /** + * Retrieves the query params for the attachments collection. + * + * @since 6.9.0 + * + * @param string $method Optional. HTTP method of the request. + * The arguments for `CREATABLE` requests are + * checked for required values and may fall-back to a given default. + * Default WP_REST_Server::CREATABLE. + * @return array Endpoint arguments. + */ + public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) { + $args = parent::get_endpoint_args_for_item_schema( $method ); + + if ( WP_REST_Server::CREATABLE === $method ) { + $args['generate_sub_sizes'] = array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to generate image sub sizes.' ), + ); + $args['convert_format'] = array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to convert image formats.' ), + ); + } + + return $args; + } + /** * Determines the allowed query_vars for a get_items() response and * prepares for WP_Query. @@ -192,6 +222,7 @@ public function create_item_permissions_check( $request ) { * Creates a single attachment. * * @since 4.7.0 + * @since 6.9.0 Added `generate_sub_sizes` and `convert_format` parameters. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. @@ -205,9 +236,28 @@ public function create_item( $request ) { ); } + // Handle generate_sub_sizes parameter. + if ( isset( $request['generate_sub_sizes'] ) && ! $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 ); + } + + // Handle convert_format parameter. + if ( isset( $request['convert_format'] ) && ! $request['convert_format'] ) { + add_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + } + $insert = $this->insert_attachment( $request ); if ( is_wp_error( $insert ) ) { + // Clean up filters on error. + 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 $insert; } @@ -283,6 +333,12 @@ public function create_item( $request ) { */ wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file ) ); + // Clean up filters. + 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 ); + $response = $this->prepare_item_for_response( $attachment, $request ); $response = rest_ensure_response( $response ); $response->set_status( 201 ); From 682a0f1feafdc5d00bb6ce4cc32c8ffe6e08567e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 4 Feb 2026 15:30:29 -0800 Subject: [PATCH 06/12] REST API: Add sideload endpoint for attachments Add sideload endpoint at /wp/v2/media/{id}/sideload for uploading sub-sized images to an existing attachment. Used by client-side media processing to upload generated image sizes without creating new attachments. Supports both images and PDFs. --- .../class-wp-rest-attachments-controller.php | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index f65132e6756c1..931569bdaa499 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -63,6 +63,43 @@ public function register_routes() { 'args' => $this->get_edit_media_item_args(), ) ); + + $valid_image_sizes = array_keys( wp_get_registered_image_subsizes() ); + // Special case to set 'original_image' in attachment metadata. + $valid_image_sizes[] = 'original'; + // Used for PDF thumbnails. + $valid_image_sizes[] = 'full'; + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/sideload', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'sideload_item' ), + 'permission_callback' => array( $this, 'sideload_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the attachment.' ), + 'type' => 'integer', + ), + 'image_size' => array( + 'description' => __( 'Image size.' ), + 'type' => 'string', + 'enum' => $valid_image_sizes, + 'required' => true, + ), + 'convert_format' => array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to convert image formats.' ), + ), + ), + ), + 'allow_batch' => $this->allow_batch, + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); } /** @@ -1874,4 +1911,189 @@ protected function get_attachment_filesize( $attachment_id ) { return null; } + + /** + * Checks if a given request has access to sideload a file. + * + * Sideloading a file for an existing attachment + * requires both update and create permissions. + * + * @since 6.9.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise. + */ + public function sideload_item_permissions_check( $request ) { + return $this->edit_media_item_permissions_check( $request ); + } + + /** + * Side-loads a media file without creating a new attachment. + * + * @since 6.9.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. + */ + public function sideload_item( WP_REST_Request $request ) { + $attachment_id = $request['id']; + + $post = $this->get_post( $attachment_id ); + + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( + ! wp_attachment_is_image( $post ) && + ! wp_attachment_is( 'pdf', $post ) + ) { + return new WP_Error( + 'rest_post_invalid_id', + __( 'Invalid post ID. Only images and PDFs can be sideloaded.' ), + array( 'status' => 400 ) + ); + } + + if ( isset( $request['convert_format'] ) && ! $request['convert_format'] ) { + // Prevent image conversion as that is done client-side. + add_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + } + + // Get the file via $_FILES or raw data. + $files = $request->get_file_params(); + $headers = $request->get_headers(); + + /* + * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts. + * See https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582 + * With the following filter we can work around this safeguard. + */ + $attachment_filename = get_attached_file( $attachment_id, true ); + $attachment_filename = $attachment_filename ? wp_basename( $attachment_filename ) : null; + + $filter_filename = function ( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number ) use ( $attachment_filename ) { + return $this->filter_wp_unique_filename( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number, $attachment_filename ); + }; + + add_filter( 'wp_unique_filename', $filter_filename, 10, 6 ); + + $parent_post = get_post_parent( $attachment_id ); + + $time = null; + + // Matches logic in media_handle_upload(). + // The post date doesn't usually matter for pages, so don't backdate this upload. + if ( $parent_post && 'page' !== $parent_post->post_type && substr( $parent_post->post_date, 0, 4 ) > 0 ) { + $time = $parent_post->post_date; + } + + if ( ! empty( $files ) ) { + $file = $this->upload_from_file( $files, $headers, $time ); + } else { + $file = $this->upload_from_data( $request->get_body(), $headers, $time ); + } + + remove_filter( 'wp_unique_filename', $filter_filename ); + remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + + if ( is_wp_error( $file ) ) { + return $file; + } + + $type = $file['type']; + $path = $file['file']; + + $image_size = $request['image_size']; + + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + + if ( ! $metadata ) { + $metadata = array(); + } + + if ( 'original' === $image_size ) { + $metadata['original_image'] = wp_basename( $path ); + } else { + $metadata['sizes'] = $metadata['sizes'] ?? array(); + + $size = wp_getimagesize( $path ); + + $metadata['sizes'][ $image_size ] = array( + 'width' => $size ? $size[0] : 0, + 'height' => $size ? $size[1] : 0, + 'file' => wp_basename( $path ), + 'mime-type' => $type, + 'filesize' => wp_filesize( $path ), + ); + } + + wp_update_attachment_metadata( $attachment_id, $metadata ); + + $response_request = new WP_REST_Request( + WP_REST_Server::READABLE, + rest_get_route_for_post( $attachment_id ) + ); + + $response_request['context'] = 'edit'; + + if ( isset( $request['_fields'] ) ) { + $response_request['_fields'] = $request['_fields']; + } + + $response = $this->prepare_item_for_response( get_post( $attachment_id ), $response_request ); + + $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) ); + + return $response; + } + + /** + * Filters wp_unique_filename during sideloads. + * + * wp_unique_filename() will always add numeric suffix if the name looks like a sub-size to avoid conflicts. + * Adding this closure to the filter helps work around this safeguard. + * + * Example: when uploading myphoto.jpeg, WordPress normally creates myphoto-150x150.jpeg, + * and when uploading myphoto-150x150.jpeg, it will be renamed to myphoto-150x150-1.jpeg + * However, here it is desired not to add the suffix in order to maintain the same + * naming convention as if the file was uploaded regularly. + * + * @since 6.9.0 + * + * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582 + * + * @param string $filename Unique file name. + * @param string $ext File extension. Example: ".png". + * @param string $dir Directory path. + * @param callable|null $unique_filename_callback Callback function that generates the unique file name. + * @param string[] $alt_filenames Array of alternate file names that were checked for collisions. + * @param int|string $number The highest number that was used to make the file name unique + * or an empty string if unused. + * @param string|null $attachment_filename Original attachment file name. + * @return string Filtered file name. + */ + private function filter_wp_unique_filename( $filename, $ext, $dir, $unique_filename_callback, $alt_filenames, $number, $attachment_filename ) { + if ( empty( $number ) || ! $attachment_filename ) { + return $filename; + } + + $ext = pathinfo( $filename, PATHINFO_EXTENSION ); + $name = pathinfo( $filename, PATHINFO_FILENAME ); + $orig_name = pathinfo( $attachment_filename, PATHINFO_FILENAME ); + + if ( ! $ext || ! $name ) { + return $filename; + } + + $matches = array(); + if ( preg_match( '/(.*)(-\d+x\d+)-' . $number . '$/', $name, $matches ) ) { + $filename_without_suffix = $matches[1] . $matches[2] . ".$ext"; + if ( $matches[1] === $orig_name && ! file_exists( "$dir/$filename_without_suffix" ) ) { + return $filename_without_suffix; + } + } + + return $filename; + } } From c0086d6e87d5a0b5098f6286ab3fc0f70b2fd82c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 4 Feb 2026 15:30:52 -0800 Subject: [PATCH 07/12] REST API: Improve missing_image_sizes for PDFs Add PDF-specific handling for the missing_image_sizes field in the attachments endpoint. PDFs use fallback_intermediate_image_sizes filter to determine which thumbnail sizes should be generated, unlike regular images which use wp_get_missing_image_subsizes(). --- .../class-wp-rest-attachments-controller.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 931569bdaa499..711096e955009 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -1081,6 +1081,34 @@ public function prepare_item_for_response( $item, $request ) { if ( in_array( 'missing_image_sizes', $fields, true ) ) { require_once ABSPATH . 'wp-admin/includes/image.php'; $data['missing_image_sizes'] = array_keys( wp_get_missing_image_subsizes( $post->ID ) ); + + // Handle PDFs which don't use wp_get_missing_image_subsizes(). + if ( empty( $data['missing_image_sizes'] ) && 'application/pdf' === get_post_mime_type( $post ) ) { + $metadata = wp_get_attachment_metadata( $post->ID, true ); + + if ( ! is_array( $metadata ) ) { + $metadata = array(); + } + + $metadata['sizes'] = $metadata['sizes'] ?? array(); + + $fallback_sizes = array( + 'thumbnail', + 'medium', + 'large', + ); + + // The filter might have been added by ::create_item(). + remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 ); + + /** This filter is documented in wp-admin/includes/image.php */ + $fallback_sizes = apply_filters( 'fallback_intermediate_image_sizes', $fallback_sizes, $metadata ); + + $registered_sizes = wp_get_registered_image_subsizes(); + $merged_sizes = array_keys( array_intersect_key( $registered_sizes, array_flip( $fallback_sizes ) ) ); + + $data['missing_image_sizes'] = array_values( array_diff( $merged_sizes, array_keys( $metadata['sizes'] ) ) ); + } } if ( in_array( 'filename', $fields, true ) ) { From 5356039b66f1a0532f1a07670788905e2e71287e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 4 Feb 2026 15:31:31 -0800 Subject: [PATCH 08/12] Media: Add cross-origin isolation support Add COOP and COEP headers in the block editor to enable SharedArrayBuffer for WebAssembly-based client-side media processing. Includes output buffer to automatically add crossorigin="anonymous" attributes to external resources. --- src/wp-includes/default-filters.php | 6 ++ src/wp-includes/media.php | 126 ++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index de0b374ef4b56..304ecf6f26060 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -674,6 +674,12 @@ add_action( 'plugins_loaded', '_wp_add_additional_image_sizes', 0 ); add_filter( 'plupload_default_settings', 'wp_show_heic_upload_error' ); +// Cross-origin isolation for client-side media processing. +add_action( 'load-post.php', 'wp_set_up_cross_origin_isolation' ); +add_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' ); +add_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' ); +add_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' ); + // Nav menu. add_filter( 'nav_menu_item_id', '_nav_menu_item_id_use_once', 10, 2 ); add_filter( 'nav_menu_css_class', 'wp_nav_menu_remove_menu_item_has_children_class', 10, 4 ); diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 5d350b1a18951..78aded2ec43ec 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -6359,3 +6359,129 @@ function wp_get_image_editor_output_format( $filename, $mime_type ) { */ return apply_filters( 'image_editor_output_format', $output_format, $filename, $mime_type ); } + +/** + * Enables cross-origin isolation in the block editor. + * + * Required for enabling SharedArrayBuffer for WebAssembly-based + * media processing in the editor. + * + * @since 6.9.0 + * + * @link https://web.dev/coop-coep/ + */ +function wp_set_up_cross_origin_isolation() { + $screen = get_current_screen(); + + if ( ! $screen ) { + return; + } + + if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) { + return; + } + + $user_id = get_current_user_id(); + if ( ! $user_id ) { + return; + } + + // Cross-origin isolation is not needed if users can't upload files anyway. + if ( ! user_can( $user_id, 'upload_files' ) ) { + return; + } + + wp_start_cross_origin_isolation_output_buffer(); +} + +/** + * Starts an output buffer to send cross-origin isolation headers. + * + * Sends headers and uses an output buffer to add crossorigin="anonymous" + * attributes where needed. + * + * @since 6.9.0 + * + * @link https://web.dev/coop-coep/ + * + * @global bool $is_safari + */ +function wp_start_cross_origin_isolation_output_buffer() { + global $is_safari; + + $coep = $is_safari ? 'require-corp' : 'credentialless'; + + ob_start( + static function ( string $output ) use ( $coep ): string { + header( 'Cross-Origin-Opener-Policy: same-origin' ); + header( "Cross-Origin-Embedder-Policy: $coep" ); + + return wp_add_crossorigin_attributes( $output ); + } + ); +} + +/** + * Adds crossorigin="anonymous" to relevant tags in the given HTML string. + * + * @since 6.9.0 + * + * @param string $html HTML input. + * @return string Modified HTML. + */ +function wp_add_crossorigin_attributes( string $html ): string { + $site_url = site_url(); + + $processor = new WP_HTML_Tag_Processor( $html ); + + // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin. + $tags = array( + 'AUDIO' => 'src', + 'IMG' => 'src', + 'LINK' => 'href', + 'SCRIPT' => 'src', + 'VIDEO' => 'src', + 'SOURCE' => 'src', + ); + + $tag_names = array_keys( $tags ); + + while ( $processor->next_tag() ) { + $tag = $processor->get_tag(); + + if ( ! in_array( $tag, $tag_names, true ) ) { + continue; + } + + if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) { + $processor->set_bookmark( 'audio-video-parent' ); + } + + $processor->set_bookmark( 'resume' ); + + $sought = false; + + $crossorigin = $processor->get_attribute( 'crossorigin' ); + + $url = $processor->get_attribute( $tags[ $tag ] ); + + if ( is_string( $url ) && ! str_starts_with( $url, $site_url ) && ! str_starts_with( $url, '/' ) && ! is_string( $crossorigin ) ) { + if ( 'SOURCE' === $tag ) { + $sought = $processor->seek( 'audio-video-parent' ); + + if ( $sought ) { + $processor->set_attribute( 'crossorigin', 'anonymous' ); + } + } else { + $processor->set_attribute( 'crossorigin', 'anonymous' ); + } + + if ( $sought ) { + $processor->seek( 'resume' ); + $processor->release_bookmark( 'audio-video-parent' ); + } + } + } + + return $processor->get_updated_html(); +} From 357c4069c8a1cdf8a8c1669591934ee9190d858e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 4 Feb 2026 15:31:53 -0800 Subject: [PATCH 09/12] Media: Add WASM MIME type to .htaccess rules Add AddType directive for WebAssembly files to the mod_rewrite_rules filter. This enables serving .wasm files for client-side media processing using libraries like wasm-vips. --- src/wp-includes/default-filters.php | 1 + src/wp-includes/media.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 304ecf6f26060..ea18ee64f4f2b 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -679,6 +679,7 @@ add_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' ); add_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' ); add_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' ); +add_filter( 'mod_rewrite_rules', 'wp_filter_mod_rewrite_rules_for_wasm' ); // Nav menu. add_filter( 'nav_menu_item_id', '_nav_menu_item_id_use_once', 10, 2 ); diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 78aded2ec43ec..c70ca6f77b7e0 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -6485,3 +6485,21 @@ function wp_add_crossorigin_attributes( string $html ): string { return $processor->get_updated_html(); } + +/** + * Filters the list of rewrite rules formatted for output to an .htaccess file. + * + * Adds support for serving WebAssembly files used by client-side media processing. + * + * @since 6.9.0 + * + * @param string $rules mod_rewrite Rewrite rules formatted for .htaccess. + * @return string Filtered rewrite rules. + */ +function wp_filter_mod_rewrite_rules_for_wasm( string $rules ): string { + $rules .= "\n# BEGIN WordPress client-side media processing\n" . + "AddType application/wasm wasm\n" . + "# END WordPress client-side media processing\n"; + + return $rules; +} From 78abc1664923364fe5a1eeb020fd88e48a0950df Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 4 Feb 2026 15:32:15 -0800 Subject: [PATCH 10/12] Media: Add crossorigin attributes to media templates Override wp_print_media_templates to add crossorigin="anonymous" attributes to audio, img, and video tags. Required for cross-origin isolation compliance in the media library modal. --- src/wp-includes/default-filters.php | 1 + src/wp-includes/media.php | 32 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index ea18ee64f4f2b..50a4ae1790b5b 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -680,6 +680,7 @@ add_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' ); add_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' ); add_filter( 'mod_rewrite_rules', 'wp_filter_mod_rewrite_rules_for_wasm' ); +add_action( 'wp_enqueue_media', 'wp_override_media_templates' ); // Nav menu. add_filter( 'nav_menu_item_id', '_nav_menu_item_id_use_once', 10, 2 ); diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index c70ca6f77b7e0..2d747730b55e6 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -6503,3 +6503,35 @@ function wp_filter_mod_rewrite_rules_for_wasm( string $rules ): string { return $rules; } + +/** + * Overrides templates from wp_print_media_templates with custom ones. + * + * Adds `crossorigin` attribute to all tags that could have assets + * loaded from a different domain for cross-origin isolation support. + * + * @since 6.9.0 + */ +function wp_override_media_templates() { + remove_action( 'admin_footer', 'wp_print_media_templates' ); + add_action( + 'admin_footer', + static function () { + ob_start(); + wp_print_media_templates(); + $html = (string) ob_get_clean(); + + $tags = array( + 'audio', + 'img', + 'video', + ); + + foreach ( $tags as $tag ) { + $html = (string) str_replace( "<$tag", "<$tag crossorigin=\"anonymous\"", $html ); + } + + echo $html; + } + ); +} From 1992ba22d632702868be3b4b0f74aff5994959f7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 4 Feb 2026 22:10:18 -0800 Subject: [PATCH 11/12] Tests: Update REST API tests for client-side media fields Update test_get_item_schema to expect 32 properties instead of 29, adding assertions for the new filename, filesize, and exif_orientation fields. Add the sideload endpoint to expected routes in schema test. Co-Authored-By: Claude Opus 4.5 --- tests/phpunit/tests/rest-api/rest-attachments-controller.php | 5 ++++- tests/phpunit/tests/rest-api/rest-schema-setup.php | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 05029e0845d96..796e58c45d97d 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -1939,9 +1939,12 @@ public function test_get_item_schema() { $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertCount( 29, $properties ); + $this->assertCount( 32, $properties ); $this->assertArrayHasKey( 'author', $properties ); $this->assertArrayHasKey( 'alt_text', $properties ); + $this->assertArrayHasKey( 'exif_orientation', $properties ); + $this->assertArrayHasKey( 'filename', $properties ); + $this->assertArrayHasKey( 'filesize', $properties ); $this->assertArrayHasKey( 'caption', $properties ); $this->assertArrayHasKey( 'raw', $properties['caption']['properties'] ); $this->assertArrayHasKey( 'rendered', $properties['caption']['properties'] ); diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index b5e9a177a7765..d4bd59ad2c3c1 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -109,6 +109,7 @@ public function test_expected_routes_in_schema() { '/wp/v2/media/(?P[\\d]+)', '/wp/v2/media/(?P[\\d]+)/post-process', '/wp/v2/media/(?P[\\d]+)/edit', + '/wp/v2/media/(?P[\\d]+)/sideload', '/wp/v2/blocks', '/wp/v2/blocks/(?P[\d]+)', '/wp/v2/blocks/(?P[\d]+)/autosaves', From e847d8c26c57e3cac257b2ae91144bf816ca6c41 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 5 Feb 2026 08:51:13 -0800 Subject: [PATCH 12/12] Docs: Update @since tags from 6.9.0 to 7.0.0 Co-Authored-By: Claude Opus 4.5 --- src/wp-includes/media.php | 10 +++++----- .../class-wp-rest-attachments-controller.php | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 2d747730b55e6..aa5a5c2d55b78 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -6366,7 +6366,7 @@ function wp_get_image_editor_output_format( $filename, $mime_type ) { * Required for enabling SharedArrayBuffer for WebAssembly-based * media processing in the editor. * - * @since 6.9.0 + * @since 7.0.0 * * @link https://web.dev/coop-coep/ */ @@ -6400,7 +6400,7 @@ function wp_set_up_cross_origin_isolation() { * Sends headers and uses an output buffer to add crossorigin="anonymous" * attributes where needed. * - * @since 6.9.0 + * @since 7.0.0 * * @link https://web.dev/coop-coep/ * @@ -6424,7 +6424,7 @@ static function ( string $output ) use ( $coep ): string { /** * Adds crossorigin="anonymous" to relevant tags in the given HTML string. * - * @since 6.9.0 + * @since 7.0.0 * * @param string $html HTML input. * @return string Modified HTML. @@ -6491,7 +6491,7 @@ function wp_add_crossorigin_attributes( string $html ): string { * * Adds support for serving WebAssembly files used by client-side media processing. * - * @since 6.9.0 + * @since 7.0.0 * * @param string $rules mod_rewrite Rewrite rules formatted for .htaccess. * @return string Filtered rewrite rules. @@ -6510,7 +6510,7 @@ function wp_filter_mod_rewrite_rules_for_wasm( string $rules ): string { * Adds `crossorigin` attribute to all tags that could have assets * loaded from a different domain for cross-origin isolation support. * - * @since 6.9.0 + * @since 7.0.0 */ function wp_override_media_templates() { remove_action( 'admin_footer', 'wp_print_media_templates' ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 711096e955009..69c93d301b357 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -105,7 +105,7 @@ public function register_routes() { /** * Retrieves the query params for the attachments collection. * - * @since 6.9.0 + * @since 7.0.0 * * @param string $method Optional. HTTP method of the request. * The arguments for `CREATABLE` requests are @@ -259,7 +259,7 @@ public function create_item_permissions_check( $request ) { * Creates a single attachment. * * @since 4.7.0 - * @since 6.9.0 Added `generate_sub_sizes` and `convert_format` parameters. + * @since 7.0.0 Added `generate_sub_sizes` and `convert_format` parameters. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. @@ -1894,7 +1894,7 @@ protected function get_edit_media_item_args() { /** * Gets the attachment's original file name. * - * @since 6.9.0 + * @since 7.0.0 * * @param int $attachment_id Attachment ID. * @return string|null Attachment file name, or null if not found. @@ -1918,7 +1918,7 @@ protected function get_attachment_filename( $attachment_id ) { /** * Gets the attachment's file size in bytes. * - * @since 6.9.0 + * @since 7.0.0 * * @param int $attachment_id Attachment ID. * @return int|null Attachment file size in bytes, or null if not available. @@ -1946,7 +1946,7 @@ protected function get_attachment_filesize( $attachment_id ) { * Sideloading a file for an existing attachment * requires both update and create permissions. * - * @since 6.9.0 + * @since 7.0.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise. @@ -1958,7 +1958,7 @@ public function sideload_item_permissions_check( $request ) { /** * Side-loads a media file without creating a new attachment. * - * @since 6.9.0 + * @since 7.0.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. @@ -2087,7 +2087,7 @@ public function sideload_item( WP_REST_Request $request ) { * However, here it is desired not to add the suffix in order to maintain the same * naming convention as if the file was uploaded regularly. * - * @since 6.9.0 + * @since 7.0.0 * * @link https://github.com/WordPress/wordpress-develop/blob/30954f7ac0840cfdad464928021d7f380940c347/src/wp-includes/functions.php#L2576-L2582 *