Skip to content

Commit 4c94fd5

Browse files
Media: Re-introduce client-side media processing feature.
Reverts the removal in [62081] now that WordPress 7.1 has forked. Restores all PHP functions, REST API endpoints, cross-origin isolation infrastructure, VIPS script module handling, build configuration, and associated tests. Props adamsilverstein, jorbin. See #64906. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent c863860 commit 4c94fd5

14 files changed

Lines changed: 1625 additions & 23 deletions

Gruntfile.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,9 @@ module.exports = function(grunt) {
659659
src: [
660660
'**/*',
661661
'!**/*.map',
662-
'!vips/**',
662+
// Skip non-minified VIPS files — they are ~16MB of inlined WASM
663+
// with no debugging value over the minified versions.
664+
'!vips/!(*.min).js',
663665
],
664666
dest: WORKING_DIR + 'wp-includes/js/dist/script-modules/',
665667
} ],

src/wp-includes/default-filters.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,13 @@
678678
add_action( 'plugins_loaded', '_wp_add_additional_image_sizes', 0 );
679679
add_filter( 'plupload_default_settings', 'wp_show_heic_upload_error' );
680680

681+
// Client-side media processing.
682+
add_action( 'admin_init', 'wp_set_client_side_media_processing_flag' );
683+
// Cross-origin isolation for client-side media processing.
684+
add_action( 'load-post.php', 'wp_set_up_cross_origin_isolation' );
685+
add_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' );
686+
add_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' );
687+
add_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' );
681688
// Nav menu.
682689
add_filter( 'nav_menu_item_id', '_nav_menu_item_id_use_once', 10, 2 );
683690
add_filter( 'nav_menu_css_class', 'wp_nav_menu_remove_menu_item_has_children_class', 10, 4 );

