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 +203 -163 23.0.1 → trunk View file →
@@ -1,112 +1,52 @@
1 1 <?php
2 2 /**
3 3 * Adds media-related functionality for client-side media processing.
4 4 *
5 + * This file is structured in two tiers:
6 + *
7 + * 1. HEIC infrastructure — loaded whenever the feature filter is enabled.
8 + * Browsers like Safari can decode HEIC via createImageBitmap() even
9 + * without VIPS/SharedArrayBuffer, so HEIC MIME types, the custom REST
10 + * controller, and REST field/index registrations are always needed.
11 + *
12 + * 2. Full VIPS/WASM processing — loaded only when the feature filter is
13 + * enabled AND requires cross-origin isolation (DIP) at runtime.
14 + *
5 15 * @package gutenberg
6 16 */
7 17
8 -// Client-side media processing is currently plugin-only while the feature matures.
9 -if ( ! defined( 'IS_GUTENBERG_PLUGIN' ) || ! IS_GUTENBERG_PLUGIN ) {
10 - return;
11 -}
12 -
13 18 if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
14 19 return;
15 20 }
16 21
17 -/**
18 - * Sets a global JS variable to indicate that client-side media processing is enabled.
19 - */
20 -function gutenberg_set_client_side_media_processing_flag() {
21 - if ( ! gutenberg_is_client_side_media_processing_enabled() ) {
22 - return;
23 - }
24 - wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true', 'before' );
25 -}
26 -add_action( 'admin_init', 'gutenberg_set_client_side_media_processing_flag' );
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';
27 27
28 -/**
29 - * Returns a list of all available image sizes.
30 - *
31 - * @return array Existing image sizes.
32 - */
33 -function gutenberg_get_all_image_sizes(): array {
34 - $sizes = wp_get_registered_image_subsizes();
28 +// ── Tier 1: HEIC infrastructure (always loaded) ─────────────────────
35 29
36 - foreach ( $sizes as $name => &$size ) {
37 - $size['height'] = (int) $size['height'];
38 - $size['width'] = (int) $size['width'];
39 - $size['name'] = $name;
40 - }
41 - unset( $size );
42 -
43 - return $sizes;
44 -}
45 -
46 30 /**
47 - * Returns the default output format mapping for the supported image formats.
31 + * Registers HEIC/HEIF as allowed upload MIME types.
48 32 *
49 - * @return array<string,string> Map of input formats to output formats.
50 - */
51 -function gutenberg_get_default_image_output_formats() {
52 - $input_formats = array(
53 - 'image/jpeg',
54 - 'image/png',
55 - 'image/gif',
56 - 'image/webp',
57 - 'image/avif',
58 - 'image/heic',
59 - );
60 -
61 - $output_formats = array();
62 -
63 - foreach ( $input_formats as $mime_type ) {
64 - /** This filter is documented in wp-includes/media.php */
65 - $output_formats = apply_filters(
66 - 'image_editor_output_format', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
67 - $output_formats,
68 - '',
69 - $mime_type
70 - );
71 - }
72 -
73 - return $output_formats;
74 -}
75 -
76 -/**
77 - * Filters the REST API root index data to add custom settings.
33 + * HEIC images can be decoded in the browser (via canvas/VideoDecoder).
34 + * Registering these MIME types ensures the file picker's accept attribute
35 + * includes them, preventing macOS from silently converting HEIC to JPEG
36 + * on selection.
78 37 *
79 - * @param WP_REST_Response $response Response data.
38 + * @param array $mimes Allowed MIME types (extension => type).
39 + * @return array Modified MIME types.
80 40 */
81 -function gutenberg_media_processing_filter_rest_index( WP_REST_Response $response ) {
82 - /** This filter is documented in wp-admin/includes/images.php */
83 - $image_size_threshold = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
84 -
85 - $default_image_output_formats = gutenberg_get_default_image_output_formats();
86 -
87 - /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
88 - $jpeg_interlaced = (bool) apply_filters( 'image_save_progressive', false, 'image/jpeg' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
89 - /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
90 - $png_interlaced = (bool) apply_filters( 'image_save_progressive', false, 'image/png' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
91 - /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
92 - $gif_interlaced = (bool) apply_filters( 'image_save_progressive', false, 'image/gif' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
93 -
94 - if ( current_user_can( 'upload_files' ) ) {
95 - $response->data['image_sizes'] = gutenberg_get_all_image_sizes();
96 - $response->data['image_size_threshold'] = $image_size_threshold;
97 - $response->data['image_output_formats'] = (object) $default_image_output_formats;
98 - $response->data['jpeg_interlaced'] = $jpeg_interlaced;
99 - $response->data['png_interlaced'] = $png_interlaced;
100 - $response->data['gif_interlaced'] = $gif_interlaced;
101 - }
102 -
103 - return $response;
41 +function gutenberg_add_heic_upload_mimes( array $mimes ): array {
42 + $mimes['heic'] = 'image/heic';
43 + $mimes['heif'] = 'image/heif';
44 + return $mimes;
104 45 }
105 46
106 -add_filter( 'rest_index', 'gutenberg_media_processing_filter_rest_index' );
47 +add_filter( 'upload_mimes', 'gutenberg_add_heic_upload_mimes' );
107 48
108 -
109 49 /**
110 50 * Overrides the REST controller for the attachment post type.
111 51 *
112 52 * @param array $args Array of arguments for registering a post type.
@@ -124,9 +64,8 @@
124 64 }
125 65
126 66 add_filter( 'register_post_type_args', 'gutenberg_filter_attachment_post_type_args', 10, 2 );
127 67
128 -
129 68 /**
130 69 * Registers additional REST fields for attachments.
131 70 */
132 71 function gutenberg_media_processing_register_rest_fields(): void {
@@ -206,8 +145,126 @@
206 145 return null;
207 146 }
208 147
209 148 /**
149 + * Returns a list of all available image sizes.
150 + *
151 + * @return array Existing image sizes.
152 + */
153 +function gutenberg_get_all_image_sizes(): array {
154 + $sizes = wp_get_registered_image_subsizes();
155 +
156 + foreach ( $sizes as $name => &$size ) {
157 + $size['height'] = (int) $size['height'];
158 + $size['width'] = (int) $size['width'];
159 + $size['name'] = $name;
160 + }
161 + unset( $size );
162 +
163 + return $sizes;
164 +}
165 +
166 +/**
167 + * Filters the REST API root index data to add custom settings.
168 + *
169 + * @param WP_REST_Response $response Response data.
170 + */
171 +function gutenberg_media_processing_filter_rest_index( WP_REST_Response $response ) {
172 + /** This filter is documented in wp-admin/includes/image.php */
173 + $image_size_threshold = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
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 +
189 + if ( current_user_can( 'upload_files' ) ) {
190 + $response->data['image_sizes'] = gutenberg_get_all_image_sizes();
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;
194 + }
195 +
196 + return $response;
197 +}
198 +
199 +add_filter( 'rest_index', 'gutenberg_media_processing_filter_rest_index' );
200 +
201 +/**
202 + * Sets a global JS variable to indicate that client-side media processing is enabled.
203 + *
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.
208 + */
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' );
216 +}
217 +add_action( 'admin_init', 'gutenberg_set_client_side_media_processing_flag' );
218 +
219 +/**
220 + * Deletes the source-format companion file when its attachment is deleted.
221 + *
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.
227 + *
228 + * @param int $post_id Attachment ID being deleted.
229 + * @return bool Whether a companion file was deleted.
230 + */
231 +function gutenberg_delete_heic_companion_file( int $post_id ): bool {
232 + $metadata = wp_get_attachment_metadata( $post_id, true );
233 +
234 + $source_image = $metadata['source_image'] ?? null;
235 + if ( ! is_string( $source_image ) || '' === $source_image ) {
236 + return false;
237 + }
238 +
239 + $attached_file = get_attached_file( $post_id, true );
240 +
241 + if ( ! $attached_file ) {
242 + return false;
243 + }
244 +
245 + $uploads = wp_get_upload_dir();
246 +
247 + if ( empty( $uploads['basedir'] ) ) {
248 + return false;
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'] );
258 +}
259 +
260 +add_action( 'delete_attachment', 'gutenberg_delete_heic_companion_file' );
261 +
262 +// ── Tier 2: Full client-side processing (VIPS/WASM) ─────────────────
263 +// Everything below requires cross-origin isolation (Document-Isolation-Policy)
264 +// and SharedArrayBuffer support, which is only available in Chromium 137+.
265 +
266 +/**
210 267 * Filters the list of rewrite rules formatted for output to an .htaccess file.
211 268 *
212 269 * Adds support for serving wasm-vips locally.
213 270 *
@@ -264,8 +321,15 @@
264 321 if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
265 322 return;
266 323 }
267 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 +
268 332 // Skip when a third-party page builder overrides the block editor.
269 333 // DIP isolates the document into its own agent cluster,
270 334 // which blocks same-origin iframe access that these editors rely on.
271 335 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
@@ -282,9 +346,9 @@
282 346 if ( ! user_can( $user_id, 'upload_files' ) ) {
283 347 return;
284 348 }
285 349
286 - gutenberg_start_cross_origin_isolation_output_buffer();
350 + gutenberg_send_document_isolation_policy_header();
287 351 }
288 352
289 353 add_action( 'load-post.php', 'gutenberg_set_up_cross_origin_isolation' );
290 354 add_action( 'load-post-new.php', 'gutenberg_set_up_cross_origin_isolation' );
@@ -300,11 +364,18 @@
300 364
301 365 /**
302 366 * Sends the Document-Isolation-Policy header for cross-origin isolation.
303 367 *
304 - * 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.
305 376 */
306 -function gutenberg_start_cross_origin_isolation_output_buffer(): void {
377 +function gutenberg_send_document_isolation_policy_header(): bool {
307 378 $chromium_version = gutenberg_get_chromium_major_version();
308 379
309 380 /**
310 381 * Filters whether to use Document-Isolation-Policy for cross-origin isolation.
@@ -322,90 +393,69 @@
322 393 null !== $chromium_version && $chromium_version >= 137
323 394 );
324 395
325 396 if ( ! $use_dip ) {
326 - return;
397 + return false;
327 398 }
328 399
329 - ob_start(
330 - function ( string $output ): string {
331 - header( 'Document-Isolation-Policy: isolate-and-credentialless' );
400 + header( 'Document-Isolation-Policy: isolate-and-credentialless' );
332 401
333 - return gutenberg_add_crossorigin_attributes( $output );
334 - }
335 - );
402 + return true;
336 403 }
337 404
338 405 /**
339 - * Adds crossorigin="anonymous" to relevant tags in the given HTML string.
406 + * Removes `crossorigin` attributes from the printed media templates.
340 407 *
341 - * @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.
342 415 *
343 - * @return string Modified HTML.
416 + * @param string $html The printed media templates.
417 + *
418 + * @return string Modified media templates.
344 419 */
345 -function gutenberg_add_crossorigin_attributes( string $html ): string {
346 - $site_url = site_url();
347 -
348 - $processor = new WP_HTML_Tag_Processor( $html );
349 -
350 - // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin.
351 - $tags = array(
352 - 'AUDIO' => 'src',
353 - 'LINK' => 'href',
354 - 'SCRIPT' => 'src',
355 - 'VIDEO' => 'src',
356 - 'SOURCE' => 'src',
357 - );
358 -
359 - $tag_names = array_keys( $tags );
360 -
361 - while ( $processor->next_tag() ) {
362 - $tag = $processor->get_tag();
363 -
364 - 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' ) ) {
365 430 continue;
366 431 }
367 -
368 - if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) {
369 - $processor->set_bookmark( 'audio-video-parent' );
370 - }
371 -
372 - $processor->set_bookmark( 'resume' );
373 -
374 - $sought = false;
375 -
376 - $crossorigin = $processor->get_attribute( 'crossorigin' );
377 -
378 - $url = $processor->get_attribute( $tags[ $tag ] );
379 -
380 - if ( is_string( $url ) && ! str_starts_with( $url, $site_url ) && ! str_starts_with( $url, '/' ) && ! is_string( $crossorigin ) ) {
381 - if ( 'SOURCE' === $tag ) {
382 - $sought = $processor->seek( 'audio-video-parent' );
383 -
384 - if ( $sought ) {
385 - $processor->set_attribute( 'crossorigin', 'anonymous' );
386 - }
387 - } else {
388 - $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' );
389 439 }
390 -
391 - if ( $sought ) {
392 - $processor->seek( 'resume' );
393 - $processor->release_bookmark( 'audio-video-parent' );
394 - }
395 440 }
441 + $script_processor->set_modifiable_text( $template_processor->get_updated_html() );
396 442 }
397 443
398 - return $processor->get_updated_html();
444 + return $script_processor->get_updated_html();
399 445 }
400 446
401 447 /**
402 448 * Overrides templates from wp_print_media_templates with custom ones.
403 449 *
404 - * Adds `crossorigin` attribute to all tags that
405 - * 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.
406 452 */
407 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 +
408 458 remove_action( 'admin_footer', 'wp_print_media_templates' );
409 459 add_action(
410 460 'admin_footer',
411 461 static function (): void {
@@ -412,19 +462,9 @@
412 462 ob_start();
413 463 wp_print_media_templates();
414 464 $html = (string) ob_get_clean();
415 465
416 - $tags = array(
417 - 'audio',
418 - 'img',
419 - 'video',
420 - );
421 -
422 - foreach ( $tags as $tag ) {
423 - $html = (string) str_replace( "<$tag", "<$tag crossorigin=\"anonymous\"", $html );
424 - }
425 -
426 - echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
466 + echo gutenberg_remove_media_template_crossorigin_attributes( $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
427 467 }
428 468 );
429 469 }
430 470