Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"build:storage-vercel-blob": "turbo build --filter \"@payloadcms/storage-vercel-blob\"",
"build:tests": "pnpm --filter payload-test-suite run typecheck",
"build:tools": "turbo build --filter=./tools/*",
"build:transformer-sharp": "turbo build --filter \"@payloadcms/transformer-sharp\"",
"build:translations": "turbo build --filter \"@payloadcms/translations\"",
"build:ui": "turbo build --filter \"@payloadcms/ui\"",
"clean": "turbo clean",
Expand Down Expand Up @@ -206,7 +207,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"rimraf": "6.0.1",
"sharp": "0.32.6",
"sharp": "0.35.4",
"shelljs": "0.8.5",
"slash": "3.0.0",
"sort-package-json": "^2.10.0",
Expand Down
3 changes: 1 addition & 2 deletions packages/payload/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,7 @@
"react-datepicker": "7.6.0",
"rimraf": "6.0.1",
"rollup": "4.59.0",
"rollup-plugin-dts": "6.2.3",
"sharp": "0.32.6"
"rollup-plugin-dts": "6.2.3"
},
"peerDependencies": {
"graphql": "^16.8.1"
Expand Down
15 changes: 15 additions & 0 deletions packages/transformer-sharp/.swcrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
}
},
"module": {
"type": "es6"
}
}
105 changes: 105 additions & 0 deletions packages/transformer-sharp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Sharp Transformer for Payload

Payload's official [file transformer](https://payloadcms.com/docs/upload/transformers) for Sharp-based image processing: upload-time resizing/format conversion (the same behavior Payload has always provided) plus opt-in request-time dynamic resizing.

## Installation

```sh
pnpm add @payloadcms/transformer-sharp
```

## Usage

Register `sharpTransformer()` under `upload.transformers` in your Payload Config:

```ts
import { buildConfig } from 'payload'
import { sharpTransformer } from '@payloadcms/transformer-sharp'

export default buildConfig({
upload: {
transformers: [sharpTransformer()],
},
})
```

### Upload-time image processing

Configure per-collection Sharp settings through `sharpTransformer({ collections })`:

```ts
sharpTransformer({
collections: {
media: {
variants: [
{ name: 'thumbnail', width: 400, height: 300 },
{ name: 'card', width: 768, height: 1024 },
],
resizeOptions: { width: 2048, height: 2048, withoutEnlargement: true },
formatOptions: { format: 'webp' },
trimOptions: undefined,
constructorOptions: undefined,
withMetadata: false,
crop: true,
focalPoint: true,
},
},
})
```

`variants`, `crop`, and `focalPoint` are also written back onto the collection's own sanitized `upload` config at startup (name/`admin`/`generateImageName` only, for `variants`), so the Admin Panel, generated types, and the `sizes` shape on your documents behave exactly as before.

### Dynamic (request-time) resizing

Dynamic resizing is **disabled by default** — resize query parameters are ignored and the original file is served. Enable it for every upload collection, or only the ones that need it:

```ts
sharpTransformer({ dynamic: true })
sharpTransformer({ dynamic: { collections: ['media'] } })
```

Once enabled, requesting a stored image with recognized query parameters resizes it on the fly, without storing the result:

```
GET /api/media/file/photo.png?width=400
GET /api/media/file/photo.png?width=400&height=300
GET /api/media/file/photo.png?width=400&withoutEnlargement=true
```

Configure limits and defaults for these requests:

```ts
sharpTransformer({
dynamic: {
collections: ['media'], // omit to allow every upload collection
fit: 'cover', // default
position: 'center', // default
maxWidth: 4096, // default
maxHeight: 4096, // default
maxPixels: 16_777_216, // default
withoutEnlargement: false, // default
},
})
```

