diff --git a/packages/payload/src/uploads/cropImage.ts b/packages/payload/src/uploads/cropImage.ts index 86e47ea976..46cd82aea7 100644 --- a/packages/payload/src/uploads/cropImage.ts +++ b/packages/payload/src/uploads/cropImage.ts @@ -9,13 +9,13 @@ export const percentToPixel = (value: string, dimension: number): number => { export default async function cropImage({ cropData, dimensions, file }) { try { - const fileIsAnimated = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype) + const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype) const { height, width, x, y } = cropData const sharpOptions: SharpOptions = {} - if (fileIsAnimated) sharpOptions.animated = true + if (fileIsAnimatedType) sharpOptions.animated = true const formattedCropData: sharp.Region = { height: percentToPixel(height, dimensions.height), diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index cc95a91a70..4d544c002e 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -121,7 +121,7 @@ export const generateFileData = async ({ let newData = data const filesToSave: FileToSave[] = [] const fileData: Partial = {} - const fileIsAnimated = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype) + const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype) const cropData = typeof uploadEdits === 'object' && 'crop' in uploadEdits ? uploadEdits.crop : undefined @@ -139,9 +139,9 @@ export const generateFileData = async ({ const sharpOptions: SharpOptions = {} - if (fileIsAnimated) sharpOptions.animated = true + if (fileIsAnimatedType) sharpOptions.animated = true - if (sharp && (fileIsAnimated || fileHasAdjustments)) { + if (sharp && (fileIsAnimatedType || fileHasAdjustments)) { if (file.tempFilePath) { sharpFile = sharp(file.tempFilePath, sharpOptions).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081 } else { @@ -225,7 +225,7 @@ export const generateFileData = async ({ } fileData.width = info.width fileData.height = info.height - if (fileIsAnimated) { + if (fileIsAnimatedType) { const metadata = await sharpFile.metadata() fileData.height = metadata.pages ? info.height / metadata.pages : info.height } diff --git a/packages/payload/src/uploads/imageResizer.ts b/packages/payload/src/uploads/imageResizer.ts index c3a9e2c26a..44c117ca1e 100644 --- a/packages/payload/src/uploads/imageResizer.ts +++ b/packages/payload/src/uploads/imageResizer.ts @@ -253,10 +253,10 @@ export default async function resizeAndTransformImageSizes({ if (!imageSizes) return defaultResult // Determine if the file is animated - const fileIsAnimated = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype) + const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype) const sharpOptions: SharpOptions = {} - if (fileIsAnimated) sharpOptions.animated = true + if (fileIsAnimatedType) sharpOptions.animated = true 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 @@ -277,16 +277,26 @@ export default async function resizeAndTransformImageSizes({ const metadata = await sharpBase.metadata() if (incomingFocalPoint && applyPayloadAdjustments(imageResizeConfig, dimensions)) { - const { height: resizeHeight, width: resizeWidth } = imageResizeConfig - const resizeAspectRatio = resizeWidth / resizeHeight + let { height: resizeHeight, width: resizeWidth } = imageResizeConfig + const originalAspectRatio = dimensions.width / dimensions.height - const prioritizeHeight = resizeAspectRatio < originalAspectRatio + + // Calculate resizeWidth based on original aspect ratio if it's undefined + if (resizeHeight && !resizeWidth) { + resizeWidth = Math.round(resizeHeight * originalAspectRatio) + } + + // Calculate resizeHeight based on original aspect ratio if it's undefined + if (resizeWidth && !resizeHeight) { + resizeHeight = Math.round(resizeWidth / originalAspectRatio) + } // Scale the image up or down to fit the resize dimensions const scaledImage = imageToResize.resize({ - height: prioritizeHeight ? resizeHeight : null, - width: prioritizeHeight ? null : resizeWidth, + height: resizeHeight, + width: resizeWidth, }) + const { info: scaledImageInfo } = await scaledImage.toBuffer({ resolveWithObject: true }) const safeResizeWidth = resizeWidth ?? scaledImageInfo.width @@ -296,10 +306,16 @@ export default async function resizeAndTransformImageSizes({ ) const safeOffsetX = Math.min(Math.max(0, leftFocalEdge), maxOffsetX) - const safeResizeHeight = resizeHeight ?? scaledImageInfo.height + const isAnimated = fileIsAnimatedType && metadata.pages - const maxOffsetY = fileIsAnimated - ? resizeHeight - safeResizeHeight + let safeResizeHeight = resizeHeight ?? scaledImageInfo.height + + if (isAnimated && resizeHeight === undefined) { + safeResizeHeight = scaledImageInfo.height / metadata.pages + } + + const maxOffsetY = isAnimated + ? safeResizeHeight - (resizeHeight ?? safeResizeHeight) : scaledImageInfo.height - safeResizeHeight const topFocalEdge = Math.round( @@ -308,7 +324,7 @@ export default async function resizeAndTransformImageSizes({ const safeOffsetY = Math.min(Math.max(0, topFocalEdge), maxOffsetY) // extract the focal area from the scaled image - resized = (fileIsAnimated ? imageToResize : scaledImage).extract({ + resized = (fileIsAnimatedType ? imageToResize : scaledImage).extract({ height: safeResizeHeight, left: safeOffsetX, top: safeOffsetY, @@ -362,7 +378,7 @@ export default async function resizeAndTransformImageSizes({ name: imageResizeConfig.name, filename: imageNameWithDimensions, filesize: size, - height: fileIsAnimated && metadata.pages ? height / metadata.pages : height, + height: fileIsAnimatedType && metadata.pages ? height / metadata.pages : height, mimeType: mimeInfo?.mime || mimeType, sizesToSave: [{ buffer: bufferData, path: imagePath }], width, diff --git a/test/uploads/animated.webp b/test/uploads/animated.webp new file mode 100644 index 0000000000..bc1b177821 Binary files /dev/null and b/test/uploads/animated.webp differ diff --git a/test/uploads/config.ts b/test/uploads/config.ts index d5ef468533..c5a76bdc1f 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -8,6 +8,7 @@ import { Uploads1 } from './collections/Upload1' import Uploads2 from './collections/Upload2' import AdminThumbnailCol from './collections/admin-thumbnail' import { + animatedTypeMedia, audioSlug, cropOnlySlug, enlargeSlug, @@ -317,6 +318,43 @@ export default buildConfigWithDefaults({ ], }, }, + { + slug: animatedTypeMedia, + fields: [], + upload: { + staticDir: './media', + staticURL: '/media', + resizeOptions: { + position: 'center', + width: 200, + height: 200, + }, + imageSizes: [ + { + name: 'squareSmall', + width: 480, + height: 480, + position: 'centre', + withoutEnlargement: false, + }, + { + name: 'undefinedHeight', + width: 300, + height: undefined, + }, + { + name: 'undefinedWidth', + width: undefined, + height: 300, + }, + { + name: 'undefinedAll', + width: undefined, + height: undefined, + }, + ], + }, + }, { slug: enlargeSlug, fields: [], @@ -543,6 +581,43 @@ export default buildConfigWithDefaults({ }, }) + // Create animated type images + const animatedImageFilePath = path.resolve(__dirname, './animated.webp') + const animatedImageFile = await getFileByPath(animatedImageFilePath) + + await payload.create({ + collection: animatedTypeMedia, + data: {}, + file: animatedImageFile, + }) + + await payload.create({ + collection: versionSlug, + data: { + _status: 'published', + title: 'upload', + }, + file: animatedImageFile, + }) + + const nonAnimatedImageFilePath = path.resolve(__dirname, './non-animated.webp') + const nonAnimatedImageFile = await getFileByPath(nonAnimatedImageFilePath) + + await payload.create({ + collection: animatedTypeMedia, + data: {}, + file: nonAnimatedImageFile, + }) + + await payload.create({ + collection: versionSlug, + data: { + _status: 'published', + title: 'upload', + }, + file: nonAnimatedImageFile, + }) + // Create audio const audioFilePath = path.resolve(__dirname, './audio.mp3') const audioFile = await getFileByPath(audioFilePath) diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 60ce39ffb7..a01debf204 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -12,12 +12,20 @@ import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { initPayloadE2E } from '../helpers/configHelpers' import { RESTClient } from '../helpers/rest' import { adminThumbnailSrc } from './collections/admin-thumbnail' -import { adminThumbnailSlug, audioSlug, globalWithMedia, mediaSlug, relationSlug } from './shared' +import { + adminThumbnailSlug, + animatedTypeMedia, + audioSlug, + globalWithMedia, + mediaSlug, + relationSlug, +} from './shared' const { beforeAll, describe } = test let client: RESTClient let mediaURL: AdminUrlUtil +let animatedTypeMediaURL: AdminUrlUtil let audioURL: AdminUrlUtil let relationURL: AdminUrlUtil let adminThumbnailURL: AdminUrlUtil @@ -34,6 +42,7 @@ describe('uploads', () => { await client.login() mediaURL = new AdminUrlUtil(serverURL, mediaSlug) + animatedTypeMediaURL = new AdminUrlUtil(serverURL, animatedTypeMedia) audioURL = new AdminUrlUtil(serverURL, audioSlug) relationURL = new AdminUrlUtil(serverURL, relationSlug) adminThumbnailURL = new AdminUrlUtil(serverURL, adminThumbnailSlug) @@ -96,6 +105,26 @@ describe('uploads', () => { await saveDocAndAssert(page) }) + test('should create animated file upload', async () => { + await page.goto(animatedTypeMediaURL.create) + + await page.setInputFiles('input[type="file"]', path.resolve(__dirname, './animated.webp')) + const animatedFilename = page.locator('.file-field__filename') + + await expect(animatedFilename).toHaveValue('animated.webp') + + await saveDocAndAssert(page) + + await page.goto(animatedTypeMediaURL.create) + + await page.setInputFiles('input[type="file"]', path.resolve(__dirname, './non-animated.webp')) + const nonAnimatedFileName = page.locator('.file-field__filename') + + await expect(nonAnimatedFileName).toHaveValue('non-animated.webp') + + await saveDocAndAssert(page) + }) + test('should show resized images', async () => { await page.goto(mediaURL.edit(pngDoc.id)) @@ -186,7 +215,7 @@ describe('uploads', () => { // choose from existing await page.locator('.list-drawer__toggler').click() - await expect(page.locator('.cell-title')).toContainText('draft') + await expect(page.locator('.row-3 .cell-title')).toContainText('draft') }) test('should restrict mimetype based on filterOptions', async () => { diff --git a/test/uploads/non-animated.webp b/test/uploads/non-animated.webp new file mode 100644 index 0000000000..97e9730e72 Binary files /dev/null and b/test/uploads/non-animated.webp differ diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index 1c8c7de512..7d4e5ba121 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -172,6 +172,53 @@ export interface Media { } } } +export interface AnimatedTypeMedia { + id: string + updatedAt: string + createdAt: string + url?: string + filename?: string + mimeType?: string + filesize?: number + width?: number + height?: number + focalX?: number + focalY?: number + sizes?: { + squareSmall?: { + url?: string + width?: number + height?: number + mimeType?: string + filesize?: number + filename?: string + } + undefinedHeight?: { + url?: string + width?: number + height?: number + mimeType?: string + filesize?: number + filename?: string + } + undefinedWidth?: { + url?: string + width?: number + height?: number + mimeType?: string + filesize?: number + filename?: string + } + undefinedAll?: { + url?: string + width?: number + height?: number + mimeType?: string + filesize?: number + filename?: string + } + } +} export interface Audio { id: string audio?: string | Media diff --git a/test/uploads/shared.ts b/test/uploads/shared.ts index 96aef50ceb..920c196fb3 100644 --- a/test/uploads/shared.ts +++ b/test/uploads/shared.ts @@ -8,3 +8,4 @@ export const reduceSlug = 'reduce' export const relationSlug = 'relation' export const versionSlug = 'versions' export const globalWithMedia = 'global-with-media' +export const animatedTypeMedia = 'animated-type-media'