Store focal point data on uploads as `focalX` and `focalY` Addresses https://github.com/payloadcms/payload/discussions/4082 Mirrors #6364 for beta branch.
334 lines
9.3 KiB
TypeScript
334 lines
9.3 KiB
TypeScript
import type { OutputInfo, Sharp, SharpOptions } from 'sharp'
|
|
|
|
import fileType from 'file-type'
|
|
const { fromBuffer } = fileType
|
|
import fs from 'fs'
|
|
import mkdirp from 'mkdirp'
|
|
import sanitize from 'sanitize-filename'
|
|
|
|
import type { Collection } from '../collections/config/types.js'
|
|
import type { SanitizedConfig } from '../config/types.js'
|
|
import type { PayloadRequestWithData } from '../types/index.js'
|
|
import type { FileData, FileToSave, ProbedImageSize, UploadEdits } from './types.js'
|
|
|
|
import { FileRetrievalError, FileUploadError, MissingFile } from '../errors/index.js'
|
|
import { canResizeImage } from './canResizeImage.js'
|
|
import { cropImage } from './cropImage.js'
|
|
import { getExternalFile } from './getExternalFile.js'
|
|
import { getFileByPath } from './getFileByPath.js'
|
|
import { getImageSize } from './getImageSize.js'
|
|
import { getSafeFileName } from './getSafeFilename.js'
|
|
import { resizeAndTransformImageSizes } from './imageResizer.js'
|
|
import { isImage } from './isImage.js'
|
|
|
|
type Args<T> = {
|
|
collection: Collection
|
|
config: SanitizedConfig
|
|
data: T
|
|
operation: 'create' | 'update'
|
|
originalDoc?: T
|
|
overwriteExistingFiles?: boolean
|
|
req: PayloadRequestWithData
|
|
throwOnMissingFile?: boolean
|
|
}
|
|
|
|
type Result<T> = Promise<{
|
|
data: T
|
|
files: FileToSave[]
|
|
}>
|
|
|
|
export const generateFileData = async <T>({
|
|
collection: { config: collectionConfig },
|
|
data,
|
|
operation,
|
|
originalDoc,
|
|
overwriteExistingFiles,
|
|
req,
|
|
throwOnMissingFile,
|
|
}: Args<T>): Result<T> => {
|
|
if (!collectionConfig.upload) {
|
|
return {
|
|
data,
|
|
files: [],
|
|
}
|
|
}
|
|
|
|
const { sharp } = req.payload.config
|
|
|
|
let file = req.file
|
|
|
|
const uploadEdits = parseUploadEditsFromReqOrIncomingData({
|
|
data,
|
|
operation,
|
|
originalDoc,
|
|
req,
|
|
})
|
|
|
|
const {
|
|
disableLocalStorage,
|
|
focalPoint: focalPointEnabled = true,
|
|
formatOptions,
|
|
imageSizes,
|
|
resizeOptions,
|
|
staticDir,
|
|
trimOptions,
|
|
} = collectionConfig.upload
|
|
|
|
const staticPath = staticDir
|
|
|
|
if (!file && uploadEdits && data) {
|
|
const { filename, url } = data as FileData
|
|
|
|
try {
|
|
if (url && url.startsWith('/') && !disableLocalStorage) {
|
|
const filePath = `${staticPath}/${filename}`
|
|
const response = await getFileByPath(filePath)
|
|
file = response
|
|
overwriteExistingFiles = true
|
|
} else if (filename && url) {
|
|
file = await getExternalFile({
|
|
data: data as FileData,
|
|
req,
|
|
uploadConfig: collectionConfig.upload,
|
|
})
|
|
overwriteExistingFiles = true
|
|
}
|
|
} catch (err: unknown) {
|
|
throw new FileRetrievalError(req.t, err instanceof Error ? err.message : undefined)
|
|
}
|
|
}
|
|
|
|
if (!file) {
|
|
if (throwOnMissingFile) throw new MissingFile(req.t)
|
|
|
|
return {
|
|
data,
|
|
files: [],
|
|
}
|
|
}
|
|
|
|
if (!disableLocalStorage) {
|
|
mkdirp.sync(staticPath)
|
|
}
|
|
|
|
let newData = data
|
|
const filesToSave: FileToSave[] = []
|
|
const fileData: Partial<FileData> = {}
|
|
const fileIsAnimated = file.mimetype === 'image/gif' || file.mimetype === 'image/webp'
|
|
const cropData =
|
|
typeof uploadEdits === 'object' && 'crop' in uploadEdits ? uploadEdits.crop : undefined
|
|
|
|
try {
|
|
const fileSupportsResize = canResizeImage(file.mimetype)
|
|
let fsSafeName: string
|
|
let sharpFile: Sharp | undefined
|
|
let dimensions: ProbedImageSize | undefined
|
|
let fileBuffer: { data: Buffer; info: OutputInfo }
|
|
let ext
|
|
let mime: string
|
|
const fileHasAdjustments =
|
|
fileSupportsResize &&
|
|
Boolean(resizeOptions || formatOptions || trimOptions || file.tempFilePath)
|
|
|
|
const sharpOptions: SharpOptions = {}
|
|
|
|
if (fileIsAnimated) sharpOptions.animated = true
|
|
|
|
if (fileHasAdjustments && sharp) {
|
|
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 {
|
|
sharpFile = sharp(file.data, sharpOptions).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081
|
|
}
|
|
|
|
if (resizeOptions) {
|
|
sharpFile = sharpFile.resize(resizeOptions)
|
|
}
|
|
if (formatOptions) {
|
|
sharpFile = sharpFile.toFormat(formatOptions.format, formatOptions.options)
|
|
}
|
|
if (trimOptions) {
|
|
sharpFile = sharpFile.trim(trimOptions)
|
|
}
|
|
}
|
|
|
|
if (fileSupportsResize || isImage(file.mimetype)) {
|
|
dimensions = await getImageSize(file)
|
|
fileData.width = dimensions.width
|
|
fileData.height = dimensions.height
|
|
}
|
|
|
|
if (sharpFile) {
|
|
const metadata = await sharpFile.metadata()
|
|
fileBuffer = await sharpFile.toBuffer({ resolveWithObject: true })
|
|
;({ ext, mime } = await fromBuffer(fileBuffer.data)) // This is getting an incorrect gif height back.
|
|
fileData.width = fileBuffer.info.width
|
|
fileData.height = fileBuffer.info.height
|
|
fileData.filesize = fileBuffer.info.size
|
|
|
|
// Animated GIFs + WebP aggregate the height from every frame, so we need to use divide by number of pages
|
|
if (metadata.pages) {
|
|
fileData.height = fileBuffer.info.height / metadata.pages
|
|
fileData.filesize = fileBuffer.data.length
|
|
}
|
|
} else {
|
|
mime = file.mimetype
|
|
fileData.filesize = file.size
|
|
|
|
if (file.name.includes('.')) {
|
|
ext = file.name.split('.').pop()
|
|
} else {
|
|
ext = ''
|
|
}
|
|
}
|
|
|
|
// Adust SVG mime type. fromBuffer modifies it.
|
|
if (mime === 'application/xml' && ext === 'svg') mime = 'image/svg+xml'
|
|
fileData.mimeType = mime
|
|
|
|
const baseFilename = sanitize(file.name.substring(0, file.name.lastIndexOf('.')) || file.name)
|
|
fsSafeName = `${baseFilename}${ext ? `.${ext}` : ''}`
|
|
|
|
if (!overwriteExistingFiles) {
|
|
fsSafeName = await getSafeFileName({
|
|
collectionSlug: collectionConfig.slug,
|
|
desiredFilename: fsSafeName,
|
|
req,
|
|
staticPath,
|
|
})
|
|
}
|
|
|
|
fileData.filename = fsSafeName
|
|
let fileForResize = file
|
|
|
|
if (cropData && sharp) {
|
|
const { data: croppedImage, info } = await cropImage({ cropData, dimensions, file, sharp })
|
|
|
|
filesToSave.push({
|
|
buffer: croppedImage,
|
|
path: `${staticPath}/${fsSafeName}`,
|
|
})
|
|
|
|
fileForResize = {
|
|
...file,
|
|
data: croppedImage,
|
|
size: info.size,
|
|
}
|
|
fileData.width = info.width
|
|
fileData.height = info.height
|
|
fileData.filesize = info.size
|
|
|
|
if (file.tempFilePath) {
|
|
await fs.promises.writeFile(file.tempFilePath, croppedImage) // write fileBuffer to the temp path
|
|
} else {
|
|
req.file = fileForResize
|
|
}
|
|
} else {
|
|
filesToSave.push({
|
|
buffer: fileBuffer?.data || file.data,
|
|
path: `${staticPath}/${fsSafeName}`,
|
|
})
|
|
|
|
// If using temp files and the image is being resized, write the file to the temp path
|
|
if (fileBuffer?.data || file.data.length > 0) {
|
|
if (file.tempFilePath) {
|
|
await fs.promises.writeFile(file.tempFilePath, fileBuffer?.data || file.data) // write fileBuffer to the temp path
|
|
} else {
|
|
// Assign the _possibly modified_ file to the request object
|
|
req.file = {
|
|
...file,
|
|
data: fileBuffer?.data || file.data,
|
|
size: fileBuffer?.info.size,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fileSupportsResize && (Array.isArray(imageSizes) || focalPointEnabled !== false)) {
|
|
req.payloadUploadSizes = {}
|
|
const { focalPoint, sizeData, sizesToSave } = await resizeAndTransformImageSizes({
|
|
config: collectionConfig,
|
|
dimensions: !cropData
|
|
? dimensions
|
|
: {
|
|
...dimensions,
|
|
height: fileData.height,
|
|
width: fileData.width,
|
|
},
|
|
file: fileForResize,
|
|
mimeType: fileData.mimeType,
|
|
req,
|
|
savedFilename: fsSafeName || file.name,
|
|
sharp,
|
|
staticPath,
|
|
uploadEdits,
|
|
})
|
|
|
|
fileData.sizes = sizeData
|
|
fileData.focalX = focalPoint?.x
|
|
fileData.focalY = focalPoint?.y
|
|
filesToSave.push(...sizesToSave)
|
|
}
|
|
} catch (err) {
|
|
req.payload.logger.error(err)
|
|
throw new FileUploadError(req.t)
|
|
}
|
|
|
|
newData = {
|
|
...newData,
|
|
...fileData,
|
|
}
|
|
|
|
return {
|
|
data: newData,
|
|
files: filesToSave,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse upload edits from req or incoming data
|
|
*/
|
|
function parseUploadEditsFromReqOrIncomingData(args: {
|
|
data: unknown
|
|
operation: 'create' | 'update'
|
|
originalDoc: unknown
|
|
req: PayloadRequestWithData
|
|
}): UploadEdits {
|
|
const { data, operation, originalDoc, req } = args
|
|
|
|
// Get intended focal point change from query string or incoming data
|
|
const {
|
|
uploadEdits = {},
|
|
}: {
|
|
uploadEdits?: UploadEdits
|
|
} = req.query || {}
|
|
|
|
if (uploadEdits.focalPoint) return uploadEdits
|
|
|
|
const incomingData = data as FileData
|
|
const origDoc = originalDoc as FileData
|
|
|
|
// If no change in focal point, return undefined.
|
|
// This prevents a refocal operation triggered from admin, because it always sends the focal point.
|
|
if (origDoc && incomingData.focalX === origDoc.focalX && incomingData.focalY === origDoc.focalY) {
|
|
return undefined
|
|
}
|
|
|
|
if (incomingData?.focalX && incomingData?.focalY) {
|
|
uploadEdits.focalPoint = {
|
|
x: incomingData.focalX,
|
|
y: incomingData.focalY,
|
|
}
|
|
return uploadEdits
|
|
}
|
|
|
|
// If no focal point is set, default to center
|
|
if (operation === 'create') {
|
|
uploadEdits.focalPoint = {
|
|
x: 50,
|
|
y: 50,
|
|
}
|
|
}
|
|
return uploadEdits
|
|
}
|