Resized output is not cached, and anyone who can read a file can request it at any size within these limits. Before enabling dynamic resizing on a public collection, see [Securing dynamic resizing](https://payloadcms.com/docs/upload/transformers#securing-dynamic-resizing) for how to restrict it with access control, tighten the limits, and cache the output.

### Injecting a custom Sharp build

```ts
import customSharp from 'sharp'

sharpTransformer({ sharp: customSharp })
```

See the [File Transformers](https://payloadcms.com/docs/upload/transformers) docs for the full transformer contract, access-control lifecycle, and non-goals (no caching, no CDN integration, no jobs queue).

## Migrating from Payload 3.x

If you previously configured `sharp`/`resizeOptions`/`imageSizes`/etc. directly on your Payload Config or collections, run:

```sh
npx @payloadcms/codemod --transform migrate-sharp-to-transformer
```

See the [v4 migration guide](https://payloadcms.com/docs/migration-guide/v4#sharp-moved-out-of-core-into-payloadcmstransformer-sharp) for details.
67 changes: 67 additions & 0 deletions packages/transformer-sharp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"name": "@payloadcms/transformer-sharp",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@payloadcms/transformer-sharp is absent from packagePublishList. The release tool will not update its version or publish it. Add transformer-sharp after payload and add a completeness test for public packages.

"version": "4.0.0-canary.37",
"description": "Official Sharp-based file transformer for Payload: request-time image resizing and upload-time image processing",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
"url": "https://github.com/payloadcms/payload.git",
"directory": "packages/transformer-sharp"
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"sideEffects": false,
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm build:types && pnpm build:swc",
"build:clean": "find . \\( -type d \\( -name build -o -name dist -o -name .cache \\) -o -type f -name tsconfig.tsbuildinfo \\) -exec rm -rf {} + && pnpm build",
"build:debug": "pnpm build",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf -g {dist,*.tsbuildinfo}",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"sharp": "0.35.4"
},
"devDependencies": {
"payload": "workspace:*"
},
"peerDependencies": {
"payload": "workspace:*"
},
"engines": {
"node": ">=24.15.0"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
7 changes: 7 additions & 0 deletions packages/transformer-sharp/src/canResizeImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function canResizeImage(mimeType: string): boolean {
return (
['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/tiff', 'image/avif'].indexOf(
mimeType,
) > -1
)
}
76 changes: 76 additions & 0 deletions packages/transformer-sharp/src/getImageResizeAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { ImageSize, ProbedImageSize } from 'payload'

import { isNumber } from 'payload/shared'

/**
* Determine whether or not to resize the image.
* - resize using image config
* - resize using image config with focal adjustments
* - do not resize at all
*
* `imageResizeConfig.withoutEnlargement`:
* - undefined [default]: uploading images with smaller width AND height than the image size will return null
* - false: always enlarge images to the image size
* - true: if the image is smaller than the image size, return the original image
*
* `imageResizeConfig.withoutReduction`:
* - false [default]: always enlarge images to the image size
* - true: if the image is smaller than the image size, return the original image
*
* @return 'omit' | 'resize' | 'resizeWithFocalPoint'
*/
export const getImageResizeAction = ({
dimensions: originalImage,
hasFocalPoint,
imageResizeConfig,
}: {
dimensions: ProbedImageSize
hasFocalPoint?: boolean
imageResizeConfig: ImageSize
}): 'omit' | 'resize' | 'resizeWithFocalPoint' => {
const { fit, withoutEnlargement, withoutReduction } = imageResizeConfig
const targetWidth = imageResizeConfig.width!
const targetHeight = imageResizeConfig.height!

// prevent upscaling by default when x and y are both smaller than target image size
if (targetHeight && targetWidth) {
const originalImageIsSmallerXAndY =
originalImage.width < targetWidth && originalImage.height < targetHeight
if (withoutEnlargement === undefined && originalImageIsSmallerXAndY) {
return 'omit' // prevent image size from being enlarged
}
}

if (withoutEnlargement === undefined && (!targetWidth || !targetHeight)) {
if (
(targetWidth && originalImage.width < targetWidth) ||
(targetHeight && originalImage.height < targetHeight)
) {
return 'omit'
}
}

const originalImageIsSmallerXOrY =
originalImage.width < targetWidth || originalImage.height < targetHeight
if (fit === 'contain' || fit === 'inside') {
return 'resize'
}
if (!isNumber(targetHeight) && !isNumber(targetWidth)) {
return 'resize'
}

const targetAspectRatio = targetWidth / targetHeight
const originalAspectRatio = originalImage.width / originalImage.height
if (originalAspectRatio === targetAspectRatio) {
return 'resize'
}

if (withoutEnlargement && originalImageIsSmallerXOrY) {
return 'resize'
}
if (withoutReduction && !originalImageIsSmallerXOrY) {
return 'resize'
}

return hasFocalPoint ? 'resizeWithFocalPoint' : 'resize'
}
Loading
Loading