Skip to content

feat!: add dynamic image resizing and support for file transformers adapters - #17827

Open
paulpopus wants to merge 44 commits into
mainfrom
feat/file-transformers
Open

paulpopus wants to merge 44 commits into
mainfrom
feat/file-transformers

Conversation

@paulpopus

@paulpopus paulpopus commented Aug 17, 2026 •

Copy link
Copy Markdown
Member

Note

Part 1 of 3 in a stacked PR. This PR holds the tests, fixtures, test configuration and generated test types; the description below covers the whole stack.

  1. This PR — tests, fixtures, test configuration and generated test types
  2. feat!: add support for file transformer adapters #18410 — Payload core, storage adapters, codemod, templates and documentation
  3. feat: add sharp file transformer with dynamic image resizing #18411 — @payloadcms/transformer-sharp, its README, workspace registration and lockfile

CI is only expected to pass at the top of the stack (#18411).

Summary

Payload core assumed every upload was an image, and processed it only with Sharp. It could only serve image sizes generated in advance, at upload time. This PR replaces that assumption with a generic transformer pipeline configured under upload.transformers. A transformer can process a file at upload time, serve a dynamically transformed variant at request time, or both. @payloadcms/transformer-sharp is the new official transformer. It reproduces the existing Sharp upload behavior and adds opt-in dynamic width and height resizing to Payload's existing access-controlled file endpoint.

Why

Payload could not transform a file when a client requested it. Applications had to pre-generate every image size they might need. Payload also had no extension point for video, documents, or external processing services, because Sharp support was built directly into core. This design moves file processing out of core, so any transformer can use the same upload and request lifecycle.

How

  • Payload plans a fixed, ordered list of eligible transformers before running any of them. It filters by capability, MIME type, and each transformer's own canTransform check. Each stage returns continue or complete, and a thrown error aborts the pipeline and the surrounding operation.
  • A dynamic request runs through the collection's existing read access control before Payload retrieves the source file or calls a transformer. Payload sets req.fileTransform to true only while access.read decides a transform request, so a collection can allow ordinary reads while restricting transformed variants. No transformer code, including canTransform, runs until an access check has passed.
  • Source retrieval for dynamic requests is lazy and single-use. First-party storage adapters (S3, GCS, Azure, R2, Vercel Blob) gained an internal transform file-handler operation, so they can return a readable body to a transformer even when their normal public response redirects to a signed URL.
  • @payloadcms/transformer-sharp owns all Sharp-specific collection configuration, including image sizes, which are now authored as variants on the transformer instead of imageSizes on the collection. It writes a Sharp-agnostic projection of variants (as upload.imageSizes), crop, and focalPoint back onto the sanitized collection config, so the Admin Panel, generated types, and the existing sizes document shape stay unchanged.
  • A new public generatePayloadFileURL helper always builds the access-controlled Payload file endpoint. Applications and plugins can link to a file, including transformer query parameters, without accidentally bypassing access control.

Breaking changes

  • The top-level sharp config property is removed. Register sharpTransformer() from @payloadcms/transformer-sharp under upload.transformers instead.
  • constructorOptions, formatOptions, resizeOptions, trimOptions, and withMetadata are removed from CollectionConfig['upload']. Configure them through sharpTransformer({ collections }).
  • imageSizes is removed from CollectionConfig['upload']. Image sizes are now declared as variants on sharpTransformer({ collections: { <slug>: { variants } } }); the entries themselves are unchanged. A collection that still sets upload.imageSizes fails the build. The resolved sizes remain readable on the sanitized config at collection.upload.imageSizes.
  • sharp is no longer a dependency of payload. Install @payloadcms/transformer-sharp explicitly.
  • The Sharp-specific types SharpDependency, ImageUploadFormatOptions, ImageUploadTrimOptions, and SharpImageSizeOptions are no longer exported from payload. SharpDependency is now exported from @payloadcms/transformer-sharp.

Before:

import sharp from 'sharp'
import { buildConfig } from 'payload'

export default buildConfig({
  sharp,
  collections: [
    {
      slug: 'media',
      upload: {
        resizeOptions: { width: 2048, height: 2048 },
        imageSizes: [{ name: 'thumbnail', width: 400, height: 300 }],
      },
    },
  ],
})

After:

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

export default buildConfig({
  collections: [
    {
      slug: 'media',
      upload: true,
    },
  ],
  upload: {
    transformers: [
      sharpTransformer({
        sharp, // optional - defaults to what the transformer ships with
        collections: {
          media: {
            resizeOptions: { width: 2048, height: 2048 },
            variants: [{ name: 'thumbnail', width: 400, height: 300 }],
          },
        },
      }),
    ],
  },
})

Run npx @payloadcms/codemod --transform migrate-sharp-to-transformer to migrate mechanically analyzable configuration automatically. It moves collection imageSizes into sharpTransformer as variants, and renames imageSizes to variants in configs that already use sharpTransformer. Configuration the codemod cannot rewrite safely, such as a collections array built by a function call, is flagged for manual review. See the v4 migration guide for full details.

Opt-in dynamic resizing

Dynamic resizing is disabled by default, so a migrated config keeps serving only the original file and its pre-generated variants. Enable it per collection, and restrict transformed reads with req.fileTransform in access.read:

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

const allowedWidths = new Set(['320', '640', '1280'])

export default buildConfig({
  collections: [
    {
      slug: 'media',
      access: {
        read: ({ req }) => {
          // Ordinary reads are unaffected.
          if (!req.fileTransform) {
            return true
          }

          // Transformed reads: signed-in users, and only the widths the frontend uses.
          const width = req.searchParams.get('width')

          return Boolean(req.user) && width !== null && allowedWidths.has(width)
        },
      },
      upload: true,
    },
  ],
  upload: {
    transformers: [
      sharpTransformer({
        collections: {
          media: {
            variants: [{ name: 'thumbnail', width: 400, height: 300 }],
          },
        },
        dynamic: {
          collections: ['media'],
          maxWidth: 1280,
          withoutEnlargement: true,
        },
      }),
    ],
  },
})

GET /api/media/file/photo.png?width=640 then returns a resized image for a signed-in user, and 403 for anyone else, without fetching the source file.

@paulpopus paulpopus changed the title feat: file transformers feat!: file transformers Aug 17, 2026
@paulpopus paulpopus changed the title feat!: file transformers feat!: add dynamic image resizing and support for file transformers adapters Aug 18, 2026
@github-actions

github-actions Bot commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

📦 esbuild Bundle Analysis for payload

This analysis was generated by esbuild-bundle-analyzer. 🤖

Meta File Out File Size (raw) Note
packages/next/meta_index.json esbuild/index.js 215.27 KB 🆕 Added
packages/payload/meta_index.json esbuild/index.js 1.87 MB 🆕 Added
packages/payload/meta_shared.json esbuild/exports/shared.js 554.32 KB 🆕 Added
packages/richtext-lexical/meta_client.json esbuild/exports/client_optimized/index.js 294.35 KB 🆕 Added
packages/ui/meta_client.json esbuild/exports/client_optimized/index.js 36.54 KB 🆕 Added
packages/ui/meta_shared.json esbuild/exports/shared_optimized/index.js 18.98 KB 🆕 Added
Largest paths These visualization shows top 20 largest paths in the bundle.

Meta file: packages/next/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████████████████▋ }}}$ 98.7%, 210.74 KB
dist/adapters/router.js ${{\color{Goldenrod}{ }}}$ 0.3%, 718 B
dist/adapters/server.js ${{\color{Goldenrod}{ }}}$ 0.2%, 533 B
dist/adapters/layout.js ${{\color{Goldenrod}{ }}}$ 0.2%, 529 B
dist/adapters/views.js ${{\color{Goldenrod}{ }}}$ 0.2%, 324 B
dist/utilities/initAdminContext.js ${{\color{Goldenrod}{ }}}$ 0.1%, 315 B
dist/utilities/selectiveCache.js ${{\color{Goldenrod}{ }}}$ 0.1%, 263 B
dist/esbuildEntry.js ${{\color{Goldenrod}{ }}}$ 0.0%, 0 B

Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████▉ }}}$ 71.7%, 1.33 MB
dist/collections/operations ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 54.67 KB
dist/fields/hooks ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 46.14 KB
dist/auth/operations ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 16.95 KB
dist/globals/operations ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 16.18 KB
dist/utilities/configToJSONSchema.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 16.02 KB
dist/queues/operations ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 14.31 KB
dist/fields/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 13.65 KB
dist/utilities/telemetry ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 11.88 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 10.99 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 10.82 KB
dist/cli/commands ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 9.92 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 8.76 KB
dist/uploads/fetchAPI-multipart ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 8.38 KB
dist/database/migrations ${{\color{Goldenrod}{ }}}$ 0.4%, 8.16 KB
dist/hierarchy/utils ${{\color{Goldenrod}{ }}}$ 0.4%, 7.66 KB
dist/index.js ${{\color{Goldenrod}{ }}}$ 0.4%, 7.54 KB
dist/auth/strategies ${{\color{Goldenrod}{ }}}$ 0.4%, 7.36 KB
dist/utilities/entityInputSchema ${{\color{Goldenrod}{ }}}$ 0.4%, 7.34 KB
dist/config/sanitize.js ${{\color{Goldenrod}{ }}}$ 0.4%, 7.21 KB
(other) ${{\color{Goldenrod}{ ███████ }}}$ 28.3%, 524.54 KB

Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ██████████████████████▏ }}}$ 88.6%, 486.64 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▌ }}}$ 2.0%, 10.79 KB
dist/fields/config ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 5.83 KB
dist/utilities/traverseFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 4.34 KB
dist/utilities/deepCopyObject.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 3.52 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 3.40 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 3.13 KB
dist/fields/baseFields ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 2.79 KB
dist/config/client.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 2.56 KB
dist/auth/cookies.js ${{\color{Goldenrod}{ }}}$ 0.3%, 1.55 KB
dist/utilities/flattenTopLevelFields.js ${{\color{Goldenrod}{ }}}$ 0.3%, 1.42 KB
dist/utilities/getVersionsConfig.js ${{\color{Goldenrod}{ }}}$ 0.2%, 1.04 KB
dist/globals/config ${{\color{Goldenrod}{ }}}$ 0.2%, 939 B
dist/uploads/generatePayloadFileURL.js ${{\color{Goldenrod}{ }}}$ 0.2%, 864 B
dist/utilities/unflatten.js ${{\color{Goldenrod}{ }}}$ 0.2%, 850 B
dist/utilities/flattenAllFields.js ${{\color{Goldenrod}{ }}}$ 0.1%, 794 B
dist/utilities/sanitizeUserDataForEmail.js ${{\color{Goldenrod}{ }}}$ 0.1%, 713 B
dist/auth/extractJWT.js ${{\color{Goldenrod}{ }}}$ 0.1%, 696 B
dist/utilities/getFieldPermissions.js ${{\color{Goldenrod}{ }}}$ 0.1%, 651 B
dist/utilities/fieldPath.js ${{\color{Goldenrod}{ }}}$ 0.1%, 639 B
(other) ${{\color{Goldenrod}{ ██▊ }}}$ 11.4%, 62.78 KB

Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/features/blocks ${{\color{Goldenrod}{ ███▌ }}}$ 14.2%, 41.40 KB
dist/lexical/plugins ${{\color{Goldenrod}{ ██▉ }}}$ 11.9%, 34.51 KB
dist/lexical/ui ${{\color{Goldenrod}{ ██▊ }}}$ 11.4%, 33.27 KB
dist/features/table ${{\color{Goldenrod}{ ██▎ }}}$ 9.4%, 27.46 KB
dist/features/link ${{\color{Goldenrod}{ █▋ }}}$ 6.5%, 18.91 KB
dist/features/toolbars ${{\color{Goldenrod}{ █▌ }}}$ 6.3%, 18.41 KB
dist/features/upload ${{\color{Goldenrod}{ █▏ }}}$ 4.9%, 14.28 KB
dist/features/textState ${{\color{Goldenrod}{ ▉ }}}$ 3.8%, 11.08 KB
dist/lexical/utils ${{\color{Goldenrod}{ ▊ }}}$ 3.4%, 10.02 KB
dist/features/relationship ${{\color{Goldenrod}{ ▊ }}}$ 3.2%, 9.43 KB
dist/features/converters ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 8.40 KB
dist/utilities/fieldsDrawer ${{\color{Goldenrod}{ ▋ }}}$ 2.8%, 8.12 KB
dist/features/debug ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 7.40 KB
dist/lexical/config ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 5.14 KB
dist/features/indent ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 4.19 KB
dist/features/lists ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 3.67 KB
dist/lexical/LexicalEditor.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.33 KB
dist/features/format ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.28 KB
dist/features/horizontalRule ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.18 KB
dist/field/Field.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 2.88 KB
(other) ${{\color{Goldenrod}{ █████████████████████▍ }}}$ 85.8%, 249.74 KB

Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/exports/client ${{\color{Goldenrod}{ █████████████████████████ }}}$ 100.0%, 26.90 KB

Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js

