UNPKG

@wordpress/upload-media

Version:
1,748 lines (1,591 loc) 55 kB
/** * External dependencies */ import { v4 as uuidv4 } from 'uuid'; /** * WordPress dependencies */ import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; import type { createRegistry } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; type WPDataRegistry = ReturnType< typeof createRegistry >; /** * Internal dependencies */ import { cloneFile, convertBlobToFile, isAnimatedGif, renameFile, } from '../utils'; import { canvasConvertToJpeg } from '../canvas-utils'; import { getUnappliedExifOrientation } from '../heic-parser'; import { isClientSideMediaSupported, exceedsClientProcessingMemory, } from '../feature-detection'; import { getImageDimensions } from '../get-image-dimensions'; import { CLIENT_SIDE_SUPPORTED_MIME_TYPES, HEIC_MIME_TYPES } from './constants'; import { StubFile } from '../stub-file'; import { ErrorCode, UploadError } from '../upload-error'; import { measure } from './utils/debug-logger'; import { vipsResizeImage, vipsRotateImage, vipsConvertImageFormat, vipsHasTransparency, vipsGetUltraHdrInfo, terminateVipsWorker, maybeRecycleVipsWorker, } from './utils'; import { convertGifToVideo, isUnsupportedConversionError, terminateVideoConversionWorker, } from './utils/video-conversion'; import type { AccumulateSubSizeAction, AddAction, AdditionalData, AddOperationsAction, Attachment, BatchId, CacheBlobUrlAction, ImageFormat, OnBatchSuccessHandler, OnChangeHandler, OnErrorHandler, OnSuccessHandler, Operation, OperationArgs, OperationFinishAction, OperationStartAction, PauseItemAction, PauseQueueAction, QueueItem, QueueItemId, ResumeQueueAction, RevokeBlobUrlsAction, SideloadAdditionalData, Settings, State, SubSizeData, UpdateProgressAction, UpdateSettingsAction, } from './types'; import { ItemStatus, OperationType, Type } from './types'; import type { cancelItem, executeRetry } from './actions'; import { clearRetryTimer } from './utils/retry'; const DEFAULT_OUTPUT_QUALITY = 0.82; /** * Tracks parent item IDs whose source file is an UltraHDR JPEG so that * sub-size resize operations can route through libvips's uhdrload/uhdrsave * to preserve the gain map. Entries are cleared in `removeItem` when the * parent item leaves the queue, covering both successful completion and * cancellation. */ const ultraHdrItems = new Set< QueueItemId >(); type ActionCreators = { cancelItem: typeof cancelItem; executeRetry: typeof executeRetry; addItem: typeof addItem; addSideloadItem: typeof addSideloadItem; removeItem: typeof removeItem; pauseItem: typeof pauseItem; prepareItem: typeof prepareItem; processItem: typeof processItem; finishOperation: typeof finishOperation; uploadItem: typeof uploadItem; sideloadItem: typeof sideloadItem; resizeCropItem: typeof resizeCropItem; rotateItem: typeof rotateItem; transcodeImageItem: typeof transcodeImageItem; transcodeGifItem: typeof transcodeGifItem; generateThumbnails: typeof generateThumbnails; finalizeItem: typeof finalizeItem; updateItemProgress: typeof updateItemProgress; revokeBlobUrls: typeof revokeBlobUrls; detectUltraHdr: typeof detectUltraHdr; < T = Record< string, unknown > >( args: T ): void; }; type AllSelectors = typeof import('./selectors') & typeof import('./private-selectors'); type CurriedState< F > = F extends ( state: State, ...args: infer P ) => infer R ? ( ...args: P ) => R : F; type Selectors = { [ key in keyof AllSelectors ]: CurriedState< AllSelectors[ key ] >; }; type ThunkArgs = { select: Selectors; dispatch: ActionCreators; registry: WPDataRegistry; }; interface AddItemArgs { // It should always be a File, but some consumers might still pass Blobs only. file: File | Blob; batchId?: BatchId; onChange?: OnChangeHandler; onSuccess?: OnSuccessHandler; onError?: OnErrorHandler; onBatchSuccess?: OnBatchSuccessHandler; additionalData?: AdditionalData; sourceUrl?: string; sourceAttachmentId?: number; abortController?: AbortController; operations?: Operation[]; } /** * Adds a new item to the upload queue. * * @param $0 * @param $0.file File * @param [$0.batchId] Batch ID. * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available. * @param [$0.onSuccess] Function called after the file is uploaded. * @param [$0.onBatchSuccess] Function called after a batch of files is uploaded. * @param [$0.onError] Function called when an error happens. * @param [$0.additionalData] Additional data to include in the request. * @param [$0.sourceUrl] Source URL. Used when importing a file from a URL or optimizing an existing file. * @param [$0.sourceAttachmentId] Source attachment ID. Used when optimizing an existing file for example. * @param [$0.abortController] Abort controller for upload cancellation. * @param [$0.operations] List of operations to perform. Defaults to automatically determined list, based on the file. */ export function addItem( { file: fileOrBlob, batchId, onChange, onSuccess, onBatchSuccess, onError, additionalData = {} as AdditionalData, sourceUrl, sourceAttachmentId, abortController, operations, }: AddItemArgs ) { return async ( { dispatch }: ThunkArgs ) => { const itemId = uuidv4(); // Hardening in case a Blob is passed instead of a File. // See https://github.com/WordPress/gutenberg/pull/65693 for an example. const file = convertBlobToFile( fileOrBlob ); let blobUrl; // StubFile could be coming from addItemFromUrl(). if ( ! ( file instanceof StubFile ) ) { blobUrl = createBlobURL( file ); dispatch< CacheBlobUrlAction >( { type: Type.CacheBlobUrl, id: itemId, blobUrl, } ); } dispatch< AddAction >( { type: Type.Add, item: { id: itemId, batchId, status: ItemStatus.Processing, sourceFile: cloneFile( file ), file, attachment: { url: blobUrl, }, additionalData: { generate_sub_sizes: false, ...additionalData, }, onChange, onSuccess, onBatchSuccess, onError, sourceUrl, sourceAttachmentId, abortController: abortController || new AbortController(), operations: Array.isArray( operations ) ? operations : [ OperationType.Prepare ], }, } ); dispatch.processItem( itemId ); }; } interface AddSideloadItemArgs { file: File; onChange?: OnChangeHandler; additionalData?: AdditionalData; operations?: Operation[]; batchId?: BatchId; parentId?: QueueItemId; } /** * Adds a new item to the upload queue for sideloading. * * This is typically a client-side generated thumbnail. * * @param $0 * @param $0.file File * @param [$0.batchId] Batch ID. * @param [$0.parentId] Parent ID. * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available. * @param [$0.additionalData] Additional data to include in the request. * @param [$0.operations] List of operations to perform. Defaults to automatically determined list, based on the file. */ export function addSideloadItem( { file, onChange, additionalData, operations, batchId, parentId, }: AddSideloadItemArgs ) { return ( { dispatch }: ThunkArgs ) => { const itemId = uuidv4(); dispatch< AddAction >( { type: Type.Add, item: { id: itemId, batchId, status: ItemStatus.Processing, sourceFile: cloneFile( file ), file, onChange, additionalData: { ...additionalData, }, parentId, operations: Array.isArray( operations ) ? operations : [ OperationType.Prepare ], abortController: new AbortController(), }, } ); dispatch.processItem( itemId ); }; } /** * Processes a single item in the queue. * * Runs the next operation in line and invokes any callbacks. * * @param id Item ID. */ export function processItem( id: QueueItemId ) { return async ( { select, dispatch }: ThunkArgs ) => { if ( select.isPaused() ) { return; } const item = select.getItem( id ); if ( ! item ) { return; } const { attachment, onChange, onSuccess, onBatchSuccess, batchId, parentId, } = item; const operation = Array.isArray( item.operations?.[ 0 ] ) ? item.operations[ 0 ][ 0 ] : item.operations?.[ 0 ]; const operationArgs = Array.isArray( item.operations?.[ 0 ] ) ? item.operations[ 0 ][ 1 ] : undefined; /* * If the next operation is an upload, check concurrency limit. * If at capacity, the item remains queued and will be processed * when another upload completes. */ if ( operation === OperationType.Upload ) { const settings = select.getSettings(); const activeCount = select.getActiveUploadCount(); if ( activeCount >= settings.maxConcurrentUploads ) { return; } } /* * If the next operation is image processing (resize/crop/rotate), * check the image processing concurrency limit. * If at capacity, the item remains queued and will be processed * when another image processing operation completes. */ if ( operation === OperationType.ResizeCrop || operation === OperationType.Rotate ) { const settings = select.getSettings(); const activeCount = select.getActiveImageProcessingCount(); if ( activeCount >= settings.maxConcurrentImageProcessing ) { return; } } /* * GIF-to-video conversion is memory-intensive (WebCodecs encode). * Limit to 1 concurrent transcoding operation. */ if ( operation === OperationType.TranscodeGif ) { const activeCount = select.getActiveVideoProcessingCount(); if ( activeCount >= 1 ) { return; } } if ( attachment ) { // Don't update the block with a HEIC URL — the browser can't // display it. The scaled JPEG sideload will call onChange // with a usable URL once the client-side conversion completes. const isHeicUrl = attachment.url && /\.hei[cf]$/i.test( attachment.url ); if ( ! isHeicUrl ) { onChange?.( [ attachment ] ); } } /* If there are no more operations, the item can be removed from the queue, but only if there are no thumbnails still being side-loaded, or if itself is a side-loaded item. */ if ( ! operation ) { if ( parentId || ( ! parentId && ! select.hasPendingItemsByParentId( id ) ) ) { if ( attachment ) { onSuccess?.( [ attachment ] ); } dispatch.removeItem( id ); dispatch.revokeBlobUrls( id ); if ( batchId && select.isBatchUploaded( batchId ) ) { onBatchSuccess?.(); } } // All other side-loaded items have been removed, so remove the parent too. if ( parentId && batchId && select.isBatchUploaded( batchId ) ) { const parentItem = select.getItem( parentId ) as QueueItem; if ( ! parentItem ) { 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 ] ); } dispatch.removeItem( parentId ); dispatch.revokeBlobUrls( parentId ); if ( parentItem.batchId && select.isBatchUploaded( parentItem.batchId ) ) { parentItem.onBatchSuccess?.(); } } /* At this point we are dealing with a parent whose children haven't fully uploaded yet. Do nothing and let the removal happen once the last side-loaded item finishes. */ return; } // For Finalize, wait until all child sideloads are complete. if ( operation === OperationType.Finalize && select.hasPendingItemsByParentId( id ) ) { return; } dispatch< OperationStartAction >( { type: Type.OperationStart, id, operation, } ); switch ( operation ) { case OperationType.Prepare: dispatch.prepareItem( item.id ); break; case OperationType.ResizeCrop: dispatch.resizeCropItem( item.id, operationArgs as OperationArgs[ OperationType.ResizeCrop ] ); break; case OperationType.Rotate: dispatch.rotateItem( item.id, operationArgs as OperationArgs[ OperationType.Rotate ] ); break; case OperationType.TranscodeImage: dispatch.transcodeImageItem( item.id, operationArgs as OperationArgs[ OperationType.TranscodeImage ] ); break; case OperationType.TranscodeGif: dispatch.transcodeGifItem( item.id, operationArgs as OperationArgs[ OperationType.TranscodeGif ] ); break; case OperationType.Upload: if ( item.parentId ) { dispatch.sideloadItem( id ); } else { dispatch.uploadItem( id ); } break; case OperationType.ThumbnailGeneration: dispatch.generateThumbnails( id ); break; case OperationType.Finalize: dispatch.finalizeItem( id ); break; case OperationType.DetectUltraHdr: dispatch.detectUltraHdr( id ); break; } }; } /** * Returns an action object that pauses all processing in the queue. * * Useful for testing purposes. * * @return Action object. */ export function pauseQueue(): PauseQueueAction { return { type: Type.PauseQueue, }; } /** * Resumes all processing in the queue. * * Dispatches an action object for resuming the queue itself, * and triggers processing for each remaining item in the queue individually. */ export function resumeQueue() { return async ( { select, dispatch }: ThunkArgs ) => { dispatch< ResumeQueueAction >( { type: Type.ResumeQueue, } ); for ( const item of select.getAllItems() ) { // Items left in PendingRetry while paused had their timers // cleared when the timer fired during pause. Re-trigger the // retry now that the queue is active again. if ( item.status === ItemStatus.PendingRetry ) { dispatch.executeRetry( item.id ); } else { dispatch.processItem( item.id ); } } }; } /** * Pauses a specific item in the queue. * * @param id Item ID. */ export function pauseItem( id: QueueItemId ) { return async ( { dispatch }: ThunkArgs ) => { dispatch< PauseItemAction >( { type: Type.PauseItem, id, } ); }; } /** * Removes a specific item from the queue. * * @param id Item ID. */ export function removeItem( id: QueueItemId ) { return async ( { select, dispatch }: ThunkArgs ) => { const item = select.getItem( id ); if ( ! item ) { return; } // Clear any UltraHDR tracking for this item. removeItem runs on both // successful completion and cancellation, so this prevents the set // from growing unbounded over a long editing session. ultraHdrItems.delete( id ); // Clear any pending retry timer for this item. clearRetryTimer( id ); dispatch( { type: Type.Remove, id, } ); /* * If the queue is now empty, terminate the background workers to free * their memory (WASM for VIPS, the WebCodecs encoder for video * conversion). Both are lazily re-created if needed. */ if ( select.getAllItems().length === 0 ) { terminateVipsWorker(); terminateVideoConversionWorker(); } }; } /** * Finishes an operation for a given item ID and immediately triggers processing the next one. * * @param id Item ID. * @param updates Updated item data. */ export function finishOperation( id: QueueItemId, updates: Partial< QueueItem > ) { return async ( { select, dispatch }: ThunkArgs ) => { const item = select.getItem( id ); const previousOperation = item?.currentOperation; dispatch< OperationFinishAction >( { type: Type.OperationFinish, id, item: updates, } ); dispatch.processItem( id ); /* * If an upload just finished, there may be items waiting in the queue * due to concurrency limits. Trigger processing for them. */ if ( previousOperation === OperationType.Upload ) { const pendingUploads = select.getPendingUploads(); for ( const pendingItem of pendingUploads ) { dispatch.processItem( pendingItem.id ); } } /* * If an image processing operation just finished, there may be items * waiting in the queue due to the image processing concurrency limit. * Trigger processing for them. */ if ( previousOperation === OperationType.ResizeCrop || previousOperation === OperationType.Rotate ) { const pendingItems = select.getPendingImageProcessing(); for ( const pendingItem of pendingItems ) { dispatch.processItem( pendingItem.id ); } } /* * If a video processing operation just finished, there may be items * waiting due to the video processing concurrency limit. */ if ( previousOperation === OperationType.TranscodeGif ) { const pendingItems = select.getPendingVideoProcessing(); for ( const pendingItem of pendingItems ) { dispatch.processItem( pendingItem.id ); } } // Track vips operations across success and failure paths so a // burst of failures can't bypass the recycle budget; the cancel // path calls the same helper. if ( previousOperation === OperationType.ResizeCrop || previousOperation === OperationType.Rotate || previousOperation === OperationType.TranscodeImage ) { maybeRecycleVipsWorker( select.getActiveImageProcessingCount() ); } }; } const VALID_IMAGE_FORMATS = [ 'jpeg', 'webp', 'avif', 'png', 'gif' ] as const; /** * Checks if a format string is a valid ImageFormat. * * @param format The format string to validate. * @return Whether the format is valid. */ function isValidImageFormat( format: string ): format is ImageFormat { return VALID_IMAGE_FORMATS.includes( format as ImageFormat ); } /** * Determines if an image should be transcoded to a different format. * * Handles PNG to JPEG conversion carefully by checking for transparency * to preserve the alpha channel when needed. * * @param file The image file. * @param outputMimeType The target output MIME type. * @param interlaced Whether to use interlaced encoding. * @param quality Re-encode quality (0-1). Defaults to DEFAULT_OUTPUT_QUALITY. * @return The transcode operation tuple if transcoding is needed, null otherwise. */ export async function getTranscodeImageOperation( file: File, outputMimeType: string, interlaced: boolean = false, quality: number = DEFAULT_OUTPUT_QUALITY ): Promise< | [ OperationType.TranscodeImage, OperationArgs[ OperationType.TranscodeImage ], ] | null > { // For PNG -> JPEG conversion, check if the image has transparency. // If it does, skip transcoding to preserve the alpha channel. if ( file.type === 'image/png' && outputMimeType === 'image/jpeg' ) { const blobUrl = createBlobURL( file ); try { const hasAlpha = await vipsHasTransparency( blobUrl ); if ( hasAlpha ) { // Image has transparency, skip conversion to JPEG. return null; } } catch { // If transparency check fails, err on the side of caution. return null; } finally { revokeBlobURL( blobUrl ); } } const formatPart = outputMimeType.split( '/' )[ 1 ]; if ( ! isValidImageFormat( formatPart ) ) { // Unknown format, skip transcoding. return null; } return [ OperationType.TranscodeImage, { outputFormat: formatPart, outputQuality: quality, interlaced, }, ]; } /** * Prepares an item for initial processing. * * Determines the list of operations to perform for a given image, * depending on its media type. * * For example, HEIF images first need to be converted, resized, * compressed, and then uploaded. * * Or videos need to be compressed, and then need poster generation * before upload. * * UltraHDR JPEG images are detected and uploaded unmodified — they are * already backwards compatible (SDR displays use the embedded base image). * * @param id Item ID. */ export function prepareItem( id: QueueItemId ) { return async ( { select, dispatch }: ThunkArgs ) => { const item = select.getItem( id ); if ( ! item ) { return; } const { file } = item; const operations: Operation[] = []; const settings = select.getSettings(); // Animated GIF → video. WebCodecs is required; client-side media // already runs only under cross-origin isolation, so this is a // capability check, not a browser-support fallback path. // // The GIF uploads through the normal image pipeline so the block // starts as a valid core/image. The converted video is sideloaded as // a companion file of this same attachment after upload (see // generateThumbnails) — like the HEIC original — not as a separate // media library attachment. It is recorded in attachment metadata; the // editor offers switching the block to the Video block's GIF variation // playing that companion via a block transform (see // packages/block-library/src/image/transforms.js). if ( file.type === 'image/gif' && settings.gifConvert !== false && typeof ImageDecoder !== 'undefined' && typeof VideoEncoder !== 'undefined' ) { let isAnimated = false; try { isAnimated = isAnimatedGif( await file.arrayBuffer() ); } catch { // If the GIF cannot be read/inspected, fall through to the // normal image pipeline rather than failing the upload. isAnimated = false; } if ( isAnimated ) { // Skip the conversion for transparent GIFs: a <video> cannot // reproduce GIF transparency, so converting would visibly // change the image (e.g. small decorative or emoji-like GIFs // over a colored background). Such GIFs upload as a normal // image instead. Mirrors the PNG → JPEG transparency check in // getTranscodeImageOperation(). let hasTransparency = false; const blobUrl = createBlobURL( file ); try { hasTransparency = await vipsHasTransparency( blobUrl ); } catch { // If the check fails, err on the side of caution and keep // the GIF rather than risk a lossy conversion. hasTransparency = true; } finally { revokeBlobURL( blobUrl ); } if ( ! hasTransparency ) { operations.push( OperationType.Upload, OperationType.ThumbnailGeneration, OperationType.Finalize ); dispatch< AddOperationsAction >( { type: Type.AddOperations, id, operations, } ); // Keep the original GIF so generateThumbnails can // transcode and sideload it once the attachment exists. dispatch.finishOperation( id, { animatedGifFile: item.file, } ); return; } } } let heicJpeg: File | null = null; const isImage = file.type.startsWith( 'image/' ); const isVipsSupported = CLIENT_SIDE_SUPPORTED_MIME_TYPES.includes( file.type ); const isHeic = HEIC_MIME_TYPES.includes( file.type ); // Gate very large images out of client-side processing. wasm-vips is // capped at 1 GiB of memory, so high-megapixel images, especially // interlaced/progressive ones, which can't be decoded with // shrink-on-load, can exhaust it and fail. These are routed to the // server, which has no comparable per-image ceiling. If dimensions // can't be determined, the image stays on the client-side path. let tooLargeForClient = false; if ( isImage && isVipsSupported ) { const dimensions = await getImageDimensions( file ); if ( dimensions && exceedsClientProcessingMemory( dimensions ) ) { tooLargeForClient = true; } } // Check for UltraHDR in JPEG files before other operations. Skipped for // images routed to the server: the gain map is only preserved by the // client-side resize path, and the probe runs wasm-vips, which the // large-image gate above is specifically meant to avoid. if ( file.type === 'image/jpeg' && ! tooLargeForClient ) { operations.push( OperationType.DetectUltraHdr ); } // For images that can be processed by vips, upload the original and // let generateThumbnails() handle threshold scaling as a sideload. // // Uploading the original (rather than a pre-scaled copy) preserves // the un-suffixed basename in attachment.filename, so sub-size // names are derived from the original — matching WordPress core's // wp_create_image_subsizes() naming convention where only the // scaled-down full-size copy carries the `-scaled` suffix and the // original is kept alongside it as `original_image`. // // Main-file format conversion is handled server-side via the // image_editor_output_format filter during create_item. // The response carries image_output_format so generateThumbnails // can transcode sub-sizes to the same target format. if ( isImage && isVipsSupported && ! tooLargeForClient ) { operations.push( OperationType.Upload, OperationType.ThumbnailGeneration, OperationType.Finalize ); } else if ( isImage && isHeic ) { // HEIC/HEIF: convert to JPEG client-side before upload. // The server may not support HEIC, so decode it using the // browser's native HEVC codec (createImageBitmap or VideoDecoder) // and upload the resulting JPEG. The server then handles it like // any normal JPEG (threshold scaling, sub-sizes, etc.). // This matches iOS behavior where HEIC is converted on the fly. try { heicJpeg = await canvasConvertToJpeg( file, settings.imageQuality ?? DEFAULT_OUTPUT_QUALITY ); } catch { dispatch.cancelItem( id, new UploadError( { code: ErrorCode.HEIC_DECODE_ERROR, message: 'This browser cannot decode HEIC images and the server does not support them either. Please convert to JPEG before uploading.', file, } ) ); return; } operations.push( OperationType.Upload, OperationType.ThumbnailGeneration, OperationType.Finalize ); } else { operations.push( OperationType.Upload ); } dispatch< AddOperationsAction >( { type: Type.AddOperations, id, operations, } ); // Tell the server whether to generate sub-sizes. // When vips handles processing client-side, set generate_sub_sizes // to false so the server skips the image-type support check // (allowing formats like AVIF that the server can't process). let updates: Partial< QueueItem >; if ( isHeic && heicJpeg ) { // HEIC was converted to JPEG client-side. Upload the JPEG // and let the server handle it normally (threshold scaling, // sub-sizes, format conversion). Keep the original HEIC in // a separate field so it can be sideloaded as the "original" // after upload, preserving the user's file without leaking it // into paths that expect an editor-supported image. const vipsAvailable = isClientSideMediaSupported(); updates = { file: heicJpeg, sourceFile: heicJpeg, originalHeicFile: item.file, additionalData: { ...item.additionalData, generate_sub_sizes: ! vipsAvailable, convert_format: true, }, }; } else if ( ! isVipsSupported || ! isImage || tooLargeForClient ) { // Either the format isn't vips-processable, it isn't an image, or // it's too large for client-side processing. Let the server // generate sub-sizes and handle format conversion. updates = { additionalData: { ...item.additionalData, generate_sub_sizes: true, convert_format: true, }, }; } else { updates = { additionalData: { ...item.additionalData, generate_sub_sizes: false, }, }; } dispatch.finishOperation( id, updates ); }; } /** * Detects whether a JPEG is an UltraHDR image and records the parent item * ID so that downstream resize operations route through libvips's * uhdrload/uhdrsave pipeline (which preserves the gain map). * * @param id Item ID. */ export function detectUltraHdr( id: QueueItemId ) { return async ( { select, dispatch }: ThunkArgs ) => { const item = select.getItem( id ); if ( ! item ) { return; } let info; try { const buffer = await item.file.arrayBuffer(); info = await vipsGetUltraHdrInfo( buffer ); } catch { // If UltraHDR detection fails, continue with regular upload. } // Track the item so downstream resize operations preserve the gain // map and skip format transcoding. The original file is uploaded // unmodified — UltraHDR JPEGs are already backwards compatible (SDR // displays use the embedded base image). if ( info ) { ultraHdrItems.add( id ); } dispatch.finishOperation( id, {} ); }; } /** * Uploads an item to the server. * * @param id Item ID. */ export function uploadItem( id: QueueItemId ) { return async ( { select, dispatch }: ThunkArgs ) => { const item = select.getItem( id ); if ( ! item ) { return; } const startTime = performance.now(); let finished = false; const finishUpload = ( attachment: Partial< Attachment > ) => { if ( finished ) { return; } finished = true; measure( { measureName: `Upload ${ item.file.name }`, startTime, tooltipText: item.file.name, properties: [ [ 'Item ID', item.id ], [ 'File name', item.file.name ], ], } ); dispatch.finishOperation( id, { attachment } ); }; select.getSettings().mediaUpload( { filesList: [ item.file ], additionalData: item.additionalData, signal: item.abortController?.signal, onFileChange: ( [ attachment ] ) => { if ( attachment && ! isBlobURL( attachment.url ) ) { finishUpload( attachment ); } }, onSuccess: ( [ attachment ] ) => { finishUpload( attachment ); }, onError: ( error ) => { dispatch.cancelItem( id, error ); }, } ); }; } /** * Sideloads an item to the server. * * @param id Item ID. */ export function sideloadItem( id: QueueItemId ) { return async ( { select, dispatch }: ThunkArgs ) => { const item = select.getItem( id ); if ( ! item ) { return; } const { post, ...additionalData } = item.additionalData as SideloadAdditionalData; const mediaSideload = select.getSettings().mediaSideload; if ( ! mediaSideload ) { // If sideloading is not supported, skip this operation. dispatch.finishOperation( id, {} ); return; } const startTime = performance.now(); mediaSideload( { file: item.file, attachmentId: post as number, additionalData, signal: item.abortController?.signal, onSuccess: ( subSize: SubSizeData ) => { measure( { measureName: `Sideload ${ item.file.name }`, startTime, tooltipText: item.file.name, properties: [ [ 'Item ID', item.id ], [ 'File name', item.file.name ], ], } ); // Accumulate sub-size data on the parent item for finalize. if ( item.parentId ) { dispatch< AccumulateSubSizeAction >( { type: Type.AccumulateSubSize, id: item.parentId, subSize, } ); } dispatch.finishOperation( id, {} ); }, onError: ( error ) => { dispatch.cancelItem( id, error ); }, } ); }; } type ResizeCropItemArgs = OperationArgs[ OperationType.ResizeCrop ]; /** * Resizes and crops an existing image item. * * @param id Item ID. * @param [args] Additional arguments for the operation. */ export function resizeCropItem( id: QueueItemId, args?: ResizeCropItemArgs ) { return async ( { select, dispatch }: ThunkArgs ) => { const item = select.getItem( id ); if ( ! item ) { return; } if ( ! args?.resize ) { dispatch.finishOperation( id, { file: item.file, } ); return; } const startTime = performance.now(); // Add dimension suffix for sub-sizes (thumbnails). const addSuffix = Boolean( item.parentId ); // Add '-scaled' suffix for big image threshold resizing. const scaledSuffix = Boolean( args.isThresholdResize ); try { const file = await vipsResizeImage( item.id, item.file, args.resize, false, // smartCrop addSuffix, item.abortController?.signal, scaledSuffix, args.quality ); measure( { measureName: `ResizeCrop ${ item.file.name }`, startTime, tooltipText: item.file.name, properties: [ [ 'Item ID', item.id ], [ 'File name', item.file.name ], ], } ); const blobUrl = createBlobURL( file ); dispatch< CacheBlobUrlAction >( { type: Type.CacheBlobUrl, id, blobUrl, } ); dispatch.finishOperation( id, { file, attachment: { url: blobUrl, }, } ); } catch ( error ) { dispatch.cancelItem( id, new UploadError( { code: ErrorCode.IMAGE_TRANSCODING_ERROR, message: __( 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.' ), file: item.file, cause: error instanceof Error ? error : undefined, } ) ); } }; } type RotateItemArgs = OperationArgs[ OperationType.Rotate ]; /** * Rotates an image based on EXIF orientation. * * This is used for images that need rotation but don't need resizing * (i.e., smaller than the big image size threshold). * Matches WordPress core's behavior of creating a '-rotated' version. * * @param id Item ID. * @param [args] Rotation arguments including EXIF orientation value. */ export function rotateItem( id: QueueItemId, args?: RotateItemArgs ) { return async ( { select, dispatch }: ThunkArgs ) => { const item = select.getItem( id ); if ( ! item ) { return; } // If no orientation provided or orientation is 1 (normal), skip rotation. if ( ! args?.orientation || args.orientation === 1 ) { dispatch.finishOperation( id, { file: item.file, } ); return; } const startTime = performance.now(); try { const file = await vipsRotateImage( item.id, item.file, args.orientation, item.abortController?.signal ); measure( { measureName: `Rotate ${ item.file.name }`, startTime, tooltipText: item.file.name, properties: [ [ 'Item ID', item.id ], [ 'File name', item.file.name ], ], } ); const blobUrl = createBlobURL( file ); dispatch< CacheBlobUrlAction >( { type: Type.CacheBlobUrl, id, blobUrl, } ); dispatch.finishOperation( id, { file, attachment: { url: blobUrl, }, } ); } catch ( error ) { dispatch.cancelItem( id, new UploadError( { code: ErrorCode.IMAGE_ROTATION_ERROR, message: __( 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.' ), file: item.file, cause: error instanceof Error ? error : undefined, } ) ); } }; } type TranscodeImageItemArgs = OperationArgs[ OperationType.TranscodeImage ]; /** * Transcodes an image to a different format. * * This operation converts images between formats (e.g., PNG to WebP, JPEG to AVIF) * based on the WordPress image_editor_output_format filter settings. * * @param id Item ID. * @param [args] Transcode arguments including output format, quality, and interlace settings. */ export function transcodeImageItem( id: QueueItemId, args?: TranscodeImageItemArgs ) { return async ( { select, dispatch }: ThunkArgs ) => { const item = select.getItem( id ); if ( ! item ) { return; } // If no output format specified, skip transcoding. if ( ! args?.outputFormat ) { dispatch.finishOperation( id, { file: item.file, } ); return; } const startTime = performance.now(); const outputMimeType = `image/${ args.outputFormat }` as | 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif' | 'image/gif'; const quality = args.outputQuality ?? DEFAULT_OUTPUT_QUALITY; const interlaced = args.interlaced ?? false; try { const file = await vipsConvertImageFormat( item.id, item.file, outputMimeType, quality, interlaced ); measure( { measureName: `Transcode ${ item.file.name }`, startTime, tooltipText: item.file.name, properties: [ [ 'Item ID', item.id ], [ 'File name', item.file.name ], ], } ); const blobUrl = createBlobURL( file ); dispatch< CacheBlobUrlAction >( { type: Type.CacheBlobUrl, id, blobUrl, } ); dispatch.finishOperation( id, { file, attachment: { url: blobUrl, }, } ); } catch ( error ) { dispatch.cancelItem( id, new UploadError( { code: ErrorCode.MEDIA_TRANSCODING_ERROR, message: 'Image could not be transcoded to the target format', file: item.file, cause: error instanceof Error ? error : undefined, } ) ); } }; } type TranscodeGifItemArgs = OperationArgs[ OperationType.TranscodeGif ]; /** * Converts an animated GIF to a video file (MP4 or WebM). * * Runs inside a sideload item whose parent is the GIF's image attachment * (see generateThumbnails). The next Upload op then sideloads the * transcoded video as a companion of that attachment under the * `animated_video` image size; the GIF stays the primary attachment and * the editor block stays `core/image`. * * @param id Item ID. * @param [args] Transcode arguments including output format. */ export function transcodeGifItem( id: QueueItemId, args?: TranscodeGifItemArgs ) { return async ( { select, dispatch }: ThunkArgs ) => { const item = select.getItem( id ); if ( ! item ) { return; } const outputFormat = args?.outputFormat ?? 'mp4'; const outputMimeType = `video/${ outputFormat }`; /* * item.file is the original GIF until finishOperation swaps in the * transcoded video below; capture it for the poster sideload. */ const gifFile = item.file; try { const file = await convertGifToVideo( item.id, gifFile, outputMimeType ); // Hand the transcoded video to the next Upload op as the // sideload's payload. The parent attachment (the GIF) is // already uploaded; no blob URL is needed for any block. dispatch.finishOperation( id, { file } ); /* * Only now that the video exists, sideload a static first-frame * poster as a companion (vips decodes just the first GIF frame). * Queued here rather than alongside the video in * generateThumbnails so an unsupported/failed conversion never * leaves an orphaned `animated_video_poster` with no matching * `animated_video`. This sibling is registered while the video's * own Upload op is still pending, so the parent's finalize gate * stays closed until both companions finish. Stored under * metadata `animated_video_poster`. */ dispatch.addSideloadItem( { file: gifFile, batchId: uuidv4(), parentId: item.parentId, additionalData: { post: item.additionalData?.post, image_size: 'animated_video_poster', convert_format: false, }, operations: [ [ OperationType.TranscodeImage, { outputFormat: 'jpeg', outputQuality: DEFAULT_OUTPUT_QUALITY, interlaced: false, } as OperationArgs[ OperationType.TranscodeImage ], ], OperationType.Upload, ], } ); } catch ( error ) { // An "Unsupported" outcome is a graceful skip, not a failure: // the parent GIF attachment already exists and stays as-is, so // we silently cancel this sideload (no companion video, no // user-facing error). Uploading the original GIF here would // create an `animated_video` meta entry pointing at the GIF // itself — meaningless. if ( isUnsupportedConversionError( error ) ) { dispatch.cancelItem( id, new Error( 'Animated GIF conversion unsupported' ), true ); return; } // Real engine failure. The parent GIF attachment is fine — // the user just won't get a companion video. Log the cause // for debuggability and silently cancel the sideload; we do // not surface a "could not be converted to video" toast on // what the user thinks of as an image upload. // eslint-disable-next-line no-console console.error( '[video-conversion] GIF to video conversion failed:', error ); dispatch.cancelItem( id, new UploadError( { code: ErrorCode.GIF_TRANSCODING_ERROR, message: 'Animated GIF could not be converted to video', file: item.file, cause: error instanceof Error ? error : undefined, } ), true ); } }; } /** * Adds thumbnail versions to the queue for sideloading. * * Also handles image rotation for images that need EXIF-based rotation * but weren't scaled down (and thus weren't auto-rotated by vips). * * @param id Item ID. */ export function generateThumbnails( id: QueueItemId ) { return async ( { select, dispatch }: ThunkArgs ) => { const item = select.getItem( id ); if ( ! item ) { return; } if ( ! item.attachment ) { dispatch.finishOperation( id, {} ); return; } const attachment = item.attachment; const settings = select.getSettings(); // HEIC/HEIF: preserve the original file under a dedicated metadata // key so it never collides with `original_image`, which the scaled // sideload flow owns. The HEIC was kept on item.originalHeicFile; // the uploaded file is a JPEG conversion. parentId guarantees // processItem routes this to the sideload endpoint, never the main // create endpoint. The `source_original` image_size token is // format-agnostic (it also covers HEIF) and matches the server-side // WP_REST_Attachments_Controller::IMAGE_SIZE_SOURCE_ORIGINAL constant. if ( item.originalHeicFile && attachment.id ) { dispatch.addSideloadItem( { file: item.originalHeicFile, batchId: uuidv4(), parentId: item.id, additionalData: { post: attachment.id, image_size: 'source_original', convert_format: false, }, operations: [ OperationType.Upload ], } ); } // Animated GIF: transcode the original to a video and sideload it // as a companion file of this attachment (recorded in metadata as // `animated_video`), mirroring the HEIC original flow. The // TranscodeGif step keeps the WebCodecs concurrency limit; // parentId routes the result to the sideload endpoint, so no // separate attachment is created. if ( item.animatedGifFile && attachment.id ) { const outputFormat = settings.videoOutputFormat === 'video/webm' ? 'webm' : 'mp4'; dispatch.addSideloadItem( { file: item.animatedGifFile, batchId: uuidv4(), parentId: item.id, additionalData: { post: attachment.id, image_size: 'animated_video', convert_format: false, }, operations: [ [ OperationType.TranscodeGif, { outputFormat, } as OperationArgs[ OperationType.TranscodeGif ], ], OperationType.Upload, ], } ); /* * The static first-frame poster companion is sideloaded by the * TranscodeGif operation once the video conversion succeeds (see * transcodeGifItem), so a failed conversion leaves no orphaned * poster. */ } // Determine the EXIF orientation. For JPEG/TIFF the server reads it and // libvips auto-rotates the sub-sizes from EXIF. For AVIF/HEIF libheif/ // libvips only auto-rotate from a native `irot` transform, never from // EXIF, so those sub-sizes must be rotated explicitly. Read the EXIF // orientation on the client for those formats and treat it as the // source of truth: `getUnappliedExifOrientation` returns 1 when an // `irot` transform is present (already handled on decode), otherwise // the EXIF orientation that nothing else applies. // See https://github.com/WordPress/gutenberg/issues/79383. let exifOrientation = attachment.exif_orientation || 1; const sourceType = item.sourceFile.type; const isHeifFamily = sourceType === 'image/avif' || sourceType === 'image/heif'; let needsClientRotation = false; if ( isHeifFamily ) { exifOrientation = getUnappliedExifOrientation( await item.sourceFile.arrayBuffer() ); // libvips will not auto-rotate these sub-sizes, so they must be // generated from an explicitly rotated source rather than the // original file. needsClientRotation = exifOrientation !== 1; } // Rotate the source once and reuse it for the sideloaded "original" // (original_image metadata) and, for the client-rotation case, as the // thumbnail/scaled source. Images that were scaled // (bigImageSizeThreshold) are already rotated by vips, so the original // is skipped for them, matching WordPress core. let rotatedSource: File | undefined; { const needsRotation = exifOrientation !== 1 && ! item.file.name.includes( '-scaled' ); if ( ( needsRotation || needsClientRotation ) && attachment.id ) { try { rotatedSource = await vipsRotateImage( item.id, item.sourceFile, exifOrientation, item.abortController?.signal ); } catch { // If rotation fails, continue with thumbnail generation. // Thumbnails will still be rotated correctly by vips for // server-readable formats. // eslint-disable-next-line no-console console.warn( 'Failed to rotate image, continuing with thumbnails' ); } } // Sideload the rotated file as the "original" to set // original_image metadata; the server stores it in // $metadata['original_image']. if ( needsRotation && rotatedSource && attachment.id ) { dispatch.addSideloadItem( { file: rotatedSource, batchId: uuidv4(), parentId: item.id, additionalData: { post: attachment.id, image_size: 'original', convert_format: false, }, operations: [ OperationType.Upload ], } ); } } // Client-side thumbnail generation for images. if ( ! item.parentId && attachment.missing_image_sizes && attachment.missing_image_sizes.length > 0 ) { const allImageSizes = settings.allImageSizes || {}; const sizesToGenerate: string[] = attachment.missing_image_sizes as string[]; const thumbnailSource = needsClientRotation && rotatedSource ? rotatedSource : item.sourceFile; const file = attachment.filename ? renameFile( thumbnailSource, attachment.filename ) : thumbnailSource; const batchId = uuidv4(); // Sub-sizes inherit the parent's UltraHDR status so that the // resize step routes through libvips's uhdrload/uhdrsave pipeline // (which preserves the gain map). Format transcoding is skipped // for UltraHDR sources because converting to a different codec // would strip the ISO 21496-1 gain map data. const isUltraHdr = ultraHdrItems.has( item.id ); // Read per-file format conversion data from the attachment response. const outputMimeType = attachment.image_output_format; const interlaced = attachment.image_save_progressive ?? false; // Resolve the size-aware encode quality from the // wp_editor_set_quality filter, carried in the upload response. // Quality is reported as 1-100 (WordPress scale); the vips worker // expects 0-1. Fall back to the generic setting, then the // hardcoded default, when the response predates this field. const imageQuality = attachment.image_quality; const fallbackQuality = settings.imageQuality ?? DEFAULT_OUTPUT_QUALITY; const defaultQuality = typeof imageQuality?.default === 'number' ? imageQuality.default / 100 : fallbackQuality; const qualityForSize = ( sizeName: string ): number => { const sized = imageQuality?.sizes?.[ sizeName ]; if ( typeof sized === 'number' ) { return sized / 100; } return defaultQuality; }; // Check if thumbnails should be transcoded to a different format. // Uses the same transparency-aware logic as the main image // to avoid converting transparent PNGs to JPEG. let thumbnailTranscodeOperation: | [ OperationType.TranscodeImage, OperationArgs[ OperationType.TranscodeImage ], ] | null = null; if ( ! isUltraHdr && outputMimeType ) { thumbnailTranscodeOperation = await getTranscodeImageOperation( thumbnailSource, outputMimeType, interlaced, defaultQuality ); } // Group sizes by dimensions to avoid creating duplicate files. // When multiple size names have the same width/height/crop, // only one physical file is generated and registered under // all matching size names via a single sideload request. const dimensionGroups = new Map< string, string[] >(); for ( const name of sizesToGenerate ) { const imageSize = allImageSizes[ name ]; if ( ! imageSize ) { // eslint-disable-next-line no-console console.warn( `Image size "${ name }" not found in configuration` ); continue; } const key = `${ imageSize.width }x${ imageSize.height }x${ imageSize.crop }`; const group = dimensionGroups.get( key ); if ( group ) { group.push( name ); } else { dimensionGroups.set( key, [ name ] ); } } for ( const [ , names ] of dimensionGroups ) { const imageSize = allImageSizes[ names[ 0 ] ]; // Sizes grouped here share dimensions, so wp_editor_set_quality // (which is dimension-aware) resolves to the same value for // every name in the group. const sizeQuality = qualityForSize( names[ 0 ] ); // Build operations list for this thumbnail. The resize step // is UltraHDR-aware and will preserve the gain map automatically. const thumbnailOperations: Operation[] = [ [ OperationType.ResizeCrop, { resize: imageSize, quality: sizeQuality }, ], ]; // Add transcoding if format conversion is configured and // the transparency check passed. For UltraHDR sources the // transcode is skipped so the gain map survives the resize. // Otherwise override the template's quality with this size's // resolved value. if ( ! isUltraHdr && thumbnailTranscodeOperation ) { thumbnailOperations.push( [ thumbnailTranscodeOperation[ 0 ], { ...thumbnailTranscodeOperation[ 1 ], outputQuality: sizeQuality, }, ] ); } thumbnailOperations.push( OperationType.Upload ); //