diff --git a/backport-changelog/7.0/11168.md b/backport-changelog/7.0/11168.md new file mode 100644 index 00000000000000..186f016ba2bf8e --- /dev/null +++ b/backport-changelog/7.0/11168.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/11168 + +* https://github.com/WordPress/gutenberg/pull/74913 diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index 8183e75463fac1..18fc3fb0ed8865 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -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[\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' ), + ) + ); } /** @@ -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' ); + + 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. diff --git a/package-lock.json b/package-lock.json index 4111c2f52539ab..22c2d70c3d9bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62728,7 +62728,6 @@ "version": "0.26.0", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", diff --git a/packages/block-editor/src/components/provider/use-media-upload-settings.js b/packages/block-editor/src/components/provider/use-media-upload-settings.js index 7c00c145d27a72..b92ed33b7e0cea 100644 --- a/packages/block-editor/src/components/provider/use-media-upload-settings.js +++ b/packages/block-editor/src/components/provider/use-media-upload-settings.js @@ -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, diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index c17eb4ee4cc100..940ef455a8f737 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -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'; @@ -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 } = diff --git a/packages/editor/src/utils/media-finalize/index.js b/packages/editor/src/utils/media-finalize/index.js new file mode 100644 index 00000000000000..d7459f1e8512be --- /dev/null +++ b/packages/editor/src/utils/media-finalize/index.js @@ -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', + } ); +} diff --git a/packages/editor/src/utils/media-finalize/test/index.js b/packages/editor/src/utils/media-finalize/test/index.js new file mode 100644 index 00000000000000..eeba1c0a9893f3 --- /dev/null +++ b/packages/editor/src/utils/media-finalize/test/index.js @@ -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' ); + } ); +} ); diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index 37b3fdeab8bd76..0d967ab5472bc9 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -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", diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index fa22d3f61e3df4..9d3aa2b91a6956 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -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 >; /** @@ -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; @@ -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 ] ); } @@ -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, @@ -446,6 +463,10 @@ export function processItem( id: QueueItemId ) { case OperationType.ThumbnailGeneration: dispatch.generateThumbnails( id ); break; + + case OperationType.Finalize: + dispatch.finalizeItem( id ); + break; } }; } @@ -735,7 +756,8 @@ export function prepareItem( id: QueueItemId ) { operations.push( OperationType.Upload, - OperationType.ThumbnailGeneration + OperationType.ThumbnailGeneration, + OperationType.Finalize ); } else { operations.push( OperationType.Upload ); @@ -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. @@ -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. @@ -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 @@ -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. * diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index d9f3072a1648a6..ab58b1f20704d2 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -2,7 +2,6 @@ * WordPress dependencies */ import { createRegistry } from '@wordpress/data'; - type WPDataRegistry = ReturnType< typeof createRegistry >; /** @@ -21,7 +20,13 @@ jest.mock( '@wordpress/blob', () => ( { jest.mock( '../utils', () => ( { vipsCancelOperations: jest.fn( () => Promise.resolve( true ) ), - vipsResizeImage: jest.fn(), + vipsResizeImage: jest.fn( () => + Promise.resolve( + new File( [ 'resized' ], 'example-100x100.jpg', { + type: 'image/jpeg', + } ) + ) + ), vipsRotateImage: jest.fn(), vipsHasTransparency: jest.fn( () => Promise.resolve( false ) ), vipsConvertImageFormat: jest.fn(), @@ -415,6 +420,73 @@ describe( 'actions', () => { } ); } ); + describe( 'resizeCropItem', () => { + it( 'uses imageQuality from store settings when set', async () => { + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + imageQuality: 0.5, + } ); + + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + const { vipsResizeImage } = require( '../utils' ); + ( vipsResizeImage as jest.Mock ).mockClear(); + + await unlock( registry.dispatch( uploadStore ) ).resizeCropItem( + item.id, + { resize: { width: 100, height: 100 } } + ); + + // Verify the resize was called (quality will be wired through in a future update). + expect( vipsResizeImage ).toHaveBeenCalled(); + } ); + + it( 'falls back to default quality when imageQuality is not set', async () => { + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + const { vipsResizeImage } = require( '../utils' ); + ( vipsResizeImage as jest.Mock ).mockClear(); + + await unlock( registry.dispatch( uploadStore ) ).resizeCropItem( + item.id, + { resize: { width: 100, height: 100 } } + ); + + expect( vipsResizeImage ).toHaveBeenCalled(); + } ); + + it( 'skips resize when no resize args are provided', async () => { + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + } ); + + const item = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + await unlock( registry.dispatch( uploadStore ) ).resizeCropItem( + item.id + ); + + // Item should finish without resize. + const updatedItem = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + expect( updatedItem.file ).toBe( jpegFile ); + } ); + } ); + describe( 'generateThumbnails', () => { const mockBitmapClose = jest.fn(); diff --git a/packages/upload-media/src/store/test/private-actions.js b/packages/upload-media/src/store/test/private-actions.js index 3ddeff196ddf5b..5da1dace106aea 100644 --- a/packages/upload-media/src/store/test/private-actions.js +++ b/packages/upload-media/src/store/test/private-actions.js @@ -6,7 +6,7 @@ import { createBlobURL, revokeBlobURL } from '@wordpress/blob'; /** * Internal dependencies */ -import { getTranscodeImageOperation } from '../private-actions'; +import { getTranscodeImageOperation, finalizeItem } from '../private-actions'; import { OperationType } from '../types'; import { vipsHasTransparency } from '../utils'; @@ -251,4 +251,99 @@ describe( 'private actions', () => { expect( result ).toBeNull(); } ); } ); + + describe( 'finalizeItem', () => { + it( 'should call mediaFinalize with the attachment ID', async () => { + const mediaFinalize = jest.fn().mockResolvedValue( undefined ); + const finishOperation = jest.fn(); + const select = { + getItem: () => ( { + attachment: { id: 42 }, + } ), + getSettings: () => ( { mediaFinalize } ), + }; + const dispatch = { finishOperation }; + + const thunk = finalizeItem( 'test-id' ); + await thunk( { select, dispatch } ); + + expect( mediaFinalize ).toHaveBeenCalledWith( 42 ); + expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); + } ); + + it( 'should not call mediaFinalize when no callback is provided', async () => { + const finishOperation = jest.fn(); + const select = { + getItem: () => ( { + attachment: { id: 42 }, + } ), + getSettings: () => ( {} ), + }; + const dispatch = { finishOperation }; + + const thunk = finalizeItem( 'test-id' ); + await thunk( { select, dispatch } ); + + expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); + } ); + + it( 'should not call mediaFinalize when there is no attachment ID', async () => { + const mediaFinalize = jest.fn(); + const finishOperation = jest.fn(); + const select = { + getItem: () => ( { + attachment: {}, + } ), + getSettings: () => ( { mediaFinalize } ), + }; + const dispatch = { finishOperation }; + + const thunk = finalizeItem( 'test-id' ); + await thunk( { select, dispatch } ); + + expect( mediaFinalize ).not.toHaveBeenCalled(); + expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); + } ); + + it( 'should handle mediaFinalize errors gracefully', async () => { + const mediaFinalize = jest + .fn() + .mockRejectedValue( new Error( 'Network error' ) ); + const finishOperation = jest.fn(); + const warnSpy = jest + .spyOn( console, 'warn' ) + .mockImplementation( () => {} ); + const select = { + getItem: () => ( { + attachment: { id: 42 }, + } ), + getSettings: () => ( { mediaFinalize } ), + }; + const dispatch = { finishOperation }; + + const thunk = finalizeItem( 'test-id' ); + await thunk( { select, dispatch } ); + + expect( mediaFinalize ).toHaveBeenCalledWith( 42 ); + expect( warnSpy ).toHaveBeenCalledWith( + 'Media finalization failed:', + expect.any( Error ) + ); + expect( finishOperation ).toHaveBeenCalledWith( 'test-id', {} ); + warnSpy.mockRestore(); + } ); + + it( 'should return early when item is not found', async () => { + const finishOperation = jest.fn(); + const select = { + getItem: () => undefined, + }; + const dispatch = { finishOperation }; + + const thunk = finalizeItem( 'test-id' ); + await thunk( { select, dispatch } ); + + expect( finishOperation ).not.toHaveBeenCalled(); + } ); + } ); } ); diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 579ad27d5c7e9e..da9c1d11661f8c 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -178,6 +178,11 @@ export interface Settings { pngInterlaced?: boolean; // Whether to use interlaced encoding for GIF. gifInterlaced?: boolean; + // Default image quality (0-1) for resize/crop operations. + // Default is 0.82 if not set. + imageQuality?: number; + // Function for finalizing an upload after all client-side processing is complete. + mediaFinalize?: ( id: number ) => Promise< void >; } // Matches the Attachment type from the media-utils package. @@ -232,6 +237,7 @@ export enum OperationType { Rotate = 'ROTATE', TranscodeImage = 'TRANSCODE_IMAGE', ThumbnailGeneration = 'THUMBNAIL_GENERATION', + Finalize = 'FINALIZE', } /**