Path Size
dist/graphics/Logo ${{\color{Goldenrod}{ ███████▋ }}}$ 30.7%, 5.61 KB
../../node_modules ${{\color{Goldenrod}{ ███▌ }}}$ 14.5%, 2.65 KB
dist/graphics/Icon ${{\color{Goldenrod}{ ██ }}}$ 8.3%, 1.51 KB
dist/utilities/formatDocTitle ${{\color{Goldenrod}{ █▊ }}}$ 7.2%, 1.32 KB
dist/providers/TableColumns ${{\color{Goldenrod}{ █▏ }}}$ 4.7%, 866 B
dist/utilities/getGlobalData.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 762 B
dist/utilities/api.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 756 B
dist/utilities/groupNavItems.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 745 B
dist/elements/Translation ${{\color{Goldenrod}{ ▋ }}}$ 2.7%, 493 B
dist/utilities/handleTakeOver.js ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 440 B
dist/utilities/traverseForLocalizedFields.js ${{\color{Goldenrod}{ ▌ }}}$ 2.3%, 419 B
dist/elements/withMergedProps ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 339 B
dist/utilities/getNavGroups.js ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 338 B
dist/utilities/getVisibleEntities.js ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 329 B
dist/elements/WithServerSideProps ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 232 B
dist/layouts/Root ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 230 B
dist/utilities/handleGoBack.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 180 B
dist/fields/mergeFieldStyles.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 158 B
dist/forms/Form ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
dist/utilities/handleBackToDashboard.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
(other) ${{\color{Goldenrod}{ █████████████████▎ }}}$ 69.3%, 12.68 KB
Details

