@wordpress/upload-media
Version:
Core media upload logic.
1,748 lines (1,591 loc) • 55 kB
text/typescript
/**
* 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 );
//