src/wp-includes/media-template.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ class="wp-video-shortcode {{ classes.join( ' ' ) }}"
156156
function wp_print_media_templates() {
157157
$class = 'media-modal wp-core-ui';
158158

159+
$is_cross_origin_isolation_enabled = wp_is_client_side_media_processing_enabled();
160+
161+
if ( $is_cross_origin_isolation_enabled ) {
162+
ob_start();
163+
}
164+
159165
$alt_text_description = sprintf(
160166
/* translators: 1: Link to tutorial, 2: Additional link attributes, 3: Accessibility text. */
161167
__( '<a href="%1$s" %2$s>Learn how to describe the purpose of the image%3$s</a>. Leave empty if the image is purely decorative.' ),
@@ -1582,4 +1588,42 @@ function wp_print_media_templates() {
15821588
* @since 3.5.0
15831589
*/
15841590
do_action( 'print_media_templates' );
1591+
1592+
if ( $is_cross_origin_isolation_enabled ) {
1593+
$html = (string) ob_get_clean();
1594+
1595+
/*
1596+
* The media templates are inside <script type="text/html"> tags,
1597+
* whose content is treated as raw text by the HTML Tag Processor.
1598+
* Extract each script block's content, process it separately,
1599+
* then reassemble the full output.
1600+
*/
1601+
$script_processor = new WP_HTML_Tag_Processor( $html );
1602+
while ( $script_processor->next_tag( 'SCRIPT' ) ) {
1603+
if ( 'text/html' !== $script_processor->get_attribute( 'type' ) ) {
1604+
continue;
1605+
}
1606+
/*
1607+
* Unlike wp_add_crossorigin_attributes(), this does not check whether
1608+
* URLs are actually cross-origin. Media templates use Underscore.js
1609+
* template expressions (e.g. {{ data.url }}) as placeholder URLs,
1610+
* so actual URLs are not available at parse time.
1611+
* The crossorigin attribute is added unconditionally to all relevant
1612+
* media tags to ensure cross-origin isolation works regardless of
1613+
* the final URL value at render time.
1614+
*/
1615+
$template_processor = new WP_HTML_Tag_Processor( $script_processor->get_modifiable_text() );
1616+
while ( $template_processor->next_tag() ) {
1617+
if (
1618+
in_array( $template_processor->get_tag(), array( 'AUDIO', 'IMG', 'VIDEO' ), true )
1619+
&& ! is_string( $template_processor->get_attribute( 'crossorigin' ) )
1620+
) {
1621+
$template_processor->set_attribute( 'crossorigin', 'anonymous' );
1622+
}
1623+
}
1624+
$script_processor->set_modifiable_text( $template_processor->get_updated_html() );
1625+
}
1626+
1627+
echo $script_processor->get_updated_html();
1628+
}
15851629
}

src/wp-includes/media.php

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6400,3 +6400,234 @@ function wp_get_image_editor_output_format( $filename, $mime_type ) {
64006400
return apply_filters( 'image_editor_output_format', $output_format, $filename, $mime_type );
64016401
}
64026402

6403+
/**
6404+
* Checks whether client-side media processing is enabled.
6405+
*
6406+
* Client-side media processing uses the browser's capabilities to handle
6407+
* tasks like image resizing and compression before uploading to the server.
6408+
*
6409+
* @since 7.0.0
6410+
*
6411+
* @return bool Whether client-side media processing is enabled.
6412+
*/
6413+
function wp_is_client_side_media_processing_enabled(): bool {
6414+
// This is due to SharedArrayBuffer requiring a secure context.
6415+
$host = strtolower( (string) strtok( $_SERVER['HTTP_HOST'] ?? '', ':' ) );
6416+
$enabled = ( is_ssl() || 'localhost' === $host || str_ends_with( $host, '.localhost' ) );
6417+
6418+
/**
6419+
* Filters whether client-side media processing is enabled.
6420+
*
6421+
* @since 7.0.0
6422+
*
6423+
* @param bool $enabled Whether client-side media processing is enabled. Default true if the page is served in a secure context.
6424+
*/
6425+
return (bool) apply_filters( 'wp_client_side_media_processing_enabled', $enabled );
6426+
}
6427+
6428+
/**
6429+
* Sets a global JS variable to indicate that client-side media processing is enabled.
6430+
*
6431+
* @since 7.0.0
6432+
*/
6433+
function wp_set_client_side_media_processing_flag(): void {
6434+
if ( ! wp_is_client_side_media_processing_enabled() ) {
6435+
return;
6436+
}
6437+
6438+
wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true;', 'before' );
6439+
6440+
$chromium_version = wp_get_chromium_major_version();
6441+
6442+
if ( null !== $chromium_version && $chromium_version >= 137 ) {
6443+
wp_add_inline_script( 'wp-block-editor', 'window.__documentIsolationPolicy = true;', 'before' );
6444+
}
6445+
6446+
/*
6447+
* Register the @wordpress/vips/worker script module as a dynamic dependency
6448+
* of the wp-upload-media classic script. This ensures it is included in the
6449+
* import map so that the dynamic import() in upload-media.js can resolve it.
6450+
*/
6451+
wp_scripts()->add_data(
6452+
'wp-upload-media',
6453+
'module_dependencies',
6454+
array( '@wordpress/vips/worker' )
6455+
);
6456+
}
6457+
6458+
/**
6459+
* Returns the major Chrome/Chromium version from the current request's User-Agent.
6460+
*
6461+
* Matches all Chromium-based browsers (Chrome, Edge, Opera, Brave).
6462+
*
6463+
* @since 7.0.0
6464+
*
6465+
* @return int|null The major Chrome version, or null if not a Chromium browser.
6466+
*/
6467+
function wp_get_chromium_major_version(): ?int {
6468+
if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
6469+
return null;
6470+
}
6471+
if ( preg_match( '#Chrome/(\d+)#', $_SERVER['HTTP_USER_AGENT'], $matches ) ) {
6472+
return (int) $matches[1];
6473+
}
6474+
return null;
6475+
}
6476+
6477+
/**
6478+
* Enables cross-origin isolation in the block editor.
6479+
*
6480+
* Required for enabling SharedArrayBuffer for WebAssembly-based
6481+
* media processing in the editor. Uses Document-Isolation-Policy
6482+
* on supported browsers (Chromium 137+).
6483+
*
6484+
* Skips setup when a third-party page builder overrides the block
6485+
* editor via a custom `action` query parameter, as DIP would block
6486+
* same-origin iframe access that these editors rely on.
6487+
*
6488+
* @since 7.0.0
6489+
*/
6490+
function wp_set_up_cross_origin_isolation(): void {
6491+
if ( ! wp_is_client_side_media_processing_enabled() ) {
6492+
return;
6493+
}
6494+
6495+
$screen = get_current_screen();
6496+
6497+
if ( ! $screen ) {
6498+
return;
6499+
}
6500+
6501+
if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
6502+
return;
6503+
}
6504+
6505+
/*
6506+
* Skip when a third-party page builder overrides the block editor.
6507+
* DIP isolates the document into its own agent cluster,
6508+
* which blocks same-origin iframe access that these editors rely on.
6509+
*/
6510+
if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) {
6511+
return;
6512+
}
6513+
6514+
// Cross-origin isolation is not needed if users can't upload files anyway.
6515+
if ( ! current_user_can( 'upload_files' ) ) {
6516+
return;
6517+
}
6518+
6519+
wp_start_cross_origin_isolation_output_buffer();
6520+
}
6521+
6522+
/**
6523+
* Sends the Document-Isolation-Policy header for cross-origin isolation.
6524+
*
6525+
* Uses an output buffer to add crossorigin="anonymous" where needed.
6526+
*
6527+
* @since 7.0.0
6528+
*/
6529+
function wp_start_cross_origin_isolation_output_buffer(): void {
6530+
$chromium_version = wp_get_chromium_major_version();
6531+
6532+
if ( null === $chromium_version || $chromium_version < 137 ) {
6533+
return;
6534+
}
6535+
6536+
ob_start(
6537+
static function ( string $output ): string {
6538+
header( 'Document-Isolation-Policy: isolate-and-credentialless' );
6539+
6540+
return wp_add_crossorigin_attributes( $output );
6541+
}
6542+
);
6543+
}
6544+
6545+
/**
6546+
* Adds crossorigin="anonymous" to relevant tags in the given HTML string.
6547+
*
6548+
* @since 7.0.0
6549+
*
6550+
* @param string $html HTML input.
6551+
* @return string Modified HTML.
6552+
*/
6553+
function wp_add_crossorigin_attributes( string $html ): string {
6554+
$site_url = site_url();
6555+
6556+
$processor = new WP_HTML_Tag_Processor( $html );
6557+
6558+
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin.
6559+
$cross_origin_tag_attributes = array(
6560+
'AUDIO' => array( 'src' => false ),
6561+
'LINK' => array( 'href' => false ),
6562+
'SCRIPT' => array( 'src' => false ),
6563+
'VIDEO' => array(
6564+
'src' => false,
6565+
'poster' => false,
6566+
),
6567+
'SOURCE' => array( 'src' => false ),
6568+
);
6569+
6570+
while ( $processor->next_tag() ) {
6571+
$tag = $processor->get_tag();
6572+
6573+
if ( ! isset( $cross_origin_tag_attributes[ $tag ] ) ) {
6574+
continue;
6575+
}
6576+
6577+
if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) {
6578+
$processor->set_bookmark( 'audio-video-parent' );
6579+
}
6580+
6581+
$processor->set_bookmark( 'resume' );
6582+
6583+
$sought = false;
6584+
6585+
$crossorigin = $processor->get_attribute( 'crossorigin' );
6586+
6587+
$is_cross_origin = false;
6588+
6589+
foreach ( $cross_origin_tag_attributes[ $tag ] as $attr => $is_srcset ) {
6590+
if ( $is_srcset ) {
6591+
$srcset = $processor->get_attribute( $attr );
6592+
if ( is_string( $srcset ) ) {
6593+
foreach ( explode( ',', $srcset ) as $candidate ) {
6594+
$candidate_url = strtok( trim( $candidate ), ' ' );
6595+
if ( is_string( $candidate_url ) && '' !== $candidate_url && ! str_starts_with( $candidate_url, $site_url ) && ! str_starts_with( $candidate_url, '/' ) ) {
6596+
$is_cross_origin = true;
6597+
break;
6598+
}
6599+
}
6600+
}
6601+
} else {
6602+
$url = $processor->get_attribute( $attr );
6603+
if ( is_string( $url ) && ! str_starts_with( $url, $site_url ) && ! str_starts_with( $url, '/' ) ) {
6604+
$is_cross_origin = true;
6605+
}
6606+
}
6607+
6608+
if ( $is_cross_origin ) {
6609+
break;
6610+
}
6611+
}
6612+
6613+
if ( $is_cross_origin && ! is_string( $crossorigin ) ) {
6614+
if ( 'SOURCE' === $tag ) {
6615+
$sought = $processor->seek( 'audio-video-parent' );
6616+
6617+
if ( $sought ) {
6618+
$processor->set_attribute( 'crossorigin', 'anonymous' );
6619+
}
6620+
} else {
6621+
$processor->set_attribute( 'crossorigin', 'anonymous' );
6622+
}
6623+
6624+
if ( $sought ) {
6625+
$processor->seek( 'resume' );
6626+
$processor->release_bookmark( 'audio-video-parent' );
6627+
}
6628+
}
6629+
}
6630+
6631+
return $processor->get_updated_html();
6632+
}
6633+

