Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backport-changelog/7.0/11168.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
https://github.com/WordPress/wordpress-develop/pull/11168

* https://github.com/WordPress/gutenberg/pull/74913
74 changes: 74 additions & 0 deletions lib/media/class-gutenberg-rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ public function register_routes(): void {
),
true // Override core's route so 'scaled' is included in the enum.
);

register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)/finalize',
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'finalize_item' ),
'permission_callback' => array( $this, 'edit_media_item_permissions_check' ),
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the attachment.', 'gutenberg' ),
'type' => 'integer',
),
),
),
'allow_batch' => $this->allow_batch,
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}

/**
Expand Down Expand Up @@ -232,6 +252,60 @@ public function create_item( $request ) {
return $response;
}

/**
* Finalizes an attachment after client-side media processing.
*
* Triggers the {@see 'wp_generate_attachment_metadata'} filter so that
* server-side plugins can process the attachment after all client-side
* operations (upload, thumbnail generation, sideloads) are complete.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
*/
public function finalize_item( WP_REST_Request $request ) {
$attachment_id = $request['id'];

$post = $this->get_post( $attachment_id );

if ( is_wp_error( $post ) ) {
return $post;
}

$metadata = wp_get_attachment_metadata( $attachment_id );

if ( ! is_array( $metadata ) ) {
$metadata = array();
}

/**
* Filters the attachment metadata after client-side processing.
*
* This re-applies the wp_generate_attachment_metadata filter so that
* server-side plugins (e.g. those adding custom image sizes or
* processing metadata) can run after client-side uploads are complete.
*
* @param array $metadata Attachment metadata.
* @param int $attachment_id Attachment ID.
* @param string $context Context: 'create' or 'update'.
*/
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
$metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' );
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a question, but I was wondering if we want finalize to be only for the filter, or if we'd want to call wp_update_image_subsizes as a catch all?

I'm very possibly overthinking this, but if for some reason the client-side processing didn't generate everything it needed to, I was wondering if the wp_update_image_subsizes function could cover things off on the server-side.

That said, I'm suspicious of my own comment here, so mostly just wanted to capture the thought in case there's anything there.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intention here is that wp_generate_attachment_metadata gets triggered only when all of the image processing and uploading is completed successfully. We shouldn't need to generate additional sizes once this is called.

If client side generation fails to generate all of the sub sizes for any reason, we will continue that process at the next possible opportunity (when the editor is open & connected on the same post).


wp_update_attachment_metadata( $attachment_id, $metadata );

$response_request = new WP_REST_Request(
WP_REST_Server::READABLE,
rest_get_route_for_post( $attachment_id )
);

$response_request['context'] = 'edit';

if ( isset( $request['_fields'] ) ) {
$response_request['_fields'] = $request['_fields'];
}

return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request );
}

/**
* Checks if a given request has access to sideload a file.
Expand Down
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function useMediaUploadSettings( settings = {} ) {
() => ( {
mediaUpload: settings.mediaUpload,
mediaSideload: settings.mediaSideload,
mediaFinalize: settings.mediaFinalize,
maxUploadFileSize: settings.maxUploadFileSize,
allowedMimeTypes: settings.allowedMimeTypes,
allImageSizes: settings.allImageSizes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import inserterMediaCategories from '../media-categories';
import { mediaUpload } from '../../utils';
import { default as mediaSideload } from '../../utils/media-sideload';
import { default as mediaFinalize } from '../../utils/media-finalize';
import { store as editorStore } from '../../store';
import { unlock } from '../../lock-unlock';
import { useGlobalStylesContext } from '../global-styles-provider';
Expand Down Expand Up @@ -337,6 +338,7 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
: undefined,
mediaUpload: hasUploadPermissions ? mediaUpload : undefined,
mediaSideload: hasUploadPermissions ? mediaSideload : undefined,
mediaFinalize: hasUploadPermissions ? mediaFinalize : undefined,
__experimentalBlockPatterns: blockPatterns,
[ selectBlockPatternsKey ]: ( select ) => {
const { hasFinishedResolution, getBlockPatternsForPostType } =
Expand Down
11 changes: 11 additions & 0 deletions packages/editor/src/utils/media-finalize/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* WordPress dependencies
*/
import apiFetch from '@wordpress/api-fetch';

export default async function mediaFinalize( id ) {
await apiFetch( {
path: `/wp/v2/media/${ id }/finalize`,
method: 'POST',
} );
}
34 changes: 34 additions & 0 deletions packages/editor/src/utils/media-finalize/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* WordPress dependencies
*/
import apiFetch from '@wordpress/api-fetch';

/**
* Internal dependencies
*/
import mediaFinalize from '..';

jest.mock( '@wordpress/api-fetch', () => jest.fn() );

