From 95ec682a5e5cf2e7b149880f58bb9afd756a6f4e Mon Sep 17 00:00:00 2001 From: Evan Frenkel Date: Tue, 16 Jun 2026 09:27:37 -0700 Subject: [PATCH 1/2] feat: support browser media processing --- package-lock.json | 16 + packages/decap-cms-core/index.d.ts | 17 ++ packages/decap-cms-core/package.json | 1 + .../actions/__tests__/mediaLibrary.spec.js | 130 ++++++++- .../src/actions/mediaLibrary.ts | 125 ++++++-- .../constants/__tests__/configSchema.spec.js | 47 +++ .../src/constants/configSchema.js | 35 +++ .../__tests__/imageTransformations.spec.ts | 220 ++++++++++++++ .../src/lib/imageTransformations.ts | 273 ++++++++++++++++++ .../src/reducers/__tests__/entries.spec.js | 12 + .../reducers/__tests__/mediaLibrary.spec.js | 29 ++ .../decap-cms-core/src/reducers/entries.ts | 15 +- .../src/reducers/mediaLibrary.ts | 7 +- packages/decap-cms-core/src/types/redux.ts | 18 ++ scripts/webpack.js | 4 + 15 files changed, 916 insertions(+), 33 deletions(-) create mode 100644 packages/decap-cms-core/src/lib/__tests__/imageTransformations.spec.ts create mode 100644 packages/decap-cms-core/src/lib/imageTransformations.ts diff --git a/package-lock.json b/package-lock.json index 86c924e21063..f673ac737aa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4008,6 +4008,15 @@ "tslib": "2" } }, + "node_modules/@jsquash/webp": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jsquash/webp/-/webp-1.5.0.tgz", + "integrity": "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw==", + "license": "Apache-2.0", + "dependencies": { + "wasm-feature-detect": "^1.2.11" + } + }, "node_modules/@juggle/resize-observer": { "version": "3.4.0", "license": "Apache-2.0" @@ -30790,6 +30799,12 @@ "loose-envify": "^1.0.0" } }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, "node_modules/watchpack": { "version": "2.5.1", "dev": true, @@ -32131,6 +32146,7 @@ "license": "MIT", "dependencies": { "@iarna/toml": "2.2.5", + "@jsquash/webp": "1.5.0", "@vercel/stega": "^0.1.2", "ajv": "^8.17.1", "ajv-errors": "^3.0.0", diff --git a/packages/decap-cms-core/index.d.ts b/packages/decap-cms-core/index.d.ts index 9469c5802de4..eb0e94930d09 100644 --- a/packages/decap-cms-core/index.d.ts +++ b/packages/decap-cms-core/index.d.ts @@ -60,6 +60,7 @@ declare module 'decap-cms-core' { i18n?: boolean | 'translate' | 'duplicate' | 'none'; media_folder?: string; public_folder?: string; + media_processing?: CmsMediaProcessing; comment?: string; } @@ -408,6 +409,21 @@ declare module 'decap-cms-core' { url?: string; } + export type CmsMediaProcessingFormat = 'jpeg' | 'webp'; + + export interface CmsMediaProcessing { + enabled: boolean; + format?: { + enabled: boolean; + default: CmsMediaProcessingFormat; + }; + quality?: number; + strip_metadata?: boolean; + width?: number | null; + height?: number | null; + aspect_ratio?: number | string | null; + } + export interface CmsConfig { backend: CmsBackend; collections: CmsCollection[]; @@ -423,6 +439,7 @@ declare module 'decap-cms-core' { media_folder?: string; public_folder?: string; media_folder_relative?: boolean; + media_processing?: CmsMediaProcessing; media_library?: CmsMediaLibrary; publish_mode?: CmsPublishMode; issue_reports?: CmsIssueReports; diff --git a/packages/decap-cms-core/package.json b/packages/decap-cms-core/package.json index 9688255a37b3..d52f641e5282 100644 --- a/packages/decap-cms-core/package.json +++ b/packages/decap-cms-core/package.json @@ -25,6 +25,7 @@ "license": "MIT", "dependencies": { "@iarna/toml": "2.2.5", + "@jsquash/webp": "1.5.0", "@vercel/stega": "^0.1.2", "ajv": "^8.17.1", "ajv-errors": "^3.0.0", diff --git a/packages/decap-cms-core/src/actions/__tests__/mediaLibrary.spec.js b/packages/decap-cms-core/src/actions/__tests__/mediaLibrary.spec.js index b80977abd088..79b7c3e78836 100644 --- a/packages/decap-cms-core/src/actions/__tests__/mediaLibrary.spec.js +++ b/packages/decap-cms-core/src/actions/__tests__/mediaLibrary.spec.js @@ -6,6 +6,13 @@ import { insertMedia, persistMedia, deleteMedia } from '../mediaLibrary'; jest.mock('../../backend'); jest.mock('../waitUntil'); +jest.mock('../../lib/imageTransformations', () => { + const actual = jest.requireActual('../../lib/imageTransformations'); + return { + ...actual, + transformImage: jest.fn(), + }; +}); jest.mock('decap-cms-lib-util', () => { const lib = jest.requireActual('decap-cms-lib-util'); return { @@ -74,6 +81,7 @@ describe('mediaLibrary', () => { beforeEach(() => { jest.clearAllMocks(); + window.confirm = jest.fn(() => true); }); it('should not persist media when editing draft', () => { @@ -146,7 +154,7 @@ describe('mediaLibrary', () => { }), integrations: Map(), mediaLibrary: Map({ - files: List(), + files: List([{ name: 'kittens.jpg' }]), }), entryDraft: Map({ entry: Map(), @@ -239,6 +247,126 @@ describe('mediaLibrary', () => { ); }); }); + + it('should persist a processed image as the selected media', () => { + const { transformImage } = require('../../lib/imageTransformations'); + backend.persistMedia.mockImplementation((_config, assetProxy) => ({ + id: assetProxy.path, + path: assetProxy.path, + })); + + const store = mockStore({ + config: { + media_folder: 'static/media', + media_processing: { + enabled: true, + format: { enabled: true, default: 'webp' }, + quality: 80, + strip_metadata: true, + width: 400, + height: null, + aspect_ratio: '16_9', + }, + slug: { + encoding: 'unicode', + clean_accents: false, + sanitize_replacement: '-', + }, + }, + collections: Map({ + posts: Map({ name: 'posts' }), + }), + integrations: Map(), + mediaLibrary: Map({ + files: List(), + }), + entryDraft: Map({ + entry: Map(), + }), + }); + + const file = new File(['original'], 'kittens.jpg', { type: 'image/jpeg' }); + const processed = new File(['processed'], 'kittens.webp', { type: 'image/webp' }); + + transformImage.mockResolvedValue([{ file: processed, path: 'static/media/kittens.webp' }]); + + return store.dispatch(persistMedia(file)).then(() => { + const actions = store.getActions(); + const addAssetActions = actions.filter(action => action.type === 'ADD_ASSET'); + const persistedActions = actions.filter(action => action.type === 'MEDIA_PERSIST_SUCCESS'); + + expect(transformImage).toHaveBeenCalledWith(file, 'static/media/kittens.jpg', { + format: 'webp', + quality: 0.8, + width: 400, + height: null, + aspectRatio: 16 / 9, + }); + expect(addAssetActions.map(action => action.payload.path)).toEqual([ + 'static/media/kittens.webp', + ]); + expect(persistedActions.map(action => action.payload.file.path)).toEqual([ + 'static/media/kittens.webp', + ]); + }); + }); + + it('should confirm before replacing the original output file when format conversion is disabled', () => { + const { transformImage } = require('../../lib/imageTransformations'); + backend.persistMedia.mockImplementation((_config, assetProxy) => ({ + id: assetProxy.path, + path: assetProxy.path, + })); + + const store = mockStore({ + config: { + media_folder: 'static/media', + media_processing: { + enabled: true, + format: { enabled: false, default: 'webp' }, + }, + slug: { + encoding: 'unicode', + clean_accents: false, + sanitize_replacement: '-', + }, + }, + collections: Map({ + posts: Map({ name: 'posts' }), + }), + integrations: Map(), + mediaLibrary: Map({ + files: List([{ name: 'kittens.jpg', path: 'static/media/kittens.jpg' }]), + }), + entryDraft: Map({ + entry: Map(), + }), + }); + + const file = new File(['original'], 'kittens.jpg', { type: 'image/jpeg' }); + const processed = new File(['processed'], 'kittens.jpg', { type: 'image/jpeg' }); + + transformImage.mockResolvedValue([{ file: processed, path: 'static/media/kittens.jpg' }]); + + return store.dispatch(persistMedia(file)).then(() => { + const addAssetActions = store.getActions().filter(action => action.type === 'ADD_ASSET'); + + expect(transformImage).toHaveBeenCalledWith(file, 'static/media/kittens.jpg', { + format: undefined, + quality: undefined, + width: null, + height: null, + aspectRatio: null, + }); + expect(addAssetActions.map(action => action.payload.path)).toEqual([ + 'static/media/kittens.jpg', + ]); + expect(window.confirm).toHaveBeenCalledWith( + 'kittens.jpg already exists. Do you want to replace it?', + ); + expect(backend.persistMedia).toHaveBeenCalledTimes(1); + }); + }); }); describe('deleteMedia', () => { diff --git a/packages/decap-cms-core/src/actions/mediaLibrary.ts b/packages/decap-cms-core/src/actions/mediaLibrary.ts index 705825b6b8f2..89f138c53787 100644 --- a/packages/decap-cms-core/src/actions/mediaLibrary.ts +++ b/packages/decap-cms-core/src/actions/mediaLibrary.ts @@ -16,6 +16,12 @@ import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries'; import { sanitizeSlug } from '../lib/urlHelper'; import { waitUntilWithTimeout } from './waitUntil'; import { addNotification } from './notifications'; +import { + getMediaProcessingConfig, + getMediaProcessingFileName, + shouldTransformImage, + transformImage, +} from '../lib/imageTransformations'; import type { State, @@ -210,6 +216,38 @@ function createMediaFileFromAsset({ return mediaFile; } +async function getMediaFilesForUpload({ + file, + path, + config, + field, +}: { + file: File; + path: string; + config: State['config']; + field?: EntryField; +}) { + const mediaProcessing = getMediaProcessingConfig(config, field); + + if (!shouldTransformImage(file, mediaProcessing)) { + return [{ file, path }]; + } + + return transformImage(file, path, mediaProcessing!); +} + +function getUploadFileName( + fileName: string, + file: File, + config: State['config'], + field?: EntryField, +) { + const mediaProcessing = getMediaProcessingConfig(config, field); + return shouldTransformImage(file, mediaProcessing) + ? getMediaProcessingFileName(fileName, mediaProcessing) + : fileName; +} + export function persistMedia(file: File, opts: MediaOptions = {}) { const { privateUpload, field } = opts; return async (dispatch: ThunkDispatch, getState: () => State) => { @@ -218,7 +256,10 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { const integration = selectIntegration(state, null, 'assetStore'); const files: MediaFile[] = selectMediaFiles(state, field); const fileName = sanitizeSlug(file.name.toLowerCase(), state.config.slug); - const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName); + const uploadFileName = getUploadFileName(fileName, file, state.config, field); + const existingFile = files.find( + existingFile => existingFile.name.toLowerCase() === uploadFileName, + ); const editingDraft = selectEditingDraft(state.entryDraft); @@ -241,7 +282,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { } try { - let assetProxy: AssetProxy; + let assetProxies: { file: File; assetProxy: AssetProxy }[]; if (integration) { try { const provider = getIntegrationProvider( @@ -250,15 +291,25 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { integration, ); const response = await provider.upload(file, privateUpload); - assetProxy = createAssetProxy({ - url: response.asset.url, - path: response.asset.url, - }); + assetProxies = [ + { + file, + assetProxy: createAssetProxy({ + url: response.asset.url, + path: response.asset.url, + }), + }, + ]; } catch (error) { - assetProxy = createAssetProxy({ - file, - path: fileName, - }); + assetProxies = [ + { + file, + assetProxy: createAssetProxy({ + file, + path: fileName, + }), + }, + ]; } } else if (privateUpload) { throw new Error('The Private Upload option is only available for Asset Store Integration'); @@ -266,34 +317,50 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { const entry = state.entryDraft.get('entry'); const collection = state.collections.get(entry?.get('collection')); const path = selectMediaFilePath(state.config, collection, entry, fileName, field); - assetProxy = createAssetProxy({ + const mediaFiles = await getMediaFilesForUpload({ file, path, + config: state.config, field, }); + assetProxies = mediaFiles.map(mediaFile => ({ + file: mediaFile.file, + assetProxy: createAssetProxy({ + file: mediaFile.file, + path: mediaFile.path, + field, + }), + })); } - dispatch(addAsset(assetProxy)); + let lastDispatch; - let mediaFile: ImplementationMediaFile; - if (integration) { - const id = await getBlobSHA(file); - // integration assets are persisted immediately, thus draft is false - mediaFile = createMediaFileFromAsset({ id, file, assetProxy, draft: false }); - } else if (editingDraft) { - const id = await getBlobSHA(file); - mediaFile = createMediaFileFromAsset({ - id, - file, - assetProxy, - draft: editingDraft, - }); - return dispatch(addDraftEntryMediaFile(mediaFile)); - } else { - mediaFile = await backend.persistMedia(state.config, assetProxy); + for (const { file, assetProxy } of assetProxies) { + dispatch(addAsset(assetProxy)); + + let mediaFile: ImplementationMediaFile; + if (integration) { + const id = await getBlobSHA(file); + // integration assets are persisted immediately, thus draft is false + mediaFile = createMediaFileFromAsset({ id, file, assetProxy, draft: false }); + } else if (editingDraft) { + const id = await getBlobSHA(file); + mediaFile = createMediaFileFromAsset({ + id, + file, + assetProxy, + draft: editingDraft, + }); + lastDispatch = dispatch(addDraftEntryMediaFile(mediaFile)); + continue; + } else { + mediaFile = await backend.persistMedia(state.config, assetProxy); + } + + lastDispatch = dispatch(mediaPersisted(mediaFile, { privateUpload })); } - return dispatch(mediaPersisted(mediaFile, { privateUpload })); + return lastDispatch; } catch (error) { console.error(error); dispatch( diff --git a/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js b/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js index 7a377761f0fc..0a307bb7636d 100644 --- a/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js +++ b/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js @@ -94,6 +94,53 @@ describe('config', () => { }).toThrowError("'media_folder' must be string"); }); + it('should not throw for media processing config', () => { + expect(() => { + validateConfig({ + ...validConfig, + media_processing: { + enabled: true, + format: { enabled: true, default: 'jpeg' }, + quality: 90, + strip_metadata: true, + width: null, + height: null, + aspect_ratio: '16_9', + }, + }); + }).not.toThrowError(); + }); + + it('should not throw for media processing with explicit dimensions and format', () => { + expect(() => { + validateConfig({ + ...validConfig, + media_processing: { + enabled: true, + format: { enabled: true, default: 'webp' }, + quality: 80, + width: 1200, + height: 675, + aspect_ratio: 16 / 9, + }, + }); + }).not.toThrowError(); + }); + + it('should throw if media processing format is unsupported', () => { + expect(() => { + validateConfig({ + ...validConfig, + media_processing: { + enabled: true, + format: { enabled: true, default: 'gif' }, + }, + }); + }).toThrowError( + "'media_processing.format.default' must be equal to one of the allowed values", + ); + }); + it('should throw if collections is not defined in config', () => { expect(() => { validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz' }); diff --git a/packages/decap-cms-core/src/constants/configSchema.js b/packages/decap-cms-core/src/constants/configSchema.js index 1bcddcc274f8..b8b134908979 100644 --- a/packages/decap-cms-core/src/constants/configSchema.js +++ b/packages/decap-cms-core/src/constants/configSchema.js @@ -40,6 +40,39 @@ const i18nField = { oneOf: [{ type: 'boolean' }, { type: 'string', enum: Object.values(I18N_FIELD) }], }; +const mediaProcessing = { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + format: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + default: { type: 'string', enum: ['jpeg', 'webp'] }, + }, + required: ['enabled', 'default'], + additionalProperties: false, + }, + quality: { + type: 'number', + minimum: 1, + maximum: 100, + }, + strip_metadata: { type: 'boolean' }, + width: { oneOf: [{ type: 'number', minimum: 1 }, { type: 'null' }] }, + height: { oneOf: [{ type: 'number', minimum: 1 }, { type: 'null' }] }, + aspect_ratio: { + oneOf: [ + { type: 'number', exclusiveMinimum: 0 }, + { type: 'string', pattern: '^\\d+(?:\\.\\d+)?[_:]\\d+(?:\\.\\d+)?$' }, + { type: 'null' }, + ], + }, + }, + required: ['enabled'], + additionalProperties: false, +}; + /** * Config for fields in both file and folder collections. */ @@ -60,6 +93,7 @@ function fieldsConfig() { required: { type: 'boolean' }, i18n: i18nField, hint: { type: 'string' }, + media_processing: mediaProcessing, pattern: { type: 'array', minItems: 2, @@ -172,6 +206,7 @@ function getConfigSchema() { media_folder: { type: 'string', examples: ['assets/uploads'] }, public_folder: { type: 'string', examples: ['/uploads'] }, media_folder_relative: { type: 'boolean' }, + media_processing: mediaProcessing, media_library: { type: 'object', properties: { diff --git a/packages/decap-cms-core/src/lib/__tests__/imageTransformations.spec.ts b/packages/decap-cms-core/src/lib/__tests__/imageTransformations.spec.ts new file mode 100644 index 000000000000..1db0651910dc --- /dev/null +++ b/packages/decap-cms-core/src/lib/__tests__/imageTransformations.spec.ts @@ -0,0 +1,220 @@ +import { fromJS } from 'immutable'; + +import { + getMediaProcessingConfig, + getMediaProcessingFileName, + shouldTransformImage, + transformImage, +} from '../imageTransformations'; + +const mockEncodeWebp = jest.fn(() => Promise.resolve(new ArrayBuffer(3))); +const mockCloseImage = jest.fn(); +const mockDrawImage = jest.fn(); +const mockImageData = { width: 160, height: 90, data: new Uint8ClampedArray(160 * 90 * 4) }; +const mockGetImageData = jest.fn(() => mockImageData); +const mockToBlob = jest.fn(callback => callback(new Blob(['encoded'], { type: 'image/jpeg' }))); + +jest.mock('@jsquash/webp', () => ({ encode: mockEncodeWebp }), { virtual: true }); + +describe('imageTransformations', () => { + const originalCreateImageBitmap = global.createImageBitmap; + const originalCreateElement = document.createElement; + + beforeEach(() => { + jest.clearAllMocks(); + global.createImageBitmap = jest.fn(() => + Promise.resolve({ + width: 400, + height: 300, + close: mockCloseImage, + } as unknown as ImageBitmap), + ); + document.createElement = jest.fn(() => ({ + width: 0, + height: 0, + getContext: jest.fn(() => ({ + drawImage: mockDrawImage, + getImageData: mockGetImageData, + })), + toBlob: mockToBlob, + })) as unknown as typeof document.createElement; + }); + + afterEach(() => { + global.createImageBitmap = originalCreateImageBitmap; + document.createElement = originalCreateElement; + }); + + describe('getMediaProcessingConfig', () => { + it('returns undefined when media processing is disabled', () => { + expect(getMediaProcessingConfig({ media_processing: { enabled: false } })).toBeUndefined(); + expect(getMediaProcessingConfig({})).toBeUndefined(); + }); + + it('normalizes root media processing config', () => { + expect( + getMediaProcessingConfig({ + media_processing: { + enabled: true, + format: { enabled: true, default: 'jpeg' }, + quality: 90, + strip_metadata: true, + width: 1600, + height: null, + aspect_ratio: '16_9', + }, + }), + ).toEqual({ + format: 'jpeg', + quality: 0.9, + width: 1600, + height: null, + aspectRatio: 16 / 9, + }); + }); + + it('uses field config over root config', () => { + const field = fromJS({ + media_processing: { + enabled: true, + format: { enabled: true, default: 'webp' }, + }, + }); + + expect( + getMediaProcessingConfig( + { + media_processing: { + enabled: true, + format: { enabled: true, default: 'jpeg' }, + }, + }, + field, + ), + ).toEqual({ + format: 'webp', + quality: undefined, + width: null, + height: null, + aspectRatio: null, + }); + }); + + it('supports plain object field config from media upload options', () => { + const field = { + media_processing: { + enabled: true, + format: { enabled: false, default: 'webp' }, + }, + }; + + expect(getMediaProcessingConfig({}, field)).toEqual({ + format: undefined, + quality: undefined, + width: null, + height: null, + aspectRatio: null, + }); + }); + }); + + describe('shouldTransformImage', () => { + it('requires config and a non-svg image', () => { + const config = { format: 'jpeg' as const, width: null, height: null, aspectRatio: null }; + + expect(shouldTransformImage(new File([], 'image.jpg', { type: 'image/jpeg' }), config)).toBe( + true, + ); + expect( + shouldTransformImage(new File([], 'image.svg', { type: 'image/svg+xml' }), config), + ).toBe(false); + expect( + shouldTransformImage(new File([], 'file.pdf', { type: 'application/pdf' }), config), + ).toBe(false); + expect( + shouldTransformImage(new File([], 'image.jpg', { type: 'image/jpeg' }), undefined), + ).toBe(false); + }); + }); + + describe('getMediaProcessingFileName', () => { + it('uses the configured output extension', () => { + expect( + getMediaProcessingFileName('kittens.jpeg', { + format: 'webp', + width: null, + height: null, + aspectRatio: null, + }), + ).toBe('kittens.webp'); + expect( + getMediaProcessingFileName('kittens.png', { + format: 'jpeg', + width: null, + height: null, + aspectRatio: null, + }), + ).toBe('kittens.jpg'); + }); + + it('preserves the original file name when format conversion is disabled', () => { + expect( + getMediaProcessingFileName('kittens.png', { + format: undefined, + width: null, + height: null, + aspectRatio: null, + }), + ).toBe('kittens.png'); + }); + }); + + describe('transformImage', () => { + it('encodes webp with jSquash and strips metadata by re-encoding', async () => { + const file = new File(['original'], 'kittens.jpg', { type: 'image/jpeg' }); + const files = await transformImage(file, 'static/media/kittens.jpg', { + format: 'webp', + quality: 0.8, + width: 160, + height: null, + aspectRatio: 16 / 9, + }); + + expect(files).toHaveLength(1); + expect(files[0].file.name).toBe('kittens.webp'); + expect(files[0].file.type).toBe('image/webp'); + expect(files[0].path).toBe('static/media/kittens.webp'); + expect(mockDrawImage).toHaveBeenCalledWith( + expect.any(Object), + 0, + 38, + 400, + 225, + 0, + 0, + 160, + 90, + ); + expect(mockGetImageData).toHaveBeenCalledWith(0, 0, 160, 90); + expect(mockEncodeWebp).toHaveBeenCalledWith(mockImageData, { quality: 80 }); + expect(mockCloseImage).toHaveBeenCalledTimes(1); + }); + + it('encodes jpeg with canvas', async () => { + const file = new File(['original'], 'kittens.png', { type: 'image/png' }); + const files = await transformImage(file, 'static/media/kittens.png', { + format: 'jpeg', + quality: 0.7, + width: null, + height: null, + aspectRatio: null, + }); + + expect(files[0].file.name).toBe('kittens.jpg'); + expect(files[0].file.type).toBe('image/jpeg'); + expect(files[0].path).toBe('static/media/kittens.jpg'); + expect(mockToBlob).toHaveBeenCalledWith(expect.any(Function), 'image/jpeg', 0.7); + expect(mockEncodeWebp).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/decap-cms-core/src/lib/imageTransformations.ts b/packages/decap-cms-core/src/lib/imageTransformations.ts new file mode 100644 index 000000000000..9575ea838648 --- /dev/null +++ b/packages/decap-cms-core/src/lib/imageTransformations.ts @@ -0,0 +1,273 @@ +import { basename, fileExtension, fileExtensionWithSeparator } from 'decap-cms-lib-util'; + +import type { CmsMediaProcessing, EntryField } from '../types/redux'; + +export type MediaProcessingConfig = { + format?: 'jpeg' | 'webp'; + quality?: number; + width?: number | null; + height?: number | null; + aspectRatio?: number | null; +}; + +export type ImageTransformationFile = { + file: File; + path: string; +}; + +type PlainFieldMediaProcessing = { + media_processing?: CmsMediaProcessing; +}; + +type FieldMediaProcessingGetter = { + get: (key: 'media_processing') => CmsMediaProcessing | undefined; +}; + +function hasMediaProcessingGetter( + field: EntryField | PlainFieldMediaProcessing | undefined, +): field is EntryField & FieldMediaProcessingGetter { + return typeof (field as FieldMediaProcessingGetter | undefined)?.get === 'function'; +} + +function getFieldMediaProcessing(field?: EntryField | PlainFieldMediaProcessing) { + const mediaProcessing = ( + hasMediaProcessingGetter(field) + ? field.get('media_processing') + : (field as PlainFieldMediaProcessing | undefined)?.media_processing + ) as (CmsMediaProcessing & { toJS?: () => CmsMediaProcessing }) | undefined; + + return mediaProcessing?.toJS ? mediaProcessing.toJS() : mediaProcessing; +} + +function normalizeFormat(format: string | undefined) { + const normalized = (format || '').toLowerCase(); + return normalized === 'jpg' ? 'jpeg' : normalized; +} + +function parseAspectRatio(aspectRatio: CmsMediaProcessing['aspect_ratio']) { + if (typeof aspectRatio === 'number') { + return aspectRatio > 0 ? aspectRatio : null; + } + + if (typeof aspectRatio !== 'string') { + return null; + } + + const match = aspectRatio.match(/^(\d+(?:\.\d+)?)[_:](\d+(?:\.\d+)?)$/); + if (!match) { + return null; + } + + const width = Number(match[1]); + const height = Number(match[2]); + return width > 0 && height > 0 ? width / height : null; +} + +export function getMediaProcessingConfig( + config: { media_processing?: CmsMediaProcessing }, + field?: EntryField, +): MediaProcessingConfig | undefined { + const mediaProcessing = getFieldMediaProcessing(field) ?? config.media_processing; + + if (!mediaProcessing?.enabled) { + return undefined; + } + + return { + format: mediaProcessing.format?.enabled + ? (normalizeFormat(mediaProcessing.format.default) as MediaProcessingConfig['format']) + : undefined, + quality: mediaProcessing.quality ? mediaProcessing.quality / 100 : undefined, + width: mediaProcessing.width ?? null, + height: mediaProcessing.height ?? null, + aspectRatio: parseAspectRatio(mediaProcessing.aspect_ratio), + }; +} + +export function shouldTransformImage(file: File, config: MediaProcessingConfig | undefined) { + return !!config && file.type.startsWith('image/') && file.type !== 'image/svg+xml'; +} + +function getMimeType(format: string) { + switch (format) { + case 'jpeg': + return 'image/jpeg'; + case 'webp': + return 'image/webp'; + case 'png': + return 'image/png'; + default: + return 'image/jpeg'; + } +} + +function getInputFormat(fileName: string) { + const inputFormat = fileExtension(fileName).toLowerCase(); + return normalizeFormat(inputFormat); +} + +function getOutputFormat(fileName: string, config: MediaProcessingConfig) { + if (config.format) { + return config.format; + } + + const inputFormat = getInputFormat(fileName); + if (inputFormat === 'jpeg' || inputFormat === 'png' || inputFormat === 'webp') { + return inputFormat; + } + + return 'jpeg'; +} + +function getOutputExtension(format: string) { + return format === 'jpeg' ? 'jpg' : format; +} + +export function getMediaProcessingFileName(fileName: string, config?: MediaProcessingConfig) { + if (!config) { + return fileName; + } + + if (!config.format) { + return fileName; + } + + const extension = fileExtensionWithSeparator(fileName); + const baseName = extension ? basename(fileName, extension) : basename(fileName); + return `${baseName}.${getOutputExtension(config.format)}`; +} + +function getProcessedPath(originalPath: string, fileName: string) { + const originalName = basename(originalPath); + const parent = originalPath.slice(0, Math.max(0, originalPath.length - originalName.length)); + + return `${parent}${fileName}`; +} + +function getTargetDimensions( + sourceWidth: number, + sourceHeight: number, + config: MediaProcessingConfig, +) { + const width = config.width ?? undefined; + const height = config.height ?? undefined; + const aspectRatio = config.aspectRatio ?? sourceWidth / sourceHeight; + + if (width && height) { + return { width, height }; + } + + if (width) { + return { width, height: Math.round(width / aspectRatio) }; + } + + if (height) { + return { width: Math.round(height * aspectRatio), height }; + } + + if (config.aspectRatio) { + return { width: sourceWidth, height: Math.round(sourceWidth / aspectRatio) }; + } + + return { width: sourceWidth, height: sourceHeight }; +} + +function getSourceCrop(sourceWidth: number, sourceHeight: number, aspectRatio?: number | null) { + if (!aspectRatio) { + return { x: 0, y: 0, width: sourceWidth, height: sourceHeight }; + } + + const sourceRatio = sourceWidth / sourceHeight; + if (sourceRatio > aspectRatio) { + const width = Math.round(sourceHeight * aspectRatio); + return { x: Math.round((sourceWidth - width) / 2), y: 0, width, height: sourceHeight }; + } + + const height = Math.round(sourceWidth / aspectRatio); + return { x: 0, y: Math.round((sourceHeight - height) / 2), width: sourceWidth, height }; +} + +function loadImage(file: File) { + if (typeof createImageBitmap === 'function') { + return createImageBitmap(file); + } + + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const image = new Image(); + image.onload = () => { + URL.revokeObjectURL(url); + resolve(image); + }; + image.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error(`Failed to load image '${file.name}'`)); + }; + image.src = url; + }); +} + +async function encodeImageData(imageData: ImageData, format: string, quality = 0.75) { + if (format === 'webp') { + const { encode } = await import('@jsquash/webp'); + return encode(imageData, { quality: Math.round(quality * 100) }); + } + + throw new Error(`ImageData encoding is not supported for '${format}'`); +} + +function encodeCanvas(canvas: HTMLCanvasElement, format: string, quality = 0.75) { + return new Promise((resolve, reject) => { + canvas.toBlob( + blob => { + if (!blob) { + reject(new Error(`Failed to encode image as '${format}'`)); + return; + } + + resolve(blob); + }, + getMimeType(format), + quality, + ); + }); +} + +export async function transformImage( + file: File, + originalPath: string, + config: MediaProcessingConfig, +): Promise { + const outputFormat = getOutputFormat(file.name, config); + const mimeType = getMimeType(outputFormat); + const image = await loadImage(file); + const crop = getSourceCrop(image.width, image.height, config.aspectRatio); + const { width, height } = getTargetDimensions(crop.width, crop.height, config); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('Canvas 2D context is not available'); + } + + canvas.width = width; + canvas.height = height; + context.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, width, height); + + if ('close' in image && typeof image.close === 'function') { + image.close(); + } + + const encoded = + outputFormat === 'webp' + ? await encodeImageData( + context.getImageData(0, 0, width, height), + outputFormat, + config.quality, + ) + : await encodeCanvas(canvas, outputFormat, config.quality); + const fileName = getMediaProcessingFileName(file.name, config); + const transformedFile = new File([encoded], fileName, { type: mimeType }); + + return [{ file: transformedFile, path: getProcessedPath(originalPath, fileName) }]; +} diff --git a/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js b/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js index 283099be404f..6b663cf415f9 100644 --- a/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js +++ b/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js @@ -549,6 +549,18 @@ describe('entries', () => { ).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png'); }); + it('should preserve generated transformation paths', () => { + expect( + selectMediaFilePublicPath( + { public_folder: '/uploads' }, + null, + 'public/uploads/_transformations/webp/kittens.webp', + undefined, + undefined, + ), + ).toBe('/uploads/_transformations/webp/kittens.webp'); + }); + it('should handle file public_folder', () => { const entry = fromJS({ path: 'src/posts/index.md', diff --git a/packages/decap-cms-core/src/reducers/__tests__/mediaLibrary.spec.js b/packages/decap-cms-core/src/reducers/__tests__/mediaLibrary.spec.js index 44e0411a2442..aec315ce2100 100644 --- a/packages/decap-cms-core/src/reducers/__tests__/mediaLibrary.spec.js +++ b/packages/decap-cms-core/src/reducers/__tests__/mediaLibrary.spec.js @@ -107,6 +107,35 @@ describe('mediaLibrary', () => { expect(selectMediaFolder).toHaveBeenCalledWith(state.config, collection, entry, imageField); }); + it('should select draft transformation media files from collection when editing a draft', () => { + const { selectEditingDraft, selectMediaFolder } = require('../../reducers/entries'); + + selectEditingDraft.mockReturnValue(true); + selectMediaFolder.mockReturnValue('static/images/posts'); + + const imageField = fromJS({ name: 'image' }); + const collection = fromJS({ fields: [imageField] }); + const entry = fromJS({ + collection: 'posts', + mediaFiles: [ + { id: 1, path: 'static/images/posts/_transformations/webp/logo.webp' }, + { id: 2, path: 'static/images/other/_transformations/webp/image.webp' }, + ], + data: {}, + }); + const state = { + config: {}, + collections: fromJS({ posts: collection }), + entryDraft: fromJS({ + entry, + }), + }; + + expect(selectMediaFiles(state, imageField)).toEqual([ + { id: 1, key: 1, path: 'static/images/posts/_transformations/webp/logo.webp' }, + ]); + }); + it('should select global media files when not editing a draft', () => { const { selectEditingDraft } = require('../../reducers/entries'); diff --git a/packages/decap-cms-core/src/reducers/entries.ts b/packages/decap-cms-core/src/reducers/entries.ts index e651bcf6768b..1fb4d92a868b 100644 --- a/packages/decap-cms-core/src/reducers/entries.ts +++ b/packages/decap-cms-core/src/reducers/entries.ts @@ -547,6 +547,17 @@ function getFileField(collectionFiles: CollectionFiles, slug: string | undefined return file; } +function getMediaPublicPathSegment(mediaPath: string) { + const normalizedPath = trim(mediaPath, '/'); + const transformationIndex = normalizedPath.indexOf('_transformations/'); + + if (transformationIndex >= 0) { + return normalizedPath.slice(transformationIndex); + } + + return basename(mediaPath); +} + function hasCustomFolder( folderKey: 'media_folder' | 'public_folder', collection: Collection | null, @@ -806,10 +817,10 @@ export function selectMediaFilePublicPath( } if (isAbsolutePath(publicFolder)) { - return joinUrlPath(publicFolder, basename(mediaPath)); + return joinUrlPath(publicFolder, getMediaPublicPathSegment(mediaPath)); } - return join(publicFolder, basename(mediaPath)); + return join(publicFolder, getMediaPublicPathSegment(mediaPath)); } export function selectEditingDraft(state: EntryDraft) { diff --git a/packages/decap-cms-core/src/reducers/mediaLibrary.ts b/packages/decap-cms-core/src/reducers/mediaLibrary.ts index d5ad34204151..55e4007a452f 100644 --- a/packages/decap-cms-core/src/reducers/mediaLibrary.ts +++ b/packages/decap-cms-core/src/reducers/mediaLibrary.ts @@ -33,6 +33,11 @@ import type { EntryField, } from '../types/redux'; +function isMediaFileInFolder(filePath: string, mediaFolder: string) { + const fileFolder = dirname(filePath); + return fileFolder === mediaFolder || fileFolder.startsWith(`${mediaFolder}/_transformations/`); +} + const defaultState: { isVisible: boolean; showMediaButton: boolean; @@ -269,7 +274,7 @@ export function selectMediaFiles(state: State, field?: EntryField) { const collection = state.collections.get(entry?.get('collection')); const mediaFolder = selectMediaFolder(state.config, collection, entry, field); files = entryFiles - .filter(f => dirname(f.path) === mediaFolder) + .filter(f => isMediaFileInFolder(f.path, mediaFolder)) .map(file => ({ key: file.id, ...file })); } else { files = mediaLibrary.get('files') || []; diff --git a/packages/decap-cms-core/src/types/redux.ts b/packages/decap-cms-core/src/types/redux.ts index 3f3f3d7c702d..117a8a193369 100644 --- a/packages/decap-cms-core/src/types/redux.ts +++ b/packages/decap-cms-core/src/types/redux.ts @@ -76,6 +76,7 @@ export interface CmsFieldBase { i18n?: boolean | 'translate' | 'duplicate' | 'none'; media_folder?: string; public_folder?: string; + media_processing?: CmsMediaProcessing; comment?: string; } @@ -422,6 +423,21 @@ export interface CmsIssueReports { url?: string; } +export type CmsMediaProcessingFormat = 'jpeg' | 'webp'; + +export interface CmsMediaProcessing { + enabled: boolean; + format?: { + enabled: boolean; + default: CmsMediaProcessingFormat; + }; + quality?: number; + strip_metadata?: boolean; + width?: number | null; + height?: number | null; + aspect_ratio?: number | string | null; +} + export interface CmsConfig { backend: CmsBackend; collections: CmsCollection[]; @@ -437,6 +453,7 @@ export interface CmsConfig { media_folder?: string; public_folder?: string; media_folder_relative?: boolean; + media_processing?: CmsMediaProcessing; media_library?: CmsMediaLibrary; publish_mode?: CmsPublishMode; load_config_file?: boolean; @@ -598,6 +615,7 @@ export type EntryField = StaticallyTypedRecord<{ media_folder?: string; multiple?: boolean; public_folder?: string; + media_processing?: CmsMediaProcessing; comment?: string; meta?: boolean; i18n: 'translate' | 'duplicate' | 'none'; diff --git a/scripts/webpack.js b/scripts/webpack.js index 4f3618797545..99b43cdbaa0c 100644 --- a/scripts/webpack.js +++ b/scripts/webpack.js @@ -37,6 +37,10 @@ function rules() { exclude: [/node_modules/], use: 'svg-inline-loader', }), + wasm: () => ({ + test: /\.wasm$/, + type: 'asset/resource', + }), vfile: () => ({ test: /node_modules\/vfile\/core\.js/, use: [ From 2cc1b48555ed57cc51d3af6d8b47e2e76a790a2e Mon Sep 17 00:00:00 2001 From: Evan Frenkel Date: Wed, 17 Jun 2026 12:38:23 -0700 Subject: [PATCH 2/2] fix: address media processing review feedback --- .../actions/__tests__/mediaLibrary.spec.js | 81 +++++++++++++++++++ .../src/actions/mediaLibrary.ts | 31 +++++-- .../__tests__/imageTransformations.spec.ts | 22 +++-- .../src/lib/imageTransformations.ts | 9 ++- 4 files changed, 130 insertions(+), 13 deletions(-) diff --git a/packages/decap-cms-core/src/actions/__tests__/mediaLibrary.spec.js b/packages/decap-cms-core/src/actions/__tests__/mediaLibrary.spec.js index 79b7c3e78836..40a19628d484 100644 --- a/packages/decap-cms-core/src/actions/__tests__/mediaLibrary.spec.js +++ b/packages/decap-cms-core/src/actions/__tests__/mediaLibrary.spec.js @@ -6,6 +6,9 @@ import { insertMedia, persistMedia, deleteMedia } from '../mediaLibrary'; jest.mock('../../backend'); jest.mock('../waitUntil'); +jest.mock('../../integrations', () => ({ + getIntegrationProvider: jest.fn(), +})); jest.mock('../../lib/imageTransformations', () => { const actual = jest.requireActual('../../lib/imageTransformations'); return { @@ -367,6 +370,84 @@ describe('mediaLibrary', () => { expect(backend.persistMedia).toHaveBeenCalledTimes(1); }); }); + + it('should upload a processed image when an asset store integration is configured', () => { + const { transformImage } = require('../../lib/imageTransformations'); + const { getBlobSHA } = require('decap-cms-lib-util'); + const { getIntegrationProvider } = require('../../integrations'); + const upload = jest.fn(() => + Promise.resolve({ asset: { url: 'https://assets.example.com/kittens.webp' } }), + ); + + getBlobSHA.mockReturnValue('000000000000001'); + getIntegrationProvider.mockReturnValue({ upload }); + + const store = mockStore({ + config: { + media_folder: 'static/media', + media_processing: { + enabled: true, + format: { enabled: true, default: 'webp' }, + quality: 80, + strip_metadata: true, + width: 400, + height: null, + aspect_ratio: '16_9', + }, + slug: { + encoding: 'unicode', + clean_accents: false, + sanitize_replacement: '-', + }, + }, + collections: Map({ + posts: Map({ name: 'posts' }), + }), + integrations: Map({ + hooks: Map({ assetStore: 'assetStore' }), + providers: Map({ assetStore: Map() }), + }), + mediaLibrary: Map({ + files: List(), + }), + entryDraft: Map({ + entry: Map(), + }), + }); + + const file = new File(['original'], 'kittens.jpg', { type: 'image/jpeg' }); + const processed = new File(['processed'], 'kittens.webp', { type: 'image/webp' }); + + transformImage.mockResolvedValue([{ file: processed, path: 'kittens.webp' }]); + + return store.dispatch(persistMedia(file)).then(() => { + const actions = store.getActions(); + + expect(transformImage).toHaveBeenCalledWith(file, 'kittens.jpg', { + format: 'webp', + quality: 0.8, + width: 400, + height: null, + aspectRatio: 16 / 9, + }); + expect(upload).toHaveBeenCalledWith(processed, undefined); + expect(getBlobSHA).toHaveBeenCalledWith(processed); + expect(actions).toContainEqual({ + type: 'MEDIA_PERSIST_SUCCESS', + payload: { + file: expect.objectContaining({ + id: '000000000000001', + name: 'kittens.webp', + url: 'https://assets.example.com/kittens.webp', + path: 'https://assets.example.com/kittens.webp', + file: processed, + size: processed.size, + }), + privateUpload: undefined, + }, + }); + }); + }); }); describe('deleteMedia', () => { diff --git a/packages/decap-cms-core/src/actions/mediaLibrary.ts b/packages/decap-cms-core/src/actions/mediaLibrary.ts index 89f138c53787..dda0377a4543 100644 --- a/packages/decap-cms-core/src/actions/mediaLibrary.ts +++ b/packages/decap-cms-core/src/actions/mediaLibrary.ts @@ -236,6 +236,21 @@ async function getMediaFilesForUpload({ return transformImage(file, path, mediaProcessing!); } +async function getMediaFileForUpload({ + file, + path, + config, + field, +}: { + file: File; + path: string; + config: State['config']; + field?: EntryField; +}) { + const [mediaFile] = await getMediaFilesForUpload({ file, path, config, field }); + return mediaFile; +} + function getUploadFileName( fileName: string, file: File, @@ -284,16 +299,22 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { try { let assetProxies: { file: File; assetProxy: AssetProxy }[]; if (integration) { + const mediaFile = await getMediaFileForUpload({ + file, + path: fileName, + config: state.config, + field, + }); try { const provider = getIntegrationProvider( state.integrations, backend.getToken, integration, ); - const response = await provider.upload(file, privateUpload); + const response = await provider.upload(mediaFile.file, privateUpload); assetProxies = [ { - file, + file: mediaFile.file, assetProxy: createAssetProxy({ url: response.asset.url, path: response.asset.url, @@ -303,10 +324,10 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { } catch (error) { assetProxies = [ { - file, + file: mediaFile.file, assetProxy: createAssetProxy({ - file, - path: fileName, + file: mediaFile.file, + path: mediaFile.path, }), }, ]; diff --git a/packages/decap-cms-core/src/lib/__tests__/imageTransformations.spec.ts b/packages/decap-cms-core/src/lib/__tests__/imageTransformations.spec.ts index 1db0651910dc..1be773605c34 100644 --- a/packages/decap-cms-core/src/lib/__tests__/imageTransformations.spec.ts +++ b/packages/decap-cms-core/src/lib/__tests__/imageTransformations.spec.ts @@ -119,15 +119,27 @@ describe('imageTransformations', () => { }); describe('shouldTransformImage', () => { - it('requires config and a non-svg image', () => { + it('requires config and a supported image format', () => { const config = { format: 'jpeg' as const, width: null, height: null, aspectRatio: null }; expect(shouldTransformImage(new File([], 'image.jpg', { type: 'image/jpeg' }), config)).toBe( true, ); + expect(shouldTransformImage(new File([], 'image.png', { type: 'image/png' }), config)).toBe( + true, + ); + expect(shouldTransformImage(new File([], 'image.webp', { type: 'image/webp' }), config)).toBe( + true, + ); expect( shouldTransformImage(new File([], 'image.svg', { type: 'image/svg+xml' }), config), ).toBe(false); + expect(shouldTransformImage(new File([], 'image.gif', { type: 'image/gif' }), config)).toBe( + false, + ); + expect(shouldTransformImage(new File([], 'image.avif', { type: 'image/avif' }), config)).toBe( + false, + ); expect( shouldTransformImage(new File([], 'file.pdf', { type: 'application/pdf' }), config), ).toBe(false); @@ -171,8 +183,8 @@ describe('imageTransformations', () => { describe('transformImage', () => { it('encodes webp with jSquash and strips metadata by re-encoding', async () => { - const file = new File(['original'], 'kittens.jpg', { type: 'image/jpeg' }); - const files = await transformImage(file, 'static/media/kittens.jpg', { + const file = new File(['original'], 'Kitten Photo.JPG', { type: 'image/jpeg' }); + const files = await transformImage(file, 'static/media/kitten-photo.jpg', { format: 'webp', quality: 0.8, width: 160, @@ -181,9 +193,9 @@ describe('imageTransformations', () => { }); expect(files).toHaveLength(1); - expect(files[0].file.name).toBe('kittens.webp'); + expect(files[0].file.name).toBe('kitten-photo.webp'); expect(files[0].file.type).toBe('image/webp'); - expect(files[0].path).toBe('static/media/kittens.webp'); + expect(files[0].path).toBe('static/media/kitten-photo.webp'); expect(mockDrawImage).toHaveBeenCalledWith( expect.any(Object), 0, diff --git a/packages/decap-cms-core/src/lib/imageTransformations.ts b/packages/decap-cms-core/src/lib/imageTransformations.ts index 9575ea838648..5e7b2f2fb6ee 100644 --- a/packages/decap-cms-core/src/lib/imageTransformations.ts +++ b/packages/decap-cms-core/src/lib/imageTransformations.ts @@ -23,6 +23,8 @@ type FieldMediaProcessingGetter = { get: (key: 'media_processing') => CmsMediaProcessing | undefined; }; +const SUPPORTED_INPUT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); + function hasMediaProcessingGetter( field: EntryField | PlainFieldMediaProcessing | undefined, ): field is EntryField & FieldMediaProcessingGetter { @@ -85,7 +87,7 @@ export function getMediaProcessingConfig( } export function shouldTransformImage(file: File, config: MediaProcessingConfig | undefined) { - return !!config && file.type.startsWith('image/') && file.type !== 'image/svg+xml'; + return !!config && SUPPORTED_INPUT_TYPES.has(file.type.toLowerCase()); } function getMimeType(format: string) { @@ -238,7 +240,8 @@ export async function transformImage( originalPath: string, config: MediaProcessingConfig, ): Promise { - const outputFormat = getOutputFormat(file.name, config); + const originalFileName = basename(originalPath); + const outputFormat = getOutputFormat(originalFileName, config); const mimeType = getMimeType(outputFormat); const image = await loadImage(file); const crop = getSourceCrop(image.width, image.height, config.aspectRatio); @@ -266,7 +269,7 @@ export async function transformImage( config.quality, ) : await encodeCanvas(canvas, outputFormat, config.quality); - const fileName = getMediaProcessingFileName(file.name, config); + const fileName = getMediaProcessingFileName(originalFileName, config); const transformedFile = new File([encoded], fileName, { type: mimeType }); return [{ file: transformedFile, path: getProcessedPath(originalPath, fileName) }];