diff --git a/packages/payload/src/uploads/cropImage.ts b/packages/payload/src/uploads/cropImage.ts index a167e8d99..e7ab7fc02 100644 --- a/packages/payload/src/uploads/cropImage.ts +++ b/packages/payload/src/uploads/cropImage.ts @@ -8,11 +8,11 @@ export async function cropImage({ cropData, dimensions, file, sharp }) { try { const { height, width, x, y } = cropData - 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 formattedCropData = { height: percentToPixel(height, dimensions.height), diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index c65755386..a156a2447 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -113,7 +113,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 @@ -131,9 +131,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 { @@ -217,7 +217,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/getBaseFields.ts b/packages/payload/src/uploads/getBaseFields.ts index 66204c24f..dcedabcae 100644 --- a/packages/payload/src/uploads/getBaseFields.ts +++ b/packages/payload/src/uploads/getBaseFields.ts @@ -192,7 +192,7 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => hooks: { afterRead: [ ({ data, value }) => { - if (value) return value + if (value && size.height && size.width) return value const sizeFilename = data?.sizes?.[size.name]?.filename diff --git a/packages/payload/src/uploads/imageResizer.ts b/packages/payload/src/uploads/imageResizer.ts index 052b8427e..f3fb69a8f 100644 --- a/packages/payload/src/uploads/imageResizer.ts +++ b/packages/payload/src/uploads/imageResizer.ts @@ -255,10 +255,10 @@ export async function resizeAndTransformImageSizes({ } // 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 @@ -279,16 +279,26 @@ export 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 @@ -298,10 +308,16 @@ export 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( @@ -310,7 +326,7 @@ export 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, @@ -364,7 +380,7 @@ export 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/packages/ui/src/elements/Thumbnail/index.tsx b/packages/ui/src/elements/Thumbnail/index.tsx index ae79e1e4a..7b975b219 100644 --- a/packages/ui/src/elements/Thumbnail/index.tsx +++ b/packages/ui/src/elements/Thumbnail/index.tsx @@ -30,7 +30,7 @@ const ThumbnailContext = React.createContext({ export const useThumbnailContext = () => React.useContext(ThumbnailContext) export const Thumbnail: React.FC = (props) => { - const { className = '', doc: { filename } = {}, fileSrc, size } = props + const { className = '', doc: { filename } = {}, fileSrc, imageCacheTag, size } = props const [fileExists, setFileExists] = React.useState(undefined) const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ') @@ -54,7 +54,12 @@ export const Thumbnail: React.FC = (props) => { return (
{fileExists === undefined && } - {fileExists && {filename} + {fileExists && ( + {filename + )} {fileExists === false && }
) diff --git a/packages/ui/src/elements/Upload/index.scss b/packages/ui/src/elements/Upload/index.scss index 772a812f5..1c6b358f3 100644 --- a/packages/ui/src/elements/Upload/index.scss +++ b/packages/ui/src/elements/Upload/index.scss @@ -30,17 +30,6 @@ } } - .file-details { - img { - position: relative; - min-width: 100%; - height: 100%; - transform: scale(var(--file-details-thumbnail--zoom)); - top: var(--file-details-thumbnail--top-offset); - left: var(--file-details-thumbnail--left-offset); - } - } - &__remove { margin: calc($baseline * 1.5) $baseline $baseline 0; place-self: flex-start; diff --git a/packages/ui/src/elements/Upload/index.tsx b/packages/ui/src/elements/Upload/index.tsx index f0255e558..25b99cd0d 100644 --- a/packages/ui/src/elements/Upload/index.tsx +++ b/packages/ui/src/elements/Upload/index.tsx @@ -6,7 +6,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { fieldBaseClass } from '../../fields/shared/index.js' import { FieldError } from '../../forms/FieldError/index.js' -import { useForm, useFormSubmitted } from '../../forms/Form/context.js' +import { useForm } from '../../forms/Form/context.js' import { useField } from '../../forms/useField/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useFormQueryParams } from '../../providers/FormQueryParams/index.js' @@ -62,7 +62,6 @@ export type UploadProps = { export const Upload: React.FC = (props) => { const { collectionSlug, initialState, onChange, updatedAt, uploadConfig } = props - const submitted = useFormSubmitted() const [replacingFile, setReplacingFile] = useState(false) const [fileSrc, setFileSrc] = useState(null) const { t } = useTranslation() @@ -106,17 +105,7 @@ export const Upload: React.FC = (props) => { x: crop.x || 0, y: crop.y || 0, }) - const zoomScale = 100 / Math.min(crop.width, crop.height) - document.documentElement.style.setProperty('--file-details-thumbnail--zoom', `${zoomScale}`) - document.documentElement.style.setProperty( - '--file-details-thumbnail--top-offset', - `${zoomScale * (50 - crop.height / 2 - crop.y)}%`, - ) - document.documentElement.style.setProperty( - '--file-details-thumbnail--left-offset', - `${zoomScale * (50 - crop.width / 2 - crop.x)}%`, - ) setModified(true) dispatchFormQueryParams({ type: 'SET', @@ -171,8 +160,6 @@ export const Upload: React.FC = (props) => { const showFocalPoint = focalPoint && (hasImageSizes || hasResizeOptions || focalPointEnabled) - const lastSubmittedTime = submitted ? new Date().toISOString() : null - return (
@@ -183,7 +170,7 @@ export const Upload: React.FC = (props) => { doc={doc} handleRemove={canRemoveUpload ? handleFileRemoval : undefined} hasImageSizes={hasImageSizes} - imageCacheTag={lastSubmittedTime} + imageCacheTag={doc.updatedAt} uploadConfig={uploadConfig} /> )} @@ -238,7 +225,7 @@ export const Upload: React.FC = (props) => { = (props) => { slug={sizePreviewSlug} title={t('upload:sizesFor', { label: doc?.filename })} > - + )}
diff --git a/test/fields/collections/Upload/e2e.spec.ts b/test/fields/collections/Upload/e2e.spec.ts index 639c7a2b9..bcd5e7791 100644 --- a/test/fields/collections/Upload/e2e.spec.ts +++ b/test/fields/collections/Upload/e2e.spec.ts @@ -91,7 +91,7 @@ describe('Upload', () => { await uploadImage() await expect(page.locator('.file-field .file-details img')).toHaveAttribute( 'src', - '/api/uploads/file/payload-1.jpg', + /\/api\/uploads\/file\/payload-1\.jpg(\?.*)?$/, ) }) diff --git a/test/uploads/animated.webp b/test/uploads/animated.webp new file mode 100644 index 000000000..bc1b17782 Binary files /dev/null and b/test/uploads/animated.webp differ diff --git a/test/uploads/config.ts b/test/uploads/config.ts index e862ce4ce..a30ae5ded 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -10,6 +10,7 @@ import { AdminThumbnailSize } from './collections/AdminThumbnailSize/index.js' import { Uploads1 } from './collections/Upload1/index.js' import { Uploads2 } from './collections/Upload2/index.js' import { + animatedTypeMedia, audioSlug, enlargeSlug, focalNoSizesSlug, @@ -290,6 +291,42 @@ export default buildConfigWithDefaults({ ], }, }, + { + slug: animatedTypeMedia, + fields: [], + upload: { + staticDir: path.resolve(dirname, './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: [], @@ -501,6 +538,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 c06bacb40..19f9f6ae3 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -22,6 +22,7 @@ import { TEST_TIMEOUT_LONG } from '../playwright.config.js' import { adminThumbnailFunctionSlug, adminThumbnailSizeSlug, + animatedTypeMedia, audioSlug, mediaSlug, relationSlug, @@ -35,6 +36,7 @@ let payload: PayloadTestSDK let client: RESTClient let serverURL: string let mediaURL: AdminUrlUtil +let animatedTypeMediaURL: AdminUrlUtil let audioURL: AdminUrlUtil let relationURL: AdminUrlUtil let adminThumbnailSizeURL: AdminUrlUtil @@ -52,6 +54,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) adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug) @@ -120,6 +123,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)) @@ -209,7 +232,7 @@ describe('uploads', () => { // choose from existing await openDocDrawer(page, '.list-drawer__toggler') - 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/getMimeType.ts b/test/uploads/getMimeType.ts index 908418d13..f773d3278 100644 --- a/test/uploads/getMimeType.ts +++ b/test/uploads/getMimeType.ts @@ -21,6 +21,9 @@ export const getMimeType = ( case 'svg': type = 'image/svg+xml' break + case 'webp': + type = 'image/webp' + break default: type = 'image/png' } diff --git a/test/uploads/non-animated.webp b/test/uploads/non-animated.webp new file mode 100644 index 000000000..97e9730e7 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 ac96f94b0..0ecf54b86 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -190,6 +190,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 + } + } +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "versions". @@ -821,6 +868,6 @@ export interface PayloadMigration { declare module 'payload' { - // @ts-ignore + // @ts-ignore export interface GeneratedTypes extends Config {} -} \ No newline at end of file +} diff --git a/test/uploads/shared.ts b/test/uploads/shared.ts index 890422aed..a5cb47fdd 100644 --- a/test/uploads/shared.ts +++ b/test/uploads/shared.ts @@ -10,3 +10,4 @@ export const adminThumbnailFunctionSlug = 'admin-thumbnail-function' export const adminThumbnailSizeSlug = 'admin-thumbnail-size' export const unstoredMediaSlug = 'unstored-media' export const versionSlug = 'versions' +export const animatedTypeMedia = 'animated-type-media'