describe( 'mediaFinalize', () => {
beforeEach( () => {
jest.clearAllMocks();
} );

it( 'should call the finalize endpoint with the correct path and method', async () => {
apiFetch.mockResolvedValue( {} );

await mediaFinalize( 123 );

expect( apiFetch ).toHaveBeenCalledWith( {
path: '/wp/v2/media/123/finalize',
method: 'POST',
} );
} );

it( 'should propagate errors from apiFetch', async () => {
apiFetch.mockRejectedValue( new Error( 'Network error' ) );

await expect( mediaFinalize( 456 ) ).rejects.toThrow( 'Network error' );
} );
} );
1 change: 0 additions & 1 deletion packages/upload-media/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
"build-module/store/index.mjs"
],
"dependencies": {
"@wordpress/api-fetch": "file:../api-fetch",
"@wordpress/blob": "file:../blob",
"@wordpress/compose": "file:../compose",
"@wordpress/data": "file:../data",
Expand Down
69 changes: 64 additions & 5 deletions packages/upload-media/src/store/private-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { v4 as uuidv4 } from 'uuid';
*/
import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob';
import type { createRegistry } from '@wordpress/data';

type WPDataRegistry = ReturnType< typeof createRegistry >;

/**
Expand Down Expand Up @@ -74,6 +73,7 @@ type ActionCreators = {
rotateItem: typeof rotateItem;
transcodeImageItem: typeof transcodeImageItem;
generateThumbnails: typeof generateThumbnails;
finalizeItem: typeof finalizeItem;
updateItemProgress: typeof updateItemProgress;
revokeBlobUrls: typeof revokeBlobUrls;
< T = Record< string, unknown > >( args: T ): void;
Expand Down Expand Up @@ -380,6 +380,15 @@ export function processItem( id: QueueItemId ) {
return;
}

// If parent has pending operations (like Finalize), trigger them.
if (
parentItem.operations &&
parentItem.operations.length > 0
) {
dispatch.processItem( parentId );
return;
}

if ( attachment ) {
parentItem.onSuccess?.( [ attachment ] );
}
Expand All @@ -403,6 +412,14 @@ export function processItem( id: QueueItemId ) {
return;
}

// For Finalize, wait until all child sideloads are complete.
if (
operation === OperationType.Finalize &&
select.hasPendingItemsByParentId( id )
) {
return;
}

dispatch< OperationStartAction >( {
type: Type.OperationStart,
id,
Expand Down Expand Up @@ -446,6 +463,10 @@ export function processItem( id: QueueItemId ) {
case OperationType.ThumbnailGeneration:
dispatch.generateThumbnails( id );
break;

case OperationType.Finalize:
dispatch.finalizeItem( id );
break;
}
};
}
Expand Down Expand Up @@ -735,7 +756,8 @@ export function prepareItem( id: QueueItemId ) {

operations.push(
OperationType.Upload,
OperationType.ThumbnailGeneration
OperationType.ThumbnailGeneration,
OperationType.Finalize
);
} else {
operations.push( OperationType.Upload );
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When not dealing with an image I assume we don't need to also fire the Finalize operation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right! Finalize is only needed for images since it triggers wp_generate_attachment_metadata after client-side processing completes. For non-images, the server handles everything
during upload via generate_sub_sizes: true (line 774-782), so there's no client-side work to finalize.

Expand Down Expand Up @@ -1108,6 +1130,11 @@ export function generateThumbnails( id: QueueItemId ) {
attachment.missing_image_sizes &&
attachment.missing_image_sizes.length > 0
) {
const settings = select.getSettings();
const allImageSizes = settings.allImageSizes || {};
const sizesToGenerate: string[] =
attachment.missing_image_sizes as string[];

// Use sourceFile for thumbnail generation to preserve quality.
// WordPress core generates thumbnails from the original (unscaled) image.
// Vips will auto-rotate based on EXIF orientation during thumbnail generation.
Expand All @@ -1116,8 +1143,6 @@ export function generateThumbnails( id: QueueItemId ) {
: item.sourceFile;
const batchId = uuidv4();

const settings = select.getSettings();
const allImageSizes = settings.allImageSizes || {};
const { imageOutputFormats } = settings;

// Check if thumbnails should be transcoded to a different format.
Expand All @@ -1141,7 +1166,7 @@ export function generateThumbnails( id: QueueItemId ) {
);
}

for ( const name of attachment.missing_image_sizes ) {
for ( const name of sizesToGenerate ) {
const imageSize = allImageSizes[ name ];
if ( ! imageSize ) {
// eslint-disable-next-line no-console
Expand Down Expand Up @@ -1255,6 +1280,40 @@ export function generateThumbnails( id: QueueItemId ) {
};
}

/**
* Finalizes an uploaded item by calling the server's finalize endpoint.
*
* This triggers the wp_generate_attachment_metadata filter so that PHP
* plugins can process the attachment after all client-side operations
* (including thumbnail sideloads) are complete.
*
* @param id Item ID.
*/
export function finalizeItem( id: QueueItemId ) {
return async ( { select, dispatch }: ThunkArgs ) => {
const item = select.getItem( id );
if ( ! item ) {
return;
}

const attachment = item.attachment;
const { mediaFinalize } = select.getSettings();

// Only finalize if we have an attachment ID and a mediaFinalize callback.
if ( attachment?.id && mediaFinalize ) {
try {
await mediaFinalize( attachment.id );
} catch ( error ) {
// Log but don't fail the upload if finalization fails.
// eslint-disable-next-line no-console
console.warn( 'Media finalization failed:', error );
}
}

dispatch.finishOperation( id, {} );
};
}

/**
* Revokes all blob URLs for a given item, freeing up memory.
*
Expand Down
Loading