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) | | **`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 | | **`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) | | **`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 ### Payload-wide Upload Options

View File

@@ -58,6 +58,7 @@ export const createClientCollectionConfig = ({
delete sanitized.upload.handlers delete sanitized.upload.handlers
delete sanitized.upload.adminThumbnail delete sanitized.upload.adminThumbnail
delete sanitized.upload.externalFileHeaderFilter delete sanitized.upload.externalFileHeaderFilter
delete sanitized.upload.withMetadata
} }
if ('auth' in sanitized && typeof sanitized.auth === 'object') { 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 { SanitizedConfig } from '../config/types.js'
import type { PayloadRequest } from '../types/index.js' import type { PayloadRequest } from '../types/index.js'
import type { WithMetadata } from './optionallyAppendMetadata.js'
import type { UploadEdits } from './types.js' import type { UploadEdits } from './types.js'
import { optionallyAppendMetadata } from './optionallyAppendMetadata.js'
export const percentToPixel = (value, dimension) => { export const percentToPixel = (value, dimension) => {
return Math.floor((parseFloat(value) / 100) * dimension) return Math.floor((parseFloat(value) / 100) * dimension)
} }
@@ -13,16 +16,20 @@ type CropImageArgs = {
dimensions: { height: number; width: number } dimensions: { height: number; width: number }
file: PayloadRequest['file'] file: PayloadRequest['file']
heightInPixels: number heightInPixels: number
req?: PayloadRequest
sharp: SanitizedConfig['sharp'] sharp: SanitizedConfig['sharp']
widthInPixels: number widthInPixels: number
withMetadata?: WithMetadata
} }
export async function cropImage({ export async function cropImage({
cropData, cropData,
dimensions, dimensions,
file, file,
heightInPixels, heightInPixels,
req,
sharp, sharp,
widthInPixels, widthInPixels,
withMetadata,
}: CropImageArgs) { }: CropImageArgs) {
try { try {
const { x, y } = cropData const { x, y } = cropData
@@ -40,7 +47,13 @@ export async function cropImage({
width: Number(widthInPixels), 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({ return await cropped.toBuffer({
resolveWithObject: true, resolveWithObject: true,

View File

@@ -19,6 +19,7 @@ import { getImageSize } from './getImageSize.js'
import { getSafeFileName } from './getSafeFilename.js' import { getSafeFileName } from './getSafeFilename.js'
import { resizeAndTransformImageSizes } from './imageResizer.js' import { resizeAndTransformImageSizes } from './imageResizer.js'
import { isImage } from './isImage.js' import { isImage } from './isImage.js'
import { optionallyAppendMetadata } from './optionallyAppendMetadata.js'
type Args<T> = { type Args<T> = {
collection: Collection collection: Collection
@@ -71,6 +72,7 @@ export const generateFileData = async <T>({
resizeOptions, resizeOptions,
staticDir, staticDir,
trimOptions, trimOptions,
withMetadata,
} = collectionConfig.upload } = collectionConfig.upload
const staticPath = staticDir const staticPath = staticDir
@@ -161,6 +163,11 @@ export const generateFileData = async <T>({
if (sharpFile) { if (sharpFile) {
const metadata = await sharpFile.metadata() const metadata = await sharpFile.metadata()
sharpFile = await optionallyAppendMetadata({
req,
sharpFile,
withMetadata,
})
fileBuffer = await sharpFile.toBuffer({ resolveWithObject: true }) fileBuffer = await sharpFile.toBuffer({ resolveWithObject: true })
;({ ext, mime } = await fileTypeFromBuffer(fileBuffer.data)) // This is getting an incorrect gif height back. ;({ ext, mime } = await fileTypeFromBuffer(fileBuffer.data)) // This is getting an incorrect gif height back.
fileData.width = fileBuffer.info.width fileData.width = fileBuffer.info.width
@@ -208,8 +215,10 @@ export const generateFileData = async <T>({
dimensions, dimensions,
file, file,
heightInPixels: uploadEdits.heightInPixels, heightInPixels: uploadEdits.heightInPixels,
req,
sharp, sharp,
widthInPixels: uploadEdits.widthInPixels, widthInPixels: uploadEdits.widthInPixels,
withMetadata,
}) })
filesToSave.push({ filesToSave.push({
@@ -274,6 +283,7 @@ export const generateFileData = async <T>({
sharp, sharp,
staticPath, staticPath,
uploadEdits, uploadEdits,
withMetadata,
}) })
fileData.sizes = sizeData fileData.sizes = sizeData

View File

@@ -7,6 +7,7 @@ import sanitize from 'sanitize-filename'
import type { SanitizedCollectionConfig } from '../collections/config/types.js' import type { SanitizedCollectionConfig } from '../collections/config/types.js'
import type { SharpDependency } from '../config/types.js' import type { SharpDependency } from '../config/types.js'
import type { PayloadRequest } from '../types/index.js' import type { PayloadRequest } from '../types/index.js'
import type { WithMetadata } from './optionallyAppendMetadata.js'
import type { import type {
FileSize, FileSize,
FileSizes, FileSizes,
@@ -18,6 +19,7 @@ import type {
import { isNumber } from '../utilities/isNumber.js' import { isNumber } from '../utilities/isNumber.js'
import fileExists from './fileExists.js' import fileExists from './fileExists.js'
import { optionallyAppendMetadata } from './optionallyAppendMetadata.js'
type ResizeArgs = { type ResizeArgs = {
config: SanitizedCollectionConfig config: SanitizedCollectionConfig
@@ -29,6 +31,7 @@ type ResizeArgs = {
sharp?: SharpDependency sharp?: SharpDependency
staticPath: string staticPath: string
uploadEdits?: UploadEdits uploadEdits?: UploadEdits
withMetadata?: WithMetadata
} }
/** Result from resizing and transforming the requested image sizes */ /** Result from resizing and transforming the requested image sizes */
@@ -245,6 +248,7 @@ export async function resizeAndTransformImageSizes({
sharp, sharp,
staticPath, staticPath,
uploadEdits, uploadEdits,
withMetadata,
}: ResizeArgs): Promise<ImageSizesResult> { }: ResizeArgs): Promise<ImageSizesResult> {
const { focalPoint: focalPointEnabled = true, imageSizes } = config.upload const { focalPoint: focalPointEnabled = true, imageSizes } = config.upload
@@ -320,8 +324,15 @@ export async function resizeAndTransformImageSizes({
width: prioritizeHeight ? undefined : resizeWidth, width: prioritizeHeight ? undefined : resizeWidth,
}) })
// must read from buffer, resize.metadata will return the original image metadata const metadataAppendedFile = await optionallyAppendMetadata({
const { info } = await resized.toBuffer({ resolveWithObject: true }) 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({ resizeImageMeta.height = extractHeightFromImage({
...originalImageMeta, ...originalImageMeta,
height: info.height, height: info.height,
@@ -379,7 +390,13 @@ export async function resizeAndTransformImageSizes({
resized = resized.trim(imageResizeConfig.trimOptions) 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, 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 { TypeWithID } from '../collections/config/types.js'
import type { PayloadRequest } from '../types/index.js' import type { PayloadRequest } from '../types/index.js'
import type { WithMetadata } from './optionallyAppendMetadata.js'
export type FileSize = { export type FileSize = {
filename: null | string filename: null | string
@@ -153,6 +154,17 @@ export type UploadConfig = {
*/ */
staticDir?: string staticDir?: string
trimOptions?: ImageUploadTrimOptions 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 = { export type SanitizedUploadConfig = {

View File

@@ -133,6 +133,60 @@ export default buildConfigWithDefaults({
staticDir: path.resolve(dirname, './object-fit'), 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', slug: 'crop-only',
fields: [], fields: [],

View File

@@ -1,5 +1,4 @@
import type { Page } from '@playwright/test' import type { Page } from '@playwright/test'
import type { Payload } from 'payload'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'path' import path from 'path'
@@ -27,6 +26,9 @@ import {
focalOnlySlug, focalOnlySlug,
mediaSlug, mediaSlug,
relationSlug, relationSlug,
withMetadataSlug,
withOnlyJPEGMetadataSlug,
withoutMetadataSlug,
} from './shared.js' } from './shared.js'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@@ -43,6 +45,9 @@ let relationURL: AdminUrlUtil
let adminThumbnailSizeURL: AdminUrlUtil let adminThumbnailSizeURL: AdminUrlUtil
let adminThumbnailFunctionURL: AdminUrlUtil let adminThumbnailFunctionURL: AdminUrlUtil
let focalOnlyURL: AdminUrlUtil let focalOnlyURL: AdminUrlUtil
let withMetadataURL: AdminUrlUtil
let withoutMetadataURL: AdminUrlUtil
let withOnlyJPEGMetadataURL: AdminUrlUtil
describe('uploads', () => { describe('uploads', () => {
let page: Page let page: Page
@@ -62,6 +67,9 @@ describe('uploads', () => {
adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug) adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug)
adminThumbnailFunctionURL = new AdminUrlUtil(serverURL, adminThumbnailFunctionSlug) adminThumbnailFunctionURL = new AdminUrlUtil(serverURL, adminThumbnailFunctionSlug)
focalOnlyURL = new AdminUrlUtil(serverURL, focalOnlySlug) 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() const context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
@@ -346,6 +354,116 @@ describe('uploads', () => {
expect(uploadedImage.mimeType).toEqual('image/png') 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', () => { describe('image manipulation', () => {
test('should crop image correctly', async () => { test('should crop image correctly', async () => {
const positions = { const positions = {

View File

@@ -12,3 +12,6 @@ export const unstoredMediaSlug = 'unstored-media'
export const versionSlug = 'versions' export const versionSlug = 'versions'
export const animatedTypeMedia = 'animated-type-media' export const animatedTypeMedia = 'animated-type-media'
export const customUploadFieldSlug = 'custom-upload-field' 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'