Commit 928a811611 for wordpress.org
commit 928a8116112f227943a520f88732305077e8fab4
Author: adamsilverstein <adamsilverstein@git.wordpress.org>
Date: Tue Oct 20 05:03:59 2026 +0000
Media: Enable client-side uploads in the Media Library and modal.
Enable client-side media processing for Uploads from the Media Library grid, the Add New Media File screen and the media modal in the block editor (in supported browsers).
These screens now use the same pipeline as block editor uploads: the original is uploaded through the REST API, sub-sizes are generated in the browser and sideloaded, then the attachment is finalized. `wp_set_up_cross_origin_isolation()` now also isolates the Media Library grid and the Add New Media File screen using Document Isolation Policy. Uploads huse the client-side processing still use the existing progress, tile and error UI.
When client-side processing is disabled, uploads fall back to the classic flow unchanged. Audio files, uploaders that do not post attachments, and JPEG/PNG files while plupload resizing is enabled also stay on the classic path, and a HEIC the browser cannot decode is handed to the server when its image editor supports HEIC. The Customizer, Site Icon picker and classic editor are not isolated and keep classic uploads.
See https://github.com/WordPress/gutenberg/issues/82409.
Props adamsilverstein, westonruter, andrewserong, sanayasir, arkaprabhachowdhury, logicrays.
Fixes #65661. See #66220.
Built from https://develop.svn.wordpress.org/trunk@64588
git-svn-id: http://core.svn.wordpress.org/trunk@63738 1a063a9b-81f0-0310-95a4-ce76da25c4cd
diff --git a/wp-admin/js/media-frame-upload.js b/wp-admin/js/media-frame-upload.js
new file mode 100644
index 0000000000..f5af17d799
--- /dev/null
+++ b/wp-admin/js/media-frame-upload.js
@@ -0,0 +1,271 @@
+/**
+ * Routes media frame uploads through the client-side media pipeline.
+ *
+ * Every wp.media frame - the Media Library grid on wp-admin/upload.php and
+ * the media modal the block editor, the site editor and the block widgets
+ * screen open - uploads via wp.Uploader / plupload to async-upload.php.
+ * When the browser is cross-origin isolated and supports the client-side
+ * pipeline, this script intercepts the uploader's FilesAdded handler and
+ * hands files to wp.mediaUploadPipeline (media-upload-pipeline.js)
+ * instead: the original image is uploaded via the REST API and thumbnails
+ * are generated in the browser (wasm-vips), then sideloaded and finalized.
+ *
+ * The frame's own UI is reused: the same placeholder tiles, progress bars,
+ * "Uploading n/m" status, and error list that wp-plupload.js drives.
+ *
+ * When client-side support is unavailable the script cleanly no-ops and
+ * the classic plupload flow is left untouched.
+ *
+ * @output wp-admin/js/media-frame-upload.js
+ */
+
+/* global plupload */
+
+( function () {
+ // Guard against double execution (e.g. duplicate enqueues).
+ if ( window.__wpMediaFrameUpload ) {
+ return;
+ }
+
+ const pipeline = window.wp && wp.mediaUploadPipeline;
+
+ // Bail unless the browser actually supports client-side processing. This
+ // is the clean no-op: when the isolation headers did not land, classic
+ // plupload keeps handling uploads.
+ if (
+ ! pipeline ||
+ typeof plupload === 'undefined' ||
+ ! wp.Uploader ||
+ ! wp.media ||
+ ! pipeline.configure()
+ ) {
+ return;
+ }
+
+ window.__wpMediaFrameUpload = true;
+
+ /**
+ * Resets the upload queue once every attachment has finished uploading.
+ *
+ * Parity with wp-plupload.js so browse mode flips back when done.
+ */
+ function maybeResetQueue() {
+ const complete = wp.Uploader.queue.all( function (
+ /** @type {WPAttachment} */ attachment
+ ) {
+ return ! attachment.get( 'uploading' );
+ } );
+
+ if ( complete ) {
+ wp.Uploader.queue.reset();
+ }
+ }
+
+ /**
+ * Handles a completed upload by syncing the grid tile with the server data.
+ *
+ * @param {WPUploader} wpUploader The wp.Uploader instance that queued the file.
+ * @param {WPAttachment} model The placeholder Attachment model.
+ * @param {PipelineAttachment} attachment The finalized attachment from the pipeline.
+ */
+ function handleSuccess( wpUploader, model, attachment ) {
+ // Not silent: Attachments re-keys a model from its cid to its id when
+ // it sees the change event. Without that the collection cannot find the
+ // attachment by id, and refreshing the library duplicates its tile.
+ model.set( { id: attachment.id } );
+
+ // Register the model in Attachments.all (parity with wp-plupload.js).
+ wp.media.model.Attachment.get( attachment.id, model );
+
+ // Clear the uploading state before the refetch lands, not after. The
+ // attachment details view re-renders on the refetch's changes, so it has
+ // to see `uploading: false` by then or it keeps showing a progress bar.
+ // wp-plupload.js sets both in one call for the same reason.
+ [ 'file', 'loaded', 'size', 'percent' ].forEach( function ( key ) {
+ model.unset( key, { silent: true } );
+ } );
+ model.set( { uploading: false }, { silent: true } );
+
+ model
+ .fetch()
+ .fail( function () {
+ // Fetch failed, but the upload succeeded: fill the tile with what
+ // the pipeline gave us so it is not left empty.
+ model.set( attachment );
+ } )
+ .always( function () {
+ maybeResetQueue();
+
+ // Parity with wp-plupload.js, which exposes this callback so
+ // other code can react to a finished upload.
+ wpUploader.success( model );
+ } );
+ }
+
+ /**
+ * Handles an upload error by removing the tile and surfacing the message.
+ *
+ * The error goes into wp.Uploader.errors exactly like a classic upload
+ * error, so the grid's error sidebar renders it, announces it, and
+ * moves focus to its Dismiss button.
+ *
+ * @param {WPUploader} wpUploader The wp.Uploader instance that queued the file.
+ * @param {WPAttachment} model The placeholder Attachment model.
+ * @param {UploadError} error The upload error.
+ * @param {string} fileName Name of the file (for the error label).
+ */
+ function handleError( wpUploader, model, error, fileName ) {
+ const message = pipeline.getErrorText( error, fileName );
+ const file = { name: fileName };
+
+ model.destroy();
+
+ wp.Uploader.errors.unshift( {
+ message: message,
+ data: {},
+ file: file,
+ } );
+
+ maybeResetQueue();
+
+ // Parity with wp-plupload.js, which exposes this callback so other
+ // code can react to a failed upload.
+ wpUploader.error( message, {}, file );
+ }
+
+ /**
+ * Whether a plupload uploader posts attachments the way wp.Uploader does
+ * by default.
+ *
+ * The init wrapper below reaches every wp.Uploader built on the page,
+ * and a plugin can build one for its own purposes. Core's defaults tell
+ * an attachment upload apart: it posts to async-upload.php with the
+ * `upload-attachment` action. Anything a plugin pointed elsewhere, or
+ * gave another action, is uploading something else and is left alone.
+ *
+ * @param {plupload.Uploader} up The plupload uploader instance.
+ * @return {boolean} True when the uploader posts attachments.
+ */
+ function isAttachmentUploader( up ) {
+ const settings = up.settings || {};
+ const params = settings.multipart_params || {};
+ const url = new URL( settings.url || '', location.href );
+
+ return (
+ 'upload-attachment' === params.action &&
+ url.pathname.endsWith( 'async-upload.php' )
+ );
+ }
+
+ /**
+ * Intercepts files added to a plupload uploader.
+ *
+ * Builds the same placeholder tiles as wp-plupload for the files the
+ * pipeline takes (see wp.mediaUploadPipeline.routeBatch()). Returns
+ * undefined to leave the whole batch to plupload's built-in handler.
+ *
+ * @param {WPUploader} wpUploader The wp.Uploader instance.
+ * @param {plupload.Uploader} up The plupload uploader instance.
+ * @param {plupload.File[]} files Files added to the queue.
+ * @return {boolean|undefined} False to suppress the built-in handler.
+ */
+ function handleFilesAdded( wpUploader, up, files ) {
+ if ( ! isAttachmentUploader( up ) ) {
+ return;
+ }
+
+ // The classic flow attaches to `post_id` the same way, and rejects
+ // a post the user cannot edit just as the REST API does.
+ const params = ( up.settings && up.settings.multipart_params ) || {};
+
+ return pipeline.routeBatch( up, files, {
+ postId: parseInt( params.post_id, 10 ) || 0,
+ onQueue: function (
+ /** @type {plupload.File} */ file,
+ /** @type {File} */ nativeFile
+ ) {
+ // Build the same placeholder attributes as wp-plupload.js so
+ // the grid's progress tiles and "Uploading n/m" status work
+ // unchanged.
+ /** @type {PlaceholderAttributes} */
+ const attributes = {
+ file: file,
+ uploading: true,
+ date: new Date(),
+ filename: file.name,
+ menuOrder: 0,
+ uploadedTo: wp.media.model.settings.post.id,
+ loaded: file.loaded,
+ size: file.size,
+ percent: file.percent,
+ };
+
+ /*
+ * Early mime type scanning for images, as wp-plupload.js does,
+ * extended with the formats the client-side pipeline accepts.
+ */
+ const image = /(?:jpe?g|png|gif|webp|avif|heic|heif)$/i.exec(
+ file.name
+ );
+ if ( image ) {
+ attributes.type = 'image';
+ // `jpg` is not a valid subtype, so map it to `jpeg`.
+ const subtype = image[ 0 ].toLowerCase();
+ attributes.subtype = 'jpg' === subtype ? 'jpeg' : subtype;
+ }
+
+ const model = wp.media.model.Attachment.create( attributes );
+ wp.Uploader.queue.add( model );
+ wpUploader.added( model );
+
+ return {
+ onSuccess: function (
+ /** @type {PipelineAttachment} */ attachment
+ ) {
+ handleSuccess( wpUploader, model, attachment );
+ },
+ onError: function ( /** @type {UploadError} */ error ) {
+ handleError( wpUploader, model, error, nativeFile.name );
+ },
+ // plupload builds its own tile for the file it took over.
+ onHandOff: function () {
+ model.destroy();
+ },
+ onProgress: function ( /** @type {number} */ percent ) {
+ model.set( { percent: percent } );
+ wpUploader.progress( model );
+ },
+ };
+ },
+ } );
+ }
+
+ // Wrap wp.Uploader.prototype.init (an empty stub called once per instance
+ // after plupload is initialized) to bind a higher-priority FilesAdded
+ // handler on every uploader instance: the grid's, and the one each media
+ // modal creates when it opens.
+ const originalInit = wp.Uploader.prototype.init;
+ wp.Uploader.prototype.init = function () {
+ originalInit.apply( this, arguments );
+
+ const wpUploader = /** @type {WPUploader} */ ( this );
+ const up = /** @type {plupload.Uploader|undefined} */ ( this.uploader );
+
+ if ( ! up || up.__wpMediaFrameUploadBound ) {
+ return;
+ }
+ up.__wpMediaFrameUploadBound = true;
+
+ // plupload sorts handlers by priority (descending) and a `false`
+ // return breaks the chain, so priority 100 runs before and suppresses
+ // the built-in FilesAdded handler.
+ up.bind(
+ 'FilesAdded',
+ function ( uploader, files ) {
+ return handleFilesAdded( wpUploader, uploader, files );
+ },
+ this,
+ 100
+ );
+ };
+} )();
diff --git a/wp-admin/js/media-frame-upload.min.js b/wp-admin/js/media-frame-upload.min.js
new file mode 100644
index 0000000000..f119011f5f
--- /dev/null
+++ b/wp-admin/js/media-frame-upload.min.js
@@ -0,0 +1,2 @@
+/*! This file is auto-generated */
+!function(){if(!window.__wpMediaFrameUpload){const r=window.wp&&wp.mediaUploadPipeline;if(r&&"undefined"!=typeof plupload&&wp.Uploader&&wp.media&&r.configure()){window.__wpMediaFrameUpload=!0;const t=wp.Uploader.prototype.init;function d(){wp.Uploader.queue.all(function(e){return!e.get("uploading")})&&wp.Uploader.queue.reset()}function a(p,e,t){var n,a;if(a=(n=(n=e).settings||{}).multipart_params||{},n=new URL(n.url||"",location.href),"upload-attachment"===a.action&&n.pathname.endsWith("async-upload.php"))return a=e.settings&&e.settings.multipart_params||{},r.routeBatch(e,t,{postId:parseInt(a.post_id,10)||0,onQueue:function(e,o){var t={file:e,uploading:!0,date:new Date,filename:e.name,menuOrder:0,uploadedTo:wp.media.model.settings.post.id,loaded:e.loaded,size:e.size,percent:e.percent},e=/(?:jpe?g|png|gif|webp|avif|heic|heif)$/i.exec(e.name);e&&(t.type="image",e=e[0].toLowerCase(),t.subtype="jpg"===e?"jpeg":e);const i=wp.media.model.Attachment.create(t);return wp.Uploader.queue.add(i),p.added(i),{onSuccess:function(e){var t,n,a;t=p,n=i,a=e,n.set({id:a.id}),wp.media.model.Attachment.get(a.id,n),["file","loaded","size","percent"].forEach(function(e){n.unset(e,{silent:!0})}),n.set({uploading:!1},{silent:!0}),n.fetch().fail(function(){n.set(a)}).always(function(){d(),t.success(n)})},onError:function(e){var t,n,a;t=p,n=i,e=e,a=o.name,e=r.getErrorText(e,a),a={name:a},n.destroy(),wp.Uploader.errors.unshift({message:e,data:{},file:a}),d(),t.error(e,{},a)},onHandOff:function(){i.destroy()},onProgress:function(e){i.set({percent:e}),p.progress(i)}}}})}wp.Uploader.prototype.init=function(){t.apply(this,arguments);const n=this;var e=this.uploader;e&&!e.__wpMediaFrameUploadBound&&(e.__wpMediaFrameUploadBound=!0,e.bind("FilesAdded",function(e,t){return a(n,e,t)},this,100))}}}}();
\ No newline at end of file
diff --git a/wp-admin/js/media-new-upload.js b/wp-admin/js/media-new-upload.js
new file mode 100644
index 0000000000..e48c6900b8
--- /dev/null
+++ b/wp-admin/js/media-new-upload.js
@@ -0,0 +1,339 @@
+/**
+ * Routes "Add New Media File" uploads through the client-side media pipeline.
+ *
+ * On wp-admin/media-new.php WordPress uploads via a raw plupload.Uploader
+ * (created by plupload-handlers) posting to async-upload.php. When the
+ * browser is cross-origin isolated and supports the client-side pipeline,
+ * this script intercepts the uploader's FilesAdded handler and hands files
+ * to wp.mediaUploadPipeline (media-upload-pipeline.js) instead: the
+ * original image is uploaded via the REST API and thumbnails are generated
+ * in the browser (wasm-vips), then sideloaded and finalized.
+ *
+ * The screen's existing UI helpers from plupload-handlers are reused:
+ * fileQueued() builds the progress item, uploadSuccess() renders the
+ * finished attachment row (via the async-upload.php markup endpoint), and
+ * uploadComplete() runs when the queue drains. Failed uploads render the
+ * same notice async-upload.php returns for a server-side failure, so the
+ * screen looks and behaves unchanged.
+ *
+ * When client-side support is unavailable the script cleanly no-ops and
+ * the classic plupload flow is left untouched.
+ *
+ * @output wp-admin/js/media-new-upload.js
+ */
+
+/* global plupload, pluploadL10n, wpUploaderInit, fileQueued, uploadStart, uploadSuccess, uploadComplete */
+
+( function () {
+ // Guard against double execution (e.g. duplicate enqueues).
+ if ( window.__wpMediaNewUpload ) {
+ return;
+ }
+
+ const pipeline = window.wp && wp.mediaUploadPipeline;
+
+ // Bail unless the browser actually supports client-side processing. This
+ // is the clean no-op: when the isolation headers did not land, classic
+ // plupload keeps handling uploads.
+ if (
+ ! pipeline ||
+ typeof plupload === 'undefined' ||
+ ! wp.a11y ||
+ ! pipeline.configure()
+ ) {
+ return;
+ }
+
+ window.__wpMediaNewUpload = true;
+
+ const __ = wp.i18n.__;
+ const sprintf = wp.i18n.sprintf;
+
+ // Number of pipeline uploads currently in flight, used to fire
+ // uploadComplete() once both the pipeline and plupload are done.
+ let inFlightCount = 0;
+
+ /**
+ * Marks one pipeline upload as finished, firing uploadComplete() when
+ * nothing is left uploading.
+ *
+ * The built-in UploadComplete binding never fires for pipeline uploads
+ * because every file is removed from plupload before its queue starts.
+ * Files plupload is still uploading (a batch's classic share, a HEIC
+ * handed back) end with plupload's own UploadComplete instead.
+ *
+ * @param {plupload.Uploader} up The plupload uploader.
+ */
+ function finishUpload( up ) {
+ inFlightCount--;
+ if ( inFlightCount === 0 && up.state !== plupload.STARTED ) {
+ uploadComplete();
+ }
+ }
+
+ /**
+ * Returns the post the screen attaches uploads to, or 0.
+ *
+ * media-new.php validates `post_id` (the post must exist and be
+ * editable by the user) before printing it into the form, whereas the
+ * raw request value is what plupload carries in its multipart params.
+ * async-upload.php re-validates that raw value and silently uploads
+ * unattached; the REST API rejects it instead, so read the validated one.
+ *
+ * @return {number} The parent post ID, or 0.
+ */
+ function getParentPostId() {
+ const input = /** @type {HTMLInputElement|null} */ (
+ document.getElementById( 'post_id' )
+ );
+ const postId = input ? parseInt( input.value, 10 ) : 0;
+ return postId > 0 ? postId : 0;
+ }
+
+ /**
+ * Returns the screen's progress item for a plupload file.
+ *
+ * @param {plupload.File} file The plupload file.
+ * @return {HTMLElement|null} The item fileQueued() built, if it is still on the page.
+ */
+ function getItem( file ) {
+ return document.getElementById( 'media-item-' + file.id );
+ }
+
+ /**
+ * Renders a failed upload the way async-upload.php does for a
+ * server-side failure: an error notice with a real Dismiss button
+ * described by the notice, a screen reader announcement, and focus
+ * returned to the browse button once dismissed.
+ *
+ * @param {plupload.File} file The plupload file that failed.
+ * @param {string} message The reason the upload failed.
+ */
+ function renderError( file, message ) {
+ const item = getItem( file );
+ if ( ! item ) {
+ return;
+ }
+
+ const descriptionId = 'error-description-' + file.id;
+
+ const button = document.createElement( 'button' );
+ button.type = 'button';
+ button.id = 'dismiss-' + file.id;
+ button.className = 'dismiss button-link';
+ button.setAttribute( 'aria-describedby', descriptionId );
+ button.textContent = pluploadL10n.dismiss;
+
+ const heading = document.createElement( 'strong' );
+ heading.textContent = pluploadL10n.error_uploading.replace(
+ '%s',
+ file.name
+ );
+
+ const notice = document.createElement( 'div' );
+ notice.id = descriptionId;
+ notice.className = 'notice notice-error error-div error';
+ notice.append(
+ button,
+ ' ',
+ heading,
+ document.createElement( 'br' ),
+ message
+ );
+
+ item.replaceChildren( notice );
+ // Read by itemAjaxError() in plupload-handlers so a later server
+ // error for the same file is not rendered twice.
+ item.dataset.lastErr = file.id;
+
+ setTimeout( function () {
+ wp.a11y.speak(
+ sprintf(
+ /* translators: %s: Name of the file that failed to upload. */
+ __( '%s has failed to upload.' ),
+ file.name
+ )
+ );
+ }, 1500 );
+
+ button.addEventListener( 'click', function () {
+ item.remove();
+ wp.a11y.speak( __( 'Error dismissed.' ) );
+ const browseButton = document.getElementById(
+ 'plupload-browse-button'
+ );
+ if ( browseButton ) {
+ browseButton.focus();
+ }
+ } );
+ }
+
+ /**
+ * Reflects pipeline progress onto the screen's progress bar for a file.
+ *
+ * The bar is 200px wide at 100%, matching uploadProgress() in
+ * plupload-handlers.
+ *
+ * @param {plupload.File} file The plupload file being uploaded.
+ * @param {number} percent Progress percentage.
+ */
+ function renderProgress( file, percent ) {
+ const item = getItem( file );
+ if ( ! item ) {
+ return;
+ }
+
+ const bar = /** @type {HTMLElement|null} */ (
+ item.querySelector( '.bar' )
+ );
+ if ( bar ) {
+ bar.style.width = 2 * percent + 'px';
+ }
+
+ const label = item.querySelector( '.percent' );
+ if ( label ) {
+ label.textContent = percent + '%';
+ }
+ }
+
+ /**
+ * Intercepts files added to the plupload uploader.
+ *
+ * Builds the screen's progress items for the files the pipeline takes
+ * (see wp.mediaUploadPipeline.routeBatch()). Returns undefined to leave
+ * the whole batch to plupload's built-in handler.
+ *
+ * @param {plupload.Uploader} up The plupload uploader instance.
+ * @param {plupload.File[]} files Files added to the queue.
+ * @return {boolean|undefined} False to suppress the built-in handler.
+ */
+ function handleFilesAdded( up, files ) {
+ return pipeline.routeBatch( up, files, {
+ // Without the parent post a file uploaded from
+ // media-new.php?post_id=N would land unattached even though the
+ // screen counts it against that post.
+ postId: getParentPostId(),
+ onStart: function ( /** @type {plupload.File[]} */ queued ) {
+ // Parity with the built-in handler: clear stale queue errors
+ // and run the shared upload-start housekeeping.
+ const queueErrors = document.getElementById( 'media-upload-error' );
+ if ( queueErrors ) {
+ queueErrors.replaceChildren();
+ }
+ uploadStart();
+
+ // Count the whole batch first: addItems() can reject a file
+ // synchronously, which must not drain the count mid-batch.
+ inFlightCount += queued.length;
+ },
+ onQueue: function (
+ /** @type {plupload.File} */ file,
+ /** @type {File} */ nativeFile
+ ) {
+ // Build the screen's progress item for this file.
+ fileQueued( file );
+
+ return {
+ onSuccess: function (
+ /** @type {PipelineAttachment} */ attachment
+ ) {
+ // uploadSuccess() renders the finished attachment row
+ // via the existing async-upload.php markup endpoint;
+ // the server normally returns the ID as a string.
+ uploadSuccess( file, String( attachment.id ) );
+ finishUpload( up );
+ },
+ onError: function ( /** @type {UploadError} */ error ) {
+ renderError(
+ file,
+ pipeline.getErrorText( error, nativeFile.name )
+ );
+ finishUpload( up );
+ },
+ // plupload builds its own progress item for the file it
+ // took over, so this one goes away.
+ onHandOff: function () {
+ const item = getItem( file );
+ if ( item ) {
+ item.remove();
+ }
+ finishUpload( up );
+ },
+ onProgress: function ( /** @type {number} */ percent ) {
+ renderProgress( file, percent );
+ },
+ };
+ },
+ } );
+ }
+
+ /**
+ * Binds the interceptor to an uploader once it has initialized.
+ *
+ * plupload sorts handlers by priority (descending) and a `false` return
+ * breaks the chain, so priority 100 runs before and suppresses the
+ * built-in FilesAdded and UploadComplete handlers.
+ *
+ * @param {plupload.Uploader} up The uploader that initialized.
+ */
+ function bindUploader( up ) {
+ if ( up.__wpMediaNewUploadBound ) {
+ return;
+ }
+ up.__wpMediaNewUploadBound = true;
+
+ up.bind(
+ 'FilesAdded',
+ function ( up, files ) {
+ return handleFilesAdded( up, files );
+ },
+ null,
+ 100
+ );
+
+ // Hold back the built-in uploadComplete() while pipeline uploads
+ // are still running; finishUpload() fires it once they are done.
+ up.bind(
+ 'UploadComplete',
+ function () {
+ return inFlightCount > 0 ? false : undefined;
+ },
+ null,
+ 100
+ );
+ }
+
+ // plupload-handlers creates its uploader from the `wpUploaderInit`
+ // settings once the DOM is ready, after this script has run, and
+ // plupload binds the handlers in a settings `init` map while the
+ // uploader initializes. Hooking PostInit there reaches the uploader
+ // without depending on ready-callback ordering. The settings are
+ // missing when the browser uploader is disabled (the html-uploader
+ // fallback), in which case there is nothing to intercept.
+ if ( typeof wpUploaderInit !== 'object' || ! wpUploaderInit ) {
+ return;
+ }
+
+ // Either shape may already hold another script's handlers, which keep
+ // running ahead of the interceptor being bound.
+ const init = wpUploaderInit.init;
+ if ( typeof init === 'function' ) {
+ wpUploaderInit.init = function ( up ) {
+ init( up );
+ bindUploader( up );
+ };
+ } else {
+ const postInit = init && init.PostInit;
+ wpUploaderInit.init = Object.assign( {}, init, {
+ PostInit: function (
+ /** @type {plupload.Uploader} */ up,
+ /** @type {unknown[]} */ ...args
+ ) {
+ if ( typeof postInit === 'function' ) {
+ postInit.call( this, up, ...args );
+ }
+ bindUploader( up );
+ },
+ } );
+ }
+} )();
diff --git a/wp-admin/js/media-new-upload.min.js b/wp-admin/js/media-new-upload.min.js
new file mode 100644
index 0000000000..0f927f95ab
--- /dev/null
+++ b/wp-admin/js/media-new-upload.min.js
@@ -0,0 +1,2 @@
+/*! This file is auto-generated */
+!function(){if(!window.__wpMediaNewUpload){const s=window.wp&&wp.mediaUploadPipeline;if(s&&"undefined"!=typeof plupload&&wp.a11y&&s.configure()){window.__wpMediaNewUpload=!0;const f=wp.i18n.__,m=wp.i18n.sprintf;let n=0;if("object"==typeof wpUploaderInit&&wpUploaderInit){const i=wpUploaderInit.init;if("function"==typeof i)wpUploaderInit.init=function(e){i(e),o(e)};else{const d=i&&i.PostInit;wpUploaderInit.init=Object.assign({},i,{PostInit:function(e,...t){"function"==typeof d&&d.call(this,e,...t),o(e)}})}}function u(e){0===--n&&e.state!==plupload.STARTED&&uploadComplete()}function c(e){return document.getElementById("media-item-"+e.id)}function t(l,e){return s.routeBatch(l,e,{postId:0<(e=(e=document.getElementById("post_id"))?parseInt(e.value,10):0)?e:0,onStart:function(e){var t=document.getElementById("media-upload-error");t&&t.replaceChildren(),uploadStart(),n+=e.length},onQueue:function(a,p){return fileQueued(a),{onSuccess:function(e){uploadSuccess(a,String(e.id)),u(l)},onError:function(e){{var t=a,n,o,i,d;e=s.getErrorText(e,p.name);const r=c(t);r&&(n="error-description-"+t.id,(o=document.createElement("button")).type="button",o.id="dismiss-"+t.id,o.className="dismiss button-link",o.setAttribute("aria-describedby",n),o.textContent=pluploadL10n.dismiss,(i=document.createElement("strong")).textContent=pluploadL10n.error_uploading.replace("%s",t.name),(d=document.createElement("div")).id=n,d.className="notice notice-error error-div error",d.append(o," ",i,document.createElement("br"),e),r.replaceChildren(d),r.dataset.lastErr=t.id,setTimeout(function(){wp.a11y.speak(m(f("%s has failed to upload."),t.name))},1500),o.addEventListener("click",function(){r.remove(),wp.a11y.speak(f("Error dismissed."));var e=document.getElementById("plupload-browse-button");e&&e.focus()}))}u(l)},onHandOff:function(){var e=c(a);e&&e.remove(),u(l)},onProgress:function(e){var t,n;e=e,(t=c(t=a))&&((n=t.querySelector(".bar"))&&(n.style.width=2*e+"px"),n=t.querySelector(".percent"))&&(n.textContent=e+"%")}}}})}function o(e){e.__wpMediaNewUploadBound||(e.__wpMediaNewUploadBound=!0,e.bind("FilesAdded",t,null,100),e.bind("UploadComplete",function(){return!(0<n)&&void 0},null,100))}}}}();
\ No newline at end of file
diff --git a/wp-admin/js/media-upload-pipeline.js b/wp-admin/js/media-upload-pipeline.js
new file mode 100644
index 0000000000..58dceae7af
--- /dev/null
+++ b/wp-admin/js/media-upload-pipeline.js
@@ -0,0 +1,739 @@
+/**
+ * Shared glue for routing classic admin uploads through the client-side
+ * media pipeline.
+ *
+ * The media frames (media-frame-upload.js) and the Add New Media
+ * File screen (media-new-upload.js) both intercept plupload and hand files
+ * to @wordpress/upload-media instead. Everything that is not specific to
+ * one screen's UI lives here: feature detection, configuring the
+ * upload-media store once, the REST sideload/finalize/delete helpers the
+ * store needs, queueing a file with success, error, and progress
+ * callbacks, the error text shown for a failed upload, and the guard that
+ * warns before leaving while uploads are in flight.
+ *
+ * Exposed as wp.mediaUploadPipeline.
+ *
+ * @output wp-admin/js/media-upload-pipeline.js
+ */
+
+/* global plupload */
+
+window.wp = window.wp || {};
+
+( function ( wp ) {
+ const __ = wp.i18n.__;
+ const settings = window._wpMediaUploadPipelineSettings || {};
+ /** @type {any} */
+ let uploadStore;
+ let configured = false;
+
+ // Uploads in flight, keyed by the onSuccess callback queueFile() passes
+ // to addItems(). The store keeps that callback on the queue item, so it
+ // identifies the item even after HEIC conversion swaps its file.
+ const entries = new Map();
+
+ // Files handed back to classic plupload after the pipeline gave up on
+ // them, so the interceptors let plupload's own handler take them.
+ const classicFiles = new WeakSet();
+
+ const imageSizeCount = Object.keys( settings.allImageSizes || {} ).length;
+
+ /**
+ * Recursively appends data to a FormData object, supporting nested objects.
+ *
+ * Mirrors flattenFormData() in the media-utils package.
+ *
+ * @param {FormData} formData The form data to append to.
+ * @param {string} key The key to append under.
+ * @param {*} data The value to append.
+ */
+ function flattenFormData( formData, key, data ) {
+ if (
+ data !== null &&
+ typeof data === 'object' &&
+ Object.getPrototypeOf( data ) === Object.prototype
+ ) {
+ Object.keys( data ).forEach( function ( name ) {
+ flattenFormData( formData, key + '[' + name + ']', data[ name ] );
+ } );
+ } else if ( data !== undefined ) {
+ formData.append( key, String( data ) );
+ }
+ }
+
+ /**
+ * Sideloads a client-generated thumbnail to an existing attachment.
+ *
+ * Reimplements the private sideloadMedia() helper from the media-utils
+ * package as a thin apiFetch wrapper.
+ *
+ * @param {Object} args The sideload arguments.
+ * @param {File} args.file The sub-size to sideload.
+ * @param {number} args.attachmentId The attachment to sideload it to.
+ * @param {Record<string, unknown>} [args.additionalData] Extra fields to send with it.
+ * @param {AbortSignal} [args.signal] Signal aborting the request.
+ * @param {( subSize: Object ) => void} [args.onSuccess] Called with the sideloaded sub-size.
+ * @param {( error: Error ) => void} [args.onError] Called when the sideload failed.
+ */
+ function mediaSideload( args ) {
+ const file = args.file;
+ const additionalData = args.additionalData || {};
+
+ const data = new FormData();
+ data.append( 'file', file, file.name || file.type.replace( '/', '.' ) );
+ Object.keys( additionalData ).forEach( function ( key ) {
+ flattenFormData( data, key, additionalData[ key ] );
+ } );
+
+ wp.apiFetch( {
+ path: '/wp/v2/media/' + args.attachmentId + '/sideload',
+ body: data,
+ method: 'POST',
+ signal: args.signal,
+ } )
+ .then( function ( /** @type {Object} */ subSize ) {
+ if ( args.onSuccess ) {
+ args.onSuccess( subSize );
+ }
+ } )
+ .catch( function ( /** @type {unknown} */ error ) {
+ if ( args.onError ) {
+ let normalized = error;
+ if ( ! ( error instanceof Error ) ) {
+ const rest = /** @type {UploadError} */ ( error );
+ normalized = new Error(
+ rest && rest.message ? rest.message : String( error )
+ );
+ }
+ args.onError( /** @type {Error} */ ( normalized ) );
+ }
+ } );
+ }
+
+ /**
+ * Finalizes an upload once all client-side processing is complete.
+ *
+ * Reimplements the private mediaFinalize() helper. The returned
+ * attachment is load-bearing: it carries the post-finalize (scaled)
+ * URL used for srcset.
+ *
+ * @param {number} id The parent attachment ID.
+ * @param {Object[]} subSizes Accumulated sub-size data.
+ * @return {Promise<PipelineAttachment|undefined>} Resolves with the transformed attachment.
+ */
+ async function mediaFinalize( id, subSizes ) {
+ /** @type {Object|undefined} */
+ const response = await wp.apiFetch( {
+ path: '/wp/v2/media/' + id + '/finalize',
+ method: 'POST',
+ data: { sub_sizes: subSizes || [] },
+ } );
+ if ( ! response ) {
+ return undefined;
+ }
+ return wp.mediaUtils.transformAttachment( response );
+ }
+
+ /**
+ * Deletes an attachment whose client-side processing failed outright.
+ *
+ * The queue calls this when every sub-size sideload for an upload fails:
+ * without it the original file is left behind as an attachment with no
+ * metadata, visible in the Media Library after the next page load. The
+ * block editor passes the same setting.
+ *
+ * @param {number} id The attachment ID to delete.
+ * @return {Promise<unknown>} Resolves once the attachment is deleted.
+ */
+ function mediaDelete( id ) {
+ return wp.apiFetch( {
+ path: '/wp/v2/media/' + id + '?force=true',
+ method: 'DELETE',
+ } );
+ }
+
+ /**
+ * Whether every script the pipeline relies on is present.
+ *
+ * @return {boolean} True when the dependencies loaded.
+ */
+ function hasDependencies() {
+ return Boolean(
+ typeof plupload !== 'undefined' &&
+ wp.uploadMedia &&
+ wp.mediaUtils &&
+ wp.data &&
+ wp.element &&
+ wp.apiFetch
+ );
+ }
+
+ /**
+ * Whether the browser can run the client-side pipeline on this page.
+ *
+ * False when the isolation headers did not land (the page is not
+ * cross-origin isolated) or the browser lacks the required features,
+ * in which case classic plupload keeps handling uploads.
+ *
+ * @return {boolean} True when client-side processing is available.
+ */
+ function isSupported() {
+ return Boolean(
+ hasDependencies() &&
+ wp.uploadMedia.detectClientSideMediaSupport &&
+ wp.uploadMedia.detectClientSideMediaSupport().supported
+ );
+ }
+
+ /**
+ * Estimates the progress (0-100) of a queue item.
+ *
+ * The pipeline never reports a numeric `progress` on its queue items
+ * (nothing dispatches updateItemProgress), so estimate one from the
+ * item's operation queue instead: each finished operation (prepare,
+ * transcode, upload, thumbnails, finalize) advances the bar, and the
+ * sub-sizes sideloaded so far advance it within thumbnail generation.
+ * `item.progress` is preferred whenever it is present.
+ *
+ * @param {QueueItem} item The upload-media queue item.
+ * @param {UploadEntry} entry The pipeline's bookkeeping for the upload.
+ * @return {number} Estimated progress.
+ */
+ function estimateProgress( item, entry ) {
+ if ( typeof item.progress === 'number' ) {
+ return item.progress;
+ }
+
+ const remaining = item.operations ? item.operations.length : 0;
+ let totals = entry.totals;
+ if ( ! totals ) {
+ totals = { total: remaining, remaining: remaining };
+ entry.totals = totals;
+ }
+ // Operations are appended after preparation, so grow the total.
+ if ( remaining > totals.remaining ) {
+ totals.total += remaining - totals.remaining;
+ }
+ totals.remaining = remaining;
+
+ if ( totals.total === 0 ) {
+ return 0;
+ }
+
+ const completed = totals.total - remaining;
+ let fraction = 0;
+ if (
+ 'THUMBNAIL_GENERATION' === item.currentOperation &&
+ imageSizeCount > 0
+ ) {
+ fraction = Math.min(
+ 1,
+ ( item.subSizes || [] ).length / imageSizeCount
+ );
+ }
+
+ return ( ( completed + fraction ) / totals.total ) * 100;
+ }
+
+ /**
+ * Tracks queued uploads on every change to the upload-media store.
+ *
+ * Reports progress, which holds at 99 until the upload's success
+ * callback has run so nothing looks finished before the screen has
+ * synced the result, and notes the attachment once the original is
+ * uploaded. Sub-size children are skipped; only top-level items matter.
+ *
+ * The store can also remove an item without calling either of its
+ * callbacks (it only calls onSuccess when the item has an attachment),
+ * so an upload that was seen in the store and is gone fails here
+ * instead of staying in flight, and holding the unload guard, for good.
+ */
+ function onStoreChange() {
+ if ( entries.size === 0 ) {
+ return;
+ }
+
+ const present = new Set();
+ const items = wp.data.select( uploadStore ).getItems();
+ items.forEach( function ( /** @type {QueueItem} */ item ) {
+ if ( item.parentId ) {
+ return;
+ }
+
+ const entry = entries.get( item.onSuccess );
+ if ( ! entry ) {
+ return;
+ }
+
+ present.add( item.onSuccess );
+ entry.seen = true;
+ if ( item.attachment && item.attachment.id ) {
+ entry.attachmentId = item.attachment.id;
+ }
+
+ if ( ! entry.onProgress ) {
+ return;
+ }
+
+ const percent = Math.min(
+ 99,
+ Math.round( estimateProgress( item, entry ) )
+ );
+ if ( percent !== entry.lastPercent ) {
+ entry.lastPercent = percent;
+ entry.onProgress( percent );
+ }
+ } );
+
+ entries.forEach( function ( entry, key ) {
+ if ( entry.seen && ! present.has( key ) ) {
+ entry.fail( {
+ message: __( 'An error occurred while uploading the file.' ),
+ } );
+ }
+ } );
+ }
+
+ /**
+ * Whether something else already configured the store on this page.
+ *
+ * The block editor renders its own MediaUploadProvider into the same
+ * store. Neither `mediaSideload` nor `mediaFinalize` has a default, so
+ * their presence proves a provider's settings landed, whichever one
+ * rendered them.
+ *
+ * @return {boolean} True when the store carries a provider's settings.
+ */
+ function isStoreConfigured() {
+ const storeSettings = wp.data.select( uploadStore ).getSettings();
+ return Boolean(
+ storeSettings &&
+ storeSettings.mediaSideload &&
+ storeSettings.mediaFinalize
+ );
+ }
+
+ /**
+ * Configures the upload-media store for this page, once.
+ *
+ * Rendering the provider with useSubRegistry: false wires the settings
+ * into the store that wp.data.dispatch/select address. The block editor
+ * does the same for its own uploads, so on its screens no second
+ * provider is rendered: whichever committed last would win, and this
+ * one could replace the editor's mediaUpload. Uploads wait for the
+ * editor's settings instead (see isReady()).
+ *
+ * @return {boolean} True when the pipeline is configured and usable.
+ */
+ function configure() {
+ if ( configured ) {
+ return true;
+ }
+
+ if ( ! isSupported() ) {
+ return false;
+ }
+
+ configured = true;
+ uploadStore = wp.uploadMedia.store;
+
+ if ( settings.isBlockEditor || isStoreConfigured() ) {
+ wp.data.subscribe( onStoreChange, uploadStore );
+ addUnloadGuard();
+ return true;
+ }
+
+ /*
+ * The media-utils package branches on this flag: without it
+ * uploadMedia() creates and revokes a throwaway blob URL per file
+ * and emits an extra onFileChange carrying it. The block editor
+ * sets the same flag before configuring the same pipeline.
+ */
+ window.__clientSideMediaProcessing = true;
+
+ wp.element
+ .createRoot( document.createElement( 'div' ) )
+ .render(
+ wp.element.createElement( wp.uploadMedia.MediaUploadProvider, {
+ settings: {
+ mediaUpload: wp.mediaUtils.uploadMedia,
+ mediaSideload: mediaSideload,
+ mediaFinalize: mediaFinalize,
+ mediaDelete: mediaDelete,
+ maxUploadFileSize: settings.maxUploadFileSize,
+ allowedMimeTypes: settings.allowedMimeTypes,
+ allImageSizes: settings.allImageSizes,
+ bigImageSizeThreshold: settings.bigImageSizeThreshold,
+ imageStripMeta: settings.imageStripMeta,
+ imageMaxBitDepth: settings.imageMaxBitDepth,
+ generateAnimatedImageSubsizes:
+ settings.generateAnimatedImageSubsizes,
+ },
+ useSubRegistry: false,
+ } )
+ );
+
+ // Only the upload-media store can change what this listener reads.
+ wp.data.subscribe( onStoreChange, uploadStore );
+ addUnloadGuard();
+
+ return true;
+ }
+
+ /**
+ * Warns before leaving while uploads are in flight.
+ *
+ * Thumbnails that have not been sideloaded yet are lost and the
+ * attachment is left unfinalized, unlike classic uploads that complete
+ * server-side once the bytes arrive.
+ */
+ function addUnloadGuard() {
+ window.addEventListener( 'beforeunload', function ( event ) {
+ if ( entries.size > 0 ) {
+ event.preventDefault();
+ }
+ } );
+ }
+
+ /**
+ * Whether the store has received a provider's settings.
+ *
+ * A provider renders asynchronously, so a file added before its
+ * settings land must be left to classic plupload - a degradation,
+ * never data loss. The store's default state already carries a no-op
+ * `mediaUpload`, so only settings a provider alone supplies prove the
+ * real ones have landed: a file queued before that would be handed to
+ * the no-op and never upload. On block editor screens only the
+ * editor's provider supplies them.
+ *
+ * @return {boolean} True when the store is ready to accept files.
+ */
+ function isReady() {
+ return configured && isStoreConfigured();
+ }
+
+ /**
+ * Splits a batch of plupload files between the pipeline and classic
+ * plupload.
+ *
+ * Audio files stay classic so they keep the title and description that
+ * media_handle_upload() derives from their ID3 tags, which the REST
+ * endpoint does not do. So do JPEG and PNG images while plupload's own
+ * client-side resize is enabled (the "Scale images" setting, or a
+ * `resize` passed through `plupload_init`): plupload scales those down
+ * before uploading them, the pipeline would not.
+ *
+ * The whole batch stays classic when a file cannot be re-queued on
+ * plupload or is already plupload's: plupload returns no native File
+ * for sources it cannot expose as one (the html4 runtime, say), and a
+ * file the pipeline handed back (see handOffToClassic()) is plupload's
+ * to upload.
+ *
+ * @param {plupload.Uploader} up The plupload uploader the files were added to.
+ * @param {plupload.File[]} files Files added to the plupload queue.
+ * @return {{ pipeline: plupload.File[], classic: plupload.File[] }|null} The split,
+ * or null when the whole batch stays on the classic path.
+ */
+ function partitionBatch( up, files ) {
+ const resize = up.settings && up.settings.resize;
+ const isResizing = Boolean( resize && resize.enabled );
+ /** @type {{ pipeline: plupload.File[], classic: plupload.File[] }} */
+ const batch = { pipeline: [], classic: [] };
+
+ for ( const file of files ) {
+ if ( plupload.FAILED === file.status ) {
+ continue;
+ }
+ const nativeFile = file.getNative && file.getNative();
+ if ( ! nativeFile || classicFiles.has( nativeFile ) ) {
+ return null;
+ }
+ const type = file.type || '';
+ if (
+ /^audio\//i.test( type ) ||
+ ( isResizing && /^image\/(?:jpeg|png)$/i.test( type ) )
+ ) {
+ batch.classic.push( file );
+ } else {
+ batch.pipeline.push( file );
+ }
+ }
+
+ return batch.pipeline.length ? batch : null;
+ }
+
+ /**
+ * Hands a file to classic plupload, whose own FilesAdded handler queues
+ * and starts the upload.
+ *
+ * @param {plupload.Uploader} up The plupload uploader.
+ * @param {File} nativeFile The file to upload.
+ */
+ function addClassicFile( up, nativeFile ) {
+ classicFiles.add( nativeFile );
+ up.addFile( nativeFile );
+ }
+
+ /**
+ * Re-queues the classic share of a batch on plupload.
+ *
+ * Suppressing plupload's built-in FilesAdded handler covers the whole
+ * batch, so the files partitionBatch() left to plupload are removed and
+ * added back on their own.
+ *
+ * @param {plupload.Uploader} up The plupload uploader.
+ * @param {plupload.File[]} files The files to upload classically.
+ */
+ function handOffBatchToClassic( up, files ) {
+ files.forEach( function ( file ) {
+ // partitionBatch() already established that every file has one.
+ const nativeFile = /** @type {File} */ ( file.getNative() );
+ up.removeFile( file );
+ addClassicFile( up, nativeFile );
+ } );
+ }
+
+ /**
+ * Re-queues a file on classic plupload when the server can do what the
+ * browser could not.
+ *
+ * The pipeline converts HEIC in the browser and fails outright when the
+ * platform has no HEIC decoder (Linux, and Windows without the HEVC
+ * extension), whereas the classic upload lets the server convert it.
+ * When the server's image editor supports HEIC - plupload carries no
+ * `heic_upload_error` setting then - the file goes back to plupload,
+ * whose own FilesAdded handler queues and starts the classic upload.
+ * Nothing else can convert it, so any other failure stands.
+ *
+ * @param {plupload.Uploader} up The plupload uploader the file came from.
+ * @param {UploadError} error Why the pipeline gave up on it.
+ * @param {File} nativeFile The file the pipeline was given.
+ * @return {boolean} True when the file was handed to classic plupload.
+ */
+ function handOffToClassic( up, error, nativeFile ) {
+ const errorCodes = wp.uploadMedia.ErrorCode || {};
+ if (
+ ! error ||
+ ! errorCodes.HEIC_DECODE_ERROR ||
+ error.code !== errorCodes.HEIC_DECODE_ERROR ||
+ ! up ||
+ typeof up.addFile !== 'function' ||
+ ( up.settings && up.settings.heic_upload_error )
+ ) {
+ return false;
+ }
+
+ addClassicFile( up, nativeFile );
+ return true;
+ }
+
+ /**
+ * Builds the extra fields to send with an upload from plupload's
+ * multipart params.
+ *
+ * Anything a plugin added through the `plupload_default_params` filter
+ * or wp.Uploader.param() reached the classic upload as $_POST fields,
+ * so it is forwarded to the REST request the same way. The classic
+ * transport's own fields are dropped: `action` and `_wpnonce` belong to
+ * async-upload.php, and `post_id` is spelled `post` by the REST API and
+ * passed separately after the screen has validated it.
+ *
+ * @param {Record<string, string>} params Plupload's multipart_params.
+ * @param {number} postId The validated post to attach the upload to, or 0.
+ * @return {Record<string, unknown>} Additional data for the upload.
+ */
+ function additionalDataFromParams( params, postId ) {
+ /** @type {Record<string, unknown>} */
+ const additionalData = {};
+ Object.keys( params || {} ).forEach( function ( key ) {
+ if ( 'action' === key || '_wpnonce' === key || 'post_id' === key ) {
+ return;
+ }
+ additionalData[ key ] = params[ key ];
+ } );
+ if ( postId ) {
+ additionalData.post = postId;
+ }
+ return additionalData;
+ }
+
+ /**
+ * Queues a file for client-side processing and upload.
+ *
+ * @param {File} nativeFile The file to upload.
+ * @param {Record<string, unknown>} additionalData Extra fields to send with the attachment.
+ * @param {QueueFileCallbacks} [callbacks] Lifecycle callbacks.
+ */
+ function queueFile( nativeFile, additionalData, callbacks = {} ) {
+ /**
+ * Handles successful upload.
+ *
+ * @param {PipelineAttachment[]} attachments The uploaded attachments.
+ */
+ function onSuccess( attachments ) {
+ entries.delete( onSuccess );
+ if ( callbacks.onSuccess ) {
+ callbacks.onSuccess( attachments[ 0 ] );
+ }
+ }
+
+ /** @type {UploadEntry} */
+ const entry = {
+ onProgress: callbacks.onProgress,
+ lastPercent: -1,
+ totals: null,
+ seen: false,
+ attachmentId: 0,
+ fail: function ( error ) {
+ if ( ! entries.delete( onSuccess ) ) {
+ return;
+ }
+ // The original already reached the server (finalize failed,
+ // say), so delete it: otherwise a retry leaves a duplicate
+ // next to an attachment whose metadata was never finalized.
+ // The store only deletes it itself when every sub-size failed.
+ if ( entry.attachmentId ) {
+ mediaDelete( entry.attachmentId ).catch( function () {} );
+ }
+ if ( callbacks.onError ) {
+ callbacks.onError( error );
+ }
+ },
+ };
+ entries.set( onSuccess, entry );
+
+ wp.data.dispatch( uploadStore ).addItems( {
+ files: [ nativeFile ],
+ additionalData: additionalData || {},
+ onSuccess: onSuccess,
+ onError: function ( /** @type {UploadError|string} */ error ) {
+ // Also reached for files addItems() rejects before queueing.
+ // The block editor's mediaUpload() setting, which the store
+ // uses once the editor has configured it, reports transport
+ // failures as a bare message string.
+ entry.fail(
+ typeof error === 'string' ? { message: error } : error
+ );
+ },
+ } );
+ }
+
+ /**
+ * Routes a batch of files added to a plupload uploader through the
+ * pipeline.
+ *
+ * Returns undefined (not false) when the pipeline cannot take the batch
+ * so plupload's built-in FilesAdded handler runs and uploads it
+ * server-side - a degradation, never data loss. Otherwise queues the
+ * files the pipeline can take, re-queues the rest on plupload, and
+ * returns false to suppress the built-in handler, which would otherwise
+ * start a classic upload of every file.
+ *
+ * The classic flow posts plupload's multipart params to
+ * async-upload.php, so they are forwarded to the REST request for
+ * plugins reading $_POST, with `post_id` replaced by `options.postId`.
+ *
+ * @param {plupload.Uploader} up The plupload uploader the files were added to.
+ * @param {plupload.File[]} files Files added to the queue.
+ * @param {RouteBatchOptions} options The screen's hooks.
+ * @return {boolean|undefined} False to suppress the built-in handler.
+ */
+ function routeBatch( up, files, options ) {
+ if ( ! isReady() ) {
+ return;
+ }
+
+ const batch = partitionBatch( up, files );
+ if ( ! batch ) {
+ return;
+ }
+
+ const params = ( up.settings && up.settings.multipart_params ) || {};
+ const additionalData = additionalDataFromParams(
+ params,
+ options.postId
+ );
+
+ if ( options.onStart ) {
+ options.onStart( batch.pipeline );
+ }
+
+ batch.pipeline.forEach( function ( /** @type {plupload.File} */ file ) {
+ // partitionBatch() already established that every file has one.
+ const nativeFile = /** @type {File} */ ( file.getNative() );
+ const callbacks = options.onQueue( file, nativeFile );
+
+ // Remove the file from plupload so it is not uploaded twice.
+ up.removeFile( file );
+
+ queueFile( nativeFile, additionalData, {
+ onSuccess: callbacks.onSuccess,
+ onProgress: callbacks.onProgress,
+ onError: function ( /** @type {UploadError} */ error ) {
+ // When the server can convert what the browser could
+ // not, plupload takes the file from here and shows its
+ // own progress.
+ if ( handOffToClassic( up, error, nativeFile ) ) {
+ callbacks.onHandOff();
+ return;
+ }
+ callbacks.onError( error );
+ },
+ } );
+ } );
+
+ handOffBatchToClassic( up, batch.classic );
+
+ up.refresh();
+
+ return false;
+ }
+
+ /**
+ * Builds the display text for a failed upload.
+ *
+ * wp.uploadMedia.getErrorMessage() maps an error *code* and a file name
+ * to a { title, description, action } object, so it can neither be handed
+ * the Error itself nor used as a string. Only codes it actually maps are
+ * worth using: its fallback (and the GENERAL code) says nothing the
+ * error's own message does not, and preferring the message there keeps a
+ * server-supplied reason instead of replacing it with "Please try again."
+ *
+ * @param {UploadError} error The upload error.
+ * @param {string} fileName Name of the file that failed to upload.
+ * @return {string} A human-readable message.
+ */
+ function getErrorText( error, fileName ) {
+ const errorCodes = wp.uploadMedia.ErrorCode || {};
+ const code = error && error.code;
+ let details;
+
+ if (
+ code &&
+ code !== errorCodes.GENERAL &&
+ Object.prototype.hasOwnProperty.call( errorCodes, code ) &&
+ wp.uploadMedia.getErrorMessage
+ ) {
+ details = wp.uploadMedia.getErrorMessage( code, fileName );
+
+ if ( details && details.description ) {
+ return details.action ?
+ details.description + ' ' + details.action :
+ details.description;
+ }
+ }
+
+ return (
+ ( error && error.message ) ||
+ __( 'An error occurred while uploading the file.' )
+ );
+ }
+
+ wp.mediaUploadPipeline = {
+ isSupported: isSupported,
+ configure: configure,
+ isReady: isReady,
+ routeBatch: routeBatch,
+ getErrorText: getErrorText,
+ };
+} )( window.wp );
diff --git a/wp-admin/js/media-upload-pipeline.min.js b/wp-admin/js/media-upload-pipeline.min.js
new file mode 100644
index 0000000000..2c5733c08a
--- /dev/null
+++ b/wp-admin/js/media-upload-pipeline.min.js
@@ -0,0 +1,2 @@
+/*! This file is auto-generated */
+window.wp=window.wp||{},function(l){const i=l.i18n.__,e=window._wpMediaUploadPipelineSettings||{};let u,t=!1;const p=new Map,r=new WeakSet,o=Object.keys(e.allImageSizes||{}).length;function n(n){var e=n.file;const t=n.additionalData||{},a=new FormData;a.append("file",e,e.name||e.type.replace("/",".")),Object.keys(t).forEach(function(e){!function t(n,a,i){null!==i&&"object"==typeof i&&Object.getPrototypeOf(i)===Object.prototype?Object.keys(i).forEach(function(e){t(n,a+"["+e+"]",i[e])}):void 0!==i&&n.append(a,String(i))}(a,e,t[e])}),l.apiFetch({path:"/wp/v2/media/"+n.attachmentId+"/sideload",body:a,method:"POST",signal:n.signal}).then(function(e){n.onSuccess&&n.onSuccess(e)}).catch(function(t){if(n.onError){let e=t;t instanceof Error||(e=new Error(t&&t.message?t.message:String(t))),n.onError(e)}})}async function a(e,t){e=await l.apiFetch({path:"/wp/v2/media/"+e+"/finalize",method:"POST",data:{sub_sizes:t||[]}});if(e)return l.mediaUtils.transformAttachment(e)}function f(e){return l.apiFetch({path:"/wp/v2/media/"+e+"?force=true",method:"DELETE"})}function s(){return Boolean(Boolean("undefined"!=typeof plupload&&l.uploadMedia&&l.mediaUtils&&l.data&&l.element&&l.apiFetch)&&l.uploadMedia.detectClientSideMediaSupport&&l.uploadMedia.detectClientSideMediaSupport().supported)}function d(){if(0!==p.size){const n=new Set;l.data.select(u).getItems().forEach(function(e){var t;e.parentId||(t=p.get(e.onSuccess))&&(n.add(e.onSuccess),t.seen=!0,e.attachment&&e.attachment.id&&(t.attachmentId=e.attachment.id),t.onProgress)&&(e=Math.min(99,Math.round(function(e,t){if("number"==typeof e.progress)return e.progress;var n=e.operations?e.operations.length:0;let a=t.totals;if(a||(a={total:n,remaining:n},t.totals=a),n>a.remaining&&(a.total+=n-a.remaining),a.remaining=n,0===a.total)return 0;let i=0;return(a.total-n+(i="THUMBNAIL_GENERATION"===e.currentOperation&&0<o?Math.min(1,(e.subSizes||[]).length/o):i))/a.total*100}(e,t))))!==t.lastPercent&&(t.lastPercent=e,t.onProgress(e))}),p.forEach(function(e,t){e.seen&&!n.has(t)&&e.fail({message:i("An error occurred while uploading the file.")})})}}function c(){var e=l.data.select(u).getSettings();return Boolean(e&&e.mediaSideload&&e.mediaFinalize)}function g(){window.addEventListener("beforeunload",function(e){0<p.size&&e.preventDefault()})}function m(){return t&&c()}function h(e,t){r.add(t),e.addFile(t)}l.mediaUploadPipeline={isSupported:s,configure:function(){if(!t){if(!s())return!1;t=!0,u=l.uploadMedia.store,e.isBlockEditor||c()||(window.__clientSideMediaProcessing=!0,l.element.createRoot(document.createElement("div")).render(l.element.createElement(l.uploadMedia.MediaUploadProvider,{settings:{mediaUpload:l.mediaUtils.uploadMedia,mediaSideload:n,mediaFinalize:a,mediaDelete:f,maxUploadFileSize:e.maxUploadFileSize,allowedMimeTypes:e.allowedMimeTypes,allImageSizes:e.allImageSizes,bigImageSizeThreshold:e.bigImageSizeThreshold,imageStripMeta:e.imageStripMeta,imageMaxBitDepth:e.imageMaxBitDepth,generateAnimatedImageSubsizes:e.generateAnimatedImageSubsizes},useSubRegistry:!1}))),l.data.subscribe(d,u),g()}return!0},isReady:m,routeBatch:function(s,e,d){if(m()){var n,e=function(e,t){var e=e.settings&&e.settings.resize,n=Boolean(e&&e.enabled),a={pipeline:[],classic:[]};for(const o of t)if(plupload.FAILED!==o.status){var i=o.getNative&&o.getNative();if(!i||r.has(i))return null;i=o.type||"";(/^audio\//i.test(i)||n&&/^image\/(?:jpeg|png)$/i.test(i)?a.classic:a.pipeline).push(o)}return a.pipeline.length?a:null}(s,e);if(e){const c=function(t,e){const n={};return Object.keys(t||{}).forEach(function(e){"action"!==e&&"_wpnonce"!==e&&"post_id"!==e&&(n[e]=t[e])}),e&&(n.post=e),n}(s.settings&&s.settings.multipart_params||{},d.postId);return d.onStart&&d.onStart(e.pipeline),e.pipeline.forEach(function(e){const o=e.getNative(),r=d.onQueue(e,o);s.removeFile(e);{e=o;var t=c,n={onSuccess:r.onSuccess,onProgress:r.onProgress,onError:function(e){var t,n,a,i;t=s,n=e,a=o,i=l.uploadMedia.ErrorCode||{},n&&i.HEIC_DECODE_ERROR&&n.code===i.HEIC_DECODE_ERROR&&t&&"function"==typeof t.addFile&&(!t.settings||!t.settings.heic_upload_error)&&(h(t,a),1)?r.onHandOff():r.onError(e)}};function a(e){p.delete(a),n.onSuccess&&n.onSuccess(e[0])}const i={onProgress:n.onProgress,lastPercent:-1,totals:null,seen:!1,attachmentId:0,fail:function(e){p.delete(a)&&(i.attachmentId&&f(i.attachmentId).catch(function(){}),n.onError)&&n.onError(e)}};p.set(a,i),l.data.dispatch(u).addItems({files:[e],additionalData:t||{},onSuccess:a,onError:function(e){i.fail("string"==typeof e?{message:e}:e)}})}}),n=s,e.classic.forEach(function(e){var t=e.getNative();n.removeFile(e),h(n,t)}),s.refresh(),!1}}},getErrorText:function(e,t){var n=l.uploadMedia.ErrorCode||{},a=e&&e.code;return a&&a!==n.GENERAL&&Object.prototype.hasOwnProperty.call(n,a)&&l.uploadMedia.getErrorMessage&&(n=l.uploadMedia.getErrorMessage(a,t))&&n.description?n.action?n.description+" "+n.action:n.description:e&&e.message||i("An error occurred while uploading the file.")}}}(window.wp);
\ No newline at end of file
diff --git a/wp-admin/media-new.php b/wp-admin/media-new.php
index 7be2874380..1e4e35c084 100644
--- a/wp-admin/media-new.php
+++ b/wp-admin/media-new.php
@@ -17,6 +17,7 @@ if ( ! current_user_can( 'upload_files' ) ) {
}
wp_enqueue_script( 'plupload-handlers' );
+wp_enqueue_media_new_upload();
$post_id = 0;
if ( isset( $_REQUEST['post_id'] ) ) {
diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php
index c962b962fa..c229fbfcb8 100644
--- a/wp-includes/default-filters.php
+++ b/wp-includes/default-filters.php
@@ -718,6 +718,8 @@ 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' );
+add_action( 'load-upload.php', 'wp_set_up_cross_origin_isolation' );
+add_action( 'load-media-new.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/wp-includes/functions.php b/wp-includes/functions.php
index 3b8f5bd555..3b9e247fd4 100644
--- a/wp-includes/functions.php
+++ b/wp-includes/functions.php
@@ -3509,7 +3509,7 @@ function wp_get_image_mime( $file ) {
* @since 4.9.6 Support was added for AAC (.aac) files.
* @since 6.8.0 Support was added for `audio/x-wav`.
*
- * @return string[] Array of mime types keyed by the file extension regex corresponding to those types.
+ * @return array<string, string> Array of mime types keyed by the file extension regex corresponding to those types.
*/
function wp_get_mime_types() {
/**
@@ -3520,8 +3520,8 @@ function wp_get_mime_types() {
*
* @since 3.5.0
*
- * @param string[] $wp_get_mime_types Mime types keyed by the file extension regex
- * corresponding to those types.
+ * @param array<string, string> $wp_get_mime_types Mime types keyed by the file extension regex
+ * corresponding to those types.
*/
return apply_filters(
'mime_types',
@@ -3645,7 +3645,7 @@ function wp_get_mime_types() {
*
* @since 4.6.0
*
- * @return array[] Multi-dimensional array of file extensions types keyed by the type of file.
+ * @return array<string, string[]> Multi-dimensional array of file extensions types keyed by the type of file.
*/
function wp_get_ext_types() {
@@ -3656,7 +3656,7 @@ function wp_get_ext_types() {
*
* @see wp_ext2type()
*
- * @param array[] $ext2type Multi-dimensional array of file extensions types keyed by the type of file.
+ * @param array<string, string[]> $ext2type Multi-dimensional array of file extensions types keyed by the type of file.
*/
return apply_filters(
'ext2type',
@@ -3730,8 +3730,7 @@ function wp_filesize( $path ): int {
* @since 2.8.6
*
* @param int|WP_User $user Optional. User to check. Defaults to current user.
- * @return string[] Array of mime types keyed by the file extension regex corresponding
- * to those types.
+ * @return array<string, string> Array of mime types keyed by the file extension regex corresponding to those types.
*/
function get_allowed_mime_types( $user = null ) {
$t = wp_get_mime_types();
@@ -3750,8 +3749,8 @@ function get_allowed_mime_types( $user = null ) {
*
* @since 2.0.0
*
- * @param array $t Mime types keyed by the file extension regex corresponding to those types.
- * @param int|WP_User|null $user User ID, User object or null if not provided (indicates current user).
+ * @param array<string, string> $t Mime types keyed by the file extension regex corresponding to those types.
+ * @param int|WP_User|null $user User ID, User object or null if not provided (indicates current user).
*/
return apply_filters( 'upload_mimes', $t, $user );
}
diff --git a/wp-includes/media.php b/wp-includes/media.php
index a1ba0a3213..351d4c3f71 100644
--- a/wp-includes/media.php
+++ b/wp-includes/media.php
@@ -4913,6 +4913,7 @@ function wp_prepare_attachment_for_js( $attachment ) {
* all media JS APIs.
*
* @since 3.5.0
+ * @since 7.2.0 Also enqueues the client-side upload integration for the media frame on cross-origin isolated screens.
*
* @global int $content_width
* @global wpdb $wpdb WordPress database abstraction object.
@@ -5302,6 +5303,7 @@ function wp_enqueue_media( $args = array() ) {
* is registered internally before we try to localize it. See #24724.
*/
wp_enqueue_script( 'media-editor' );
+ wp_enqueue_media_frame_upload();
wp_localize_script( 'media-views', '_wpMediaViewsL10n', $strings );
wp_enqueue_script( 'media-audiovideo' );
@@ -6610,9 +6612,7 @@ function wp_set_client_side_media_processing_flag(): void {
wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true;', 'before' );
- $chromium_version = wp_get_chromium_major_version();
-
- if ( null !== $chromium_version && $chromium_version >= 137 ) {
+ if ( wp_is_document_isolation_policy_supported() ) {
wp_add_inline_script( 'wp-block-editor', 'window.__documentIsolationPolicy = true;', 'before' );
}
}
@@ -6637,18 +6637,43 @@ function wp_get_chromium_major_version(): ?int {
}
/**
- * Enables cross-origin isolation in the block editor.
+ * Determines whether the current request's browser honors the
+ * Document-Isolation-Policy header.
+ *
+ * Document-Isolation-Policy is how WordPress makes a page cross-origin
+ * isolated for client-side media processing. Chromium 137+ is the only
+ * engine that implements it; other browsers ignore the header, so the
+ * page never becomes isolated and the client-side pipeline cannot run.
+ *
+ * @since 7.2.0
+ *
+ * @return bool True when the browser is Chromium 137 or newer.
+ */
+function wp_is_document_isolation_policy_supported(): bool {
+ $chromium_version = wp_get_chromium_major_version();
+
+ return null !== $chromium_version && $chromium_version >= 137;
+}
+
+/**
+ * Enables cross-origin isolation on screens that upload media client-side.
*
* Required for enabling SharedArrayBuffer for WebAssembly-based
- * media processing in the editor. Uses Document-Isolation-Policy
+ * media processing in the block editor, the Media Library grid, and
+ * the "Add New Media File" screen. Uses Document-Isolation-Policy
* on supported browsers (Chromium 137+).
*
+ * The Media Library is only isolated in grid mode: list mode has no
+ * client-side pipeline integration, its uploads go through the
+ * "Add New Media File" screen.
+ *
* Skips setup when a third-party page builder overrides the block
* editor via a custom `action` query parameter, as DIP would block
* same-origin iframe access that these editors rely on.
*
* @since 7.1.0
- * @since 7.2.0 Returns whether the header was sent.
+ * @since 7.2.0 Also isolates the Media Library grid and the "Add New Media File" screen.
+ * Returns whether the header was sent.
*
* @return bool Whether the Document-Isolation-Policy header was sent.
*/
@@ -6663,7 +6688,14 @@ function wp_set_up_cross_origin_isolation(): bool {
return false;
}
- if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
+ $is_media_screen = 'media' === $screen->id || ( 'upload' === $screen->id && 'grid' === wp_get_media_library_mode() );
+
+ if (
+ ! $is_media_screen &&
+ ! $screen->is_block_editor() &&
+ 'site-editor' !== $screen->id &&
+ ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() )
+ ) {
return false;
}
@@ -6688,7 +6720,7 @@ function wp_set_up_cross_origin_isolation(): bool {
* DIP isolates the document into its own agent cluster,
* which blocks same-origin iframe access that these editors rely on.
*/
- if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) {
+ if ( ! $is_media_screen && isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) {
return false;
}
@@ -6700,6 +6732,237 @@ function wp_set_up_cross_origin_isolation(): bool {
return wp_send_document_isolation_policy_header();
}
+/**
+ * Returns the current Media Library mode (grid or list).
+ *
+ * Replicates the mode resolution in wp-admin/upload.php, which runs after
+ * the `load-upload.php` hook, without updating the saved user option.
+ *
+ * upload.php falls back to grid mode for any falsey saved value and renders
+ * grid mode only for the exact value 'grid'. A truthy saved value outside the
+ * two known modes - one a plugin stored, say - therefore renders list mode
+ * there and resolves to 'list' here, so callers do not isolate a page that
+ * the Media Library renders in list mode.
+ *
+ * @since 7.2.0
+ *
+ * @return string The Media Library mode, 'grid' when none is saved.
+ * @phpstan-return 'grid'|'list'
+ */
+function wp_get_media_library_mode(): string {
+ $valid_modes = array( 'grid', 'list' );
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET['mode'] ) && in_array( $_GET['mode'], $valid_modes, true ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ return $_GET['mode'];
+ }
+
+ $mode = get_user_option( 'media_library_mode', get_current_user_id() );
+
+ if ( in_array( $mode, $valid_modes, true ) ) {
+ return $mode;
+ }
+
+ // As in upload.php: nothing saved renders the grid, any other value the list.
+ return $mode ? 'list' : 'grid';
+}
+
+/**
+ * Returns the image processing settings for client-side media processing.
+ *
+ * Shared by the REST API index, which the block editor reads, and
+ * wp_get_media_library_upload_settings(), so both pipelines apply the
+ * same filters.
+ *
+ * @since 7.2.0
+ * @access private
+ *
+ * @return array<string, mixed> Image processing settings.
+ *
+ * @phpstan-return array{
+ * image_sizes: array<string, array{
+ * width: non-negative-int,
+ * height: non-negative-int,
+ * crop: array{ 'left'|'center'|'right', 'top'|'center'|'bottom' }|bool,
+ * }>,
+ * image_size_threshold: non-negative-int,
+ * image_strip_meta: bool,
+ * image_max_bit_depth: non-negative-int,
+ * generate_animated_image_subsizes: bool,
+ * }
+ */
+function _wp_get_client_side_media_image_settings(): array {
+ /** This filter is documented in wp-admin/includes/image.php */
+ $image_size_threshold = max( 0, (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ) );
+
+ /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
+ $image_strip_meta = (bool) apply_filters( 'image_strip_meta', true );
+
+ /*
+ * On the server, this filter receives the decoded image's actual bit depth.
+ * The client path never decodes the image on the server, so the filter is
+ * applied with 16 (the maximum depth the client encoder can produce) as
+ * both the value and the current depth. The client caps its output bit
+ * depth at the filtered value, so a plugin lowering it (e.g. to 8) takes
+ * effect on client-generated images too.
+ */
+ /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
+ $image_max_bit_depth = max( 0, (int) apply_filters( 'image_max_bit_depth', 16, 16 ) );
+
+ /**
+ * Filters whether client-side media processing preserves animation in image sub-sizes.
+ *
+ * By default, sub-sizes of animated GIF and WebP images retain animation,
+ * including cropped sizes. Return false to generate static sub-sizes using
+ * only the first frame, reducing processing time in the browser.
+ *
+ * This setting applies only to client-side media processing. Server-side
+ * processing continues to generate static sub-sizes.
+ *
+ * @since 7.2.0
+ *
+ * @param bool $generate_animated_image_subsizes Whether to preserve animation in image sub-sizes.
+ * Default true.
+ */
+ $generate_animated_image_subsizes = (bool) apply_filters( 'wp_generate_animated_image_subsizes', true );
+
+ return array(
+ 'image_sizes' => wp_get_registered_image_subsizes(),
+ 'image_size_threshold' => $image_size_threshold,
+ 'image_strip_meta' => $image_strip_meta,
+ 'image_max_bit_depth' => $image_max_bit_depth,
+ 'generate_animated_image_subsizes' => $generate_animated_image_subsizes,
+ );
+}
+
+/**
+ * Returns the settings for the client-side media processing pipeline
+ * in the Media Library.
+ *
+ * These mirror the values the block editor consumes for the same
+ * pipeline: the REST index (the image processing settings, shared through
+ * _wp_get_client_side_media_image_settings()) and get_block_editor_settings()
+ * (max upload size and allowed mime types).
+ *
+ * @since 7.2.0
+ *
+ * @return array<string, mixed> Settings for the client-side media processing pipeline.
+ *
+ * @phpstan-return array{
+ * maxUploadFileSize: non-negative-int, // Maximum upload file size in bytes.
+ * allowedMimeTypes: array<string, string>, // Allowed mime types keyed by file extension.
+ * allImageSizes: array<string, array{ // All registered image sub-sizes.
+ * width: non-negative-int,
+ * height: non-negative-int,
+ * crop: array{ 'left'|'center'|'right', 'top'|'center'|'bottom' }|bool,
+ * }>,
+ * bigImageSizeThreshold: non-negative-int, // Threshold above which originals are scaled down.
+ * imageStripMeta: bool, // Whether metadata is stripped from generated images.
+ * imageMaxBitDepth: non-negative-int, // Maximum bit depth for generated images.
+ * generateAnimatedImageSubsizes: bool, // Whether sub-sizes of animated images stay animated.
+ * }
+ */
+function wp_get_media_library_upload_settings(): array {
+ $image_settings = _wp_get_client_side_media_image_settings();
+
+ return array(
+ 'maxUploadFileSize' => max( 0, (int) wp_max_upload_size() ),
+ 'allowedMimeTypes' => get_allowed_mime_types(),
+ 'allImageSizes' => $image_settings['image_sizes'],
+ 'bigImageSizeThreshold' => $image_settings['image_size_threshold'],
+ 'imageStripMeta' => $image_settings['image_strip_meta'],
+ 'imageMaxBitDepth' => $image_settings['image_max_bit_depth'],
+ 'generateAnimatedImageSubsizes' => $image_settings['generate_animated_image_subsizes'],
+ );
+}
+
+/**
+ * Enqueues a screen's client-side upload integration script along with the
+ * shared pipeline glue and its settings.
+ *
+ * Nothing is enqueued unless client-side media processing is enabled and the
+ * browser honors Document-Isolation-Policy: without isolation the page never
+ * becomes cross-origin isolated, the script would no-op, and the whole
+ * wp-upload-media dependency chain would be loaded for nothing. The same
+ * goes for a screen wp_set_up_cross_origin_isolation() did not isolate,
+ * such as the Customizer or the Site Icon picker: the media frame loads
+ * there too, but its uploads stay on the classic path.
+ *
+ * The screen script self-guards at runtime too: when the browser turns out
+ * not to be isolated or lacks client-side media support, it no-ops and the
+ * classic plupload flow keeps handling uploads.
+ *
+ * @since 7.2.0
+ * @access private
+ *
+ * @param string $handle The screen script to enqueue.
+ *
+ * @phpstan-param non-empty-string $handle
+ */
+function _wp_enqueue_media_upload_pipeline_script( string $handle ): void {
+ if (
+ ! wp_is_client_side_media_processing_enabled() ||
+ ! wp_is_document_isolation_policy_supported() ||
+ ! wp_is_cross_origin_isolated_request()
+ ) {
+ return;
+ }
+
+ wp_enqueue_script( $handle );
+
+ // Both screen scripts share the pipeline script, so add its settings once.
+ if ( wp_scripts()->get_data( 'media-upload-pipeline', 'settings_added' ) ) {
+ return;
+ }
+ wp_scripts()->add_data( 'media-upload-pipeline', 'settings_added', true );
+
+ $settings = wp_get_media_library_upload_settings();
+
+ // wp_enqueue_media() can run on the front end, where there is no screen.
+ $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
+
+ // The block editor configures the upload-media store itself; the pipeline waits for it.
+ $settings['isBlockEditor'] = $screen && $screen->is_block_editor();
+
+ $js_function = <<<'JS'
+ ( settings ) => {
+ window._wpMediaUploadPipelineSettings = settings;
+ }
+ JS;
+
+ wp_add_inline_script(
+ 'media-upload-pipeline',
+ sprintf( '( %s )( %s );', $js_function, wp_json_encode( $settings, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) ),
+ 'before'
+ );
+}
+
+/**
+ * Enqueues the script that routes uploads started from a media frame
+ * through the client-side media processing pipeline.
+ *
+ * Covers every `wp.media` frame that uploads through `wp.Uploader`: the
+ * Media Library grid and the media modal the block editor, the site editor
+ * and the block widgets screen open. wp_enqueue_media() calls this on any
+ * screen that was cross-origin isolated, so it rarely needs calling directly.
+ *
+ * @since 7.2.0
+ */
+function wp_enqueue_media_frame_upload(): void {
+ _wp_enqueue_media_upload_pipeline_script( 'media-frame-upload' );
+}
+
+/**
+ * Enqueues the script that routes "Add New Media File" screen uploads
+ * through the client-side media processing pipeline.
+ *
+ * @since 7.2.0
+ */
+function wp_enqueue_media_new_upload(): void {
+ _wp_enqueue_media_upload_pipeline_script( 'media-new-upload' );
+}
+
/**
* Sends the Document-Isolation-Policy header for cross-origin isolation.
*
@@ -6711,22 +6974,56 @@ function wp_set_up_cross_origin_isolation(): bool {
*
* @since 7.2.0
*
+ * @global bool $_wp_cross_origin_isolated Whether the Document-Isolation-Policy header was sent.
+ *
* @return bool Whether the header was sent. False if the browser does not
* support Document-Isolation-Policy or headers were already sent.
*/
function wp_send_document_isolation_policy_header(): bool {
- if ( headers_sent() ) {
- return false;
- }
-
- $chromium_version = wp_get_chromium_major_version();
+ global $_wp_cross_origin_isolated;
- if ( null === $chromium_version || $chromium_version < 137 ) {
+ if ( headers_sent() || ! wp_is_document_isolation_policy_supported() ) {
return false;
}
header( 'Document-Isolation-Policy: isolate-and-credentialless' );
+ $_wp_cross_origin_isolated = true;
+
return true;
}
+/**
+ * Whether the current request has been set up for cross-origin isolation.
+ *
+ * True once wp_set_up_cross_origin_isolation() has sent the
+ * Document-Isolation-Policy header for the screen being rendered, which is
+ * what lets scripts that need a cross-origin isolated page, such as the
+ * client-side media upload integration, decide whether to load at all.
+ *
+ * A plugin can isolate a screen on its own by sending the header directly,
+ * as the Gutenberg plugin does on the block editor screens, so a header
+ * already queued for the response counts too.
+ *
+ * @since 7.2.0
+ *
+ * @global bool $_wp_cross_origin_isolated Whether the Document-Isolation-Policy header was sent.
+ *
+ * @return bool True when the response will be cross-origin isolated.
+ */
+function wp_is_cross_origin_isolated_request(): bool {
+ global $_wp_cross_origin_isolated;
+
+ if ( true === $_wp_cross_origin_isolated ) {
+ return true;
+ }
+
+ foreach ( headers_list() as $header ) {
+ if ( 0 === stripos( $header, 'Document-Isolation-Policy:' ) ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
diff --git a/wp-includes/rest-api/class-wp-rest-server.php b/wp-includes/rest-api/class-wp-rest-server.php
index 0603f79497..179edb04c8 100644
--- a/wp-includes/rest-api/class-wp-rest-server.php
+++ b/wp-includes/rest-api/class-wp-rest-server.php
@@ -1465,45 +1465,7 @@ class WP_REST_Server {
// Add media processing settings for users who can upload files.
if ( wp_is_client_side_media_processing_enabled() && current_user_can( 'upload_files' ) ) {
- // Image sizes keyed by name for client-side media processing.
- $available['image_sizes'] = array();
- foreach ( wp_get_registered_image_subsizes() as $name => $size ) {
- $available['image_sizes'][ $name ] = $size;
- }
-
- /** 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 );
-
- /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
- $available['image_strip_meta'] = (bool) apply_filters( 'image_strip_meta', true );
-
- /*
- * On the server, this filter receives the decoded image's actual bit depth.
- * The client path never decodes the image on the server, so the filter is
- * applied with 16 (the maximum depth the client encoder can produce) as
- * both the value and the current depth. The client caps its output bit
- * depth at the filtered value, so a plugin lowering it (e.g. to 8) takes
- * effect on client-generated images too.
- */
- /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
- $available['image_max_bit_depth'] = (int) apply_filters( 'image_max_bit_depth', 16, 16 );
-
- /**
- * Filters whether client-side media processing preserves animation in image sub-sizes.
- *
- * By default, sub-sizes of animated GIF and WebP images retain animation,
- * including cropped sizes. Return false to generate static sub-sizes using
- * only the first frame, reducing processing time in the browser.
- *
- * This setting applies only to client-side media processing. Server-side
- * processing continues to generate static sub-sizes.
- *
- * @since 7.2.0
- *
- * @param bool $generate_animated_image_subsizes Whether to preserve animation in image sub-sizes.
- * Default true.
- */
- $available['generate_animated_image_subsizes'] = (bool) apply_filters( 'wp_generate_animated_image_subsizes', true );
+ $available = array_merge( $available, _wp_get_client_side_media_image_settings() );
}
$response = new WP_REST_Response( $available );
diff --git a/wp-includes/script-loader.php b/wp-includes/script-loader.php
index 2c09f84bbc..fc3ebbd305 100644
--- a/wp-includes/script-loader.php
+++ b/wp-includes/script-loader.php
@@ -1495,6 +1495,14 @@ function wp_default_scripts( $scripts ) {
$scripts->add( 'media', "/wp-admin/js/media$suffix.js", array( 'jquery', 'clipboard', 'wp-i18n', 'wp-a11y' ), false, 1 );
$scripts->set_translations( 'media' );
+ $scripts->add( 'media-upload-pipeline', "/wp-admin/js/media-upload-pipeline$suffix.js", array( 'plupload', 'wp-upload-media', 'wp-media-utils', 'wp-api-fetch', 'wp-data', 'wp-element', 'wp-i18n' ), false, 1 );
+ $scripts->set_translations( 'media-upload-pipeline' );
+
+ $scripts->add( 'media-frame-upload', "/wp-admin/js/media-frame-upload$suffix.js", array( 'media-views', 'media-upload-pipeline' ), false, 1 );
+
+ $scripts->add( 'media-new-upload', "/wp-admin/js/media-new-upload$suffix.js", array( 'plupload-handlers', 'media-upload-pipeline', 'wp-a11y', 'wp-i18n' ), false, 1 );
+ $scripts->set_translations( 'media-new-upload' );
+
$scripts->add( 'image-edit', "/wp-admin/js/image-edit$suffix.js", array( 'jquery', 'jquery-ui-core', 'imgareaselect', 'wp-a11y' ), false, 1 );
$scripts->set_translations( 'image-edit' );
diff --git a/wp-includes/version.php b/wp-includes/version.php
index fb0020d1ed..4ee11ee863 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.2-alpha-64587';
+$wp_version = '7.2-alpha-64588';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.