Skip to content

Commit e148243

Browse files
authored
fix(payload, ui): unable to save animated file types with undefined image sizes (#6757)
## Description V2 PR [here](#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
1 parent 8e56328 commit e148243

File tree

15 files changed

+198
-53
lines changed

15 files changed

+198
-53
lines changed

packages/payload/src/uploads/cropImage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ export async function cropImage({ cropData, dimensions, file, sharp }) {
88
try {
99
const { height, width, x, y } = cropData
1010

11-
const fileIsAnimated = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype)
11+
const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype)
1212

1313
const sharpOptions: SharpOptions = {}
1414

15-
if (fileIsAnimated) sharpOptions.animated = true
15+
if (fileIsAnimatedType) sharpOptions.animated = true
1616

1717
const formattedCropData = {
1818
height: percentToPixel(height, dimensions.height),

packages/payload/src/uploads/generateFileData.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const generateFileData = async <T>({
113113
let newData = data
114114
const filesToSave: FileToSave[] = []
115115
const fileData: Partial<FileData> = {}
116-
const fileIsAnimated = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype)
116+
const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype)
117117
const cropData =
118118
typeof uploadEdits === 'object' && 'crop' in uploadEdits ? uploadEdits.crop : undefined
119119

@@ -131,9 +131,9 @@ export const generateFileData = async <T>({
131131

132132
const sharpOptions: SharpOptions = {}
133133

134-
if (fileIsAnimated) sharpOptions.animated = true
134+
if (fileIsAnimatedType) sharpOptions.animated = true
135135

136-
if (sharp && (fileIsAnimated || fileHasAdjustments)) {
136+
if (sharp && (fileIsAnimatedType || fileHasAdjustments)) {
137137
if (file.tempFilePath) {
138138
sharpFile = sharp(file.tempFilePath, sharpOptions).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081
139139
} else {
@@ -217,7 +217,7 @@ export const generateFileData = async <T>({
217217
}
218218
fileData.width = info.width
219219
fileData.height = info.height
220-
if (fileIsAnimated) {
220+
if (fileIsAnimatedType) {
221221
const metadata = await sharpFile.metadata()
222222
fileData.height = metadata.pages ? info.height / metadata.pages : info.height
223223
}

packages/payload/src/uploads/getBaseFields.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] =>
192192
hooks: {
193193
afterRead: [
194194
({ data, value }) => {
195-
if (value) return value
195+
if (value && size.height && size.width) return value
196196

197197
const sizeFilename = data?.sizes?.[size.name]?.filename
198198

packages/payload/src/uploads/imageResizer.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -255,10 +255,10 @@ export async function resizeAndTransformImageSizes({
255255
}
256256

257257
// Determine if the file is animated
258-
const fileIsAnimated = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype)
258+
const fileIsAnimatedType = ['image/avif', 'image/gif', 'image/webp'].includes(file.mimetype)
259259
const sharpOptions: SharpOptions = {}
260260

261-
if (fileIsAnimated) sharpOptions.animated = true
261+
if (fileIsAnimatedType) sharpOptions.animated = true
262262

263263
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
264264

@@ -279,16 +279,26 @@ export async function resizeAndTransformImageSizes({
279279
const metadata = await sharpBase.metadata()
280280

281281
if (incomingFocalPoint && applyPayloadAdjustments(imageResizeConfig, dimensions)) {
282-
const { height: resizeHeight, width: resizeWidth } = imageResizeConfig
283-
const resizeAspectRatio = resizeWidth / resizeHeight
282+
let { height: resizeHeight, width: resizeWidth } = imageResizeConfig
283+
284284
const originalAspectRatio = dimensions.width / dimensions.height
285-
const prioritizeHeight = resizeAspectRatio < originalAspectRatio
285+
286+
// Calculate resizeWidth based on original aspect ratio if it's undefined
287+
if (resizeHeight && !resizeWidth) {
288+
resizeWidth = Math.round(resizeHeight * originalAspectRatio)
289+
}
290+
291+
// Calculate resizeHeight based on original aspect ratio if it's undefined
292+
if (resizeWidth && !resizeHeight) {
293+
resizeHeight = Math.round(resizeWidth / originalAspectRatio)
294+
}
286295

287296
// Scale the image up or down to fit the resize dimensions
288297
const scaledImage = imageToResize.resize({
289-
height: prioritizeHeight ? resizeHeight : null,
290-
width: prioritizeHeight ? null : resizeWidth,
298+
height: resizeHeight,
299+
width: resizeWidth,
291300
})
301+
292302
const { info: scaledImageInfo } = await scaledImage.toBuffer({ resolveWithObject: true })
293303

294304
const safeResizeWidth = resizeWidth ?? scaledImageInfo.width
@@ -298,10 +308,16 @@ export async function resizeAndTransformImageSizes({
298308
)
299309
const safeOffsetX = Math.min(Math.max(0, leftFocalEdge), maxOffsetX)
300310

301-
const safeResizeHeight = resizeHeight ?? scaledImageInfo.height
311+
const isAnimated = fileIsAnimatedType && metadata.pages
312+
313+
let safeResizeHeight = resizeHeight ?? scaledImageInfo.height
314+
315+
if (isAnimated && resizeHeight === undefined) {
316+
safeResizeHeight = scaledImageInfo.height / metadata.pages
317+
}
302318

303-
const maxOffsetY = fileIsAnimated
304-
? resizeHeight - safeResizeHeight
319+
const maxOffsetY = isAnimated
320+
? safeResizeHeight - (resizeHeight ?? safeResizeHeight)
305321
: scaledImageInfo.height - safeResizeHeight
306322

307323
const topFocalEdge = Math.round(
@@ -310,7 +326,7 @@ export async function resizeAndTransformImageSizes({
310326
const safeOffsetY = Math.min(Math.max(0, topFocalEdge), maxOffsetY)
311327

312328
// extract the focal area from the scaled image
313-
resized = (fileIsAnimated ? imageToResize : scaledImage).extract({
329+
resized = (fileIsAnimatedType ? imageToResize : scaledImage).extract({
314330
height: safeResizeHeight,
315331
left: safeOffsetX,
316332
top: safeOffsetY,
@@ -364,7 +380,7 @@ export async function resizeAndTransformImageSizes({
364380
name: imageResizeConfig.name,
365381
filename: imageNameWithDimensions,
366382
filesize: size,
367-
height: fileIsAnimated && metadata.pages ? height / metadata.pages : height,
383+
height: fileIsAnimatedType && metadata.pages ? height / metadata.pages : height,
368384
mimeType: mimeInfo?.mime || mimeType,
369385
sizesToSave: [{ buffer: bufferData, path: imagePath }],
370386
width,

packages/ui/src/elements/Thumbnail/index.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const ThumbnailContext = React.createContext({
3030
export const useThumbnailContext = () => React.useContext(ThumbnailContext)
3131

3232
export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
33-
const { className = '', doc: { filename } = {}, fileSrc, size } = props
33+
const { className = '', doc: { filename } = {}, fileSrc, imageCacheTag, size } = props
3434
const [fileExists, setFileExists] = React.useState(undefined)
3535

3636
const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ')
@@ -54,7 +54,12 @@ export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
5454
return (
5555
<div className={classNames}>
5656
{fileExists === undefined && <ShimmerEffect height="100%" />}
57-
{fileExists && <img alt={filename as string} src={fileSrc} />}
57+
{fileExists && (
58+
<img
59+
alt={filename as string}
60+
src={`${fileSrc}${imageCacheTag ? `?${imageCacheTag}` : ''}`}
61+
/>
62+
)}
5863
{fileExists === false && <File />}
5964
</div>
6065
)

packages/ui/src/elements/Upload/index.scss

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,6 @@
3030
}
3131
}
3232

33-
.file-details {
34-
img {
35-
position: relative;
36-
min-width: 100%;
37-
height: 100%;
38-
transform: scale(var(--file-details-thumbnail--zoom));
39-
top: var(--file-details-thumbnail--top-offset);
40-
left: var(--file-details-thumbnail--left-offset);
41-
}
42-
}
43-
4433
&__remove {
4534
margin: calc($baseline * 1.5) $baseline $baseline 0;
4635
place-self: flex-start;

packages/ui/src/elements/Upload/index.tsx

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import React, { useCallback, useEffect, useState } from 'react'
66

77
import { fieldBaseClass } from '../../fields/shared/index.js'
88
import { FieldError } from '../../forms/FieldError/index.js'
9-
import { useForm, useFormSubmitted } from '../../forms/Form/context.js'
9+
import { useForm } from '../../forms/Form/context.js'
1010
import { useField } from '../../forms/useField/index.js'
1111
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
1212
import { useFormQueryParams } from '../../providers/FormQueryParams/index.js'
@@ -62,7 +62,6 @@ export type UploadProps = {
6262
export const Upload: React.FC<UploadProps> = (props) => {
6363
const { collectionSlug, initialState, onChange, updatedAt, uploadConfig } = props
6464

65-
const submitted = useFormSubmitted()
6665
const [replacingFile, setReplacingFile] = useState(false)
6766
const [fileSrc, setFileSrc] = useState<null | string>(null)
6867
const { t } = useTranslation()
@@ -106,17 +105,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
106105
x: crop.x || 0,
107106
y: crop.y || 0,
108107
})
109-
const zoomScale = 100 / Math.min(crop.width, crop.height)
110108

111-
document.documentElement.style.setProperty('--file-details-thumbnail--zoom', `${zoomScale}`)
112-
document.documentElement.style.setProperty(
113-
'--file-details-thumbnail--top-offset',
114-
`${zoomScale * (50 - crop.height / 2 - crop.y)}%`,
115-
)
116-
document.documentElement.style.setProperty(
117-
'--file-details-thumbnail--left-offset',
118-
`${zoomScale * (50 - crop.width / 2 - crop.x)}%`,
119-
)
120109
setModified(true)
121110
dispatchFormQueryParams({
122111
type: 'SET',
@@ -171,8 +160,6 @@ export const Upload: React.FC<UploadProps> = (props) => {
171160

172161
const showFocalPoint = focalPoint && (hasImageSizes || hasResizeOptions || focalPointEnabled)
173162

174-
const lastSubmittedTime = submitted ? new Date().toISOString() : null
175-
176163
return (
177164
<div className={[fieldBaseClass, baseClass].filter(Boolean).join(' ')}>
178165
<FieldError message={errorMessage} showError={showError} />
@@ -183,7 +170,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
183170
doc={doc}
184171
handleRemove={canRemoveUpload ? handleFileRemoval : undefined}
185172
hasImageSizes={hasImageSizes}
186-
imageCacheTag={lastSubmittedTime}
173+
imageCacheTag={doc.updatedAt}
187174
uploadConfig={uploadConfig}
188175
/>
189176
)}
@@ -238,7 +225,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
238225
<EditUpload
239226
fileName={value?.name || doc?.filename}
240227
fileSrc={fileSrc || doc?.url}
241-
imageCacheTag={lastSubmittedTime}
228+
imageCacheTag={doc.updatedAt}
242229
initialCrop={formQueryParams?.uploadEdits?.crop ?? {}}
243230
initialFocalPoint={{
244231
x: formQueryParams?.uploadEdits?.focalPoint.x || doc.focalX || 50,
@@ -257,7 +244,7 @@ export const Upload: React.FC<UploadProps> = (props) => {
257244
slug={sizePreviewSlug}
258245
title={t('upload:sizesFor', { label: doc?.filename })}
259246
>
260-
<PreviewSizes doc={doc} uploadConfig={uploadConfig} />
247+
<PreviewSizes doc={doc} imageCacheTag={doc.updatedAt} uploadConfig={uploadConfig} />
261248
</Drawer>
262249
)}
263250
</div>

test/fields/collections/Upload/e2e.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe('Upload', () => {
9191
await uploadImage()
9292
await expect(page.locator('.file-field .file-details img')).toHaveAttribute(
9393
'src',
94-
'/api/uploads/file/payload-1.jpg',
94+
/\/api\/uploads\/file\/payload-1\.jpg(\?.*)?$/,
9595
)
9696
})
9797

test/uploads/animated.webp

109 KB
Binary file not shown.

test/uploads/config.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AdminThumbnailSize } from './collections/AdminThumbnailSize/index.js'
1010
import { Uploads1 } from './collections/Upload1/index.js'
1111
import { Uploads2 } from './collections/Upload2/index.js'
1212
import {
13+
animatedTypeMedia,
1314
audioSlug,
1415
enlargeSlug,
1516
focalNoSizesSlug,
@@ -290,6 +291,42 @@ export default buildConfigWithDefaults({
290291
],
291292
},
292293
},
294+
{
295+
slug: animatedTypeMedia,
296+
fields: [],
297+
upload: {
298+
staticDir: path.resolve(dirname, './media'),
299+
resizeOptions: {
300+
position: 'center',
301+
width: 200,
302+
height: 200,
303+
},
304+
imageSizes: [
305+
{
306+
name: 'squareSmall',
307+
width: 480,
308+
height: 480,
309+
position: 'centre',
310+
withoutEnlargement: false,
311+
},
312+
{
313+
name: 'undefinedHeight',
314+
width: 300,
315+
height: undefined,
316+
},
317+
{
318+
name: 'undefinedWidth',
319+
width: undefined,
320+
height: 300,
321+
},
322+
{
323+
name: 'undefinedAll',
324+
width: undefined,
325+
height: undefined,
326+
},
327+
],
328+
},
329+
},
293330
{
294331
slug: enlargeSlug,
295332
fields: [],
@@ -501,6 +538,43 @@ export default buildConfigWithDefaults({
501538
},
502539
})
503540

541+
// Create animated type images
542+
const animatedImageFilePath = path.resolve(dirname, './animated.webp')
543+
const animatedImageFile = await getFileByPath(animatedImageFilePath)
544+
545+
await payload.create({
546+
collection: animatedTypeMedia,
547+
data: {},
548+
file: animatedImageFile,
549+
})
550+
551+
await payload.create({
552+
collection: versionSlug,
553+
data: {
554+
_status: 'published',
555+
title: 'upload',
556+
},
557+
file: animatedImageFile,
558+
})
559+
560+
const nonAnimatedImageFilePath = path.resolve(dirname, './non-animated.webp')
561+
const nonAnimatedImageFile = await getFileByPath(nonAnimatedImageFilePath)
562+
563+
await payload.create({
564+
collection: animatedTypeMedia,
565+
data: {},
566+
file: nonAnimatedImageFile,
567+
})
568+
569+
await payload.create({
570+
collection: versionSlug,
571+
data: {
572+
_status: 'published',
573+
title: 'upload',
574+
},
575+
file: nonAnimatedImageFile,
576+
})
577+
504578
// Create audio
505579
const audioFilePath = path.resolve(dirname, './audio.mp3')
506580
const audioFile = await getFileByPath(audioFilePath)

0 commit comments

Comments
 (0)