fix(payload, ui): unable to save animated file types with undefined image sizes (#6757)

## Description

V2 PR [here](https://github.com/payloadcms/payload/pull/6733)

Additionally fixes issue with image thumbnails not updating properly
until page refresh.

Image thumbnails properly update on document save now.

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
This commit is contained in:
Patrik
2024-06-13 09:43:44 -04:00
committed by GitHub
parent 8e56328e63
commit e148243260
15 changed files with 198 additions and 53 deletions

View File

@@ -8,11 +8,11 @@ export async function cropImage({ cropData, dimensions, file, sharp }) {
try { try {
const { height, width, x, y } = cropData 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 = {} const sharpOptions: SharpOptions = {}
if (fileIsAnimated) sharpOptions.animated = true if (fileIsAnimatedType) sharpOptions.animated = true
const formattedCropData = { const formattedCropData = {
height: percentToPixel(height, dimensions.height), height: percentToPixel(height, dimensions.height),

View File

@@ -113,7 +113,7 @@ export const generateFileData = async <T>({
let newData = data let newData = data
const filesToSave: FileToSave[] = [] const filesToSave: FileToSave[] = []
const fileData: Partial<FileData> = {} const fileData: Partial<FileData> = {}
const fileIsAnimated = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype) const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype)
const cropData = const cropData =
typeof uploadEdits === 'object' && 'crop' in uploadEdits ? uploadEdits.crop : undefined typeof uploadEdits === 'object' && 'crop' in uploadEdits ? uploadEdits.crop : undefined
@@ -131,9 +131,9 @@ export const generateFileData = async <T>({
const sharpOptions: SharpOptions = {} const sharpOptions: SharpOptions = {}
if (fileIsAnimated) sharpOptions.animated = true if (fileIsAnimatedType) sharpOptions.animated = true
if (sharp && (fileIsAnimated || fileHasAdjustments)) { if (sharp && (fileIsAnimatedType || fileHasAdjustments)) {
if (file.tempFilePath) { 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 sharpFile = sharp(file.tempFilePath, sharpOptions).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081
} else { } else {
@@ -217,7 +217,7 @@ export const generateFileData = async <T>({
} }
fileData.width = info.width fileData.width = info.width
fileData.height = info.height fileData.height = info.height
if (fileIsAnimated) { if (fileIsAnimatedType) {
const metadata = await sharpFile.metadata() const metadata = await sharpFile.metadata()
fileData.height = metadata.pages ? info.height / metadata.pages : info.height fileData.height = metadata.pages ? info.height / metadata.pages : info.height
} }

View File

@@ -192,7 +192,7 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] =>
hooks: { hooks: {
afterRead: [ afterRead: [
({ data, value }) => { ({ data, value }) => {
if (value) return value if (value && size.height && size.width) return value
const sizeFilename = data?.sizes?.[size.name]?.filename const sizeFilename = data?.sizes?.[size.name]?.filename

View File

@@ -255,10 +255,10 @@ export async function resizeAndTransformImageSizes({
} }
// Determine if the file is animated // 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 = {} 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 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() const metadata = await sharpBase.metadata()
if (incomingFocalPoint && applyPayloadAdjustments(imageResizeConfig, dimensions)) { if (incomingFocalPoint && applyPayloadAdjustments(imageResizeConfig, dimensions)) {
const { height: resizeHeight, width: resizeWidth } = imageResizeConfig let { height: resizeHeight, width: resizeWidth } = imageResizeConfig
const resizeAspectRatio = resizeWidth / resizeHeight
const originalAspectRatio = dimensions.width / dimensions.height 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 // Scale the image up or down to fit the resize dimensions
const scaledImage = imageToResize.resize({ const scaledImage = imageToResize.resize({
height: prioritizeHeight ? resizeHeight : null, height: resizeHeight,
width: prioritizeHeight ? null : resizeWidth, width: resizeWidth,
}) })
const { info: scaledImageInfo } = await scaledImage.toBuffer({ resolveWithObject: true }) const { info: scaledImageInfo } = await scaledImage.toBuffer({ resolveWithObject: true })
const safeResizeWidth = resizeWidth ?? scaledImageInfo.width const safeResizeWidth = resizeWidth ?? scaledImageInfo.width
@@ -298,10 +308,16 @@ export async function resizeAndTransformImageSizes({
) )
const safeOffsetX = Math.min(Math.max(0, leftFocalEdge), maxOffsetX) const safeOffsetX = Math.min(Math.max(0, leftFocalEdge), maxOffsetX)
const safeResizeHeight = resizeHeight ?? scaledImageInfo.height const isAnimated = fileIsAnimatedType && metadata.pages
const maxOffsetY = fileIsAnimated let safeResizeHeight = resizeHeight ?? scaledImageInfo.height
? resizeHeight - safeResizeHeight
if (isAnimated && resizeHeight === undefined) {
safeResizeHeight = scaledImageInfo.height / metadata.pages
}
const maxOffsetY = isAnimated
? safeResizeHeight - (resizeHeight ?? safeResizeHeight)
: scaledImageInfo.height - safeResizeHeight : scaledImageInfo.height - safeResizeHeight
const topFocalEdge = Math.round( const topFocalEdge = Math.round(
@@ -310,7 +326,7 @@ export async function resizeAndTransformImageSizes({
const safeOffsetY = Math.min(Math.max(0, topFocalEdge), maxOffsetY) const safeOffsetY = Math.min(Math.max(0, topFocalEdge), maxOffsetY)
// extract the focal area from the scaled image // extract the focal area from the scaled image
resized = (fileIsAnimated ? imageToResize : scaledImage).extract({ resized = (fileIsAnimatedType ? imageToResize : scaledImage).extract({
height: safeResizeHeight, height: safeResizeHeight,
left: safeOffsetX, left: safeOffsetX,
top: safeOffsetY, top: safeOffsetY,
@@ -364,7 +380,7 @@ export async function resizeAndTransformImageSizes({
name: imageResizeConfig.name, name: imageResizeConfig.name,
filename: imageNameWithDimensions, filename: imageNameWithDimensions,
filesize: size, filesize: size,
height: fileIsAnimated && metadata.pages ? height / metadata.pages : height, height: fileIsAnimatedType && metadata.pages ? height / metadata.pages : height,
mimeType: mimeInfo?.mime || mimeType, mimeType: mimeInfo?.mime || mimeType,
sizesToSave: [{ buffer: bufferData, path: imagePath }], sizesToSave: [{ buffer: bufferData, path: imagePath }],
width, width,

View File

@@ -30,7 +30,7 @@ const ThumbnailContext = React.createContext({
export const useThumbnailContext = () => React.useContext(ThumbnailContext) export const useThumbnailContext = () => React.useContext(ThumbnailContext)
export const Thumbnail: React.FC<ThumbnailProps> = (props) => { export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
const { className = '', doc: { filename } = {}, fileSrc, size } = props const { className = '', doc: { filename } = {}, fileSrc, imageCacheTag, size } = props
const [fileExists, setFileExists] = React.useState(undefined) const [fileExists, setFileExists] = React.useState(undefined)
const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ') const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ')
@@ -54,7 +54,12 @@ export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
return ( return (
<div className={classNames}> <div className={classNames}>
{fileExists === undefined && <ShimmerEffect height="100%" />} {fileExists === undefined && <ShimmerEffect height="100%" />}
{fileExists && <img alt={filename as string} src={fileSrc} />} {fileExists && (
<img
alt={filename as string}
src={`${fileSrc}${imageCacheTag ? `?${imageCacheTag}` : ''}`}
/>
)}
{fileExists === false && <File />} {fileExists === false && <File />}
</div> </div>
) )

View File

@@ -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 { &__remove {
margin: calc($baseline * 1.5) $baseline $baseline 0; margin: calc($baseline * 1.5) $baseline $baseline 0;
place-self: flex-start; place-self: flex-start;

View File

@@ -6,7 +6,7 @@ import React, { useCallback, useEffect, useState } from 'react'
import { fieldBaseClass } from '../../fields/shared/index.js' import { fieldBaseClass } from '../../fields/shared/index.js'
import { FieldError } from '../../forms/FieldError/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 { useField } from '../../forms/useField/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useFormQueryParams } from '../../providers/FormQueryParams/index.js' import { useFormQueryParams } from '../../providers/FormQueryParams/index.js'
@@ -62,7 +62,6 @@ export type UploadProps = {
export const Upload: React.FC<UploadProps> = (props) => { export const Upload: React.FC<UploadProps> = (props) => {
const { collectionSlug, initialState, onChange, updatedAt, uploadConfig } = props const { collectionSlug, initialState, onChange, updatedAt, uploadConfig } = props
const submitted = useFormSubmitted()
const [replacingFile, setReplacingFile] = useState(false) const [replacingFile, setReplacingFile] = useState(false)
const [fileSrc, setFileSrc] = useState<null | string>(null) const [fileSrc, setFileSrc] = useState<null | string>(null)
const { t } = useTranslation() const { t } = useTranslation()
@@ -106,17 +105,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
x: crop.x || 0, x: crop.x || 0,
y: crop.y || 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) setModified(true)
dispatchFormQueryParams({ dispatchFormQueryParams({
type: 'SET', type: 'SET',
@@ -171,8 +160,6 @@ export const Upload: React.FC<UploadProps> = (props) => {
const showFocalPoint = focalPoint && (hasImageSizes || hasResizeOptions || focalPointEnabled) const showFocalPoint = focalPoint && (hasImageSizes || hasResizeOptions || focalPointEnabled)
const lastSubmittedTime = submitted ? new Date().toISOString() : null
return ( return (
<div className={[fieldBaseClass, baseClass].filter(Boolean).join(' ')}> <div className={[fieldBaseClass, baseClass].filter(Boolean).join(' ')}>
<FieldError message={errorMessage} showError={showError} /> <FieldError message={errorMessage} showError={showError} />
@@ -183,7 +170,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
doc={doc} doc={doc}
handleRemove={canRemoveUpload ? handleFileRemoval : undefined} handleRemove={canRemoveUpload ? handleFileRemoval : undefined}
hasImageSizes={hasImageSizes} hasImageSizes={hasImageSizes}
imageCacheTag={lastSubmittedTime} imageCacheTag={doc.updatedAt}
uploadConfig={uploadConfig} uploadConfig={uploadConfig}
/> />
)} )}
@@ -238,7 +225,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
<EditUpload <EditUpload
fileName={value?.name || doc?.filename} fileName={value?.name || doc?.filename}
fileSrc={fileSrc || doc?.url} fileSrc={fileSrc || doc?.url}
imageCacheTag={lastSubmittedTime} imageCacheTag={doc.updatedAt}
initialCrop={formQueryParams?.uploadEdits?.crop ?? {}} initialCrop={formQueryParams?.uploadEdits?.crop ?? {}}
initialFocalPoint={{ initialFocalPoint={{
x: formQueryParams?.uploadEdits?.focalPoint.x || doc.focalX || 50, x: formQueryParams?.uploadEdits?.focalPoint.x || doc.focalX || 50,
@@ -257,7 +244,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
slug={sizePreviewSlug} slug={sizePreviewSlug}
title={t('upload:sizesFor', { label: doc?.filename })} title={t('upload:sizesFor', { label: doc?.filename })}
> >
<PreviewSizes doc={doc} uploadConfig={uploadConfig} /> <PreviewSizes doc={doc} imageCacheTag={doc.updatedAt} uploadConfig={uploadConfig} />
</Drawer> </Drawer>
)} )}
</div> </div>

View File

@@ -91,7 +91,7 @@ describe('Upload', () => {
await uploadImage() await uploadImage()
await expect(page.locator('.file-field .file-details img')).toHaveAttribute( await expect(page.locator('.file-field .file-details img')).toHaveAttribute(
'src', 'src',
'/api/uploads/file/payload-1.jpg', /\/api\/uploads\/file\/payload-1\.jpg(\?.*)?$/,
) )
}) })

BIN
test/uploads/animated.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -10,6 +10,7 @@ import { AdminThumbnailSize } from './collections/AdminThumbnailSize/index.js'
import { Uploads1 } from './collections/Upload1/index.js' import { Uploads1 } from './collections/Upload1/index.js'
import { Uploads2 } from './collections/Upload2/index.js' import { Uploads2 } from './collections/Upload2/index.js'
import { import {
animatedTypeMedia,
audioSlug, audioSlug,
enlargeSlug, enlargeSlug,
focalNoSizesSlug, 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, slug: enlargeSlug,
fields: [], 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 // Create audio
const audioFilePath = path.resolve(dirname, './audio.mp3') const audioFilePath = path.resolve(dirname, './audio.mp3')
const audioFile = await getFileByPath(audioFilePath) const audioFile = await getFileByPath(audioFilePath)

View File

@@ -22,6 +22,7 @@ import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { import {
adminThumbnailFunctionSlug, adminThumbnailFunctionSlug,
adminThumbnailSizeSlug, adminThumbnailSizeSlug,
animatedTypeMedia,
audioSlug, audioSlug,
mediaSlug, mediaSlug,
relationSlug, relationSlug,
@@ -35,6 +36,7 @@ let payload: PayloadTestSDK<Config>
let client: RESTClient let client: RESTClient
let serverURL: string let serverURL: string
let mediaURL: AdminUrlUtil let mediaURL: AdminUrlUtil
let animatedTypeMediaURL: AdminUrlUtil
let audioURL: AdminUrlUtil let audioURL: AdminUrlUtil
let relationURL: AdminUrlUtil let relationURL: AdminUrlUtil
let adminThumbnailSizeURL: AdminUrlUtil let adminThumbnailSizeURL: AdminUrlUtil
@@ -52,6 +54,7 @@ describe('uploads', () => {
await client.login() await client.login()
mediaURL = new AdminUrlUtil(serverURL, mediaSlug) mediaURL = new AdminUrlUtil(serverURL, mediaSlug)
animatedTypeMediaURL = new AdminUrlUtil(serverURL, animatedTypeMedia)
audioURL = new AdminUrlUtil(serverURL, audioSlug) audioURL = new AdminUrlUtil(serverURL, audioSlug)
relationURL = new AdminUrlUtil(serverURL, relationSlug) relationURL = new AdminUrlUtil(serverURL, relationSlug)
adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug) adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug)
@@ -120,6 +123,26 @@ describe('uploads', () => {
await saveDocAndAssert(page) 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 () => { test('should show resized images', async () => {
await page.goto(mediaURL.edit(pngDoc.id)) await page.goto(mediaURL.edit(pngDoc.id))
@@ -209,7 +232,7 @@ describe('uploads', () => {
// choose from existing // choose from existing
await openDocDrawer(page, '.list-drawer__toggler') 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 () => { test('should restrict mimetype based on filterOptions', async () => {

View File

@@ -21,6 +21,9 @@ export const getMimeType = (
case 'svg': case 'svg':
type = 'image/svg+xml' type = 'image/svg+xml'
break break
case 'webp':
type = 'image/webp'
break
default: default:
type = 'image/png' type = 'image/png'
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "versions". * via the `definition` "versions".
@@ -821,6 +868,6 @@ export interface PayloadMigration {
declare module 'payload' { declare module 'payload' {
// @ts-ignore // @ts-ignore
export interface GeneratedTypes extends Config {} export interface GeneratedTypes extends Config {}
} }

View File

@@ -10,3 +10,4 @@ export const adminThumbnailFunctionSlug = 'admin-thumbnail-function'
export const adminThumbnailSizeSlug = 'admin-thumbnail-size' export const adminThumbnailSizeSlug = 'admin-thumbnail-size'
export const unstoredMediaSlug = 'unstored-media' export const unstoredMediaSlug = 'unstored-media'
export const versionSlug = 'versions' export const versionSlug = 'versions'
export const animatedTypeMedia = 'animated-type-media'