src/wp-includes/rest-api/class-wp-rest-server.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,6 +1368,34 @@ public function get_index( $request ) {
13681368
'routes' => $this->get_data_for_routes( $this->get_routes(), $request['context'] ),
13691369
);
13701370

1371+
// Add media processing settings for users who can upload files.
1372+
if ( wp_is_client_side_media_processing_enabled() && current_user_can( 'upload_files' ) ) {
1373+
// Image sizes keyed by name for client-side media processing.
1374+
$available['image_sizes'] = array();
1375+
foreach ( wp_get_registered_image_subsizes() as $name => $size ) {
1376+
$available['image_sizes'][ $name ] = $size;
1377+
}
1378+
1379+
/** This filter is documented in wp-admin/includes/image.php */
1380+
$available['image_size_threshold'] = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 );
1381+
1382+
// Image output formats.
1383+
$input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' );
1384+
$output_formats = array();
1385+
foreach ( $input_formats as $mime_type ) {
1386+
/** This filter is documented in wp-includes/media.php */
1387+
$output_formats = apply_filters( 'image_editor_output_format', $output_formats, '', $mime_type );
1388+
}
1389+
$available['image_output_formats'] = (object) $output_formats;
1390+
1391+
/** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
1392+
$available['jpeg_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/jpeg' );
1393+
/** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
1394+
$available['png_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/png' );
1395+
/** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
1396+
$available['gif_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/gif' );
1397+
}
1398+
13711399
$response = new WP_REST_Response( $available );
13721400

13731401
$fields = $request['_fields'] ?? '';

0 commit comments

Comments
 (0)