diff --git a/package-lock.json b/package-lock.json index 3e3a2448..dd3fc814 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27334,6 +27334,7 @@ "@wordpress/data": "^10.0.0", "@wordpress/i18n": "^5.0.0", "@wordpress/preferences": "^4.0.0", + "@wordpress/private-apis": "^1.4.0", "@wordpress/url": "^4.3.0", "blurhash": "^2.0.5", "fast-average-color": "^9.3.0", diff --git a/package.json b/package.json index da8e54dd..8b22b0b9 100644 --- a/package.json +++ b/package.json @@ -70,17 +70,22 @@ "build:docs": "npm-run-all --parallel build:docs:*", "build:docs:ffmpeg": "docgen packages/ffmpeg/src/index.ts --output packages/ffmpeg/README.md --to-token --use-token \"Autogenerated API docs\"", "build:docs:heif": "docgen packages/heif/src/index.ts --output packages/heif/README.md --to-token --use-token \"Autogenerated API docs\"", - "build:docs:interface-actions": "docgen packages/interface/src/store/actions.ts --output packages/interface/README.md --to-token --use-token \"Autogenerated actions|src/store/actions.ts\"", - "build:docs:interface-selectors": "docgen packages/interface/src/store/selectors.ts --output packages/interface/README.md --to-token --use-token \"Autogenerated selectors|src/store/selectors.ts\"", + "build:docs:interface": "npm-run-all build:docs:interface:*", + "build:docs:interface:actions": "docgen packages/interface/src/store/actions.ts --output packages/interface/README.md --to-token --use-token \"Autogenerated actions|src/store/actions.ts\"", + "build:docs:interface:selectors": "docgen packages/interface/src/store/selectors.ts --output packages/interface/README.md --to-token --use-token \"Autogenerated selectors|src/store/selectors.ts\"", "build:docs:log": "docgen packages/log/src/index.ts --output packages/log/README.md --to-token --use-token \"Autogenerated API docs\"", - "build:docs:media-recording-actions": "docgen packages/media-recording/src/store/actions.ts --output packages/media-recording/README.md --to-token --use-token \"Autogenerated actions|src/store/actions.ts\"", - "build:docs:media-recording-selectors": "docgen packages/media-recording/src/store/selectors.ts --output packages/media-recording/README.md --to-token --use-token \"Autogenerated selectors|src/store/selectors.ts\"", + "build:docs:media-recording": "npm-run-all build:docs:media-recording:*", + "build:docs:media-recording:actions": "docgen packages/media-recording/src/store/actions.ts --output packages/media-recording/README.md --to-token --use-token \"Autogenerated actions|src/store/actions.ts\"", + "build:docs:media-recording:selectors": "docgen packages/media-recording/src/store/selectors.ts --output packages/media-recording/README.md --to-token --use-token \"Autogenerated selectors|src/store/selectors.ts\"", "build:docs:media-utils": "docgen packages/media-utils/src/index.ts --output packages/media-utils/README.md --to-token --use-token \"Autogenerated API docs\"", "build:docs:mime": "docgen packages/mime/src/index.ts --output packages/mime/README.md --to-token --use-token \"Autogenerated API docs\"", "build:docs:pdf": "docgen packages/pdf/src/index.ts --output packages/pdf/README.md --to-token --use-token \"Autogenerated API docs\"", "build:docs:subtitles": "docgen packages/subtitles/src/index.ts --output packages/subtitles/README.md --to-token --use-token \"Autogenerated API docs\"", - "build:docs:upload-media-actions": "docgen packages/upload-media/src/store/actions.ts --output packages/upload-media/README.md --to-token --use-token \"Autogenerated actions|src/store/actions.ts\"", - "build:docs:upload-media-selectors": "docgen packages/upload-media/src/store/selectors.ts --output packages/upload-media/README.md --to-token --use-token \"Autogenerated selectors|src/store/selectors.ts\"", + "build:docs:upload-media": "npm-run-all build:docs:upload-media:*", + "build:docs:upload-media:actions": "docgen packages/upload-media/src/store/actions.ts --output packages/upload-media/README.md --to-token --use-token \"Autogenerated actions|src/store/actions.ts\"", + "build:docs:upload-media:private-actions": "docgen packages/upload-media/src/store/private-actions.ts --output packages/upload-media/README.md --to-token --use-token \"Autogenerated actions|src/store/private-actions.ts\"", + "build:docs:upload-media:selectors": "docgen packages/upload-media/src/store/selectors.ts --output packages/upload-media/README.md --to-token --use-token \"Autogenerated selectors|src/store/selectors.ts\"", + "build:docs:upload-media:private-selectors": "docgen packages/upload-media/src/store/private-selectors.ts --output packages/upload-media/README.md --to-token --use-token \"Autogenerated selectors|src/store/private-selectors.ts\"", "build:docs:vips": "docgen packages/vips/src/index.ts --output packages/vips/README.md --to-token --use-token \"Autogenerated API docs\"", "format": "wp-scripts format", "lint:css": "wp-scripts lint-style", diff --git a/packages/editor/src/uploadQueue/index.ts b/packages/editor/src/uploadQueue/index.ts index f60f20aa..86bc0340 100644 --- a/packages/editor/src/uploadQueue/index.ts +++ b/packages/editor/src/uploadQueue/index.ts @@ -174,17 +174,14 @@ export default function blockEditorUploadMedia( { } /* + Make the upload queue aware of the function for uploading to the server. The list of available image sizes is passed via an inline script and needs to be saved in the store first. */ -void dispatch( uploadStore ).setImageSizes( - window.mediaExperiments.availableImageSizes -); - -// Make the upload queue aware of the function for uploading to the server. void dispatch( uploadStore ).updateSettings( { mediaUpload: editorUploadMedia, mediaSideload: originalSideloadMedia, + imageSizes: window.mediaExperiments.availableImageSizes, } ); // Subscribe to state updates so that we can override the mediaUpload() function at the right time. @@ -208,10 +205,4 @@ subscribe( () => { void dispatch( blockEditorStore ).updateSettings( { mediaUpload: blockEditorUploadMedia, } ); - - // addFilter( - // 'editor.MediaUpload', - // 'media-experiments/replace-media-upload', - // replaceMediaUpload - // ); }, blockEditorStore ); diff --git a/packages/editor/src/uploadStatus/indicator.tsx b/packages/editor/src/uploadStatus/indicator.tsx index 7b309f29..6c36b6a8 100644 --- a/packages/editor/src/uploadStatus/indicator.tsx +++ b/packages/editor/src/uploadStatus/indicator.tsx @@ -35,9 +35,7 @@ export function UploadStatusIndicator() { const { cancelItem } = useDispatch( uploadStore ); const { isUploading, items } = useSelect( ( select ) => { - const queueItems = select( uploadStore ) - .getItems() - .filter( ( item ) => ! item.parentId ); + const queueItems = select( uploadStore ).getItems(); return { isUploading: select( uploadStore ).isUploading(), diff --git a/packages/ffmpeg/src/index.ts b/packages/ffmpeg/src/index.ts index fc6f6465..8dbcfb74 100644 --- a/packages/ffmpeg/src/index.ts +++ b/packages/ffmpeg/src/index.ts @@ -131,8 +131,6 @@ async function runFFmpegWithConfig( fileName, { type: mimeType } ); - } catch ( err ) { - throw err; } finally { try { // Also removes MEMFS to free memory. diff --git a/packages/interface/README.md b/packages/interface/README.md index 6f83fe5c..ba41492e 100644 --- a/packages/interface/README.md +++ b/packages/interface/README.md @@ -30,7 +30,6 @@ _Returns_ - `OpenModalAction`: Action object. - ### Selectors @@ -52,4 +51,5 @@ _Returns_ - `boolean`: Whether the modal is active. + diff --git a/packages/media-recording/README.md b/packages/media-recording/README.md index 68b8f1d7..638ad0f5 100644 --- a/packages/media-recording/README.md +++ b/packages/media-recording/README.md @@ -27,7 +27,7 @@ Enters recording mode for a given block and recording type. _Parameters_ - _clientId_ `string`: Block client ID. -- _recordingType_ Recording type. +- _recordingTypes_ Recording types. #### leaveRecordingMode @@ -109,7 +109,6 @@ Returns an action object signalling that audio mode should be toggled. Updates the list of available media devices. - ### Selectors @@ -224,9 +223,9 @@ _Parameters_ - _state_ `State`: Recording state. -#### getRecordingType +#### getRecordingTypes -Returns the current recording media type. +Returns the current recording media types. _Parameters_ @@ -297,4 +296,5 @@ _Parameters_ - _state_ `State`: Recording state. + diff --git a/packages/upload-media/README.md b/packages/upload-media/README.md index 8c470d4a..1f5d6eaa 100644 --- a/packages/upload-media/README.md +++ b/packages/upload-media/README.md @@ -8,31 +8,8 @@ Core media upload logic implemented with a custom `@wordpress/data` store. The following set of dispatching action creators are available on the object returned by `wp.data.dispatch( 'media-experiments/upload' )`: -**Note:** Some actions and selectors [will be made private](https://github.com/swissspidy/media-experiments/issues/500) eventually, limiting the public API. - -#### addItem - -Adds a new item to the upload queue. - -_Parameters_ - -- _$0_ `AddItemArgs`: -- _$0.file_ `AddItemArgs[ 'file' ]`: File -- _$0.batchId_ `[AddItemArgs[ 'batchId' ]]`: Batch ID. -- _$0.onChange_ `[AddItemArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available. -- _$0.onSuccess_ `[AddItemArgs[ 'onSuccess' ]]`: Function called after the file is uploaded. -- _$0.onBatchSuccess_ `[AddItemArgs[ 'onBatchSuccess' ]]`: Function called after a batch of files is uploaded. -- _$0.onError_ `[AddItemArgs[ 'onError' ]]`: Function called when an error happens. -- _$0.additionalData_ `[AddItemArgs[ 'additionalData' ]]`: Additional data to include in the request. -- _$0.sourceUrl_ `[AddItemArgs[ 'sourceUrl' ]]`: Source URL. Used when importing a file from a URL or optimizing an existing file. -- _$0.sourceAttachmentId_ `[AddItemArgs[ 'sourceAttachmentId' ]]`: Source attachment ID. Used when optimizing an existing file for example. -- _$0.blurHash_ `[AddItemArgs[ 'blurHash' ]]`: Item's BlurHash. -- _$0.dominantColor_ `[AddItemArgs[ 'dominantColor' ]]`: Item's dominant color. -- _$0.abortController_ `[AddItemArgs[ 'abortController' ]]`: Abort controller for upload cancellation. -- _$0.operations_ `[AddItemArgs[ 'operations' ]]`: List of operations to perform. Defaults to automatically determined list, based on the file. - #### addItemFromUrl Adds a new item to the upload queue. @@ -75,30 +52,6 @@ _Parameters_ - _$0.onError_ `[AddPosterForExistingVideoArgs[ 'onError' ]]`: Function called when an error happens. - _$0.additionalData_ `[AddPosterForExistingVideoArgs[ 'additionalData' ]]`: Additional data to include in the request. -#### addPosterForItem - -Triggers poster image generation for an item. - -_Parameters_ - -- _id_ `QueueItemId`: Item ID. - -#### addSideloadItem - -Adds a new item to the upload queue for sideloading. - -This is typically a poster image or a client-side generated thumbnail. - -_Parameters_ - -- _$0_ `AddSideloadItemArgs`: -- _$0.file_ `AddSideloadItemArgs[ 'file' ]`: File -- _$0.batchId_ `[AddSideloadItemArgs[ 'batchId' ]]`: Batch ID. -- _$0.parentId_ `[AddSideloadItemArgs[ 'parentId' ]]`: Parent ID. -- _$0.onChange_ `[AddSideloadItemArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available. -- _$0.additionalData_ `[AddSideloadItemArgs[ 'additionalData' ]]`: Additional data to include in the request. -- _$0.operations_ `[AddSideloadItemArgs[ 'operations' ]]`: List of operations to perform. Defaults to automatically determined list, based on the file. - #### addSubtitlesForExistingVideo Adds a new item to the upload queue to generate subtitles for an existing video. @@ -123,6 +76,130 @@ _Parameters_ - _id_ `QueueItemId`: Item ID. - _error_ `Error`: Error instance. +#### grantApproval + +Approves a proposed optimized/converted version of a file so it can continue being processed and uploaded. + +_Parameters_ + +- _id_ `number`: Item ID. + +#### muteExistingVideo + +Adds a new item to the upload queue for muting an existing video. + +_Parameters_ + +- _$0_ `MuteExistingVideoArgs`: +- _$0.id_ `MuteExistingVideoArgs[ 'id' ]`: Attachment ID. +- _$0.url_ `MuteExistingVideoArgs[ 'url' ]`: Video URL. +- _$0.fileName_ `[MuteExistingVideoArgs[ 'fileName' ]]`: Video file name. +- _$0.poster_ `[MuteExistingVideoArgs[ 'poster' ]]`: Poster URL. +- _$0.onChange_ `[MuteExistingVideoArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available. +- _$0.onSuccess_ `[MuteExistingVideoArgs[ 'onSuccess' ]]`: Function called after the file is uploaded. +- _$0.onError_ `[MuteExistingVideoArgs[ 'onError' ]]`: Function called when an error happens. +- _$0.additionalData_ `[MuteExistingVideoArgs[ 'additionalData' ]]`: Additional data to include in the request. +- _$0.blurHash_ `[MuteExistingVideoArgs[ 'blurHash' ]]`: Item's BlurHash. +- _$0.dominantColor_ `[MuteExistingVideoArgs[ 'dominantColor' ]]`: Item's dominant color. +- _$0.generatedPosterId_ `[MuteExistingVideoArgs[ 'generatedPosterId' ]]`: Attachment ID of the generated poster image, if it exists. + +#### optimizeExistingItem + +Adds a new item to the upload queue for optimizing (compressing) an existing item. + +_Parameters_ + +- _$0_ `OptimizeExistingItemArgs`: +- _$0.id_ `OptimizeExistingItemArgs[ 'id' ]`: Attachment ID. +- _$0.url_ `OptimizeExistingItemArgs[ 'url' ]`: URL. +- _$0.fileName_ `[OptimizeExistingItemArgs[ 'fileName' ]]`: File name. +- _$0.poster_ `[OptimizeExistingItemArgs[ 'poster' ]]`: Poster URL. +- _$0.batchId_ `[OptimizeExistingItemArgs[ 'batchId' ]]`: Batch ID. +- _$0.onChange_ `[OptimizeExistingItemArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available. +- _$0.onSuccess_ `[OptimizeExistingItemArgs[ 'onSuccess' ]]`: Function called after the file is uploaded. +- _$0.onBatchSuccess_ `[OptimizeExistingItemArgs[ 'onBatchSuccess' ]]`: Function called after a batch of files is uploaded. +- _$0.onError_ `[OptimizeExistingItemArgs[ 'onError' ]]`: Function called when an error happens. +- _$0.additionalData_ `[OptimizeExistingItemArgs[ 'additionalData' ]]`: Additional data to include in the request. +- _$0.blurHash_ `[OptimizeExistingItemArgs[ 'blurHash' ]]`: Item's BlurHash. +- _$0.dominantColor_ `[OptimizeExistingItemArgs[ 'dominantColor' ]]`: Item's dominant color. +- _$0.generatedPosterId_ `[OptimizeExistingItemArgs[ 'generatedPosterId' ]]`: Attachment ID of the generated poster image, if it exists. +- _$0.startTime_ `[OptimizeExistingItemArgs[ 'startTime' ]]`: Time the action was initiated by the user (e.g. by clicking on a button). + +#### rejectApproval + +Rejects a proposed optimized/converted version of a file by essentially cancelling its further processing. + +_Parameters_ + +- _id_ `number`: Item ID. + +#### updateSettings + +Returns an action object that pauses all processing in the queue. + +Useful for testing purposes. + +_Parameters_ + +- _settings_ `Partial< Settings >`: + +_Returns_ + +- `UpdateSettingsAction`: Action object. + + + +### Private Actions + +The following set of dispatching action creators are intended to be private in the future: + + + +#### addItem + +Adds a new item to the upload queue. + +_Parameters_ + +- _$0_ `AddItemArgs`: +- _$0.file_ `AddItemArgs[ 'file' ]`: File +- _$0.batchId_ `[AddItemArgs[ 'batchId' ]]`: Batch ID. +- _$0.onChange_ `[AddItemArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available. +- _$0.onSuccess_ `[AddItemArgs[ 'onSuccess' ]]`: Function called after the file is uploaded. +- _$0.onBatchSuccess_ `[AddItemArgs[ 'onBatchSuccess' ]]`: Function called after a batch of files is uploaded. +- _$0.onError_ `[AddItemArgs[ 'onError' ]]`: Function called when an error happens. +- _$0.additionalData_ `[AddItemArgs[ 'additionalData' ]]`: Additional data to include in the request. +- _$0.sourceUrl_ `[AddItemArgs[ 'sourceUrl' ]]`: Source URL. Used when importing a file from a URL or optimizing an existing file. +- _$0.sourceAttachmentId_ `[AddItemArgs[ 'sourceAttachmentId' ]]`: Source attachment ID. Used when optimizing an existing file for example. +- _$0.blurHash_ `[AddItemArgs[ 'blurHash' ]]`: Item's BlurHash. +- _$0.dominantColor_ `[AddItemArgs[ 'dominantColor' ]]`: Item's dominant color. +- _$0.abortController_ `[AddItemArgs[ 'abortController' ]]`: Abort controller for upload cancellation. +- _$0.operations_ `[AddItemArgs[ 'operations' ]]`: List of operations to perform. Defaults to automatically determined list, based on the file. + +#### addPosterForItem + +Triggers poster image generation for an item. + +_Parameters_ + +- _id_ `QueueItemId`: Item ID. + +#### addSideloadItem + +Adds a new item to the upload queue for sideloading. + +This is typically a poster image or a client-side generated thumbnail. + +_Parameters_ + +- _$0_ `AddSideloadItemArgs`: +- _$0.file_ `AddSideloadItemArgs[ 'file' ]`: File +- _$0.batchId_ `[AddSideloadItemArgs[ 'batchId' ]]`: Batch ID. +- _$0.parentId_ `[AddSideloadItemArgs[ 'parentId' ]]`: Parent ID. +- _$0.onChange_ `[AddSideloadItemArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available. +- _$0.additionalData_ `[AddSideloadItemArgs[ 'additionalData' ]]`: Additional data to include in the request. +- _$0.operations_ `[AddSideloadItemArgs[ 'operations' ]]`: List of operations to perform. Defaults to automatically determined list, based on the file. + #### convertGifItem Converts an existing GIF item to a video. @@ -173,33 +250,6 @@ _Parameters_ - _id_ `QueueItemId`: Item ID. -#### grantApproval - -Approves a proposed optimized/converted version of a file so it can continue being processed and uploaded. - -_Parameters_ - -- _id_ `number`: Item ID. - -#### muteExistingVideo - -Adds a new item to the upload queue for muting an existing video. - -_Parameters_ - -- _$0_ `MuteExistingVideoArgs`: -- _$0.id_ `MuteExistingVideoArgs[ 'id' ]`: Attachment ID. -- _$0.url_ `MuteExistingVideoArgs[ 'url' ]`: Video URL. -- _$0.fileName_ `[MuteExistingVideoArgs[ 'fileName' ]]`: Video file name. -- _$0.poster_ `[MuteExistingVideoArgs[ 'poster' ]]`: Poster URL. -- _$0.onChange_ `[MuteExistingVideoArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available. -- _$0.onSuccess_ `[MuteExistingVideoArgs[ 'onSuccess' ]]`: Function called after the file is uploaded. -- _$0.onError_ `[MuteExistingVideoArgs[ 'onError' ]]`: Function called when an error happens. -- _$0.additionalData_ `[MuteExistingVideoArgs[ 'additionalData' ]]`: Additional data to include in the request. -- _$0.blurHash_ `[MuteExistingVideoArgs[ 'blurHash' ]]`: Item's BlurHash. -- _$0.dominantColor_ `[MuteExistingVideoArgs[ 'dominantColor' ]]`: Item's dominant color. -- _$0.generatedPosterId_ `[MuteExistingVideoArgs[ 'generatedPosterId' ]]`: Attachment ID of the generated poster image, if it exists. - #### muteVideoItem Mutes an existing video item. @@ -216,28 +266,6 @@ _Parameters_ - _id_ `QueueItemId`: Item ID. -#### optimizeExistingItem - -Adds a new item to the upload queue for optimizing (compressing) an existing item. - -_Parameters_ - -- _$0_ `OptimizeExistingItemArgs`: -- _$0.id_ `OptimizeExistingItemArgs[ 'id' ]`: Attachment ID. -- _$0.url_ `OptimizeExistingItemArgs[ 'url' ]`: URL. -- _$0.fileName_ `[OptimizeExistingItemArgs[ 'fileName' ]]`: File name. -- _$0.poster_ `[OptimizeExistingItemArgs[ 'poster' ]]`: Poster URL. -- _$0.batchId_ `[OptimizeExistingItemArgs[ 'batchId' ]]`: Batch ID. -- _$0.onChange_ `[OptimizeExistingItemArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available. -- _$0.onSuccess_ `[OptimizeExistingItemArgs[ 'onSuccess' ]]`: Function called after the file is uploaded. -- _$0.onBatchSuccess_ `[OptimizeExistingItemArgs[ 'onBatchSuccess' ]]`: Function called after a batch of files is uploaded. -- _$0.onError_ `[OptimizeExistingItemArgs[ 'onError' ]]`: Function called when an error happens. -- _$0.additionalData_ `[OptimizeExistingItemArgs[ 'additionalData' ]]`: Additional data to include in the request. -- _$0.blurHash_ `[OptimizeExistingItemArgs[ 'blurHash' ]]`: Item's BlurHash. -- _$0.dominantColor_ `[OptimizeExistingItemArgs[ 'dominantColor' ]]`: Item's dominant color. -- _$0.generatedPosterId_ `[OptimizeExistingItemArgs[ 'generatedPosterId' ]]`: Attachment ID of the generated poster image, if it exists. -- _$0.startTime_ `[OptimizeExistingItemArgs[ 'startTime' ]]`: Time the action was initiated by the user (e.g. by clicking on a button). - #### optimizeImageItem Optimizes/Compresses an existing image item. @@ -290,14 +318,6 @@ _Parameters_ - _id_ `QueueItemId`: Item ID. -#### rejectApproval - -Rejects a proposed optimized/converted version of a file by essentially cancelling its further processing. - -_Parameters_ - -- _id_ `number`: Item ID. - #### removeItem Removes a specific item from the queue. @@ -337,18 +357,6 @@ _Parameters_ - _id_ `QueueItemId`: Item ID. -#### setImageSizes - -Returns an action object that sets all image sub-sizes and their cropping information. - -_Parameters_ - -- _imageSizes_ `Record< string, ImageSizeCrop >`: Map of image size names and their cropping information. - -_Returns_ - -- `SetImageSizesAction`: Action object. - #### sideloadItem Sideloads an item to the server. @@ -357,20 +365,6 @@ _Parameters_ - _id_ `QueueItemId`: Item ID. -#### updateSettings - -Returns an action object that pauses all processing in the queue. - -Useful for testing purposes. - -_Parameters_ - -- _settings_ `Partial< Settings >`: - -_Returns_ - -- `UpdateSettingsAction`: Action object. - #### uploadItem Uploads an item to the server. @@ -397,8 +391,7 @@ _Parameters_ - _id_ `QueueItemId`: Item ID. - - + ### Selectors @@ -408,102 +401,123 @@ The following selectors are available on the object returned by `wp.data.select( -#### getBlobUrls +#### getComparisonDataForApproval -Returns all cached blob URLs for a given item ID. +Returns data to compare the old file vs. the optimized file, given the attachment ID. + +Includes both the URLs and the respective file sizes and the size difference in percentage. _Parameters_ - _state_ `State`: Upload state. -- _id_ `QueueItemId`: Item ID +- _attachmentId_ `number`: Attachment ID. _Returns_ -- `string[]`: List of blob URLs. +- `{ oldUrl: string | undefined; oldSize: number; newSize: number; newUrl: string | undefined; sizeDiff: number; } | null`: Comparison data. -#### getComparisonDataForApproval +#### getItems -Returns data to compare the old file vs. the optimized file, given the attachment ID. +Returns all items currently being uploaded, without sub-sizes (children). -Includes both the URLs as well as the respective file sizes and the size difference in percentage. +_Parameters_ + +- _state_ `State`: Upload state. + +_Returns_ + +- `QueueItem[]`: Queue items. + +#### getSettings + +Returns the media upload settings. _Parameters_ - _state_ `State`: Upload state. -- _attachmentId_ `number`: Attachment ID. _Returns_ -- `{ oldUrl: string | undefined; oldSize: number; newSize: number; newUrl: string | undefined; sizeDiff: number; } | null`: Comparison data. +- `Settings`: Settings -#### getImageSize +#### isFirstPendingApprovalByAttachmentId -Returns an image size given its name. +Determines whether an item is the first one pending approval given its associated attachment ID. _Parameters_ - _state_ `State`: Upload state. -- _name_ `string`: Image size name. +- _attachmentId_ `number`: Attachment ID. _Returns_ -- `ImageSizeCrop`: Image size data. +- `boolean`: Whether the item is first in the list of items pending approval. -#### getItem +#### isPendingApproval -Returns a specific item given its unique ID. +Determines whether there is an item pending approval. _Parameters_ - _state_ `State`: Upload state. -- _id_ `QueueItemId`: Item ID. _Returns_ -- `QueueItem | undefined`: Queue item. +- `boolean`: Whether there is an item pending approval. -#### getItemByAttachmentId +#### isUploading -Returns a specific item given its associated attachment ID. +Determines whether any upload is currently in progress. + +_Related_ + +- _Parameters_ - _state_ `State`: Upload state. -- _attachmentId_ `number`: Item ID. _Returns_ -- `QueueItem | undefined`: Queue item. +- `boolean`: Whether any upload is currently in progress. -#### getItems +#### isUploadingById -Returns all items currently being uploaded. +Determines whether an upload is currently in progress given an attachment ID. _Parameters_ - _state_ `State`: Upload state. -- _status_ `ItemStatus`: Status to filter items by. +- _attachmentId_ `number`: Attachment ID. _Returns_ -- `QueueItem[]`: Queue items. +- `boolean`: Whether upload is currently in progress for the given attachment. -#### getPausedUploadForPost +#### isUploadingByUrl -Returns the next paused upload for a given post or attachment ID. +Determines whether an upload is currently in progress given an attachment URL. _Parameters_ - _state_ `State`: Upload state. -- _postOrAttachmentId_ `number`: Post ID or attachment ID. +- _url_ `string`: Attachment URL. _Returns_ -- `QueueItem | undefined`: Paused item. +- `boolean`: Whether upload is currently in progress for the given attachment. -#### getSettings + -Returns the media upload settings. +### Private Selectors + +The following selectors are intended to be private in the future: + + + +#### getAllItems + +Returns all items currently being uploaded. _Parameters_ @@ -511,112 +525,123 @@ _Parameters_ _Returns_ -- `Settings`: Settings +- `QueueItem[]`: Queue items. -#### isBatchUploaded +#### getBlobUrls -Determines whether a batch has been successfully uploaded, given its unique ID. +Returns all cached blob URLs for a given item ID. _Parameters_ - _state_ `State`: Upload state. -- _batchId_ `BatchId`: Batch ID. +- _id_ `QueueItemId`: Item ID _Returns_ -- `boolean`: Whether a batch has been uploaded. +- `string[]`: List of blob URLs. -#### isFirstPendingApprovalByAttachmentId +#### getChildItems -Determines whether an item is the first one pending approval given its associated attachment ID. +Returns all items currently being uploaded. _Parameters_ - _state_ `State`: Upload state. -- _attachmentId_ `number`: Attachment ID. +- _parentId_ `QueueItemId`: Parent item ID. _Returns_ -- `boolean`: Whether the item is first in the list of items pending approval. +- `QueueItem[]`: Queue items. -#### isPaused +#### getImageSize -Determines whether uploading is currently paused. +Returns an image size given its name. _Parameters_ - _state_ `State`: Upload state. +- _name_ `string`: Image size name. _Returns_ -- `boolean`: Whether uploading is currently paused. +- `ImageSizeCrop`: Image size data. -#### isPendingApproval +#### getItem -Determines whether there is an item pending approval. +Returns a specific item given its unique ID. _Parameters_ - _state_ `State`: Upload state. +- _id_ `QueueItemId`: Item ID. _Returns_ -- `boolean`: Whether there is an item pending approval. +- `QueueItem | undefined`: Queue item. -#### isUploading +#### getItemByAttachmentId -Determines whether any upload is currently in progress. +Returns a specific item given its associated attachment ID. -_Related_ +_Parameters_ -- +- _state_ `State`: Upload state. +- _attachmentId_ `number`: Item ID. + +_Returns_ + +- `QueueItem | undefined`: Queue item. + +#### getPausedUploadForPost + +Returns the next paused upload for a given post or attachment ID. _Parameters_ - _state_ `State`: Upload state. +- _postOrAttachmentId_ `number`: Post ID or attachment ID. _Returns_ -- `boolean`: Whether any upload is currently in progress. +- `QueueItem | undefined`: Paused item. -#### isUploadingById +#### isBatchUploaded -Determines whether an upload is currently in progress given an attachment ID. +Determines whether a batch has been successfully uploaded, given its unique ID. _Parameters_ - _state_ `State`: Upload state. -- _attachmentId_ `number`: Attachment ID. +- _batchId_ `BatchId`: Batch ID. _Returns_ -- `boolean`: Whether upload is currently in progress for the given attachment. +- `boolean`: Whether a batch has been uploaded. -#### isUploadingByParentId +#### isPaused -Determines whether an upload is currently in progress given a parent ID. +Determines whether uploading is currently paused. _Parameters_ - _state_ `State`: Upload state. -- _parentId_ `QueueItemId`: Parent ID. _Returns_ -- `boolean`: Whether upload is currently in progress for the given parent ID. +- `boolean`: Whether uploading is currently paused. -#### isUploadingByUrl +#### isUploadingByParentId -Determines whether an upload is currently in progress given an attachment URL. +Determines whether an upload is currently in progress given a parent ID. _Parameters_ - _state_ `State`: Upload state. -- _url_ `string`: Attachment URL. +- _parentId_ `QueueItemId`: Parent ID. _Returns_ -- `boolean`: Whether upload is currently in progress for the given attachment. +- `boolean`: Whether upload is currently in progress for the given parent ID. #### isUploadingToPost @@ -631,4 +656,5 @@ _Returns_ - `boolean`: Whether upload is currently in progress for the given post or attachment. - + + diff --git a/packages/upload-media/src/store/actions.ts b/packages/upload-media/src/store/actions.ts index dc44f933..6e79a7ba 100644 --- a/packages/upload-media/src/store/actions.ts +++ b/packages/upload-media/src/store/actions.ts @@ -1,140 +1,59 @@ import { v4 as uuidv4 } from 'uuid'; -import { createWorkerFactory } from '@shopify/web-worker'; -import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; import type { WPDataRegistry } from '@wordpress/data/build-types/registry'; import { store as preferencesStore } from '@wordpress/preferences'; -import { getExtensionFromMimeType, getMediaTypeFromMimeType } from '@mexp/mime'; -import { measure, type MeasureOptions, start } from '@mexp/log'; +import { type MeasureOptions } from '@mexp/log'; -import { ImageFile } from '../imageFile'; import { MediaError } from '../mediaError'; -import { - canProcessWithFFmpeg, - cloneFile, - fetchFile, - getFileBasename, - getFileExtension, - getFileNameFromUrl, - getPosterFromVideo, - isAnimatedGif, - isHeifImage, - renameFile, - videoHasAudio, -} from '../utils'; +import { getFileBasename, getFileNameFromUrl } from '../utils'; import { PREFERENCES_NAME } from '../constants'; -import { transcodeHeifImage } from './utils/heif'; -import { - vipsCancelOperations, - vipsCompressImage, - vipsConvertImageFormat, - vipsHasTransparency, - vipsResizeImage, -} from './utils/vips'; -import { - compressImage as canvasCompressImage, - convertImageFormat as canvasConvertImageFormat, - resizeImage as canvasResizeImage, -} from './utils/canvas'; +import { StubFile } from '../stubFile'; +import { vipsCancelOperations } from './utils/vips'; import type { AddAction, AdditionalData, - AddOperationsAction, ApproveUploadAction, - Attachment, - AudioFormat, BatchId, - CacheBlobUrlAction, CancelAction, - ImageFormat, ImageLibrary, - ImageSizeCrop, OnBatchSuccessHandler, OnChangeHandler, OnErrorHandler, OnSuccessHandler, - Operation, - OperationArgs, - OperationFinishAction, - OperationStartAction, - PauseItemAction, - PauseQueueAction, - QueueItem, QueueItemId, - ResumeItemAction, - ResumeQueueAction, - RevokeBlobUrlsAction, - SetImageSizesAction, Settings, - SideloadAdditionalData, State, ThumbnailGeneration, UpdateSettingsAction, - VideoFormat, } from './types'; import { ItemStatus, OperationType, Type } from './types'; -import { StubFile } from '../stubFile'; - -const createDominantColorWorker = createWorkerFactory( - () => - import( - /* webpackChunkName: 'dominant-color' */ './workers/dominantColor' - ) -); -const dominantColorWorker = createDominantColorWorker(); - -const createBlurhashWorker = createWorkerFactory( - () => import( /* webpackChunkName: 'blurhash' */ './workers/blurhash' ) -); -const blurhashWorker = createBlurhashWorker(); - -// Safari does not currently support WebP in HTMLCanvasElement.toBlob() -// See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob -const isSafari = Boolean( - window?.navigator.userAgent && - window.navigator.userAgent.includes( 'Safari' ) && - ! window.navigator.userAgent.includes( 'Chrome' ) && - ! window.navigator.userAgent.includes( 'Chromium' ) -); +import type { + addItem, + processItem, + removeItem, + revokeBlobUrls, +} from './private-actions'; type ActionCreators = { addItem: typeof addItem; addItems: typeof addItems; addItemFromUrl: typeof addItemFromUrl; - addSideloadItem: typeof addSideloadItem; removeItem: typeof removeItem; - prepareItem: typeof prepareItem; processItem: typeof processItem; - finishOperation: typeof finishOperation; - uploadItem: typeof uploadItem; - sideloadItem: typeof sideloadItem; cancelItem: typeof cancelItem; - resumeItem: typeof resumeItem; - addPosterForItem: typeof addPosterForItem; rejectApproval: typeof rejectApproval; grantApproval: typeof grantApproval; - muteVideoItem: typeof muteVideoItem; muteExistingVideo: typeof muteExistingVideo; addSubtitlesForExistingVideo: typeof addSubtitlesForExistingVideo; addPosterForExistingVideo: typeof addPosterForExistingVideo; - convertHeifItem: typeof convertHeifItem; - resizeCropItem: typeof resizeCropItem; - convertGifItem: typeof convertGifItem; optimizeExistingItem: typeof optimizeExistingItem; - optimizeVideoItem: typeof optimizeVideoItem; - optimizeAudioItem: typeof optimizeAudioItem; - optimizeImageItem: typeof optimizeImageItem; - generateThumbnails: typeof generateThumbnails; - uploadOriginal: typeof uploadOriginal; - uploadPoster: typeof uploadPoster; revokeBlobUrls: typeof revokeBlobUrls; - fetchRemoteFile: typeof fetchRemoteFile; - generateSubtitles: typeof generateSubtitles; < T = Record< string, unknown > >( args: T ): void; }; -type AllSelectors = typeof import('./selectors'); +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; @@ -165,116 +84,6 @@ export function updateSettings( }; } -interface AddItemArgs { - file: File; - batchId?: BatchId; - onChange?: OnChangeHandler; - onSuccess?: OnSuccessHandler; - onError?: OnErrorHandler; - onBatchSuccess?: OnBatchSuccessHandler; - additionalData?: AdditionalData; - sourceUrl?: string; - sourceAttachmentId?: number; - blurHash?: string; - dominantColor?: string; - abortController?: AbortController; - operations?: Operation[]; -} - -/** - * Adds a new item to the upload queue. - * - * @todo Revisit blurHash and dominantColor fields. - * - * @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.blurHash] Item's BlurHash. - * @param [$0.dominantColor] Item's dominant color. - * @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, - batchId, - onChange, - onSuccess, - onBatchSuccess, - onError, - additionalData = {} as AdditionalData, - sourceUrl, - sourceAttachmentId, - blurHash, - dominantColor, - abortController, - operations, -}: AddItemArgs ) { - return async ( { - dispatch, - registry, - }: { - dispatch: ActionCreators; - registry: WPDataRegistry; - } ) => { - const thumbnailGeneration: ThumbnailGeneration = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'thumbnailGeneration' ); - - const itemId = uuidv4(); - - 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: 'server' === thumbnailGeneration, - ...additionalData, - }, - onChange, - onSuccess, - onBatchSuccess, - onError, - sourceUrl, - sourceAttachmentId, - blurHash, - dominantColor, - abortController: abortController || new AbortController(), - operations: Array.isArray( operations ) - ? operations - : [ OperationType.Prepare ], - }, - } ); - - dispatch.processItem( itemId ); - }; -} - interface AddItemsArgs { files: File[]; onChange?: OnChangeHandler; @@ -363,63 +172,6 @@ export function addItemFromUrl( { }; } -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 poster image or 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 async ( { dispatch }: { dispatch: ActionCreators } ) => { - const itemId = uuidv4(); - dispatch< AddAction >( { - type: Type.Add, - item: { - id: itemId, - batchId, - status: ItemStatus.Processing, - sourceFile: cloneFile( file ), - file, - onChange, - additionalData: { - generate_sub_sizes: false, - ...additionalData, - }, - parentId, - operations: Array.isArray( operations ) - ? operations - : [ OperationType.Prepare ], - abortController: new AbortController(), - }, - } ); - - dispatch.processItem( itemId ); - }; -} - interface MuteExistingVideoArgs { id: number; url: string; @@ -791,963 +543,95 @@ export function optimizeExistingItem( { } /** - * Processes a single item in the queue. - * - * Runs the next operation in line and invokes any callbacks. + * Rejects a proposed optimized/converted version of a file + * by essentially cancelling its further processing. * * @param id Item ID. */ -export function processItem( id: QueueItemId ) { +export function rejectApproval( id: number ) { return async ( { select, dispatch }: ThunkArgs ) => { - if ( select.isPaused() ) { - return; - } - - const item = select.getItem( id ) as QueueItem; - - if ( item.status === ItemStatus.PendingApproval ) { - return; - } - - const { - attachment, - onChange, - onSuccess, - onBatchSuccess, - batchId, - parentId, - } = item; - - const operation = Array.isArray( item.operations?.[ 0 ] ) - ? item.operations[ 0 ][ 0 ] - : item.operations?.[ 0 ]; - // TODO: Improve type here to avoid using "as" further down. - const operationArgs = Array.isArray( item.operations?.[ 0 ] ) - ? item.operations[ 0 ][ 1 ] - : undefined; - - // If we're sideloading a thumbnail, pause upload to avoid race conditions. - // It will be resumed after the previous upload finishes. - if ( - operation === OperationType.Upload && - item.parentId && - item.additionalData.post - ) { - const isAlreadyUploading = select.isUploadingToPost( - item.additionalData.post as number - ); - if ( isAlreadyUploading ) { - dispatch< PauseItemAction >( { - type: Type.PauseItem, - id, - } ); - return; - } - } - - if ( attachment ) { - 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 ) { - const isBatchUploaded = - batchId && select.isBatchUploaded( batchId ); - - if ( - parentId || - ( ! parentId && ! select.isUploadingByParentId( id ) ) - ) { - if ( attachment ) { - onSuccess?.( [ attachment ] ); - } - if ( isBatchUploaded ) { - onBatchSuccess?.(); - } - - dispatch.removeItem( id ); - dispatch.revokeBlobUrls( id ); - } - - // All other side-loaded items have been removed, so remove the parent too. - if ( parentId && isBatchUploaded ) { - const parentItem = select.getItem( parentId ) as QueueItem; - - if ( attachment ) { - parentItem.onSuccess?.( [ attachment ] ); - } - - if ( - parentItem.batchId && - select.isBatchUploaded( parentItem.batchId ) - ) { - parentItem.onBatchSuccess?.(); - } - - dispatch.removeItem( parentId ); - dispatch.revokeBlobUrls( parentId ); - } - - /* - 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; - } - - if ( ! operation ) { - // This shouldn't really happen. + const item = select.getItemByAttachmentId( id ); + if ( ! item ) { 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.TranscodeHeif: - dispatch.convertHeifItem( item.id ); - break; - - case OperationType.TranscodeGif: - dispatch.convertGifItem( item.id ); - break; - - case OperationType.TranscodeAudio: - dispatch.optimizeAudioItem( item.id ); - break; - - case OperationType.TranscodeVideo: - dispatch.optimizeVideoItem( - item.id, - operationArgs as OperationArgs[ OperationType.TranscodeVideo ] - ); - break; - - case OperationType.MuteVideo: - dispatch.muteVideoItem( item.id ); - break; - - case OperationType.TranscodeImage: - dispatch.optimizeImageItem( - item.id, - operationArgs as OperationArgs[ OperationType.TranscodeImage ] - ); - break; - - // TODO: Right now only handles images, but should support other types too. - case OperationType.Compress: - dispatch.optimizeImageItem( - item.id, - operationArgs as OperationArgs[ OperationType.TranscodeImage ] - ); - break; - - case OperationType.AddPoster: - dispatch.addPosterForItem( item.id ); - break; - - case OperationType.Upload: - if ( item.parentId ) { - dispatch.sideloadItem( id ); - } else { - dispatch.uploadItem( id ); - } - break; - - case OperationType.ThumbnailGeneration: - dispatch.generateThumbnails( id ); - break; - - case OperationType.UploadOriginal: - dispatch.uploadOriginal( id ); - break; - - case OperationType.UploadPoster: - dispatch.uploadPoster( id ); - break; - - case OperationType.FetchRemoteFile: - dispatch.fetchRemoteFile( - id, - operationArgs as OperationArgs[ OperationType.FetchRemoteFile ] - ); - break; - - case OperationType.GenerateSubtitles: - dispatch.generateSubtitles( id ); - break; - } + dispatch.cancelItem( + item.id, + new MediaError( { + code: 'UPLOAD_CANCELLED', + message: 'File upload was cancelled', + file: item.file, + } ) + ); }; } /** - * Resumes processing for a given post/attachment ID. + * Approves a proposed optimized/converted version of a file + * so it can continue being processed and uploaded. * - * @param postOrAttachmentId Post or attachment ID. + * @param id Item ID. */ -export function resumeItem( postOrAttachmentId: number ) { +export function grantApproval( id: number ) { return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getPausedUploadForPost( postOrAttachmentId ); - if ( item ) { - dispatch< ResumeItemAction >( { - type: Type.ResumeItem, - id: item.id, - } ); - dispatch.processItem( item.id ); + const item = select.getItemByAttachmentId( id ); + if ( ! item ) { + return; } - }; -} - -/** - * 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, + dispatch< ApproveUploadAction >( { + type: Type.ApproveUpload, + id: item.id, } ); - for ( const item of select.getItems() ) { - dispatch.processItem( item.id ); - } + dispatch.processItem( item.id ); }; } /** - * Removes a specific item from the queue. + * Cancels an item in the queue based on an error. * - * @param id Item ID. + * @param id Item ID. + * @param error Error instance. */ -export function removeItem( id: QueueItemId ) { - return async ( { select, dispatch }: ThunkArgs ) => { +export function cancelItem( id: QueueItemId, error: Error ) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { const item = select.getItem( id ); + if ( ! item ) { + /* + * Do nothing if item has already been removed. + * This can happen if an upload is cancelled manually + * while transcoding with vips is still in progress. + * Then, cancelItem() is once invoked manually and once + * by the error handler in optimizeImageItem(). + */ return; } - if ( item.timings ) { - for ( const timing of item.timings ) { - measure( timing ); - } + // When cancelling a parent item, cancel all the children too. + for ( const child of select.getChildItems( id ) ) { + dispatch.cancelItem( child.id, error ); } - dispatch( { - type: Type.Remove, - id, - } ); - }; -} - -/** - * 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 ( { dispatch }: ThunkArgs ) => { - dispatch< OperationFinishAction >( { - type: Type.OperationFinish, - id, - item: updates, - } ); + const imageLibrary: ImageLibrary = + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'imageLibrary' ) || 'vips'; - dispatch.processItem( id ); - }; -} + if ( 'vips' === imageLibrary ) { + await vipsCancelOperations( id ); + } -/** - * Triggers poster image generation for an item. - * - * @param id Item ID. - */ -export function addPosterForItem( id: QueueItemId ) { - return async ( { - select, - dispatch, - }: { - select: Selectors; - dispatch: ActionCreators; - } ) => { - const item = select.getItem( id ) as QueueItem; + item.abortController?.abort(); - // Bail early if the video already has a poster. - if ( item.poster ) { - dispatch.finishOperation( id, {} ); - return; - } - - const mediaType = getMediaTypeFromMimeType( item.file.type ); - - try { - switch ( mediaType ) { - case 'video': - let src = isBlobURL( item.attachment?.url ) - ? item.attachment?.url - : undefined; - - if ( ! src ) { - src = createBlobURL( item.file ); - - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl: src, - } ); - } - - const poster = await getPosterFromVideo( - src, - `${ getFileBasename( item.sourceFile.name ) }-poster` - ); - - const posterUrl = createBlobURL( poster ); - - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl: posterUrl, - } ); - - dispatch.finishOperation( id, { - poster, - attachment: { - url: item.attachment?.url || src, - poster: posterUrl, - }, - } ); - - break; - - case 'pdf': - const { getImageFromPdf } = await import( - /* webpackChunkName: 'pdf' */ '@mexp/pdf' - ); - - const pdfSrc = createBlobURL( item.file ); - - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl: pdfSrc, - } ); - - // TODO: is this the right place? - // Note: Causes another state update. - const pdfThumbnail = await getImageFromPdf( - pdfSrc, - // Same suffix as WP core uses, see https://github.com/WordPress/wordpress-develop/blob/8a5daa6b446e8c70ba22d64820f6963f18d36e92/src/wp-admin/includes/image.php#L609-L634 - `${ getFileBasename( item.file.name ) }-pdf` - ); - - const pdfThumbnailUrl = createBlobURL( pdfThumbnail ); - - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl: pdfThumbnailUrl, - } ); - - dispatch.finishOperation( id, { - poster: pdfThumbnail, - attachment: { - poster: pdfThumbnailUrl, - }, - } ); - break; - - default: - // We're dealing with a StubFile, e.g. via addPosterForExistingVideo() or addItemFromUrl(). - const file = await getPosterFromVideo( - // @ts-ignore -- Expected to exist at this point. - item.sourceUrl, - `${ getFileBasename( item.sourceFile.name ) }-poster` - ); - - const blobURL = createBlobURL( file ); - - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl: blobURL, - } ); - - dispatch.finishOperation( id, { - file, - attachment: { - url: blobURL, - }, - } ); - } - } catch ( err ) { - // Do not throw error. Could be a simple error such as video playback not working in tests. - - dispatch.finishOperation( id, {} ); - } - }; -} - -/** - * 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. - * - * @param id Item ID. - */ -export function prepareItem( id: QueueItemId ) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const { file } = item; - - const mediaType = getMediaTypeFromMimeType( file.type ); - - const operations: Operation[] = []; - - switch ( mediaType ) { - case 'image': - const fileBuffer = await file.arrayBuffer(); - - const isGif = isAnimatedGif( fileBuffer ); - - const convertAnimatedGifs: boolean = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'gif_convert' ); - - if ( - isGif && - window.crossOriginIsolated && - convertAnimatedGifs - ) { - operations.push( - OperationType.TranscodeGif, - OperationType.AddPoster, - OperationType.Upload, - // Try poster generation again *after* upload if it's still missing. - OperationType.AddPoster, - OperationType.UploadPoster - ); - - break; - } - - const isHeif = isHeifImage( fileBuffer ); - - if ( isHeif ) { - operations.push( OperationType.TranscodeHeif ); - } - - const imageSizeThreshold: number = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'bigImageSizeThreshold' ); - - if ( imageSizeThreshold ) { - operations.push( [ - OperationType.ResizeCrop, - { - resize: { - width: imageSizeThreshold, - height: imageSizeThreshold, - }, - }, - ] ); - } - - const optimizeOnUpload: boolean = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'optimizeOnUpload' ); - - if ( optimizeOnUpload ) { - operations.push( OperationType.TranscodeImage ); - } - - operations.push( - OperationType.Upload, - OperationType.ThumbnailGeneration - ); - - const keepOriginal: boolean = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'keepOriginal' ); - - if ( ( imageSizeThreshold && keepOriginal ) || isHeif ) { - operations.push( OperationType.UploadOriginal ); - } - - break; - - case 'video': - // Here we are potentially dealing with an unsupported file type (e.g. MOV) - // that cannot be *played* by the browser, but could still be used for generating a poster. - - operations.push( OperationType.AddPoster ); - - // TODO: First check if video already meets criteria. - // No need to compress a video that's already quite small. - - if ( - window.crossOriginIsolated && - canProcessWithFFmpeg( file ) - ) { - operations.push( [ - OperationType.TranscodeVideo, - // Don't make a fuzz if video cannot be transcoded. - { continueOnError: true }, - ] ); - } - - operations.push( - OperationType.Upload, - // Try poster generation again *after* upload if it's still missing. - OperationType.AddPoster, - OperationType.UploadPoster - ); - - break; - - case 'audio': - if ( - window.crossOriginIsolated && - canProcessWithFFmpeg( file ) - ) { - operations.push( OperationType.TranscodeAudio ); - } - - operations.push( OperationType.Upload ); - - break; - - case 'pdf': - operations.push( - OperationType.AddPoster, - OperationType.Upload, - OperationType.ThumbnailGeneration - ); - - break; - - default: - operations.push( OperationType.Upload ); - - break; - } - - dispatch< AddOperationsAction >( { - type: Type.AddOperations, - id, - operations, - } ); - - dispatch.finishOperation( id, {} ); - }; -} - -/** - * Adds an item's poster image to the queue for uploading. - * - * @param id Item ID. - */ -export function uploadPoster( id: QueueItemId ) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const attachment: Attachment = item.attachment as Attachment; - - // In the event that the uploaded video already has a poster, do not upload another one. - // Can happen when using muteExistingVideo() or when a poster is generated server-side. - // TODO: Make the latter scenario actually work. - // Use getEntityRecord to actually get poster URL from posterID returned by uploadToServer() - if ( - ( ! attachment.poster || isBlobURL( attachment.poster ) ) && - item.poster - ) { - try { - const abortController = new AbortController(); - - const operations: Operation[] = []; - - const imageSizeThreshold: number = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'bigImageSizeThreshold' ); - - if ( imageSizeThreshold ) { - operations.push( [ - OperationType.ResizeCrop, - { - resize: { - width: imageSizeThreshold, - height: imageSizeThreshold, - }, - }, - ] ); - } - - const outputFormat: ImageFormat = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'default_outputFormat' ); - - const outputQuality: number = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'default_quality' ); - - const interlaced: boolean = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'default_interlaced' ); - - operations.push( - [ - OperationType.TranscodeImage, - { outputFormat, outputQuality, interlaced }, - ], - OperationType.Upload, - OperationType.ThumbnailGeneration, - OperationType.UploadOriginal - ); - - // Adding the poster to the queue on its own allows for it to be optimized, etc. - dispatch.addItem( { - file: item.poster, - onChange: ( [ posterAttachment ] ) => { - if ( - ! posterAttachment.url || - isBlobURL( posterAttachment.url ) - ) { - return; - } - - // TODO: Pass poster ID as well so that the video block can update `featured_media` via the REST API. - const updatedAttachment = { - ...attachment, - // Video block expects such a structure for the poster. - // https://github.com/WordPress/gutenberg/blob/e0a413d213a2a829ece52c6728515b10b0154d8d/packages/block-library/src/video/edit.js#L154 - image: { - src: posterAttachment.url, - }, - // Expected by ImportMedia / addItemFromUrl() - poster: posterAttachment.url, - }; - - // This might be confusing, but the idea is to update the original - // video item in the editor with the newly uploaded poster. - item.onChange?.( [ updatedAttachment ] ); - }, - additionalData: { - // Reminder: Parent post ID might not be set, depending on context, - // but should be carried over if it does. - post: item.additionalData.post, - }, - blurHash: item.blurHash, - dominantColor: item.dominantColor, - abortController, - operations, - } ); - } catch ( err ) { - // TODO: Debug & catch & throw. - } - } - - dispatch.finishOperation( id, {} ); - }; -} - -/** - * Adds thumbnail versions to the queue for sideloading. - * - * @param id Item ID. - */ -export function generateThumbnails( id: QueueItemId ) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const attachment: Attachment = item.attachment as Attachment; - - const mediaType = getMediaTypeFromMimeType( item.file.type ); - - const thumbnailGeneration: ThumbnailGeneration = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'thumbnailGeneration' ); - - // Client-side thumbnail generation. - // Works for images and PDF posters. - - if ( - ! item.parentId && - attachment.missing_image_sizes && - 'server' !== thumbnailGeneration - ) { - let file = attachment.mexp_filename - ? renameFile( item.file, attachment.mexp_filename ) - : item.file; - const batchId = uuidv4(); - - if ( 'pdf' === mediaType && item.poster ) { - file = item.poster; - - const outputFormat: ImageFormat = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'default_outputFormat' ); - - const outputQuality: number = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'default_quality' ); - - const interlaced: boolean = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'default_interlaced' ); - - // Upload the "full" version without a resize param. - dispatch.addSideloadItem( { - file: item.poster, - additionalData: { - // Sideloading does not use the parent post ID but the - // attachment ID as the image sizes need to be added to it. - post: attachment.id, - image_size: 'full', - }, - operations: [ - [ - OperationType.TranscodeImage, - { outputFormat, outputQuality, interlaced }, - ], - OperationType.Upload, - ], - parentId: item.id, - } ); - } - - for ( const name of attachment.missing_image_sizes ) { - const imageSize = select.getImageSize( name ); - if ( imageSize ) { - // Force thumbnails to be soft crops, see wp_generate_attachment_metadata(). - if ( 'pdf' === mediaType && 'thumbnail' === name ) { - imageSize.crop = false; - } - - dispatch.addSideloadItem( { - file, - onChange: ( [ updatedAttachment ] ) => { - // This might be confusing, but the idea is to update the original - // image item in the editor with the new one with the added sub-size. - item.onChange?.( [ updatedAttachment ] ); - }, - batchId, - parentId: item.id, - additionalData: { - // Sideloading does not use the parent post ID but the - // attachment ID as the image sizes need to be added to it. - post: attachment.id, - // Reference the same upload_request if needed. - upload_request: item.additionalData.upload_request, - image_size: name, - }, - operations: [ - [ OperationType.ResizeCrop, { resize: imageSize } ], - OperationType.Upload, - ], - } ); - } - } - } - - dispatch.finishOperation( id, {} ); - }; -} - -/** - * Adds the original file to the queue for sideloading. - * - * If an item was downsized due to the big image size threshold, - * this adds the original file for storing. - * - * @param id Item ID. - */ -export function uploadOriginal( id: QueueItemId ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const attachment: Attachment = item.attachment as Attachment; - - const mediaType = getMediaTypeFromMimeType( item.file.type ); - - /* - Upload the original image file if it was a HEIF image, - or if it was resized because of the big image size threshold. - */ - - if ( 'image' === mediaType ) { - if ( - ! item.parentId && - ( ( item.file instanceof ImageFile && item.file?.wasResized ) || - isHeifImage( await item.sourceFile.arrayBuffer() ) ) - ) { - const originalBaseName = getFileBasename( - attachment.mexp_filename || item.file.name - ); - - dispatch.addSideloadItem( { - file: renameFile( - item.sourceFile, - `${ originalBaseName }-original.${ getFileExtension( - item.sourceFile.name - ) }` - ), - parentId: item.id, - additionalData: { - // Sideloading does not use the parent post ID but the - // attachment ID as the image sizes need to be added to it. - post: attachment.id, - // Reference the same upload_request if needed. - upload_request: item.additionalData.upload_request, - image_size: 'original', - }, - // Skip any resizing or optimization of the original image. - operations: [ OperationType.Upload ], - } ); - } - } - - dispatch.finishOperation( id, {} ); - }; -} - -/** - * Rejects a proposed optimized/converted version of a file - * by essentially cancelling its further processing. - * - * @param id Item ID. - */ -export function rejectApproval( id: number ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItemByAttachmentId( id ); - if ( ! item ) { - return; - } - - dispatch.cancelItem( - item.id, - new MediaError( { - code: 'UPLOAD_CANCELLED', - message: 'File upload was cancelled', - file: item.file, - } ) - ); - }; -} - -/** - * Approves a proposed optimized/converted version of a file - * so it can continue being processed and uploaded. - * - * @param id Item ID. - */ -export function grantApproval( id: number ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItemByAttachmentId( id ); - if ( ! item ) { - return; - } - - dispatch< ApproveUploadAction >( { - type: Type.ApproveUpload, - id: item.id, - } ); - - dispatch.processItem( item.id ); - }; -} - -/** - * Cancels an item in the queue based on an error. - * - * @param id Item ID. - * @param error Error instance. - */ -export function cancelItem( id: QueueItemId, error: Error ) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ); - - if ( ! item ) { - /* - * Do nothing if item has already been removed. - * This can happen if an upload is cancelled manually - * while transcoding with vips is still in progress. - * Then, cancelItem() is once invoked manually and once - * by the error handler in optimizeImageItem(). - */ - return; - } - - // When cancelling a parent item, cancel all the children too. - for ( const child of select - .getItems() - .filter( ( _item ) => _item.parentId === id ) ) { - dispatch.cancelItem( child.id, error ); - } - - const imageLibrary: ImageLibrary = - registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'imageLibrary' ) || 'vips'; - - if ( 'vips' === imageLibrary ) { - await vipsCancelOperations( id ); - } - - item.abortController?.abort(); - - // TODO: Do not log error for children if cancelling a parent and all its children. - const { onError } = item; - onError?.( error ?? new Error( 'Upload cancelled' ) ); - if ( ! onError && error ) { - // TODO: Find better way to surface errors with sideloads etc. - // eslint-disable-next-line no-console -- Deliberately log errors here. - console.error( 'Upload cancelled', error ); + // TODO: Do not log error for children if cancelling a parent and all its children. + const { onError } = item; + onError?.( error ?? new Error( 'Upload cancelled' ) ); + if ( ! onError && error ) { + // TODO: Find better way to surface errors with sideloads etc. + // eslint-disable-next-line no-console -- Deliberately log errors here. + console.error( 'Upload cancelled', error ); } dispatch< CancelAction >( { @@ -1759,909 +643,3 @@ export function cancelItem( id: QueueItemId, error: Error ) { dispatch.revokeBlobUrls( id ); }; } - -type OptimizeImageItemArgs = OperationArgs[ OperationType.TranscodeImage ]; - -/** - * Optimizes/Compresses an existing image item. - * - * @param id Item ID. - * @param [args] Additional arguments for the operation. - */ -export function optimizeImageItem( - id: QueueItemId, - args?: OptimizeImageItemArgs -) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const startTime = performance.now(); - - const imageLibrary: ImageLibrary = - registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'imageLibrary' ) || 'vips'; - - let stop: undefined | ( () => void ); - - try { - let file: File; - - const inputFormat = getExtensionFromMimeType( item.file.type ); - - if ( ! inputFormat ) { - throw new Error( 'Unsupported file type' ); - } - - const outputFormat: ImageFormat = - args?.outputFormat || - registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, `${ inputFormat }_outputFormat` ) || - inputFormat; - - const outputQuality: number = - args?.outputQuality || - registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, `${ inputFormat }_quality` ) || - 80; - - const interlaced: boolean = - args?.interlaced || - registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, `${ inputFormat }_interlaced` ) || - false; - - stop = start( - `Optimize Item: ${ item.file.name } | ${ imageLibrary } | ${ inputFormat } | ${ outputFormat } | ${ outputQuality }` - ); - - switch ( outputFormat ) { - case inputFormat: - default: - if ( 'browser' === imageLibrary ) { - file = await canvasCompressImage( - item.file, - outputQuality / 100 - ); - } else { - file = await vipsCompressImage( - item.id, - item.file, - outputQuality / 100, - interlaced - ); - } - break; - - case 'webp': - // Safari doesn't support WebP in HTMLCanvasElement.toBlob(). - // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob - if ( 'browser' === imageLibrary && ! isSafari ) { - file = await canvasConvertImageFormat( - item.file, - 'image/webp', - outputQuality / 100 - ); - } else { - file = await vipsConvertImageFormat( - item.id, - item.file, - 'image/webp', - outputQuality / 100 - ); - } - break; - - case 'avif': - // No browsers support AVIF in HTMLCanvasElement.toBlob() yet, so always use vips. - // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob - file = await vipsConvertImageFormat( - item.id, - item.file, - 'image/avif', - outputQuality / 100 - ); - break; - - case 'gif': - // Browsers don't typically support image/gif in HTMLCanvasElement.toBlob() yet, so always use vips. - // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob - file = await vipsConvertImageFormat( - item.id, - item.file, - 'image/avif', - outputQuality / 100, - interlaced - ); - break; - - case 'jpeg': - case 'png': - if ( 'browser' === imageLibrary ) { - file = await canvasConvertImageFormat( - item.file, - `image/${ outputFormat }`, - outputQuality / 100 - ); - } else { - file = await vipsConvertImageFormat( - item.id, - item.file, - `image/${ outputFormat }`, - outputQuality / 100, - interlaced - ); - } - } - - if ( item.file instanceof ImageFile ) { - file = new ImageFile( - file, - item.file.width, - item.file.height, - item.file.originalWidth, - item.file.originalHeight - ); - } - - const blobUrl = createBlobURL( file ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - const endTime = performance.now(); - - const timing: MeasureOptions = { - measureName: `Optimize image ${ item.file.name }`, - startTime, - endTime, - tooltipText: 'This is a rendering task', - properties: [ - [ 'Item ID', item.id ], - [ 'File name', item.file.name ], - [ 'Image library', imageLibrary ], - [ 'Input format', inputFormat ], - [ 'Output format', outputFormat ], - [ 'Output quality', outputQuality ], - ], - }; - - const timings = [ timing ]; - - if ( args?.requireApproval ) { - dispatch.finishOperation( id, { - status: ItemStatus.PendingApproval, - file, - attachment: { - url: blobUrl, - mime_type: file.type, - }, - timings, - } ); - } else { - dispatch.finishOperation( id, { - file, - attachment: { - url: blobUrl, - }, - timings, - } ); - } - } catch ( error ) { - dispatch.cancelItem( - id, - error instanceof Error - ? error - : new MediaError( { - code: 'MEDIA_TRANSCODING_ERROR', - message: 'File could not be uploaded', - file: item.file, - } ) - ); - } finally { - stop?.(); - } - }; -} - -type OptimizeVideoItemArgs = OperationArgs[ OperationType.TranscodeVideo ]; - -/** - * Optimizes/Compresses an existing video item. - * - * @param id Item ID. - * @param [args] Additional arguments for the operation. - */ -export function optimizeVideoItem( - id: QueueItemId, - args?: OptimizeVideoItemArgs -) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const outputFormat: VideoFormat = - registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'video_outputFormat' ) || 'mp4'; - - const videoSizeThreshold: number = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'bigVideoSizeThreshold' ); - - try { - const { transcodeVideo } = await import( - /* webpackChunkName: 'ffmpeg' */ '@mexp/ffmpeg' - ); - - const file = await transcodeVideo( - item.file, - getFileBasename( item.file.name ), - `video/${ outputFormat }`, - videoSizeThreshold - ); - - const blobUrl = createBlobURL( file ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - dispatch.finishOperation( id, { - file, - attachment: { - url: blobUrl, - }, - } ); - } catch ( error ) { - if ( args?.continueOnError ) { - dispatch.finishOperation( id, {} ); - return; - } - - dispatch.cancelItem( - id, - error instanceof Error - ? error - : new MediaError( { - code: 'VIDEO_TRANSCODING_ERROR', - message: 'File could not be uploaded', - file: item.file, - } ) - ); - } - }; -} - -/** - * Mutes an existing video item. - * - * @param id Item ID. - */ -export function muteVideoItem( id: QueueItemId ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - try { - const { muteVideo } = await import( - /* webpackChunkName: 'ffmpeg' */ '@mexp/ffmpeg' - ); - const file = await muteVideo( item.file ); - - const blobUrl = createBlobURL( file ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - dispatch.finishOperation( id, { - file, - attachment: { - url: blobUrl, - }, - additionalData: { - mexp_is_muted: true, - }, - } ); - } catch ( error ) { - dispatch.cancelItem( - id, - error instanceof Error - ? error - : new MediaError( { - code: 'VIDEO_MUTING_ERROR', - message: 'File could not be uploaded', - file: item.file, - } ) - ); - } - }; -} - -/** - * Optimizes/Compresses an existing audio item. - * - * @param id Item ID. - */ -export function optimizeAudioItem( id: QueueItemId ) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const outputFormat: AudioFormat = - registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'audio_outputFormat' ) || 'mp3'; - - try { - let file: File; - const { transcodeAudio } = await import( - /* webpackChunkName: 'ffmpeg' */ '@mexp/ffmpeg' - ); - - switch ( outputFormat ) { - case 'ogg': - file = await transcodeAudio( - item.file, - getFileBasename( item.file.name ), - 'audio/ogg' - ); - break; - - case 'mp3': - default: - file = await transcodeAudio( - item.file, - getFileBasename( item.file.name ), - 'audio/mp3' - ); - break; - } - - const blobUrl = createBlobURL( file ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - dispatch.finishOperation( id, { - file, - attachment: { - url: blobUrl, - }, - } ); - } catch ( error ) { - dispatch.cancelItem( - id, - error instanceof Error - ? error - : new MediaError( { - code: 'AUDIO_TRANSCODING_ERROR', - message: 'File could not be uploaded', - file: item.file, - } ) - ); - } - }; -} - -/** - * Converts an existing GIF item to a video. - * - * @param id Item ID. - */ -export function convertGifItem( id: QueueItemId ) { - return async ( { select, dispatch, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - const outputFormat: VideoFormat = - registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'video_outputFormat' ) || 'video/mp4'; - - const videoSizeThreshold: number = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'bigVideoSizeThreshold' ); - - try { - const { convertGifToVideo } = await import( - /* webpackChunkName: 'ffmpeg' */ '@mexp/ffmpeg' - ); - - const file = await convertGifToVideo( - item.file, - getFileBasename( item.file.name ), - `video/${ outputFormat }`, - videoSizeThreshold - ); - - const blobUrl = createBlobURL( file ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - dispatch.finishOperation( id, { - file, - attachment: { - url: blobUrl, - }, - } ); - } catch ( error ) { - dispatch.cancelItem( - id, - error instanceof Error - ? error - : new MediaError( { - code: 'VIDEO_TRANSCODING_ERROR', - message: 'File could not be uploaded', - file: item.file, - } ) - ); - } - }; -} - -/** - * Converts an existing HEIF image item to another format. - * - * @param id Item ID. - */ -export function convertHeifItem( id: QueueItemId ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - try { - const file = await transcodeHeifImage( item.file ); - - const blobUrl = createBlobURL( file ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - dispatch.finishOperation( id, { - file, - attachment: { - url: blobUrl, - }, - } ); - } catch ( error ) { - dispatch.cancelItem( - id, - error instanceof Error - ? error - : new MediaError( { - code: 'IMAGE_TRANSCODING_ERROR', - message: 'File could not be uploaded', - file: item.file, - } ) - ); - } - }; -} - -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, registry }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - if ( ! args?.resize ) { - dispatch.finishOperation( id, { - file: item.file, - } ); - return; - } - - const thumbnailGeneration: ThumbnailGeneration = registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'thumbnailGeneration' ); - - const smartCrop = Boolean( thumbnailGeneration === 'smart' ); - - const imageLibrary: ImageLibrary = - registry - .select( preferencesStore ) - .get( PREFERENCES_NAME, 'imageLibrary' ) || 'vips'; - - const addSuffix = Boolean( item.parentId ); - - const stop = start( - `Resize Item: ${ item.file.name } | ${ imageLibrary } | ${ thumbnailGeneration } | ${ args.resize.width }x${ args.resize.height }` - ); - - try { - let file: File; - - // No browsers support GIF/AVIF in HTMLCanvasElement.toBlob(). - // Safari doesn't support WebP. - // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob - if ( - 'browser' === imageLibrary && - ! [ 'image/gif', 'image/avif' ].includes( item.file.type ) && - ! ( 'image/webp' === item.file.type && isSafari ) - ) { - file = await canvasResizeImage( - item.file, - args.resize, - addSuffix - ); - } else { - file = await vipsResizeImage( - item.id, - item.file, - args.resize, - smartCrop, - addSuffix - ); - } - - const blobUrl = createBlobURL( file ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - dispatch.finishOperation( id, { - file, - attachment: { - url: blobUrl, - }, - } ); - } catch ( error ) { - dispatch.cancelItem( - id, - error instanceof Error - ? error - : new MediaError( { - code: 'IMAGE_TRANSCODING_ERROR', - message: 'File could not be uploaded', - file: item.file, - } ) - ); - } finally { - stop?.(); - } - }; -} - -/** - * 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 ) as QueueItem; - - const startTime = performance.now(); - - const { poster } = item; - - const additionalData: Record< string, unknown > = { - ...item.additionalData, - // generatedPosterId is set when using muteExistingVideo() for example. - meta: { - mexp_generated_poster_id: item.generatedPosterId || undefined, - mexp_original_id: item.sourceAttachmentId || undefined, - }, - mexp_blurhash: item.blurHash, - mexp_dominant_color: item.dominantColor, - featured_media: item.generatedPosterId || undefined, - }; - - const mediaType = getMediaTypeFromMimeType( item.file.type ); - - let stillUrl = [ 'video', 'pdf' ].includes( mediaType ) - ? item.attachment?.poster - : item.attachment?.url; - - // Freshly converted GIF. - if ( - ! stillUrl && - 'video' === mediaType && - 'image' === getMediaTypeFromMimeType( item.sourceFile.type ) - ) { - stillUrl = createBlobURL( item.sourceFile ); - - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl: stillUrl, - } ); - } - - // TODO: Make this async after upload? - // Could be made reusable to enable back-filling of existing blocks. - if ( - typeof additionalData.mexp_is_muted === 'undefined' && - 'video' === mediaType - ) { - try { - const hasAudio = - item.attachment?.url && - ( await videoHasAudio( item.attachment.url ) ); - additionalData.mexp_is_muted = ! hasAudio; - } catch { - // No big deal if this fails, we can still continue uploading. - } - } - - if ( - ! additionalData.mexp_dominant_color && - stillUrl && - [ 'video', 'image', 'pdf' ].includes( mediaType ) - ) { - // TODO: Make this async after upload? - // Could be made reusable to enable backfilling of existing blocks. - // TODO: Create a scaled-down version of the image first for performance reasons. - try { - additionalData.mexp_dominant_color = - await dominantColorWorker.getDominantColor( stillUrl ); - } catch ( err ) { - // No big deal if this fails, we can still continue uploading. - // TODO: Debug & catch & throw. - } - } - - if ( 'image' === mediaType && stillUrl && window.crossOriginIsolated ) { - // TODO: Make this async after upload? - // Could be made reusable to enable backfilling of existing blocks. - // TODO: Create a scaled-down version of the image first for performance reasons. - try { - additionalData.mexp_has_transparency = - await vipsHasTransparency( stillUrl ); - } catch ( err ) { - // No big deal if this fails, we can still continue uploading. - // TODO: Debug & catch & throw. - } - } - - if ( - ! additionalData.mexp_blurhash && - stillUrl && - [ 'video', 'image', 'pdf' ].includes( mediaType ) - ) { - // TODO: Make this async after upload? - // Could be made reusable to enable backfilling of existing blocks. - // TODO: Create a scaled-down version of the image first for performance reasons. - try { - additionalData.mexp_blurhash = - await blurhashWorker.getBlurHash( stillUrl ); - } catch ( err ) { - // No big deal if this fails, we can still continue uploading. - // TODO: Debug & catch & throw. - } - } - - const timing: MeasureOptions = { - measureName: `Upload item ${ item.file.name }`, - startTime, - endTime: performance.now(), - tooltipText: 'This is a rendering task', - properties: [ - [ 'Item ID', id ], - [ 'File name', item.file.name ], - ], - }; - - const timings = [ timing ]; - - select.getSettings().mediaUpload( { - filesList: [ item.file ], - additionalData, - signal: item.abortController?.signal, - onFileChange: ( [ attachment ] ) => { - // TODO: Get the poster URL from the ID if one exists already. - if ( 'video' === mediaType && ! attachment.featured_media ) { - /* - The newly uploaded file won't have a poster yet. - However, we'll likely still have one on file. - Add it back so we're never without one. - */ - if ( item.attachment?.poster ) { - attachment.poster = item.attachment.poster; - } else if ( poster ) { - attachment.poster = createBlobURL( poster ); - - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl: attachment.poster, - } ); - } - } - - dispatch.finishOperation( id, { - attachment, - timings, - } ); - }, - 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 ) as QueueItem; - - const { post, ...additionalData } = - item.additionalData as SideloadAdditionalData; - - select.getSettings().mediaSideload( { - file: item.file, - attachmentId: post as number, - additionalData, - signal: item.abortController?.signal, - onFileChange: ( [ attachment ] ) => { - dispatch.finishOperation( id, { attachment } ); - dispatch.resumeItem( post as number ); - }, - onError: ( error ) => { - dispatch.cancelItem( id, error ); - dispatch.resumeItem( post as number ); - }, - } ); - }; -} - -type FetchRemoteFileArgs = OperationArgs[ OperationType.FetchRemoteFile ]; - -/** - * Fetches a remote file from another server and adds it to the item. - * - * @param id Item ID. - * @param args Additional arguments for the operation. - */ -export function fetchRemoteFile( id: QueueItemId, args: FetchRemoteFileArgs ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - try { - const sourceFile = await fetchFile( args.url, args.fileName ); - - if ( args.skipAttachment ) { - dispatch.finishOperation( id, { - sourceFile, - } ); - } else { - const file = args.newFileName - ? renameFile( cloneFile( sourceFile ), args.newFileName ) - : cloneFile( sourceFile ); - - const blobUrl = createBlobURL( sourceFile ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - dispatch.finishOperation( id, { - sourceFile, - file, - attachment: { - url: blobUrl, - }, - } ); - } - } catch ( error ) { - dispatch.cancelItem( - id, - error instanceof Error - ? error - : new MediaError( { - code: 'FETCH_REMOTE_FILE_ERROR', - message: 'Remote file could not be downloaded', - file: item.file, - } ) - ); - } - }; -} - -/** - * Generates subtitles for the video item. - * - * @param id Item ID. - */ -export function generateSubtitles( id: QueueItemId ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const item = select.getItem( id ) as QueueItem; - - try { - const { generateSubtitles: _generateSubtitles } = await import( - /* webpackChunkName: 'subtitles' */ '@mexp/subtitles' - ); - - const file = await _generateSubtitles( - item.sourceFile, - getFileBasename( item.sourceFile.name ) - ); - - const blobUrl = createBlobURL( file ); - dispatch< CacheBlobUrlAction >( { - type: Type.CacheBlobUrl, - id, - blobUrl, - } ); - - dispatch.finishOperation( id, { - file, - attachment: { - url: blobUrl, - }, - } ); - } catch ( error ) { - dispatch.cancelItem( - id, - error instanceof Error - ? error - : new MediaError( { - code: 'FETCH_REMOTE_FILE_ERROR', - message: 'Remote file could not be downloaded', - file: item.file, - } ) - ); - } - }; -} - -/** - * Returns an action object that sets all image sub-sizes and their cropping information. - * - * @param imageSizes Map of image size names and their cropping information. - * - * @return Action object. - */ -export function setImageSizes( - imageSizes: Record< string, ImageSizeCrop > -): SetImageSizesAction { - return { - type: Type.SetImageSizes, - imageSizes, - }; -} - -/** - * Revokes all blob URLs for a given item, freeing up memory. - * - * @param id Item ID. - */ -export function revokeBlobUrls( id: QueueItemId ) { - return async ( { select, dispatch }: ThunkArgs ) => { - const blobUrls = select.getBlobUrls( id ); - - for ( const blobUrl of blobUrls ) { - revokeBlobURL( blobUrl ); - } - - dispatch< RevokeBlobUrlsAction >( { - type: Type.RevokeBlobUrls, - id, - } ); - }; -} diff --git a/packages/upload-media/src/store/index.ts b/packages/upload-media/src/store/index.ts index 8442b570..a5d9081e 100644 --- a/packages/upload-media/src/store/index.ts +++ b/packages/upload-media/src/store/index.ts @@ -2,14 +2,30 @@ import { createReduxStore, register } from '@wordpress/data'; import reducer from './reducer'; import * as selectors from './selectors'; +import * as privateSelectors from './private-selectors'; import * as actions from './actions'; +import * as privateActions from './private-actions'; export const STORE_NAME = 'media-experiments/upload'; +/* + Private selectors and actions would be normally be registered via @wordpress/private-apis, + but that package cannot be used by non-WordPress packages. + However, separating the functions into two groups helps with code organization, + making a later migration to Gutenberg easier. + See https://github.com/swissspidy/media-experiments/pull/591. + */ + export const store = createReduxStore( STORE_NAME, { reducer, - selectors, - actions, + selectors: { + ...selectors, + ...privateSelectors, + } as typeof selectors & typeof privateSelectors, + actions: { + ...actions, + ...privateActions, + } as typeof actions & typeof privateActions, } ); register( store ); diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts new file mode 100644 index 00000000..4171ee33 --- /dev/null +++ b/packages/upload-media/src/store/private-actions.ts @@ -0,0 +1,2058 @@ +import { v4 as uuidv4 } from 'uuid'; +import { createWorkerFactory } from '@shopify/web-worker'; + +import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; +import type { WPDataRegistry } from '@wordpress/data/build-types/registry'; +import { store as preferencesStore } from '@wordpress/preferences'; + +import { getExtensionFromMimeType, getMediaTypeFromMimeType } from '@mexp/mime'; +import { measure, type MeasureOptions, start } from '@mexp/log'; + +import { ImageFile } from '../imageFile'; +import { MediaError } from '../mediaError'; +import { + canProcessWithFFmpeg, + cloneFile, + fetchFile, + getFileBasename, + getFileExtension, + getPosterFromVideo, + isAnimatedGif, + isHeifImage, + renameFile, + videoHasAudio, +} from '../utils'; +import { PREFERENCES_NAME } from '../constants'; +import { StubFile } from '../stubFile'; +import { transcodeHeifImage } from './utils/heif'; +import { + vipsCompressImage, + vipsConvertImageFormat, + vipsHasTransparency, + vipsResizeImage, +} from './utils/vips'; +import { + compressImage as canvasCompressImage, + convertImageFormat as canvasConvertImageFormat, + resizeImage as canvasResizeImage, +} from './utils/canvas'; +import type { + AddAction, + AdditionalData, + AddOperationsAction, + Attachment, + AudioFormat, + BatchId, + CacheBlobUrlAction, + ImageFormat, + ImageLibrary, + OnBatchSuccessHandler, + OnChangeHandler, + OnErrorHandler, + OnSuccessHandler, + Operation, + OperationArgs, + OperationFinishAction, + OperationStartAction, + PauseItemAction, + PauseQueueAction, + QueueItem, + QueueItemId, + ResumeItemAction, + ResumeQueueAction, + RevokeBlobUrlsAction, + SideloadAdditionalData, + State, + ThumbnailGeneration, + VideoFormat, +} from './types'; +import { ItemStatus, OperationType, Type } from './types'; +import type { cancelItem } from './actions'; + +const createDominantColorWorker = createWorkerFactory( + () => + import( + /* webpackChunkName: 'dominant-color' */ './workers/dominantColor' + ) +); +const dominantColorWorker = createDominantColorWorker(); + +const createBlurhashWorker = createWorkerFactory( + () => import( /* webpackChunkName: 'blurhash' */ './workers/blurhash' ) +); +const blurhashWorker = createBlurhashWorker(); + +// Safari does not currently support WebP in HTMLCanvasElement.toBlob() +// See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob +const isSafari = Boolean( + window?.navigator.userAgent && + window.navigator.userAgent.includes( 'Safari' ) && + ! window.navigator.userAgent.includes( 'Chrome' ) && + ! window.navigator.userAgent.includes( 'Chromium' ) +); + +type ActionCreators = { + cancelItem: typeof cancelItem; + addItem: typeof addItem; + addSideloadItem: typeof addSideloadItem; + removeItem: typeof removeItem; + prepareItem: typeof prepareItem; + processItem: typeof processItem; + finishOperation: typeof finishOperation; + uploadItem: typeof uploadItem; + sideloadItem: typeof sideloadItem; + resumeItem: typeof resumeItem; + addPosterForItem: typeof addPosterForItem; + muteVideoItem: typeof muteVideoItem; + convertHeifItem: typeof convertHeifItem; + resizeCropItem: typeof resizeCropItem; + convertGifItem: typeof convertGifItem; + optimizeVideoItem: typeof optimizeVideoItem; + optimizeAudioItem: typeof optimizeAudioItem; + optimizeImageItem: typeof optimizeImageItem; + generateThumbnails: typeof generateThumbnails; + uploadOriginal: typeof uploadOriginal; + uploadPoster: typeof uploadPoster; + revokeBlobUrls: typeof revokeBlobUrls; + fetchRemoteFile: typeof fetchRemoteFile; + generateSubtitles: typeof generateSubtitles; + < 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 { + file: File; + batchId?: BatchId; + onChange?: OnChangeHandler; + onSuccess?: OnSuccessHandler; + onError?: OnErrorHandler; + onBatchSuccess?: OnBatchSuccessHandler; + additionalData?: AdditionalData; + sourceUrl?: string; + sourceAttachmentId?: number; + blurHash?: string; + dominantColor?: string; + abortController?: AbortController; + operations?: Operation[]; +} + +/** + * Adds a new item to the upload queue. + * + * @todo Revisit blurHash and dominantColor fields. + * + * @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.blurHash] Item's BlurHash. + * @param [$0.dominantColor] Item's dominant color. + * @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, + batchId, + onChange, + onSuccess, + onBatchSuccess, + onError, + additionalData = {} as AdditionalData, + sourceUrl, + sourceAttachmentId, + blurHash, + dominantColor, + abortController, + operations, +}: AddItemArgs ) { + return async ( { + dispatch, + registry, + }: { + dispatch: ActionCreators; + registry: WPDataRegistry; + } ) => { + const thumbnailGeneration: ThumbnailGeneration = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'thumbnailGeneration' ); + + const itemId = uuidv4(); + + 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: 'server' === thumbnailGeneration, + ...additionalData, + }, + onChange, + onSuccess, + onBatchSuccess, + onError, + sourceUrl, + sourceAttachmentId, + blurHash, + dominantColor, + 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 poster image or 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 async ( { dispatch }: { dispatch: ActionCreators } ) => { + const itemId = uuidv4(); + dispatch< AddAction >( { + type: Type.Add, + item: { + id: itemId, + batchId, + status: ItemStatus.Processing, + sourceFile: cloneFile( file ), + file, + onChange, + additionalData: { + generate_sub_sizes: false, + ...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 ) as QueueItem; + + if ( item.status === ItemStatus.PendingApproval ) { + return; + } + + const { + attachment, + onChange, + onSuccess, + onBatchSuccess, + batchId, + parentId, + } = item; + + const operation = Array.isArray( item.operations?.[ 0 ] ) + ? item.operations[ 0 ][ 0 ] + : item.operations?.[ 0 ]; + // TODO: Improve type here to avoid using "as" further down. + const operationArgs = Array.isArray( item.operations?.[ 0 ] ) + ? item.operations[ 0 ][ 1 ] + : undefined; + + // If we're sideloading a thumbnail, pause upload to avoid race conditions. + // It will be resumed after the previous upload finishes. + if ( + operation === OperationType.Upload && + item.parentId && + item.additionalData.post + ) { + const isAlreadyUploading = select.isUploadingToPost( + item.additionalData.post as number + ); + if ( isAlreadyUploading ) { + dispatch< PauseItemAction >( { + type: Type.PauseItem, + id, + } ); + return; + } + } + + if ( attachment ) { + 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 ) { + const isBatchUploaded = + batchId && select.isBatchUploaded( batchId ); + + if ( + parentId || + ( ! parentId && ! select.isUploadingByParentId( id ) ) + ) { + if ( attachment ) { + onSuccess?.( [ attachment ] ); + } + if ( isBatchUploaded ) { + onBatchSuccess?.(); + } + + dispatch.removeItem( id ); + dispatch.revokeBlobUrls( id ); + } + + // All other side-loaded items have been removed, so remove the parent too. + if ( parentId && isBatchUploaded ) { + const parentItem = select.getItem( parentId ) as QueueItem; + + if ( attachment ) { + parentItem.onSuccess?.( [ attachment ] ); + } + + if ( + parentItem.batchId && + select.isBatchUploaded( parentItem.batchId ) + ) { + parentItem.onBatchSuccess?.(); + } + + dispatch.removeItem( parentId ); + dispatch.revokeBlobUrls( parentId ); + } + + /* + 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; + } + + if ( ! operation ) { + // This shouldn't really happen. + 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.TranscodeHeif: + dispatch.convertHeifItem( item.id ); + break; + + case OperationType.TranscodeGif: + dispatch.convertGifItem( item.id ); + break; + + case OperationType.TranscodeAudio: + dispatch.optimizeAudioItem( item.id ); + break; + + case OperationType.TranscodeVideo: + dispatch.optimizeVideoItem( + item.id, + operationArgs as OperationArgs[ OperationType.TranscodeVideo ] + ); + break; + + case OperationType.MuteVideo: + dispatch.muteVideoItem( item.id ); + break; + + case OperationType.TranscodeImage: + dispatch.optimizeImageItem( + item.id, + operationArgs as OperationArgs[ OperationType.TranscodeImage ] + ); + break; + + // TODO: Right now only handles images, but should support other types too. + case OperationType.Compress: + dispatch.optimizeImageItem( + item.id, + operationArgs as OperationArgs[ OperationType.TranscodeImage ] + ); + break; + + case OperationType.AddPoster: + dispatch.addPosterForItem( item.id ); + break; + + case OperationType.Upload: + if ( item.parentId ) { + dispatch.sideloadItem( id ); + } else { + dispatch.uploadItem( id ); + } + break; + + case OperationType.ThumbnailGeneration: + dispatch.generateThumbnails( id ); + break; + + case OperationType.UploadOriginal: + dispatch.uploadOriginal( id ); + break; + + case OperationType.UploadPoster: + dispatch.uploadPoster( id ); + break; + + case OperationType.FetchRemoteFile: + dispatch.fetchRemoteFile( + id, + operationArgs as OperationArgs[ OperationType.FetchRemoteFile ] + ); + break; + + case OperationType.GenerateSubtitles: + dispatch.generateSubtitles( id ); + break; + } + }; +} + +/** + * Resumes processing for a given post/attachment ID. + * + * @param postOrAttachmentId Post or attachment ID. + */ +export function resumeItem( postOrAttachmentId: number ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getPausedUploadForPost( postOrAttachmentId ); + if ( item ) { + dispatch< ResumeItemAction >( { + type: Type.ResumeItem, + id: item.id, + } ); + dispatch.processItem( item.id ); + } + }; +} + +/** + * 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() ) { + dispatch.processItem( item.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; + } + + if ( item.timings ) { + for ( const timing of item.timings ) { + measure( timing ); + } + } + + dispatch( { + type: Type.Remove, + id, + } ); + }; +} + +/** + * 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 ( { dispatch }: ThunkArgs ) => { + dispatch< OperationFinishAction >( { + type: Type.OperationFinish, + id, + item: updates, + } ); + + dispatch.processItem( id ); + }; +} + +/** + * Triggers poster image generation for an item. + * + * @param id Item ID. + */ +export function addPosterForItem( id: QueueItemId ) { + return async ( { + select, + dispatch, + }: { + select: Selectors; + dispatch: ActionCreators; + } ) => { + const item = select.getItem( id ) as QueueItem; + + // Bail early if the video already has a poster. + if ( item.poster ) { + dispatch.finishOperation( id, {} ); + return; + } + + const mediaType = getMediaTypeFromMimeType( item.file.type ); + + try { + switch ( mediaType ) { + case 'video': + let src = isBlobURL( item.attachment?.url ) + ? item.attachment?.url + : undefined; + + if ( ! src ) { + src = createBlobURL( item.file ); + + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl: src, + } ); + } + + const poster = await getPosterFromVideo( + src, + `${ getFileBasename( item.sourceFile.name ) }-poster` + ); + + const posterUrl = createBlobURL( poster ); + + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl: posterUrl, + } ); + + dispatch.finishOperation( id, { + poster, + attachment: { + url: item.attachment?.url || src, + poster: posterUrl, + }, + } ); + + break; + + case 'pdf': + const { getImageFromPdf } = await import( + /* webpackChunkName: 'pdf' */ '@mexp/pdf' + ); + + const pdfSrc = createBlobURL( item.file ); + + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl: pdfSrc, + } ); + + // TODO: is this the right place? + // Note: Causes another state update. + const pdfThumbnail = await getImageFromPdf( + pdfSrc, + // Same suffix as WP core uses, see https://github.com/WordPress/wordpress-develop/blob/8a5daa6b446e8c70ba22d64820f6963f18d36e92/src/wp-admin/includes/image.php#L609-L634 + `${ getFileBasename( item.file.name ) }-pdf` + ); + + const pdfThumbnailUrl = createBlobURL( pdfThumbnail ); + + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl: pdfThumbnailUrl, + } ); + + dispatch.finishOperation( id, { + poster: pdfThumbnail, + attachment: { + poster: pdfThumbnailUrl, + }, + } ); + break; + + default: + // We're dealing with a StubFile, e.g. via addPosterForExistingVideo() or addItemFromUrl(). + const file = await getPosterFromVideo( + // @ts-ignore -- Expected to exist at this point. + item.sourceUrl, + `${ getFileBasename( item.sourceFile.name ) }-poster` + ); + + const blobURL = createBlobURL( file ); + + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl: blobURL, + } ); + + dispatch.finishOperation( id, { + file, + attachment: { + url: blobURL, + }, + } ); + } + } catch ( err ) { + // Do not throw error. Could be a simple error such as video playback not working in tests. + + dispatch.finishOperation( id, {} ); + } + }; +} + +/** + * 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. + * + * @param id Item ID. + */ +export function prepareItem( id: QueueItemId ) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const { file } = item; + + const mediaType = getMediaTypeFromMimeType( file.type ); + + const operations: Operation[] = []; + + switch ( mediaType ) { + case 'image': + const fileBuffer = await file.arrayBuffer(); + + const isGif = isAnimatedGif( fileBuffer ); + + const convertAnimatedGifs: boolean = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'gif_convert' ); + + if ( + isGif && + window.crossOriginIsolated && + convertAnimatedGifs + ) { + operations.push( + OperationType.TranscodeGif, + OperationType.AddPoster, + OperationType.Upload, + // Try poster generation again *after* upload if it's still missing. + OperationType.AddPoster, + OperationType.UploadPoster + ); + + break; + } + + const isHeif = isHeifImage( fileBuffer ); + + if ( isHeif ) { + operations.push( OperationType.TranscodeHeif ); + } + + const imageSizeThreshold: number = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'bigImageSizeThreshold' ); + + if ( imageSizeThreshold ) { + operations.push( [ + OperationType.ResizeCrop, + { + resize: { + width: imageSizeThreshold, + height: imageSizeThreshold, + }, + }, + ] ); + } + + const optimizeOnUpload: boolean = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'optimizeOnUpload' ); + + if ( optimizeOnUpload ) { + operations.push( OperationType.TranscodeImage ); + } + + operations.push( + OperationType.Upload, + OperationType.ThumbnailGeneration + ); + + const keepOriginal: boolean = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'keepOriginal' ); + + if ( ( imageSizeThreshold && keepOriginal ) || isHeif ) { + operations.push( OperationType.UploadOriginal ); + } + + break; + + case 'video': + // Here we are potentially dealing with an unsupported file type (e.g. MOV) + // that cannot be *played* by the browser, but could still be used for generating a poster. + + operations.push( OperationType.AddPoster ); + + // TODO: First check if video already meets criteria. + // No need to compress a video that's already quite small. + + if ( + window.crossOriginIsolated && + canProcessWithFFmpeg( file ) + ) { + operations.push( [ + OperationType.TranscodeVideo, + // Don't make a fuzz if video cannot be transcoded. + { continueOnError: true }, + ] ); + } + + operations.push( + OperationType.Upload, + // Try poster generation again *after* upload if it's still missing. + OperationType.AddPoster, + OperationType.UploadPoster + ); + + break; + + case 'audio': + if ( + window.crossOriginIsolated && + canProcessWithFFmpeg( file ) + ) { + operations.push( OperationType.TranscodeAudio ); + } + + operations.push( OperationType.Upload ); + + break; + + case 'pdf': + operations.push( + OperationType.AddPoster, + OperationType.Upload, + OperationType.ThumbnailGeneration + ); + + break; + + default: + operations.push( OperationType.Upload ); + + break; + } + + dispatch< AddOperationsAction >( { + type: Type.AddOperations, + id, + operations, + } ); + + dispatch.finishOperation( id, {} ); + }; +} + +/** + * Adds an item's poster image to the queue for uploading. + * + * @param id Item ID. + */ +export function uploadPoster( id: QueueItemId ) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const attachment: Attachment = item.attachment as Attachment; + + // In the event that the uploaded video already has a poster, do not upload another one. + // Can happen when using muteExistingVideo() or when a poster is generated server-side. + // TODO: Make the latter scenario actually work. + // Use getEntityRecord to actually get poster URL from posterID returned by uploadToServer() + if ( + ( ! attachment.poster || isBlobURL( attachment.poster ) ) && + item.poster + ) { + try { + const abortController = new AbortController(); + + const operations: Operation[] = []; + + const imageSizeThreshold: number = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'bigImageSizeThreshold' ); + + if ( imageSizeThreshold ) { + operations.push( [ + OperationType.ResizeCrop, + { + resize: { + width: imageSizeThreshold, + height: imageSizeThreshold, + }, + }, + ] ); + } + + const outputFormat: ImageFormat = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'default_outputFormat' ); + + const outputQuality: number = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'default_quality' ); + + const interlaced: boolean = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'default_interlaced' ); + + operations.push( + [ + OperationType.TranscodeImage, + { outputFormat, outputQuality, interlaced }, + ], + OperationType.Upload, + OperationType.ThumbnailGeneration, + OperationType.UploadOriginal + ); + + // Adding the poster to the queue on its own allows for it to be optimized, etc. + dispatch.addItem( { + file: item.poster, + onChange: ( [ posterAttachment ] ) => { + if ( + ! posterAttachment.url || + isBlobURL( posterAttachment.url ) + ) { + return; + } + + // TODO: Pass poster ID as well so that the video block can update `featured_media` via the REST API. + const updatedAttachment = { + ...attachment, + // Video block expects such a structure for the poster. + // https://github.com/WordPress/gutenberg/blob/e0a413d213a2a829ece52c6728515b10b0154d8d/packages/block-library/src/video/edit.js#L154 + image: { + src: posterAttachment.url, + }, + // Expected by ImportMedia / addItemFromUrl() + poster: posterAttachment.url, + }; + + // This might be confusing, but the idea is to update the original + // video item in the editor with the newly uploaded poster. + item.onChange?.( [ updatedAttachment ] ); + }, + additionalData: { + // Reminder: Parent post ID might not be set, depending on context, + // but should be carried over if it does. + post: item.additionalData.post, + }, + blurHash: item.blurHash, + dominantColor: item.dominantColor, + abortController, + operations, + } ); + } catch ( err ) { + // TODO: Debug & catch & throw. + } + } + + dispatch.finishOperation( id, {} ); + }; +} + +/** + * Adds thumbnail versions to the queue for sideloading. + * + * @param id Item ID. + */ +export function generateThumbnails( id: QueueItemId ) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const attachment: Attachment = item.attachment as Attachment; + + const mediaType = getMediaTypeFromMimeType( item.file.type ); + + const thumbnailGeneration: ThumbnailGeneration = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'thumbnailGeneration' ); + + // Client-side thumbnail generation. + // Works for images and PDF posters. + + if ( + ! item.parentId && + attachment.missing_image_sizes && + 'server' !== thumbnailGeneration + ) { + let file = attachment.mexp_filename + ? renameFile( item.file, attachment.mexp_filename ) + : item.file; + const batchId = uuidv4(); + + if ( 'pdf' === mediaType && item.poster ) { + file = item.poster; + + const outputFormat: ImageFormat = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'default_outputFormat' ); + + const outputQuality: number = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'default_quality' ); + + const interlaced: boolean = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'default_interlaced' ); + + // Upload the "full" version without a resize param. + dispatch.addSideloadItem( { + file: item.poster, + additionalData: { + // Sideloading does not use the parent post ID but the + // attachment ID as the image sizes need to be added to it. + post: attachment.id, + image_size: 'full', + }, + operations: [ + [ + OperationType.TranscodeImage, + { outputFormat, outputQuality, interlaced }, + ], + OperationType.Upload, + ], + parentId: item.id, + } ); + } + + for ( const name of attachment.missing_image_sizes ) { + const imageSize = select.getImageSize( name ); + if ( imageSize ) { + // Force thumbnails to be soft crops, see wp_generate_attachment_metadata(). + if ( 'pdf' === mediaType && 'thumbnail' === name ) { + imageSize.crop = false; + } + + dispatch.addSideloadItem( { + file, + onChange: ( [ updatedAttachment ] ) => { + // This might be confusing, but the idea is to update the original + // image item in the editor with the new one with the added sub-size. + item.onChange?.( [ updatedAttachment ] ); + }, + batchId, + parentId: item.id, + additionalData: { + // Sideloading does not use the parent post ID but the + // attachment ID as the image sizes need to be added to it. + post: attachment.id, + // Reference the same upload_request if needed. + upload_request: item.additionalData.upload_request, + image_size: name, + }, + operations: [ + [ OperationType.ResizeCrop, { resize: imageSize } ], + OperationType.Upload, + ], + } ); + } + } + } + + dispatch.finishOperation( id, {} ); + }; +} + +/** + * Adds the original file to the queue for sideloading. + * + * If an item was downsized due to the big image size threshold, + * this adds the original file for storing. + * + * @param id Item ID. + */ +export function uploadOriginal( id: QueueItemId ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const attachment: Attachment = item.attachment as Attachment; + + const mediaType = getMediaTypeFromMimeType( item.file.type ); + + /* + Upload the original image file if it was a HEIF image, + or if it was resized because of the big image size threshold. + */ + + if ( 'image' === mediaType ) { + if ( + ! item.parentId && + ( ( item.file instanceof ImageFile && item.file?.wasResized ) || + isHeifImage( await item.sourceFile.arrayBuffer() ) ) + ) { + const originalBaseName = getFileBasename( + attachment.mexp_filename || item.file.name + ); + + dispatch.addSideloadItem( { + file: renameFile( + item.sourceFile, + `${ originalBaseName }-original.${ getFileExtension( + item.sourceFile.name + ) }` + ), + parentId: item.id, + additionalData: { + // Sideloading does not use the parent post ID but the + // attachment ID as the image sizes need to be added to it. + post: attachment.id, + // Reference the same upload_request if needed. + upload_request: item.additionalData.upload_request, + image_size: 'original', + }, + // Skip any resizing or optimization of the original image. + operations: [ OperationType.Upload ], + } ); + } + } + + dispatch.finishOperation( id, {} ); + }; +} + +type OptimizeImageItemArgs = OperationArgs[ OperationType.TranscodeImage ]; + +/** + * Optimizes/Compresses an existing image item. + * + * @param id Item ID. + * @param [args] Additional arguments for the operation. + */ +export function optimizeImageItem( + id: QueueItemId, + args?: OptimizeImageItemArgs +) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const startTime = performance.now(); + + const imageLibrary: ImageLibrary = + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'imageLibrary' ) || 'vips'; + + let stop: undefined | ( () => void ); + + try { + let file: File; + + const inputFormat = getExtensionFromMimeType( item.file.type ); + + if ( ! inputFormat ) { + throw new Error( 'Unsupported file type' ); + } + + const outputFormat: ImageFormat = + args?.outputFormat || + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, `${ inputFormat }_outputFormat` ) || + inputFormat; + + const outputQuality: number = + args?.outputQuality || + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, `${ inputFormat }_quality` ) || + 80; + + const interlaced: boolean = + args?.interlaced || + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, `${ inputFormat }_interlaced` ) || + false; + + stop = start( + `Optimize Item: ${ item.file.name } | ${ imageLibrary } | ${ inputFormat } | ${ outputFormat } | ${ outputQuality }` + ); + + switch ( outputFormat ) { + case inputFormat: + default: + if ( 'browser' === imageLibrary ) { + file = await canvasCompressImage( + item.file, + outputQuality / 100 + ); + } else { + file = await vipsCompressImage( + item.id, + item.file, + outputQuality / 100, + interlaced + ); + } + break; + + case 'webp': + // Safari doesn't support WebP in HTMLCanvasElement.toBlob(). + // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob + if ( 'browser' === imageLibrary && ! isSafari ) { + file = await canvasConvertImageFormat( + item.file, + 'image/webp', + outputQuality / 100 + ); + } else { + file = await vipsConvertImageFormat( + item.id, + item.file, + 'image/webp', + outputQuality / 100 + ); + } + break; + + case 'avif': + // No browsers support AVIF in HTMLCanvasElement.toBlob() yet, so always use vips. + // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob + file = await vipsConvertImageFormat( + item.id, + item.file, + 'image/avif', + outputQuality / 100 + ); + break; + + case 'gif': + // Browsers don't typically support image/gif in HTMLCanvasElement.toBlob() yet, so always use vips. + // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob + file = await vipsConvertImageFormat( + item.id, + item.file, + 'image/avif', + outputQuality / 100, + interlaced + ); + break; + + case 'jpeg': + case 'png': + if ( 'browser' === imageLibrary ) { + file = await canvasConvertImageFormat( + item.file, + `image/${ outputFormat }`, + outputQuality / 100 + ); + } else { + file = await vipsConvertImageFormat( + item.id, + item.file, + `image/${ outputFormat }`, + outputQuality / 100, + interlaced + ); + } + } + + if ( item.file instanceof ImageFile ) { + file = new ImageFile( + file, + item.file.width, + item.file.height, + item.file.originalWidth, + item.file.originalHeight + ); + } + + const blobUrl = createBlobURL( file ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + const endTime = performance.now(); + + const timing: MeasureOptions = { + measureName: `Optimize image ${ item.file.name }`, + startTime, + endTime, + tooltipText: 'This is a rendering task', + properties: [ + [ 'Item ID', item.id ], + [ 'File name', item.file.name ], + [ 'Image library', imageLibrary ], + [ 'Input format', inputFormat ], + [ 'Output format', outputFormat ], + [ 'Output quality', outputQuality ], + ], + }; + + const timings = [ timing ]; + + if ( args?.requireApproval ) { + dispatch.finishOperation( id, { + status: ItemStatus.PendingApproval, + file, + attachment: { + url: blobUrl, + mime_type: file.type, + }, + timings, + } ); + } else { + dispatch.finishOperation( id, { + file, + attachment: { + url: blobUrl, + }, + timings, + } ); + } + } catch ( error ) { + dispatch.cancelItem( + id, + error instanceof Error + ? error + : new MediaError( { + code: 'MEDIA_TRANSCODING_ERROR', + message: 'File could not be uploaded', + file: item.file, + } ) + ); + } finally { + stop?.(); + } + }; +} + +type OptimizeVideoItemArgs = OperationArgs[ OperationType.TranscodeVideo ]; + +/** + * Optimizes/Compresses an existing video item. + * + * @param id Item ID. + * @param [args] Additional arguments for the operation. + */ +export function optimizeVideoItem( + id: QueueItemId, + args?: OptimizeVideoItemArgs +) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const outputFormat: VideoFormat = + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'video_outputFormat' ) || 'mp4'; + + const videoSizeThreshold: number = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'bigVideoSizeThreshold' ); + + try { + const { transcodeVideo } = await import( + /* webpackChunkName: 'ffmpeg' */ '@mexp/ffmpeg' + ); + + const file = await transcodeVideo( + item.file, + getFileBasename( item.file.name ), + `video/${ outputFormat }`, + videoSizeThreshold + ); + + const blobUrl = createBlobURL( file ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + dispatch.finishOperation( id, { + file, + attachment: { + url: blobUrl, + }, + } ); + } catch ( error ) { + if ( args?.continueOnError ) { + dispatch.finishOperation( id, {} ); + return; + } + + dispatch.cancelItem( + id, + error instanceof Error + ? error + : new MediaError( { + code: 'VIDEO_TRANSCODING_ERROR', + message: 'File could not be uploaded', + file: item.file, + } ) + ); + } + }; +} + +/** + * Mutes an existing video item. + * + * @param id Item ID. + */ +export function muteVideoItem( id: QueueItemId ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + try { + const { muteVideo } = await import( + /* webpackChunkName: 'ffmpeg' */ '@mexp/ffmpeg' + ); + const file = await muteVideo( item.file ); + + const blobUrl = createBlobURL( file ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + dispatch.finishOperation( id, { + file, + attachment: { + url: blobUrl, + }, + additionalData: { + mexp_is_muted: true, + }, + } ); + } catch ( error ) { + dispatch.cancelItem( + id, + error instanceof Error + ? error + : new MediaError( { + code: 'VIDEO_MUTING_ERROR', + message: 'File could not be uploaded', + file: item.file, + } ) + ); + } + }; +} + +/** + * Optimizes/Compresses an existing audio item. + * + * @param id Item ID. + */ +export function optimizeAudioItem( id: QueueItemId ) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const outputFormat: AudioFormat = + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'audio_outputFormat' ) || 'mp3'; + + try { + let file: File; + const { transcodeAudio } = await import( + /* webpackChunkName: 'ffmpeg' */ '@mexp/ffmpeg' + ); + + switch ( outputFormat ) { + case 'ogg': + file = await transcodeAudio( + item.file, + getFileBasename( item.file.name ), + 'audio/ogg' + ); + break; + + case 'mp3': + default: + file = await transcodeAudio( + item.file, + getFileBasename( item.file.name ), + 'audio/mp3' + ); + break; + } + + const blobUrl = createBlobURL( file ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + dispatch.finishOperation( id, { + file, + attachment: { + url: blobUrl, + }, + } ); + } catch ( error ) { + dispatch.cancelItem( + id, + error instanceof Error + ? error + : new MediaError( { + code: 'AUDIO_TRANSCODING_ERROR', + message: 'File could not be uploaded', + file: item.file, + } ) + ); + } + }; +} + +/** + * Converts an existing GIF item to a video. + * + * @param id Item ID. + */ +export function convertGifItem( id: QueueItemId ) { + return async ( { select, dispatch, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + const outputFormat: VideoFormat = + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'video_outputFormat' ) || 'video/mp4'; + + const videoSizeThreshold: number = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'bigVideoSizeThreshold' ); + + try { + const { convertGifToVideo } = await import( + /* webpackChunkName: 'ffmpeg' */ '@mexp/ffmpeg' + ); + + const file = await convertGifToVideo( + item.file, + getFileBasename( item.file.name ), + `video/${ outputFormat }`, + videoSizeThreshold + ); + + const blobUrl = createBlobURL( file ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + dispatch.finishOperation( id, { + file, + attachment: { + url: blobUrl, + }, + } ); + } catch ( error ) { + dispatch.cancelItem( + id, + error instanceof Error + ? error + : new MediaError( { + code: 'VIDEO_TRANSCODING_ERROR', + message: 'File could not be uploaded', + file: item.file, + } ) + ); + } + }; +} + +/** + * Converts an existing HEIF image item to another format. + * + * @param id Item ID. + */ +export function convertHeifItem( id: QueueItemId ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + try { + const file = await transcodeHeifImage( item.file ); + + const blobUrl = createBlobURL( file ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + dispatch.finishOperation( id, { + file, + attachment: { + url: blobUrl, + }, + } ); + } catch ( error ) { + dispatch.cancelItem( + id, + error instanceof Error + ? error + : new MediaError( { + code: 'IMAGE_TRANSCODING_ERROR', + message: 'File could not be uploaded', + file: item.file, + } ) + ); + } + }; +} + +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, registry }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + if ( ! args?.resize ) { + dispatch.finishOperation( id, { + file: item.file, + } ); + return; + } + + const thumbnailGeneration: ThumbnailGeneration = registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'thumbnailGeneration' ); + + const smartCrop = Boolean( thumbnailGeneration === 'smart' ); + + const imageLibrary: ImageLibrary = + registry + .select( preferencesStore ) + .get( PREFERENCES_NAME, 'imageLibrary' ) || 'vips'; + + const addSuffix = Boolean( item.parentId ); + + const stop = start( + `Resize Item: ${ item.file.name } | ${ imageLibrary } | ${ thumbnailGeneration } | ${ args.resize.width }x${ args.resize.height }` + ); + + try { + let file: File; + + // No browsers support GIF/AVIF in HTMLCanvasElement.toBlob(). + // Safari doesn't support WebP. + // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob + if ( + 'browser' === imageLibrary && + ! [ 'image/gif', 'image/avif' ].includes( item.file.type ) && + ! ( 'image/webp' === item.file.type && isSafari ) + ) { + file = await canvasResizeImage( + item.file, + args.resize, + addSuffix + ); + } else { + file = await vipsResizeImage( + item.id, + item.file, + args.resize, + smartCrop, + addSuffix + ); + } + + const blobUrl = createBlobURL( file ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + dispatch.finishOperation( id, { + file, + attachment: { + url: blobUrl, + }, + } ); + } catch ( error ) { + dispatch.cancelItem( + id, + error instanceof Error + ? error + : new MediaError( { + code: 'IMAGE_TRANSCODING_ERROR', + message: 'File could not be uploaded', + file: item.file, + } ) + ); + } finally { + stop?.(); + } + }; +} + +/** + * 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 ) as QueueItem; + + const startTime = performance.now(); + + const { poster } = item; + + const additionalData: Record< string, unknown > = { + ...item.additionalData, + // generatedPosterId is set when using muteExistingVideo() for example. + meta: { + mexp_generated_poster_id: item.generatedPosterId || undefined, + mexp_original_id: item.sourceAttachmentId || undefined, + }, + mexp_blurhash: item.blurHash, + mexp_dominant_color: item.dominantColor, + featured_media: item.generatedPosterId || undefined, + }; + + const mediaType = getMediaTypeFromMimeType( item.file.type ); + + let stillUrl = [ 'video', 'pdf' ].includes( mediaType ) + ? item.attachment?.poster + : item.attachment?.url; + + // Freshly converted GIF. + if ( + ! stillUrl && + 'video' === mediaType && + 'image' === getMediaTypeFromMimeType( item.sourceFile.type ) + ) { + stillUrl = createBlobURL( item.sourceFile ); + + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl: stillUrl, + } ); + } + + // TODO: Make this async after upload? + // Could be made reusable to enable back-filling of existing blocks. + if ( + typeof additionalData.mexp_is_muted === 'undefined' && + 'video' === mediaType + ) { + try { + const hasAudio = + item.attachment?.url && + ( await videoHasAudio( item.attachment.url ) ); + additionalData.mexp_is_muted = ! hasAudio; + } catch { + // No big deal if this fails, we can still continue uploading. + } + } + + if ( + ! additionalData.mexp_dominant_color && + stillUrl && + [ 'video', 'image', 'pdf' ].includes( mediaType ) + ) { + // TODO: Make this async after upload? + // Could be made reusable to enable backfilling of existing blocks. + // TODO: Create a scaled-down version of the image first for performance reasons. + try { + additionalData.mexp_dominant_color = + await dominantColorWorker.getDominantColor( stillUrl ); + } catch ( err ) { + // No big deal if this fails, we can still continue uploading. + // TODO: Debug & catch & throw. + } + } + + if ( 'image' === mediaType && stillUrl && window.crossOriginIsolated ) { + // TODO: Make this async after upload? + // Could be made reusable to enable backfilling of existing blocks. + // TODO: Create a scaled-down version of the image first for performance reasons. + try { + additionalData.mexp_has_transparency = + await vipsHasTransparency( stillUrl ); + } catch ( err ) { + // No big deal if this fails, we can still continue uploading. + // TODO: Debug & catch & throw. + } + } + + if ( + ! additionalData.mexp_blurhash && + stillUrl && + [ 'video', 'image', 'pdf' ].includes( mediaType ) + ) { + // TODO: Make this async after upload? + // Could be made reusable to enable backfilling of existing blocks. + // TODO: Create a scaled-down version of the image first for performance reasons. + try { + additionalData.mexp_blurhash = + await blurhashWorker.getBlurHash( stillUrl ); + } catch ( err ) { + // No big deal if this fails, we can still continue uploading. + // TODO: Debug & catch & throw. + } + } + + const timing: MeasureOptions = { + measureName: `Upload item ${ item.file.name }`, + startTime, + endTime: performance.now(), + tooltipText: 'This is a rendering task', + properties: [ + [ 'Item ID', id ], + [ 'File name', item.file.name ], + ], + }; + + const timings = [ timing ]; + + select.getSettings().mediaUpload( { + filesList: [ item.file ], + additionalData, + signal: item.abortController?.signal, + onFileChange: ( [ attachment ] ) => { + // TODO: Get the poster URL from the ID if one exists already. + if ( 'video' === mediaType && ! attachment.featured_media ) { + /* + The newly uploaded file won't have a poster yet. + However, we'll likely still have one on file. + Add it back so we're never without one. + */ + if ( item.attachment?.poster ) { + attachment.poster = item.attachment.poster; + } else if ( poster ) { + attachment.poster = createBlobURL( poster ); + + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl: attachment.poster, + } ); + } + } + + dispatch.finishOperation( id, { + attachment, + timings, + } ); + }, + 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 ) as QueueItem; + + const { post, ...additionalData } = + item.additionalData as SideloadAdditionalData; + + select.getSettings().mediaSideload( { + file: item.file, + attachmentId: post as number, + additionalData, + signal: item.abortController?.signal, + onFileChange: ( [ attachment ] ) => { + dispatch.finishOperation( id, { attachment } ); + dispatch.resumeItem( post as number ); + }, + onError: ( error ) => { + dispatch.cancelItem( id, error ); + dispatch.resumeItem( post as number ); + }, + } ); + }; +} + +type FetchRemoteFileArgs = OperationArgs[ OperationType.FetchRemoteFile ]; + +/** + * Fetches a remote file from another server and adds it to the item. + * + * @param id Item ID. + * @param args Additional arguments for the operation. + */ +export function fetchRemoteFile( id: QueueItemId, args: FetchRemoteFileArgs ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + try { + const sourceFile = await fetchFile( args.url, args.fileName ); + + if ( args.skipAttachment ) { + dispatch.finishOperation( id, { + sourceFile, + } ); + } else { + const file = args.newFileName + ? renameFile( cloneFile( sourceFile ), args.newFileName ) + : cloneFile( sourceFile ); + + const blobUrl = createBlobURL( sourceFile ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + dispatch.finishOperation( id, { + sourceFile, + file, + attachment: { + url: blobUrl, + }, + } ); + } + } catch ( error ) { + dispatch.cancelItem( + id, + error instanceof Error + ? error + : new MediaError( { + code: 'FETCH_REMOTE_FILE_ERROR', + message: 'Remote file could not be downloaded', + file: item.file, + } ) + ); + } + }; +} + +/** + * Generates subtitles for the video item. + * + * @param id Item ID. + */ +export function generateSubtitles( id: QueueItemId ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const item = select.getItem( id ) as QueueItem; + + try { + const { generateSubtitles: _generateSubtitles } = await import( + /* webpackChunkName: 'subtitles' */ '@mexp/subtitles' + ); + + const file = await _generateSubtitles( + item.sourceFile, + getFileBasename( item.sourceFile.name ) + ); + + const blobUrl = createBlobURL( file ); + dispatch< CacheBlobUrlAction >( { + type: Type.CacheBlobUrl, + id, + blobUrl, + } ); + + dispatch.finishOperation( id, { + file, + attachment: { + url: blobUrl, + }, + } ); + } catch ( error ) { + dispatch.cancelItem( + id, + error instanceof Error + ? error + : new MediaError( { + code: 'FETCH_REMOTE_FILE_ERROR', + message: 'Remote file could not be downloaded', + file: item.file, + } ) + ); + } + }; +} + +/** + * Revokes all blob URLs for a given item, freeing up memory. + * + * @param id Item ID. + */ +export function revokeBlobUrls( id: QueueItemId ) { + return async ( { select, dispatch }: ThunkArgs ) => { + const blobUrls = select.getBlobUrls( id ); + + for ( const blobUrl of blobUrls ) { + revokeBlobURL( blobUrl ); + } + + dispatch< RevokeBlobUrlsAction >( { + type: Type.RevokeBlobUrls, + id, + } ); + }; +} diff --git a/packages/upload-media/src/store/private-selectors.ts b/packages/upload-media/src/store/private-selectors.ts new file mode 100644 index 00000000..c156c613 --- /dev/null +++ b/packages/upload-media/src/store/private-selectors.ts @@ -0,0 +1,172 @@ +import { + type BatchId, + type ImageSizeCrop, + ItemStatus, + OperationType, + type QueueItem, + type QueueItemId, + type State, +} from './types'; + +/** + * Returns all items currently being uploaded. + * + * @param state Upload state. + * + * @return Queue items. + */ +export function getAllItems( state: State ): QueueItem[] { + return state.queue; +} + +/** + * Returns all items currently being uploaded. + * + * @param state Upload state. + * @param parentId Parent item ID. + * + * @return Queue items. + */ +export function getChildItems( + state: State, + parentId: QueueItemId +): QueueItem[] { + return state.queue.filter( ( item ) => item.parentId === parentId ); +} + +/** + * Returns a specific item given its unique ID. + * + * @param state Upload state. + * @param id Item ID. + * + * @return Queue item. + */ +export function getItem( + state: State, + id: QueueItemId +): QueueItem | undefined { + return state.queue.find( ( item ) => item.id === id ); +} + +/** + * Returns a specific item given its associated attachment ID. + * + * @param state Upload state. + * @param attachmentId Item ID. + * + * @return Queue item. + */ +export function getItemByAttachmentId( + state: State, + attachmentId: number +): QueueItem | undefined { + return state.queue.find( + ( item ) => + item.attachment?.id === attachmentId || + item.sourceAttachmentId === attachmentId + ); +} + +/** + * Determines whether a batch has been successfully uploaded, given its unique ID. + * + * @param state Upload state. + * @param batchId Batch ID. + * + * @return Whether a batch has been uploaded. + */ +export function isBatchUploaded( state: State, batchId: BatchId ): boolean { + const batchItems = state.queue.filter( + ( item ) => batchId === item.batchId + ); + return batchItems.length <= 1; +} + +/** + * Determines whether an upload is currently in progress given a post or attachment ID. + * + * @param state Upload state. + * @param postOrAttachmentId Post ID or attachment ID. + * + * @return Whether upload is currently in progress for the given post or attachment. + */ +export function isUploadingToPost( + state: State, + postOrAttachmentId: number +): boolean { + return state.queue.some( + ( item ) => + item.currentOperation === OperationType.Upload && + item.additionalData.post === postOrAttachmentId + ); +} + +/** + * Returns the next paused upload for a given post or attachment ID. + * + * @param state Upload state. + * @param postOrAttachmentId Post ID or attachment ID. + * + * @return Paused item. + */ +export function getPausedUploadForPost( + state: State, + postOrAttachmentId: number +): QueueItem | undefined { + return state.queue.find( + ( item ) => + item.status === ItemStatus.Paused && + item.additionalData.post === postOrAttachmentId + ); +} + +/** + * Determines whether an upload is currently in progress given a parent ID. + * + * @param state Upload state. + * @param parentId Parent ID. + * + * @return Whether upload is currently in progress for the given parent ID. + */ +export function isUploadingByParentId( + state: State, + parentId: QueueItemId +): boolean { + return state.queue.some( ( item ) => item.parentId === parentId ); +} + +/** + * Determines whether uploading is currently paused. + * + * @param state Upload state. + * + * @return Whether uploading is currently paused. + */ +export function isPaused( state: State ): boolean { + return state.queueStatus === 'paused'; +} + +/** + * Returns an image size given its name. + * + * @param state Upload state. + * @param name Image size name. + * + * @return Image size data. + */ +export function getImageSize( state: State, name: string ): ImageSizeCrop { + return state.settings.imageSizes[ name ]; +} + +/** + * Returns all cached blob URLs for a given item ID. + * + * @param state Upload state. + * @param id Item ID + * + * @return List of blob URLs. + */ +export function getBlobUrls( state: State, id: QueueItemId ): string[] { + return state.blobUrls[ id ] || []; +} diff --git a/packages/upload-media/src/store/reducer.ts b/packages/upload-media/src/store/reducer.ts index 5ae422c5..722b981e 100644 --- a/packages/upload-media/src/store/reducer.ts +++ b/packages/upload-media/src/store/reducer.ts @@ -14,7 +14,6 @@ import { type ResumeItemAction, type ResumeQueueAction, type RevokeBlobUrlsAction, - type SetImageSizesAction, type State, Type, type UnknownAction, @@ -25,12 +24,12 @@ const noop = () => {}; const DEFAULT_STATE: State = { queue: [], - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: noop, mediaSideload: noop, + imageSizes: {}, }, }; @@ -46,7 +45,6 @@ type Action = | ApproveUploadAction | OperationFinishAction | OperationStartAction - | SetImageSizesAction | CacheBlobUrlAction | RevokeBlobUrlsAction | UpdateSettingsAction @@ -211,13 +209,6 @@ function reducer( ), }; - case Type.SetImageSizes: { - return { - ...state, - imageSizes: action.imageSizes, - }; - } - case Type.CacheBlobUrl: { const blobUrls = state.blobUrls[ action.id ] || []; return { diff --git a/packages/upload-media/src/store/selectors.ts b/packages/upload-media/src/store/selectors.ts index 4476ba24..0e816781 100644 --- a/packages/upload-media/src/store/selectors.ts +++ b/packages/upload-media/src/store/selectors.ts @@ -1,43 +1,14 @@ -import { - type BatchId, - type ImageSizeCrop, - ItemStatus, - OperationType, - type QueueItem, - type QueueItemId, - type Settings, - type State, -} from './types'; +import { ItemStatus, type QueueItem, type Settings, type State } from './types'; /** - * Returns all items currently being uploaded. - * - * @param state Upload state. - * @param status Status to filter items by. - * - * @return Queue items. - */ -export function getItems( state: State, status?: ItemStatus ): QueueItem[] { - if ( status ) { - return state.queue.filter( ( item ) => item.status === status ); - } - - return state.queue; -} - -/** - * Returns a specific item given its unique ID. + * Returns all items currently being uploaded, without sub-sizes (children). * * @param state Upload state. - * @param id Item ID. * - * @return Queue item. + * @return Queue items. */ -export function getItem( - state: State, - id: QueueItemId -): QueueItem | undefined { - return state.queue.find( ( item ) => item.id === id ); +export function getItems( state: State ): QueueItem[] { + return state.queue.filter( ( item ) => ! Boolean( item.parentId ) ); } /** @@ -53,25 +24,6 @@ export function isPendingApproval( state: State ): boolean { ); } -/** - * Returns a specific item given its associated attachment ID. - * - * @param state Upload state. - * @param attachmentId Item ID. - * - * @return Queue item. - */ -export function getItemByAttachmentId( - state: State, - attachmentId: number -): QueueItem | undefined { - return state.queue.find( - ( item ) => - item.attachment?.id === attachmentId || - item.sourceAttachmentId === attachmentId - ); -} - /** * Determines whether an item is the first one pending approval given its associated attachment ID. * @@ -101,7 +53,7 @@ export function isFirstPendingApprovalByAttachmentId( /** * Returns data to compare the old file vs. the optimized file, given the attachment ID. * - * Includes both the URLs as well as the respective file sizes and the size difference in percentage. + * Includes both the URLs and the respective file sizes and the size difference in percentage. * * @param state Upload state. * @param attachmentId Attachment ID. @@ -138,21 +90,6 @@ export function getComparisonDataForApproval( }; } -/** - * Determines whether a batch has been successfully uploaded, given its unique ID. - * - * @param state Upload state. - * @param batchId Batch ID. - * - * @return Whether a batch has been uploaded. - */ -export function isBatchUploaded( state: State, batchId: BatchId ): boolean { - const batchItems = state.queue.filter( - ( item ) => batchId === item.batchId - ); - return batchItems.length <= 1; -} - /** * Determines whether any upload is currently in progress. * @@ -197,94 +134,6 @@ export function isUploadingById( state: State, attachmentId: number ): boolean { ); } -/** - * Determines whether an upload is currently in progress given a post or attachment ID. - * - * @param state Upload state. - * @param postOrAttachmentId Post ID or attachment ID. - * - * @return Whether upload is currently in progress for the given post or attachment. - */ -export function isUploadingToPost( - state: State, - postOrAttachmentId: number -): boolean { - return state.queue.some( - ( item ) => - item.currentOperation === OperationType.Upload && - item.additionalData.post === postOrAttachmentId - ); -} - -/** - * Returns the next paused upload for a given post or attachment ID. - * - * @param state Upload state. - * @param postOrAttachmentId Post ID or attachment ID. - * - * @return Paused item. - */ -export function getPausedUploadForPost( - state: State, - postOrAttachmentId: number -): QueueItem | undefined { - return state.queue.find( - ( item ) => - item.status === ItemStatus.Paused && - item.additionalData.post === postOrAttachmentId - ); -} - -/** - * Determines whether an upload is currently in progress given a parent ID. - * - * @param state Upload state. - * @param parentId Parent ID. - * - * @return Whether upload is currently in progress for the given parent ID. - */ -export function isUploadingByParentId( - state: State, - parentId: QueueItemId -): boolean { - return state.queue.some( ( item ) => item.parentId === parentId ); -} - -/** - * Determines whether uploading is currently paused. - * - * @param state Upload state. - * - * @return Whether uploading is currently paused. - */ -export function isPaused( state: State ): boolean { - return state.queueStatus === 'paused'; -} - -/** - * Returns an image size given its name. - * - * @param state Upload state. - * @param name Image size name. - * - * @return Image size data. - */ -export function getImageSize( state: State, name: string ): ImageSizeCrop { - return state.imageSizes[ name ]; -} - -/** - * Returns all cached blob URLs for a given item ID. - * - * @param state Upload state. - * @param id Item ID - * - * @return List of blob URLs. - */ -export function getBlobUrls( state: State, id: QueueItemId ): string[] { - return state.blobUrls[ id ] || []; -} - /** * Returns the media upload settings. * diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index ca4a167b..f706f994 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -327,23 +327,4 @@ describe( 'actions', () => { ); } ); } ); - - describe( 'setImageSizes', () => { - it( 'adds image sizes to state', () => { - registry.dispatch( uploadStore ).setImageSizes( { - thumbnail: { width: 150, height: 150, crop: true }, - large: { width: 1000, height: 0, crop: false }, - } ); - - expect( - registry.select( uploadStore ).getImageSize( 'thumbnail' ) - ).toStrictEqual( { width: 150, height: 150, crop: true } ); - expect( - registry.select( uploadStore ).getImageSize( 'large' ) - ).toStrictEqual( { width: 1000, height: 0, crop: false } ); - expect( - registry.select( uploadStore ).getImageSize( 'unknown' ) - ).toBe( undefined ); - } ); - } ); } ); diff --git a/packages/upload-media/src/store/test/reducer.ts b/packages/upload-media/src/store/test/reducer.ts index 8d2a34cd..64bab14e 100644 --- a/packages/upload-media/src/store/test/reducer.ts +++ b/packages/upload-media/src/store/test/reducer.ts @@ -11,12 +11,12 @@ describe( 'reducer', () => { describe( `${ Type.Add }`, () => { it( 'adds an item to the queue', () => { const initialState: State = { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, queue: [ { @@ -34,12 +34,12 @@ describe( 'reducer', () => { } ); expect( state ).toEqual( { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: expect.any( Function ), mediaSideload: expect.any( Function ), + imageSizes: {}, }, queue: [ { @@ -58,12 +58,12 @@ describe( 'reducer', () => { describe( `${ Type.Cancel }`, () => { it( 'removes an item from the queue', () => { const initialState: State = { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, queue: [ { @@ -83,12 +83,12 @@ describe( 'reducer', () => { } ); expect( state ).toEqual( { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: expect.any( Function ), mediaSideload: expect.any( Function ), + imageSizes: {}, }, queue: [ { @@ -108,12 +108,12 @@ describe( 'reducer', () => { describe( `${ Type.Remove }`, () => { it( 'removes an item from the queue', () => { const initialState: State = { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, queue: [ { @@ -132,12 +132,12 @@ describe( 'reducer', () => { } ); expect( state ).toEqual( { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: expect.any( Function ), mediaSideload: expect.any( Function ), + imageSizes: {}, }, queue: [ { @@ -152,12 +152,12 @@ describe( 'reducer', () => { describe( `${ Type.PauseItem }`, () => { it( 'marks an item as paused', () => { const initialState: State = { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, queue: [ { @@ -176,12 +176,12 @@ describe( 'reducer', () => { } ); expect( state ).toEqual( { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: expect.any( Function ), mediaSideload: expect.any( Function ), + imageSizes: {}, }, queue: [ { @@ -200,12 +200,12 @@ describe( 'reducer', () => { describe( `${ Type.ResumeItem }`, () => { it( 'marks an item as processing', () => { const initialState: State = { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, queue: [ { @@ -224,12 +224,12 @@ describe( 'reducer', () => { } ); expect( state ).toEqual( { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: expect.any( Function ), mediaSideload: expect.any( Function ), + imageSizes: {}, }, queue: [ { @@ -248,12 +248,12 @@ describe( 'reducer', () => { describe( `${ Type.AddOperations }`, () => { it( 'appends operations to the list', () => { const initialState: State = { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, queue: [ { @@ -270,12 +270,12 @@ describe( 'reducer', () => { } ); expect( state ).toEqual( { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: expect.any( Function ), mediaSideload: expect.any( Function ), + imageSizes: {}, }, queue: [ { @@ -295,12 +295,12 @@ describe( 'reducer', () => { describe( `${ Type.OperationStart }`, () => { it( 'marks an item as processing', () => { const initialState: State = { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, queue: [ { @@ -328,12 +328,12 @@ describe( 'reducer', () => { } ); expect( state ).toEqual( { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: expect.any( Function ), mediaSideload: expect.any( Function ), + imageSizes: {}, }, queue: [ { @@ -361,12 +361,12 @@ describe( 'reducer', () => { describe( `${ Type.OperationFinish }`, () => { it( 'marks an item as processing', () => { const initialState: State = { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, queue: [ { @@ -389,12 +389,12 @@ describe( 'reducer', () => { } ); expect( state ).toEqual( { - imageSizes: {}, queueStatus: 'active', blobUrls: {}, settings: { mediaUpload: expect.any( Function ), mediaSideload: expect.any( Function ), + imageSizes: {}, }, queue: [ { diff --git a/packages/upload-media/src/store/test/selectors.ts b/packages/upload-media/src/store/test/selectors.ts index 2cfc8320..b054c759 100644 --- a/packages/upload-media/src/store/test/selectors.ts +++ b/packages/upload-media/src/store/test/selectors.ts @@ -15,50 +15,17 @@ describe( 'selectors', () => { it( 'should return empty array by default', () => { const state: State = { queue: [], - imageSizes: {}, queueStatus: 'paused', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, }; expect( getItems( state ) ).toHaveLength( 0 ); } ); - - it( 'should return items with the given status', () => { - const state: State = { - queue: [ - { - status: ItemStatus.Processing, - }, - { - status: ItemStatus.PendingApproval, - }, - { - status: ItemStatus.Processing, - }, - { - status: ItemStatus.Processing, - }, - { - status: ItemStatus.PendingApproval, - }, - ] as QueueItem[], - imageSizes: {}, - queueStatus: 'paused', - blobUrls: {}, - settings: { - mediaUpload: jest.fn(), - mediaSideload: jest.fn(), - }, - }; - - expect( getItems( state, ItemStatus.Processing ) ).toHaveLength( - 3 - ); - } ); } ); describe( 'isUploading', () => { @@ -78,12 +45,12 @@ describe( 'selectors', () => { status: ItemStatus.Paused, }, ] as QueueItem[], - imageSizes: {}, queueStatus: 'paused', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, }; @@ -109,12 +76,12 @@ describe( 'selectors', () => { status: ItemStatus.Processing, }, ] as QueueItem[], - imageSizes: {}, queueStatus: 'paused', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, }; @@ -148,12 +115,12 @@ describe( 'selectors', () => { status: ItemStatus.PendingApproval, }, ] as QueueItem[], - imageSizes: {}, queueStatus: 'paused', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, }; @@ -192,12 +159,12 @@ describe( 'selectors', () => { status: ItemStatus.Processing, }, ] as QueueItem[], - imageSizes: {}, queueStatus: 'paused', blobUrls: {}, settings: { mediaUpload: jest.fn(), mediaSideload: jest.fn(), + imageSizes: {}, }, }; diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 5531d1af..b264e3ed 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -35,7 +35,6 @@ export type QueueItem = { export interface State { queue: QueueItem[]; - imageSizes: Record< string, ImageSizeCrop >; queueStatus: QueueStatus; blobUrls: Record< QueueItemId, string[] >; settings: Settings; @@ -101,10 +100,6 @@ export type ResumeItemAction = Action< Type.ResumeItem, { id: QueueItemId } >; export type PauseQueueAction = Action< Type.PauseQueue >; export type ResumeQueueAction = Action< Type.ResumeQueue >; export type RemoveAction = Action< Type.Remove, { id: QueueItemId } >; -export type SetImageSizesAction = Action< - Type.SetImageSizes, - { imageSizes: Record< string, ImageSizeCrop > } ->; export type CacheBlobUrlAction = Action< Type.CacheBlobUrl, { id: QueueItemId; blobUrl: string } @@ -157,6 +152,7 @@ interface SideloadMediaArgs { export type Settings = { mediaUpload: ( args: UploadMediaArgs ) => void; mediaSideload: ( args: SideloadMediaArgs ) => void; + imageSizes: Record< string, ImageSizeCrop >; }; // Must match the Attachment type from the media-utils package. diff --git a/packages/view-upload-request/src/index.tsx b/packages/view-upload-request/src/index.tsx index 09f73173..1ba0778a 100644 --- a/packages/view-upload-request/src/index.tsx +++ b/packages/view-upload-request/src/index.tsx @@ -123,17 +123,14 @@ function uploadRequestUploadMedia( { } /* + Make the upload queue aware of the function for uploading to the server. The list of available image sizes is passed via an inline script and needs to be saved in the store first. */ -void dispatch( uploadStore ).setImageSizes( - window.mediaExperiments.availableImageSizes -); - -// Make the upload queue aware of the function for uploading to the server. void dispatch( uploadStore ).updateSettings( { mediaUpload: uploadMedia, mediaSideload: originalSideloadMedia, + imageSizes: window.mediaExperiments.availableImageSizes, } ); function App() {