Next to the size is how much the size has increased or decreased compared with the base branch of this PR.

  • ‼️: Size increased by 20% or more. Special attention should be given to this.
  • ⚠️: Size increased in acceptable range (lower than 20%).
  • ✅: No change or even downsized.
  • 🗑️: The out file is deleted: not found in base branch.
  • 🆕: The out file is newly found: will be added to base branch.

@paulpopus
paulpopus marked this pull request as ready for review August 20, 2026 18:53
@paulpopus
paulpopus marked this pull request as draft September 21, 2026 17:06
Resolves 37 conflicts between the file-transformer refactor and 141
commits of upload work on main.

Notable semantic resolutions:

- `generateFileData` keeps the transformer pipeline from this branch and
  main's large-client-upload handling. A transformer only runs when the
  full bytes are available (`hasFullFileContents`); otherwise the temp
  file is copied straight to its destination or skipped, never buffered.
  Dimension probing is likewise gated to image types, since probing
  reads the file. Main's filename-ownership, upload-reference,
  detected-mime, and external-upload-source fixes are preserved.
- `cropImage`/`image-resizing` stay deleted; main's only change to them
  (the corrected animated-image mime list) already lives in core's
  `isAnimatedImage`.
- `canResizeImage` is restored in core, which still needs it for
  `isProcessableImage` and `getFileContentRequirement`.
