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:
Patrik
2024-07-24 15:32:39 -04:00
committed by GitHub
parent 0627272d6c
commit b5afc62e14
10 changed files with 264 additions and 6 deletions

View File

@@ -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

View File

@@ -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') {

View File

@@ -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,

View File

@@ -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

View File

@@ -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,
})

View 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
}

View File

@@ -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 = {

View File

@@ -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: [],

View File

@@ -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 = {

View File

@@ -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'