PluginProbe
Gutenberg / trunk
Gutenberg vtrunk
24.1.0 24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 All 404 releases
← All changes | lib/media/load.php +116 -111 23.1.1 → trunk View file →
@@ -14,16 +14,17 @@
14 14 *
15 15 * @package gutenberg
16 16 */
17 17
18 -// Client-side media processing is currently plugin-only while the feature matures.
19 -if ( ! defined( 'IS_GUTENBERG_PLUGIN' ) || ! IS_GUTENBERG_PLUGIN ) {
18 +if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
20 19 return;
21 20 }
22 21
23 -if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
24 - return;
25 -}
22 +// Animated GIF → video: clean up the sideloaded companion video and
23 +// poster when their GIF attachment is deleted. The GIF→video swap itself
24 +// happens in the editor (the converted block is a real core/video), so no
25 +// render-time filtering is needed.
26 +require_once __DIR__ . '/animated-gif-to-video.php';
26 27
27 28 // ── Tier 1: HEIC infrastructure (always loaded) ─────────────────────
28 29
29 30 /**
@@ -167,14 +168,30 @@
167 168 *
168 169 * @param WP_REST_Response $response Response data.
169 170 */
170 171 function gutenberg_media_processing_filter_rest_index( WP_REST_Response $response ) {
171 - /** This filter is documented in wp-admin/includes/images.php */
172 + /** This filter is documented in wp-admin/includes/image.php */
172 173 $image_size_threshold = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
173 174
175 + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
176 + $image_strip_meta = (bool) apply_filters( 'image_strip_meta', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
177 +
178 + /*
179 + * On the server, this filter receives the decoded image's actual bit depth.
180 + * The client path never decodes the image on the server, so the filter is
181 + * applied with 16 (the maximum depth vips can produce) as both the value
182 + * and the current depth. The client caps its output bit depth at the
183 + * filtered value, so a plugin lowering it (e.g. to 8) takes effect on
184 + * client-generated images too.
185 + */
186 + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
187 + $image_max_bit_depth = (int) apply_filters( 'image_max_bit_depth', 16, 16 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
188 +
174 189 if ( current_user_can( 'upload_files' ) ) {
175 190 $response->data['image_sizes'] = gutenberg_get_all_image_sizes();
176 191 $response->data['image_size_threshold'] = $image_size_threshold;
192 + $response->data['image_strip_meta'] = $image_strip_meta;
193 + $response->data['image_max_bit_depth'] = $image_max_bit_depth;
177 194 }
178 195
179 196 return $response;
180 197 }
@@ -181,48 +198,64 @@
181 198
182 199 add_filter( 'rest_index', 'gutenberg_media_processing_filter_rest_index' );
183 200
184 201 /**
185 - * Sets a global JS variable to indicate that HEIC canvas-based upload support is available.
202 + * Sets a global JS variable to indicate that client-side media processing is enabled.
186 203 *
187 - * This flag is set whenever the media processing feature is enabled,
188 - * regardless of whether the browser supports full VIPS-based processing.
189 - * Browsers like Safari can use createImageBitmap() to decode HEIC images
190 - * and convert them to JPEG for server-side sub-size generation.
204 + * The flag gates both processing modes: the full VIPS/WASM pipeline (browsers
205 + * that pass feature detection) and the HEIC canvas fallback used by browsers
206 + * such as Safari that can decode HEIC via createImageBitmap() but lack
207 + * SharedArrayBuffer support. The browser-capability check happens client-side.
191 208 */
192 -function gutenberg_set_heic_upload_support_flag() {
193 - wp_add_inline_script( 'wp-block-editor', 'window.__heicUploadSupport = true', 'before' );
209 +function gutenberg_set_client_side_media_processing_flag() {
210 + // Re-check the filter at action time, since other plugins (loaded after Gutenberg)
211 + // may have added a filter to disable client-side media processing.
212 + if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
213 + return;
214 + }
215 + wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true', 'before' );
194 216 }
195 -add_action( 'admin_init', 'gutenberg_set_heic_upload_support_flag' );
217 +add_action( 'admin_init', 'gutenberg_set_client_side_media_processing_flag' );
196 218
197 219 /**
198 - * Deletes the HEIC companion file when its attachment is deleted.
220 + * Deletes the source-format companion file when its attachment is deleted.
199 221 *
200 - * The HEIC is sideloaded alongside a JPEG derivative and recorded in
201 - * $metadata['original']. WordPress core's wp_delete_attachment_files()
202 - * only knows about 'original_image', so without this hook the HEIC
203 - * would linger on disk after the attachment is deleted.
222 + * When the client-side media flow sideloads a source-format original (such as
223 + * a HEIC file) alongside a web-viewable derivative, the original's filename is
224 + * recorded in the 'source_image' metadata key. WordPress only tracks
225 + * 'original_image' in wp_delete_attachment_files(), so without this hook the
226 + * companion file would linger on disk after the attachment is deleted.
204 227 *
205 228 * @param int $post_id Attachment ID being deleted.
229 + * @return bool Whether a companion file was deleted.
206 230 */
207 -function gutenberg_delete_heic_companion_file( int $post_id ): void {
231 +function gutenberg_delete_heic_companion_file( int $post_id ): bool {
208 232 $metadata = wp_get_attachment_metadata( $post_id, true );
209 233
210 - if ( empty( $metadata['original'] ) ) {
211 - return;
234 + $source_image = $metadata['source_image'] ?? null;
235 + if ( ! is_string( $source_image ) || '' === $source_image ) {
236 + return false;
212 237 }
213 238
214 239 $attached_file = get_attached_file( $post_id, true );
215 240
216 241 if ( ! $attached_file ) {
217 - return;
242 + return false;
218 243 }
219 244
220 - $heic_path = path_join( dirname( $attached_file ), $metadata['original'] );
245 + $uploads = wp_get_upload_dir();
221 246
222 - if ( file_exists( $heic_path ) ) {
223 - wp_delete_file( $heic_path );
247 + if ( empty( $uploads['basedir'] ) ) {
248 + return false;
224 249 }
250 +
251 + $companion_path = path_join( dirname( $attached_file ), wp_basename( $source_image ) );
252 +
253 + if ( ! file_exists( $companion_path ) ) {
254 + return false;
255 + }
256 +
257 + return wp_delete_file_from_directory( $companion_path, $uploads['basedir'] );
225 258 }
226 259
227 260 add_action( 'delete_attachment', 'gutenberg_delete_heic_companion_file' );
228 261
@@ -230,19 +263,8 @@
230 263 // Everything below requires cross-origin isolation (Document-Isolation-Policy)
231 264 // and SharedArrayBuffer support, which is only available in Chromium 137+.
232 265
233 266 /**
234 - * Sets a global JS variable to indicate that client-side media processing is enabled.
235 - */
236 -function gutenberg_set_client_side_media_processing_flag() {
237 - if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
238 - return;
239 - }
240 - wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true', 'before' );
241 -}
242 -add_action( 'admin_init', 'gutenberg_set_client_side_media_processing_flag' );
243 -
244 -/**
245 267 * Filters the list of rewrite rules formatted for output to an .htaccess file.
246 268 *
247 269 * Adds support for serving wasm-vips locally.
248 270 *
@@ -299,8 +321,15 @@
299 321 if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
300 322 return;
301 323 }
302 324
325 + // Skip when rendering the classic-theme home route, which shows the site
326 + // preview in an iframe and must reach its `contentDocument` to neutralize
327 + // interactive elements — DIP would block that.
328 + if ( 'site-editor' === $screen->id && ! wp_is_block_theme() && ( ! isset( $_GET['p'] ) || '/' === $_GET['p'] ) ) {
329 + return;
330 + }
331 +
303 332 // Skip when a third-party page builder overrides the block editor.
304 333 // DIP isolates the document into its own agent cluster,
305 334 // which blocks same-origin iframe access that these editors rely on.
306 335 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
@@ -317,9 +346,9 @@
317 346 if ( ! user_can( $user_id, 'upload_files' ) ) {
318 347 return;
319 348 }
320 349
321 - gutenberg_start_cross_origin_isolation_output_buffer();
350 + gutenberg_send_document_isolation_policy_header();
322 351 }
323 352
324 353 add_action( 'load-post.php', 'gutenberg_set_up_cross_origin_isolation' );
325 354 add_action( 'load-post-new.php', 'gutenberg_set_up_cross_origin_isolation' );
@@ -335,11 +364,18 @@
335 364
336 365 /**
337 366 * Sends the Document-Isolation-Policy header for cross-origin isolation.
338 367 *
339 - * Uses an output buffer to add crossorigin="anonymous" where needed.
368 + * `isolate-and-credentialless` loads cross-origin subresources without
369 + * credentials instead of blocking them, so no `crossorigin` attribute is
370 + * needed on scripts, styles, images, audio, or video for the page to work.
371 + * Forcing `crossorigin="anonymous"` would turn those into CORS requests
372 + * and break any resource served without `Access-Control-Allow-Origin`,
373 + * such as media offloaded to a CDN.
374 + *
375 + * @return bool Whether the header was sent.
340 376 */
341 -function gutenberg_start_cross_origin_isolation_output_buffer(): void {
377 +function gutenberg_send_document_isolation_policy_header(): bool {
342 378 $chromium_version = gutenberg_get_chromium_major_version();
343 379
344 380 /**
345 381 * Filters whether to use Document-Isolation-Policy for cross-origin isolation.
@@ -357,90 +393,69 @@
357 393 null !== $chromium_version && $chromium_version >= 137
358 394 );
359 395
360 396 if ( ! $use_dip ) {
361 - return;
397 + return false;
362 398 }
363 399
364 - ob_start(
365 - function ( string $output ): string {
366 - header( 'Document-Isolation-Policy: isolate-and-credentialless' );
400 + header( 'Document-Isolation-Policy: isolate-and-credentialless' );
367 401
368 - return gutenberg_add_crossorigin_attributes( $output );
369 - }
370 - );
402 + return true;
371 403 }
372 404
373 405 /**
374 - * Adds crossorigin="anonymous" to relevant tags in the given HTML string.
406 + * Removes `crossorigin` attributes from the printed media templates.
375 407 *
376 - * @param string $html HTML input.
408 + * WordPress 7.1 forces `crossorigin="anonymous"` onto the AUDIO and VIDEO
409 + * tags inside the Backbone `<script type="text/html">` templates whenever
410 + * client-side media processing is enabled. Under
411 + * `Document-Isolation-Policy: isolate-and-credentialless` the attribute is
412 + * not needed to play cross-origin media, and it turns the load into a CORS
413 + * request that fails for media served without CORS headers, such as media
414 + * offloaded to a CDN. See https://core.trac.wordpress.org/ticket/65930.
377 415 *
378 - * @return string Modified HTML.
416 + * @param string $html The printed media templates.
417 + *
418 + * @return string Modified media templates.
379 419 */
380 -function gutenberg_add_crossorigin_attributes( string $html ): string {
381 - $site_url = site_url();
382 -
383 - $processor = new WP_HTML_Tag_Processor( $html );
384 -
385 - // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin.
386 - $tags = array(
387 - 'AUDIO' => 'src',
388 - 'LINK' => 'href',
389 - 'SCRIPT' => 'src',
390 - 'VIDEO' => 'src',
391 - 'SOURCE' => 'src',
392 - );
393 -
394 - $tag_names = array_keys( $tags );
395 -
396 - while ( $processor->next_tag() ) {
397 - $tag = $processor->get_tag();
398 -
399 - if ( ! in_array( $tag, $tag_names, true ) ) {
420 +function gutenberg_remove_media_template_crossorigin_attributes( string $html ): string {
421 + /*
422 + * The media templates are inside <script type="text/html"> tags,
423 + * whose content is treated as raw text by the HTML Tag Processor.
424 + * Extract each script block's content, process it separately,
425 + * then reassemble the full output.
426 + */
427 + $script_processor = new WP_HTML_Tag_Processor( $html );
428 + while ( $script_processor->next_tag( 'SCRIPT' ) ) {
429 + if ( 'text/html' !== $script_processor->get_attribute( 'type' ) ) {
400 430 continue;
401 431 }
402 -
403 - if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) {
404 - $processor->set_bookmark( 'audio-video-parent' );
405 - }
406 -
407 - $processor->set_bookmark( 'resume' );
408 -
409 - $sought = false;
410 -
411 - $crossorigin = $processor->get_attribute( 'crossorigin' );
412 -
413 - $url = $processor->get_attribute( $tags[ $tag ] );
414 -
415 - if ( is_string( $url ) && ! str_starts_with( $url, $site_url ) && ! str_starts_with( $url, '/' ) && ! is_string( $crossorigin ) ) {
416 - if ( 'SOURCE' === $tag ) {
417 - $sought = $processor->seek( 'audio-video-parent' );
418 -
419 - if ( $sought ) {
420 - $processor->set_attribute( 'crossorigin', 'anonymous' );
421 - }
422 - } else {
423 - $processor->set_attribute( 'crossorigin', 'anonymous' );
432 + $template_processor = new WP_HTML_Tag_Processor( $script_processor->get_modifiable_text() );
433 + while ( $template_processor->next_tag() ) {
434 + if (
435 + in_array( $template_processor->get_tag(), array( 'AUDIO', 'IMG', 'VIDEO' ), true )
436 + && 'anonymous' === $template_processor->get_attribute( 'crossorigin' )
437 + ) {
438 + $template_processor->remove_attribute( 'crossorigin' );
424 439 }
425 -
426 - if ( $sought ) {
427 - $processor->seek( 'resume' );
428 - $processor->release_bookmark( 'audio-video-parent' );
429 - }
430 440 }
441 + $script_processor->set_modifiable_text( $template_processor->get_updated_html() );
431 442 }
432 443
433 - return $processor->get_updated_html();
444 + return $script_processor->get_updated_html();
434 445 }
435 446
436 447 /**
437 448 * Overrides templates from wp_print_media_templates with custom ones.
438 449 *
439 - * Adds `crossorigin` attribute to all tags that
440 - * could have assets loaded from a different domain.
450 + * Only needed on WordPress 7.1, the one release whose
451 + * `wp_print_media_templates()` injects `crossorigin="anonymous"` itself.
441 452 */
442 453 function gutenberg_override_media_templates(): void {
454 + if ( ! function_exists( 'wp_add_crossorigin_attributes' ) || function_exists( 'wp_send_document_isolation_policy_header' ) ) {
455 + return;
456 + }
457 +
443 458 remove_action( 'admin_footer', 'wp_print_media_templates' );
444 459 add_action(
445 460 'admin_footer',
446 461 static function (): void {
@@ -447,19 +462,9 @@
447 462 ob_start();
448 463 wp_print_media_templates();
449 464 $html = (string) ob_get_clean();
450 465
451 - $tags = array(
452 - 'audio',
453 - 'img',
454 - 'video',
455 - );
456 -
457 - foreach ( $tags as $tag ) {
458 - $html = (string) str_replace( "<$tag", "<$tag crossorigin=\"anonymous\"", $html );
459 - }
460 -
461 - echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
466 + echo gutenberg_remove_media_template_crossorigin_attributes( $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
462 467 }
463 468 );
464 469 }
465 470