- `getFileContentRequirement` now reads `upload.hasImageAdjustments` -
  the transformer-agnostic projection `sharpTransformer` writes back -
  instead of the removed per-collection Sharp options.
- Storage adapters keep this branch's `operation: 'transform'` handler
  and main's `doc`-based prefix resolution (dropping the client-supplied
  `prefix` query param) and XML content-security-policy handling. Their
  specs move from the renamed `getFileKey` to `buildStoragePathData`.
- Template `package.json` files keep main's dependency bumps and only
  drop `sharp` in favour of `@payloadcms/transformer-sharp`, so the
  lockfile differs from main solely by that new workspace package.
- Test suites and fixtures added on either side are migrated to the
  other's conventions: `upload-transformers` and the shared
  animated-resize/transformer-contract helpers move to the `test.suite`
  fixture, and the `media-header-only-with-sizes` client-upload
  fixtures declare their `imageSizes` through `sharpTransformer`.
@paulpopus
paulpopus marked this pull request as ready for review September 23, 2026 12:17
resolveUploadDocument looks up the filename without access filtering, and
filenames are only unique per prefix. Without a ?prefix it could match another
tenant's document, which was then passed as `doc` to storage handlers — they
trust `doc` for the object key, so the other tenant's file was served.

Use the document checkFileAccess authorized instead, re-planning the pipeline
from its mimeType and re-checking transform access when needed.
Removes unit specs that mocked Payload internals or storage SDKs and only asserted mock calls, for behaviour already exercised by the upload-transformers, uploads, and storage int suites.

