Repository: payloadcms/payload (ts)

Issue:
Uploading files from Apple Photos fails
### Link to reproduction

_No response_

### Describe the Bug

Currently trying to upload a photo picked from Apple Photo Gallery fails with an error:

```
err: {
      "type": "Error",
      "message": "extract_area: bad extract area",
      "stack":
          Error: extract_area: bad extract area
    }
```

would it be possible to pass `failOnError: false` to sharp? I see that you construct the options object but there is no way to pass props down to it.

### To Reproduce

Try uploading a photo made with an iPhone

### Payload Version

payload@2.28.0

### Adapters and Plugins

_No response_

Pull request description:
fix: calculates correct aspect ratio dimensions on sharp based files
Fixes #8317 

Sharp based images are auto-oriented based on the EXIF data i.e. `.rotate()`.

This can be problematic when resizing images as the `originalAspectRatio` calculation we do in the `imageResizer` can become incorrect if the files dimensions are rotated from sharp.

For example, uploading an `ios` based image with dimensions of 3024 x 4032 will be auto rotated to 4032 x 3024 because the exif data gives the image an orientation of `6` - which means it needs to be rotated 90 degrees clockwise.

As a result, the original aspect ratio goes from being `0.75` to `1.3333` - which is incorrect.

This PR preserves the original aspect ratio to properly resize images based on the original dimensions - not the sharp based dimensions.


Patch:
diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts
index e69611f352..56d68a69c9 100644
--- a/packages/payload/src/uploads/generateFileData.ts
+++ b/packages/payload/src/uploads/generateFileData.ts
@@ -228,23 +228,58 @@ export const generateFileData = async <T>({
         withMetadata,
       })
 
-      filesToSave.push({
-        buffer: croppedImage,
-        path: `${staticPath}/${fsSafeName}`,
-      })
+      // Apply resize after cropping to ensure it conforms to resizeOptions
+      if (resizeOptions) {
+        const resizedAfterCrop = await sharp(croppedImage)
+          .resize({
+            fit: resizeOptions?.fit || 'cover',
+            height: resizeOptions?.height,
+            position: resizeOptions?.position || 'center',
+            width: resizeOptions?.width,
+          })
+          .toBuffer({ resolveWithObject: true })
+
+        filesToSave.push({
+          buffer: resizedAfterCrop.data,
+          path: `${staticPath}/${fsSafeName}`,
+        })
+
+        fileForResize = {
+          ...fileForResize,
+          data: resizedAfterCrop.data,
+          size: resizedAfterCrop.info.size,
+        }
 
-      fileForResize = {
-        ...file,
-        data: croppedImage,
-        size: info.size,
-      }
-      fileData.width = info.width
-      fileData.height = info.height
-      if (fileIsAnimatedType) {
-        const metadata = await sharpFile.metadata()
-        fileData.height = metadata.pages ? info.height / metadata.pages : info.height
+        fileData.width = resizedAfterCrop.info.width
+        fileData.height = resizedAfterCrop.info.height
+        if (fileIsAnimatedType) {
+          const metadata = await sharpFile.metadata()
+          fileData.height = metadata.pages
+            ? resizedAfterCrop.info.height / metadata.pages
+            : resizedAfterCrop.info.height
+        }
+        fileData.filesize = resizedAfterCrop.info.size
+      } else {
+        // If resizeOptions is not present, just save the cropped image
+        filesToSave.push({
+          buffer: croppedImage,
+          path: `${staticPath}/${fsSafeName}`,
+        })
+
+        fileForResize = {
+          ...file,
+          data: croppedImage,
+          size: info.size,
+        }
+
+        fileData.width = info.width
+        fileData.height = info.height
+        if (fileIsAnimatedType) {
+          const metadata = await sharpFile.metadata()
+          fileData.height = metadata.pages ? info.height / metadata.pages : info.height
+        }
+        fileData.filesize = info.size
       }
-      fileData.filesize = info.size
 
       if (file.tempFilePath) {
         await fs.promises.writeFile(file.tempFilePath, croppedImage) // write fileBuffer to the temp path
diff --git a/packages/payload/src/uploads/imageResizer.ts b/packages/payload/src/uploads/imageResizer.ts
index 5e09f2ee7f..61274a0c99 100644
--- a/packages/payload/src/uploads/imageResizer.ts
+++ b/packages/payload/src/uploads/imageResizer.ts
@@ -191,31 +191,6 @@ const getImageResizeAction = ({
   return hasFocalPoint ? 'resizeWithFocalPoint' : 'resize'
 }
 
-/**
- * Check if the image should be passed directly to sharp without payload adjusting properties.
- *
- * @param resizeConfig - object containing the requested dimensions and resize options
- * @param original - the original image size
- * @returns true if the image should passed directly to sharp
- */
-const applyPayloadAdjustments = (
-  { fit, height, width, withoutEnlargement, withoutReduction }: ImageSize,
-  original: ProbedImageSize,
-) => {
-  if (fit === 'contain' || fit === 'inside') return false
-  if (!isNumber(height) && !isNumber(width)) return false
-
-  const targetAspectRatio = width / height
-  const originalAspectRatio = original.width / original.height
-  if (originalAspectRatio === targetAspectRatio) return false
-
-  const skipEnlargement = withoutEnlargement && (original.height < height || original.width < width)
-  const skipReduction = withoutReduction && (original.height > height || original.width > width)
-  if (skipEnlargement || skipReduction) return false
-
-  return true
-}
-
 /**
  * Sanitize the resize config. If the resize config has the `withoutReduction`
  * property set to true, the `fit` and `position` properties will be set to `contain`
@@ -302,6 +277,18 @@ export default async function resizeAndTransformImageSizes({
   const sharpBase: Sharp | undefined = sharp(file.tempFilePath || file.data, sharpOptions).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081
   const originalImageMeta = await sharpBase.metadata()
 
+  let adjustedDimensions = { ...dimensions }
+
+  // Images with an exif orientation of 5, 6, 7, or 8 are auto-rotated by sharp
+  // Need to adjust the dimensions to match the original image
+  if ([5, 6, 7, 8].includes(originalImageMeta.orientation)) {
+    adjustedDimensions = {
+      ...dimensions,
+      height: dimensions.width,
+      width: dimensions.height,
+    }
+  }
+
   const resizeImageMeta = {
     height: extractHeightFromImage(originalImageMeta),
     width: originalImageMeta.width,
@@ -324,7 +311,7 @@ export default async function resizeAndTransformImageSizes({
       if (resizeAction === 'resizeWithFocalPoint') {
         let { height: resizeHeight, width: resizeWidth } = imageResizeConfig
 
-        const originalAspectRatio = dimensions.width / dimensions.height
+        const originalAspectRatio = adjustedDimensions.width / adjustedDimensions.height
 
         // Calculate resizeWidth based on original aspect ratio if it's undefined
         if (resizeHeight && !resizeWidth) {


Test patch:
diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts
index caefa8b90e..c4eaa1c7d2 100644
--- a/test/uploads/e2e.spec.ts
+++ b/test/uploads/e2e.spec.ts
@@ -123,6 +123,18 @@ describe('uploads', () => {
     await saveDocAndAssert(page)
   })
 
+  test('should properly create IOS file upload', async () => {
+    await page.goto(mediaURL.create)
+
+    await page.setInputFiles('input[type="file"]', path.resolve(__dirname, './ios-image.jpeg'))
+
+    const filename = page.locator('.file-field__filename')
+
+    await expect(filename).toHaveValue('ios-image.jpeg')
+
+    await saveDocAndAssert(page)
+  })
+
   test('should create animated file upload', async () => {
     await page.goto(animatedTypeMediaURL.create)
 
@@ -559,6 +571,34 @@ describe('uploads', () => {
       // without focal point update this generated size was equal to 1736
       expect(redDoc.sizes.focalTest.filesize).toEqual(1598)
     })
+
+    test('should resize image after crop if resizeOptions defined', async () => {
+      await page.goto(animatedTypeMediaURL.create)
+      await page.waitForURL(animatedTypeMediaURL.create)
+
+      const fileChooserPromise = page.waitForEvent('filechooser')
+      await page.getByText('Select a file').click()
+      const fileChooser = await fileChooserPromise
+      await wait(1000)
+      await fileChooser.setFiles(path.join(__dirname, 'horizontal-squares.jpg'))
+
+      await page.locator('.file-field__edit').click()
+
+      // set crop
+      await page.locator('.edit-upload__input input[name="Width (px)"]').fill('400')
+      await page.locator('.edit-upload__input input[name="Height (px)"]').fill('800')
+      // set focal point
+      await page.locator('.edit-upload__input input[name="X %"]').fill('75') // init left focal point
+      await page.locator('.edit-upload__input input[name="Y %"]').fill('50') // init top focal point
+
+      await page.locator('button:has-text("Apply Changes")').click()
+      await page.waitForSelector('button#action-save')
+      await page.locator('button#action-save').click()
+      await wait(1000) // Wait for the save
+
+      const resizeOptionMedia = page.locator('.file-meta .file-meta__size-type')
+      await expect(resizeOptionMedia).toContainText('200x200')
+    })
   })
 
   test('should see upload previews in relation list if allowed in config', async () => {