feat(payload): allows metadata to be appended to the file of the output media (#7293)
## Description Fixes #6951 `Feat`: Adds new prop `withMetadata` to `uploads` config that allows the user to allow media metadata to be appended to the file of the output media. - [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) - [x] New feature (non-breaking change which adds functionality) - [x] This change requires a documentation update ## 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 - [x] I have made corresponding changes to the documentation
This commit is contained in:
@@ -104,6 +104,7 @@ _An asterisk denotes that an option is required._
|
||||
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
|
||||
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
|
||||
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
|
||||
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
|
||||
|
||||
|
||||
### Payload-wide Upload Options
|
||||
|
||||
@@ -58,6 +58,7 @@ export const createClientCollectionConfig = ({
|
||||
delete sanitized.upload.handlers
|
||||
delete sanitized.upload.adminThumbnail
|
||||
delete sanitized.upload.externalFileHeaderFilter
|
||||
delete sanitized.upload.withMetadata
|
||||
}
|
||||
|
||||
if ('auth' in sanitized && typeof sanitized.auth === 'object') {
|
||||
|
||||
@@ -2,8 +2,11 @@ import type { SharpOptions } from 'sharp'
|
||||
|
||||
import type { SanitizedConfig } from '../config/types.js'
|
||||
import type { PayloadRequest } from '../types/index.js'
|
||||
import type { WithMetadata } from './optionallyAppendMetadata.js'
|
||||
import type { UploadEdits } from './types.js'
|
||||
|
||||
import { optionallyAppendMetadata } from './optionallyAppendMetadata.js'
|
||||
|
||||
export const percentToPixel = (value, dimension) => {
|
||||
return Math.floor((parseFloat(value) / 100) * dimension)
|
||||
}
|
||||
@@ -13,16 +16,20 @@ type CropImageArgs = {
|
||||
dimensions: { height: number; width: number }
|
||||
file: PayloadRequest['file']
|
||||
heightInPixels: number
|
||||
req?: PayloadRequest
|
||||
sharp: SanitizedConfig['sharp']
|
||||
widthInPixels: number
|
||||
withMetadata?: WithMetadata
|
||||
}
|
||||
export async function cropImage({
|
||||
cropData,
|
||||
dimensions,
|
||||
file,
|
||||
heightInPixels,
|
||||
req,
|
||||
sharp,
|
||||
widthInPixels,
|
||||
withMetadata,
|
||||
}: CropImageArgs) {
|
||||
try {
|
||||
const { x, y } = cropData
|
||||
@@ -40,7 +47,13 @@ export async function cropImage({
|
||||
width: Number(widthInPixels),
|
||||
}
|
||||
|
||||
const cropped = sharp(file.tempFilePath || file.data, sharpOptions).extract(formattedCropData)
|
||||
let cropped = sharp(file.tempFilePath || file.data, sharpOptions).extract(formattedCropData)
|
||||
|
||||
cropped = await optionallyAppendMetadata({
|
||||
req,
|
||||
sharpFile: cropped,
|
||||
withMetadata,
|
||||
})
|
||||
|
||||
return await cropped.toBuffer({
|
||||
resolveWithObject: true,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getImageSize } from './getImageSize.js'
|
||||
import { getSafeFileName } from './getSafeFilename.js'
|
||||
import { resizeAndTransformImageSizes } from './imageResizer.js'
|
||||
import { isImage } from './isImage.js'
|
||||
import { optionallyAppendMetadata } from './optionallyAppendMetadata.js'
|
||||
|
||||
type Args<T> = {
|
||||
collection: Collection
|
||||
@@ -71,6 +72,7 @@ export const generateFileData = async <T>({
|
||||
resizeOptions,
|
||||
staticDir,
|
||||
trimOptions,
|
||||
withMetadata,
|
||||
} = collectionConfig.upload
|
||||
|
||||
const staticPath = staticDir
|
||||
@@ -161,6 +163,11 @@ export const generateFileData = async <T>({
|
||||
|
||||
if (sharpFile) {
|
||||
const metadata = await sharpFile.metadata()
|
||||
sharpFile = await optionallyAppendMetadata({
|
||||
req,
|
||||
sharpFile,
|
||||
withMetadata,
|
||||
})
|
||||
fileBuffer = await sharpFile.toBuffer({ resolveWithObject: true })
|
||||
;({ ext, mime } = await fileTypeFromBuffer(fileBuffer.data)) // This is getting an incorrect gif height back.
|
||||
fileData.width = fileBuffer.info.width
|
||||
@@ -208,8 +215,10 @@ export const generateFileData = async <T>({
|
||||
dimensions,
|
||||
file,
|
||||
heightInPixels: uploadEdits.heightInPixels,
|
||||
req,
|
||||
sharp,
|
||||
widthInPixels: uploadEdits.widthInPixels,
|
||||
withMetadata,
|
||||
})
|
||||
|
||||
filesToSave.push({
|
||||
@@ -274,6 +283,7 @@ export const generateFileData = async <T>({
|
||||
sharp,
|
||||
staticPath,
|
||||
uploadEdits,
|
||||
withMetadata,
|
||||
})
|
||||
|
||||
fileData.sizes = sizeData
|
||||
|
||||
@@ -7,6 +7,7 @@ import sanitize from 'sanitize-filename'
|
||||
import type { SanitizedCollectionConfig } from '../collections/config/types.js'
|
||||
import type { SharpDependency } from '../config/types.js'
|
||||
import type { PayloadRequest } from '../types/index.js'
|
||||
import type { WithMetadata } from './optionallyAppendMetadata.js'
|
||||
import type {
|
||||
FileSize,
|
||||
FileSizes,
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
|
||||
import { isNumber } from '../utilities/isNumber.js'
|
||||
import fileExists from './fileExists.js'
|
||||
import { optionallyAppendMetadata } from './optionallyAppendMetadata.js'
|
||||
|
||||
type ResizeArgs = {
|
||||
config: SanitizedCollectionConfig
|
||||
@@ -29,6 +31,7 @@ type ResizeArgs = {
|
||||
sharp?: SharpDependency
|
||||
staticPath: string
|
||||
uploadEdits?: UploadEdits
|
||||
withMetadata?: WithMetadata
|
||||
}
|
||||
|
||||
/** Result from resizing and transforming the requested image sizes */
|
||||
@@ -245,6 +248,7 @@ export async function resizeAndTransformImageSizes({
|
||||
sharp,
|
||||
staticPath,
|
||||
uploadEdits,
|
||||
withMetadata,
|
||||
}: ResizeArgs): Promise<ImageSizesResult> {
|
||||
const { focalPoint: focalPointEnabled = true, imageSizes } = config.upload
|
||||
|
||||
@@ -320,8 +324,15 @@ export async function resizeAndTransformImageSizes({
|
||||
width: prioritizeHeight ? undefined : resizeWidth,
|
||||
})
|
||||
|
||||
// must read from buffer, resize.metadata will return the original image metadata
|
||||
const { info } = await resized.toBuffer({ resolveWithObject: true })
|
||||
const metadataAppendedFile = await optionallyAppendMetadata({
|
||||
req,
|
||||
sharpFile: resized,
|
||||
withMetadata,
|
||||
})
|
||||
|
||||
// Must read from buffer, resized.metadata will return the original image metadata
|
||||
const { info } = await metadataAppendedFile.toBuffer({ resolveWithObject: true })
|
||||
|
||||
resizeImageMeta.height = extractHeightFromImage({
|
||||
...originalImageMeta,
|
||||
height: info.height,
|
||||
@@ -379,7 +390,13 @@ export async function resizeAndTransformImageSizes({
|
||||
resized = resized.trim(imageResizeConfig.trimOptions)
|
||||
}
|
||||
|
||||
const { data: bufferData, info: bufferInfo } = await resized.toBuffer({
|
||||
const metadataAppendedFile = await optionallyAppendMetadata({
|
||||
req,
|
||||
sharpFile: resized,
|
||||
withMetadata,
|
||||
})
|
||||
|
||||
const { data: bufferData, info: bufferInfo } = await metadataAppendedFile.toBuffer({
|
||||
resolveWithObject: true,
|
||||
})
|
||||
|
||||
|
||||
29
packages/payload/src/uploads/optionallyAppendMetadata.ts
Normal file
29
packages/payload/src/uploads/optionallyAppendMetadata.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Sharp, Metadata as SharpMetadata } from 'sharp'
|
||||
|
||||
import type { PayloadRequest } from '../types/index.js'
|
||||
|
||||
export type WithMetadata =
|
||||
| ((options: { metadata: SharpMetadata; req: PayloadRequest }) => Promise<boolean>)
|
||||
| boolean
|
||||
|
||||
export async function optionallyAppendMetadata({
|
||||
req,
|
||||
sharpFile,
|
||||
withMetadata,
|
||||
}: {
|
||||
req: PayloadRequest
|
||||
sharpFile: Sharp
|
||||
withMetadata: WithMetadata
|
||||
}): Promise<Sharp> {
|
||||
const metadata = await sharpFile.metadata()
|
||||
|
||||
if (withMetadata === true) {
|
||||
return sharpFile.withMetadata()
|
||||
} else if (typeof withMetadata === 'function') {
|
||||
const useMetadata = await withMetadata({ metadata, req })
|
||||
|
||||
if (useMetadata) return sharpFile.withMetadata()
|
||||
}
|
||||
|
||||
return sharpFile
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ResizeOptions, Sharp } from 'sharp'
|
||||
import type { ResizeOptions, Sharp, Metadata as SharpMetadata } from 'sharp'
|
||||
|
||||
import type { TypeWithID } from '../collections/config/types.js'
|
||||
import type { PayloadRequest } from '../types/index.js'
|
||||
import type { WithMetadata } from './optionallyAppendMetadata.js'
|
||||
|
||||
export type FileSize = {
|
||||
filename: null | string
|
||||
@@ -153,6 +154,17 @@ export type UploadConfig = {
|
||||
*/
|
||||
staticDir?: string
|
||||
trimOptions?: ImageUploadTrimOptions
|
||||
/**
|
||||
* Optionally append metadata to the image during processing.
|
||||
*
|
||||
* Can be a boolean or a function.
|
||||
*
|
||||
* If true, metadata will be appended to the image.
|
||||
* If false, no metadata will be appended.
|
||||
* If a function, it will receive an object containing the metadata and should return a boolean indicating whether to append the metadata.
|
||||
* @default false
|
||||
*/
|
||||
withMetadata?: WithMetadata
|
||||
}
|
||||
|
||||
export type SanitizedUploadConfig = {
|
||||
|
||||
@@ -133,6 +133,60 @@ export default buildConfigWithDefaults({
|
||||
staticDir: path.resolve(dirname, './object-fit'),
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'with-meta-data',
|
||||
fields: [],
|
||||
upload: {
|
||||
imageSizes: [
|
||||
{
|
||||
name: 'sizeOne',
|
||||
height: 300,
|
||||
width: 400,
|
||||
},
|
||||
],
|
||||
mimeTypes: ['image/png', 'image/jpg', 'image/jpeg'],
|
||||
staticDir: path.resolve(dirname, './with-meta-data'),
|
||||
withMetadata: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'without-meta-data',
|
||||
fields: [],
|
||||
upload: {
|
||||
imageSizes: [
|
||||
{
|
||||
name: 'sizeTwo',
|
||||
height: 400,
|
||||
width: 300,
|
||||
},
|
||||
],
|
||||
mimeTypes: ['image/png', 'image/jpg', 'image/jpeg'],
|
||||
staticDir: path.resolve(dirname, './without-meta-data'),
|
||||
withMetadata: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'with-only-jpeg-meta-data',
|
||||
fields: [],
|
||||
upload: {
|
||||
imageSizes: [
|
||||
{
|
||||
name: 'sizeThree',
|
||||
height: 400,
|
||||
width: 300,
|
||||
withoutEnlargement: false,
|
||||
},
|
||||
],
|
||||
staticDir: path.resolve(dirname, './with-only-jpeg-meta-data'),
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
withMetadata: async ({ metadata }) => {
|
||||
if (metadata.format === 'jpeg') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'crop-only',
|
||||
fields: [],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
import path from 'path'
|
||||
@@ -27,6 +26,9 @@ import {
|
||||
focalOnlySlug,
|
||||
mediaSlug,
|
||||
relationSlug,
|
||||
withMetadataSlug,
|
||||
withOnlyJPEGMetadataSlug,
|
||||
withoutMetadataSlug,
|
||||
} from './shared.js'
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
@@ -43,6 +45,9 @@ let relationURL: AdminUrlUtil
|
||||
let adminThumbnailSizeURL: AdminUrlUtil
|
||||
let adminThumbnailFunctionURL: AdminUrlUtil
|
||||
let focalOnlyURL: AdminUrlUtil
|
||||
let withMetadataURL: AdminUrlUtil
|
||||
let withoutMetadataURL: AdminUrlUtil
|
||||
let withOnlyJPEGMetadataURL: AdminUrlUtil
|
||||
|
||||
describe('uploads', () => {
|
||||
let page: Page
|
||||
@@ -62,6 +67,9 @@ describe('uploads', () => {
|
||||
adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug)
|
||||
adminThumbnailFunctionURL = new AdminUrlUtil(serverURL, adminThumbnailFunctionSlug)
|
||||
focalOnlyURL = new AdminUrlUtil(serverURL, focalOnlySlug)
|
||||
withMetadataURL = new AdminUrlUtil(serverURL, withMetadataSlug)
|
||||
withoutMetadataURL = new AdminUrlUtil(serverURL, withoutMetadataSlug)
|
||||
withOnlyJPEGMetadataURL = new AdminUrlUtil(serverURL, withOnlyJPEGMetadataSlug)
|
||||
|
||||
const context = await browser.newContext()
|
||||
page = await context.newPage()
|
||||
@@ -346,6 +354,116 @@ describe('uploads', () => {
|
||||
expect(uploadedImage.mimeType).toEqual('image/png')
|
||||
})
|
||||
|
||||
test('should upload image with metadata', async () => {
|
||||
await page.goto(withMetadataURL.create)
|
||||
await page.waitForURL(withMetadataURL.create)
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page.getByText('Select a file').click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await wait(1000)
|
||||
await fileChooser.setFiles(path.join(dirname, 'test-image.jpg'))
|
||||
|
||||
await page.waitForSelector('button#action-save')
|
||||
await page.locator('button#action-save').click()
|
||||
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
|
||||
await wait(1000)
|
||||
|
||||
const mediaID = page.url().split('/').pop()
|
||||
|
||||
const { doc: mediaDoc } = await client.findByID({
|
||||
id: mediaID,
|
||||
slug: withMetadataSlug,
|
||||
auth: true,
|
||||
})
|
||||
|
||||
const acceptableFileSizes = [9431, 9435]
|
||||
|
||||
expect(acceptableFileSizes).toContain(mediaDoc.sizes.sizeOne.filesize)
|
||||
})
|
||||
|
||||
test('should upload image without metadata', async () => {
|
||||
await page.goto(withoutMetadataURL.create)
|
||||
await page.waitForURL(withoutMetadataURL.create)
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page.getByText('Select a file').click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await wait(1000)
|
||||
await fileChooser.setFiles(path.join(dirname, 'test-image.jpg'))
|
||||
|
||||
await page.waitForSelector('button#action-save')
|
||||
await page.locator('button#action-save').click()
|
||||
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
|
||||
await wait(1000)
|
||||
|
||||
const mediaID = page.url().split('/').pop()
|
||||
|
||||
const { doc: mediaDoc } = await client.findByID({
|
||||
id: mediaID,
|
||||
slug: withoutMetadataSlug,
|
||||
auth: true,
|
||||
})
|
||||
|
||||
const acceptableFileSizes = [2424, 2445]
|
||||
|
||||
expect(acceptableFileSizes).toContain(mediaDoc.sizes.sizeTwo.filesize)
|
||||
})
|
||||
|
||||
test('should only upload image with metadata if jpeg mimetype', async () => {
|
||||
await page.goto(withOnlyJPEGMetadataURL.create)
|
||||
await page.waitForURL(withOnlyJPEGMetadataURL.create)
|
||||
|
||||
const fileChooserPromiseForJPEG = page.waitForEvent('filechooser')
|
||||
await page.getByText('Select a file').click()
|
||||
const fileChooserForJPEG = await fileChooserPromiseForJPEG
|
||||
await wait(1000)
|
||||
await fileChooserForJPEG.setFiles(path.join(dirname, 'test-image.jpg'))
|
||||
|
||||
await page.waitForSelector('button#action-save')
|
||||
await page.locator('button#action-save').click()
|
||||
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
|
||||
await wait(1000)
|
||||
|
||||
const jpegMediaID = page.url().split('/').pop()
|
||||
|
||||
const { doc: jpegMediaDoc } = await client.findByID({
|
||||
id: jpegMediaID,
|
||||
slug: withOnlyJPEGMetadataSlug,
|
||||
auth: true,
|
||||
})
|
||||
|
||||
const acceptableFileSizesForJPEG = [9554, 9575]
|
||||
|
||||
// without metadata appended, the jpeg image filesize would be 2424
|
||||
expect(acceptableFileSizesForJPEG).toContain(jpegMediaDoc.sizes.sizeThree.filesize)
|
||||
|
||||
await page.goto(withOnlyJPEGMetadataURL.create)
|
||||
await page.waitForURL(withOnlyJPEGMetadataURL.create)
|
||||
|
||||
const fileChooserPromiseForWEBP = page.waitForEvent('filechooser')
|
||||
await page.getByText('Select a file').click()
|
||||
const fileChooserForWEBP = await fileChooserPromiseForWEBP
|
||||
await wait(1000)
|
||||
await fileChooserForWEBP.setFiles(path.join(dirname, 'animated.webp'))
|
||||
|
||||
await page.waitForSelector('button#action-save')
|
||||
await page.locator('button#action-save').click()
|
||||
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
|
||||
await wait(1000)
|
||||
|
||||
const webpMediaID = page.url().split('/').pop()
|
||||
|
||||
const { doc: webpMediaDoc } = await client.findByID({
|
||||
id: webpMediaID,
|
||||
slug: withOnlyJPEGMetadataSlug,
|
||||
auth: true,
|
||||
})
|
||||
|
||||
// With metadata, the animated image filesize would be 218762
|
||||
expect(webpMediaDoc.sizes.sizeThree.filesize).toEqual(211638)
|
||||
})
|
||||
|
||||
describe('image manipulation', () => {
|
||||
test('should crop image correctly', async () => {
|
||||
const positions = {
|
||||
|
||||
@@ -12,3 +12,6 @@ export const unstoredMediaSlug = 'unstored-media'
|
||||
export const versionSlug = 'versions'
|
||||
export const animatedTypeMedia = 'animated-type-media'
|
||||
export const customUploadFieldSlug = 'custom-upload-field'
|
||||
export const withMetadataSlug = 'with-meta-data'
|
||||
export const withoutMetadataSlug = 'without-meta-data'
|
||||
export const withOnlyJPEGMetadataSlug = 'with-only-jpeg-meta-data'
|
||||
|
||||
Reference in New Issue
Block a user