- storage: mocked s3/gcs/azure getFile specs replaced by a shared runTransformReadsRealSourceTest run against real emulators; r2/vercel-blob keep only adapter-specific cases
- upload-transformers: drop the kitchen-sink sharp transformer, ResizePreview playground UI + e2e spec, and fixture binaries duplicated from test/uploads; add int tests for the transformer contract error, Range being ignored for transform sources, and resized response headers
- unit specs trimmed to pure functions, config validation, and cases int tests can't reach
- drop the tautological transformer contract shape test (validateTransformers already runs on boot)
- restore the third-party RegisteredImageSizeOptions type test
@r1tsuu

r1tsuu commented Sep 25, 2026

Copy link
Copy Markdown
Member

Pushed 6cc0c01 to trim the tests. The PR diff goes from +12,679 / −2,067 across 197 files to +8,383 / −2,054 across 182 files.

  • Removed:
    • mock-heavy unit specs whose behaviour the upload-transformers, uploads and storage integration suites already cover
    • the kitchen-sink transformer
    • the ResizePreview playground and its e2e spec
    • duplicated fixture binaries
  • Storage: a shared integration test replaces the mocked getFile specs. It runs against the real emulators for s3, gcs, azure and vercel-blob.
  • Kept: unit specs for pure functions, config validation, and cases integration tests can't reach.

All the touched suites pass locally.

# Conflicts:
#	test/benchmark-blocks/config.ts
Comment thread test/upload-transformers/int.spec.ts Outdated
Comment thread test/upload-transformers/int.spec.ts
r1tsuu added 4 commits October 2, 2026 11:29
…ough integration tests

Replaces mocked handleDynamicFileRequest, getFileFromUploadInstructions and generateFileData unit tests with real Payload and Azure handler coverage, and drops two duplicate upload-transformers tests.
…r int tests

Moves the Media and storage Sharp options into shared test helpers, inlines the single-use animated resize helpers, switches upload-transformers to per-test resets, and drops an int test duplicated by existing coverage.
# Conflicts:
#	test/a11y/collections/Media/index.ts
}).toPass({ intervals: [1000], timeout: 15000 })
}

test.describe('Resize preview component', () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Register this suite in .github/workflows/e2e.config.ts. CI currently creates no Next or TanStack job for these tests. Add { file: 'upload-transformers', shards: 1 } and run both jobs.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants