@inoo-ch/payload-image-optimizer 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ import type { GroupField } from 'payload'
2
+
3
+ export const getImageOptimizerField = (): GroupField => ({
4
+ name: 'imageOptimizer',
5
+ type: 'group',
6
+ admin: {
7
+ position: 'sidebar',
8
+ readOnly: true,
9
+ components: {
10
+ Field: '@inoo-ch/payload-image-optimizer/client#OptimizationStatus',
11
+ },
12
+ },
13
+ fields: [
14
+ {
15
+ name: 'thumbHash',
16
+ type: 'text',
17
+ },
18
+ {
19
+ name: 'originalSize',
20
+ type: 'number',
21
+ },
22
+ {
23
+ name: 'optimizedSize',
24
+ type: 'number',
25
+ },
26
+ {
27
+ name: 'status',
28
+ type: 'select',
29
+ options: ['pending', 'processing', 'complete', 'error'],
30
+ },
31
+ {
32
+ name: 'error',
33
+ type: 'text',
34
+ },
35
+ {
36
+ name: 'variants',
37
+ type: 'array',
38
+ fields: [
39
+ {
40
+ name: 'format',
41
+ type: 'text',
42
+ },
43
+ {
44
+ name: 'filename',
45
+ type: 'text',
46
+ },
47
+ {
48
+ name: 'filesize',
49
+ type: 'number',
50
+ },
51
+ {
52
+ name: 'width',
53
+ type: 'number',
54
+ },
55
+ {
56
+ name: 'height',
57
+ type: 'number',
58
+ },
59
+ {
60
+ name: 'mimeType',
61
+ type: 'text',
62
+ },
63
+ {
64
+ name: 'url',
65
+ type: 'text',
66
+ },
67
+ ],
68
+ },
69
+ ],
70
+ })
@@ -0,0 +1,77 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+ import type { CollectionAfterChangeHook } from 'payload'
4
+
5
+ import type { ResolvedImageOptimizerConfig } from '../types.js'
6
+ import { resolveCollectionConfig } from '../defaults.js'
7
+
8
+ export const createAfterChangeHook = (
9
+ resolvedConfig: ResolvedImageOptimizerConfig,
10
+ collectionSlug: string,
11
+ ): CollectionAfterChangeHook => {
12
+ return async ({ context, doc, req }) => {
13
+ if (context?.imageOptimizer_skip) return doc
14
+
15
+ if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc
16
+
17
+ const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config
18
+ let staticDir: string =
19
+ typeof collectionConfig.upload === 'object' ? collectionConfig.upload.staticDir || '' : ''
20
+
21
+ if (staticDir && !path.isAbsolute(staticDir)) {
22
+ staticDir = path.resolve(process.cwd(), staticDir)
23
+ }
24
+
25
+ const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)
26
+
27
+ // Overwrite the file on disk with the processed (stripped/resized/converted) buffer
28
+ // Payload 3.0 writes the original buffer to disk; we replace it here
29
+ const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined
30
+ if (processedBuffer && doc.filename && staticDir) {
31
+ const safeFilename = path.basename(doc.filename as string)
32
+ const filePath = path.join(staticDir, safeFilename)
33
+ await fs.writeFile(filePath, processedBuffer)
34
+
35
+ // If replaceOriginal changed the filename, clean up the old file Payload wrote
36
+ const originalFilename = context.imageOptimizer_originalFilename as string | undefined
37
+ if (originalFilename && originalFilename !== safeFilename) {
38
+ const oldFilePath = path.join(staticDir, path.basename(originalFilename))
39
+ await fs.unlink(oldFilePath).catch(() => {
40
+ // Old file may not exist if Payload used the new filename
41
+ })
42
+ }
43
+ }
44
+
45
+ // When replaceOriginal is on and only one format is configured, the main file
46
+ // is already converted — skip the async job and mark complete immediately.
47
+ if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {
48
+ await req.payload.update({
49
+ collection: collectionSlug,
50
+ id: doc.id,
51
+ data: {
52
+ imageOptimizer: {
53
+ status: 'complete',
54
+ variants: [],
55
+ },
56
+ },
57
+ context: { imageOptimizer_skip: true },
58
+ })
59
+ return doc
60
+ }
61
+
62
+ // Queue async format conversion job for remaining variants
63
+ await req.payload.jobs.queue({
64
+ task: 'imageOptimizer_convertFormats',
65
+ input: {
66
+ collectionSlug,
67
+ docId: String(doc.id),
68
+ },
69
+ })
70
+
71
+ req.payload.jobs.run().catch((err: unknown) => {
72
+ req.payload.logger.error({ err }, 'Image optimizer job runner failed')
73
+ })
74
+
75
+ return doc
76
+ }
77
+ }
@@ -0,0 +1,64 @@
1
+ import path from 'path'
2
+ import type { CollectionBeforeChangeHook } from 'payload'
3
+
4
+ import type { ResolvedImageOptimizerConfig } from '../types.js'
5
+ import { resolveCollectionConfig } from '../defaults.js'
6
+ import { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js'
7
+
8
+ export const createBeforeChangeHook = (
9
+ resolvedConfig: ResolvedImageOptimizerConfig,
10
+ collectionSlug: string,
11
+ ): CollectionBeforeChangeHook => {
12
+ return async ({ context, data, req }) => {
13
+ if (context?.imageOptimizer_skip) return data
14
+
15
+ if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data
16
+
17
+ const originalSize = req.file.data.length
18
+
19
+ const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)
20
+
21
+ // Process in memory: strip EXIF, resize, generate blur
22
+ const processed = await stripAndResize(
23
+ req.file.data,
24
+ perCollectionConfig.maxDimensions,
25
+ resolvedConfig.stripMetadata,
26
+ )
27
+
28
+ let finalBuffer = processed.buffer
29
+ let finalSize = processed.size
30
+
31
+ if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {
32
+ // Convert to primary format (first in the formats array)
33
+ const primaryFormat = perCollectionConfig.formats[0]
34
+ const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)
35
+
36
+ finalBuffer = converted.buffer
37
+ finalSize = converted.size
38
+
39
+ // Update filename and mimeType so Payload stores the correct metadata
40
+ const originalFilename = data.filename || req.file.name || ''
41
+ const newFilename = `${path.parse(originalFilename).name}.${primaryFormat.format}`
42
+ context.imageOptimizer_originalFilename = originalFilename
43
+ data.filename = newFilename
44
+ data.mimeType = converted.mimeType
45
+ data.filesize = finalSize
46
+ }
47
+
48
+ data.imageOptimizer = {
49
+ originalSize,
50
+ optimizedSize: finalSize,
51
+ status: 'pending',
52
+ }
53
+
54
+ if (resolvedConfig.generateThumbHash) {
55
+ data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)
56
+ }
57
+
58
+ // Store processed buffer in context for afterChange to write to disk
59
+ // (Payload 3.0 does not use modified req.file.data for the disk write)
60
+ context.imageOptimizer_processedBuffer = finalBuffer
61
+
62
+ return data
63
+ }
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,125 @@
1
+ import type { Config } from 'payload'
2
+
3
+ import type { ImageOptimizerConfig } from './types.js'
4
+ import { resolveConfig } from './defaults.js'
5
+ import { getImageOptimizerField } from './fields/imageOptimizerField.js'
6
+ import { createBeforeChangeHook } from './hooks/beforeChange.js'
7
+ import { createAfterChangeHook } from './hooks/afterChange.js'
8
+ import { createConvertFormatsHandler } from './tasks/convertFormats.js'
9
+ import { createRegenerateDocumentHandler } from './tasks/regenerateDocument.js'
10
+ import { createRegenerateHandler, createRegenerateStatusHandler } from './endpoints/regenerate.js'
11
+
12
+ export type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig } from './types.js'
13
+
14
+ export { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js'
15
+
16
+ export const imageOptimizer =
17
+ (pluginOptions: ImageOptimizerConfig) =>
18
+ (config: Config): Config => {
19
+ const resolvedConfig = resolveConfig(pluginOptions)
20
+
21
+ if (!config.collections) {
22
+ config.collections = []
23
+ }
24
+
25
+ // Inject imageOptimizer fields into targeted upload collections
26
+ for (const collectionSlug in resolvedConfig.collections) {
27
+ const collection = config.collections.find((c) => c.slug === collectionSlug)
28
+
29
+ if (collection) {
30
+ collection.fields.push(getImageOptimizerField())
31
+ }
32
+ }
33
+
34
+ // If disabled, keep fields for schema consistency but skip hooks/tasks
35
+ if (resolvedConfig.disabled) {
36
+ return config
37
+ }
38
+
39
+ // Inject hooks into targeted upload collections
40
+ for (const collectionSlug in resolvedConfig.collections) {
41
+ const collection = config.collections.find((c) => c.slug === collectionSlug)
42
+
43
+ if (collection) {
44
+ if (!collection.hooks) {
45
+ collection.hooks = {}
46
+ }
47
+
48
+ if (!collection.hooks.beforeChange) {
49
+ collection.hooks.beforeChange = []
50
+ }
51
+ collection.hooks.beforeChange.push(createBeforeChangeHook(resolvedConfig, collectionSlug))
52
+
53
+ if (!collection.hooks.afterChange) {
54
+ collection.hooks.afterChange = []
55
+ }
56
+ collection.hooks.afterChange.push(createAfterChangeHook(resolvedConfig, collectionSlug))
57
+
58
+ // Add RegenerationButton to the collection list view
59
+ if (!collection.admin) {
60
+ collection.admin = {}
61
+ }
62
+ if (!collection.admin.components) {
63
+ collection.admin.components = {}
64
+ }
65
+ if (!collection.admin.components.beforeListTable) {
66
+ collection.admin.components.beforeListTable = []
67
+ }
68
+ collection.admin.components.beforeListTable.push(
69
+ '@inoo-ch/payload-image-optimizer/client#RegenerationButton',
70
+ )
71
+ }
72
+ }
73
+
74
+ // Register async format conversion job task
75
+ if (!config.jobs) {
76
+ config.jobs = { tasks: [] }
77
+ }
78
+ if (!config.jobs!.tasks) {
79
+ config.jobs!.tasks = []
80
+ }
81
+
82
+ config.jobs!.tasks!.push({
83
+ slug: 'imageOptimizer_convertFormats',
84
+ inputSchema: [
85
+ { name: 'collectionSlug', type: 'text', required: true },
86
+ { name: 'docId', type: 'text', required: true },
87
+ ],
88
+ outputSchema: [
89
+ { name: 'variantsGenerated', type: 'number' },
90
+ ],
91
+ retries: 2,
92
+ handler: createConvertFormatsHandler(resolvedConfig),
93
+ } as any)
94
+
95
+ config.jobs!.tasks!.push({
96
+ slug: 'imageOptimizer_regenerateDocument',
97
+ inputSchema: [
98
+ { name: 'collectionSlug', type: 'text', required: true },
99
+ { name: 'docId', type: 'text', required: true },
100
+ ],
101
+ outputSchema: [
102
+ { name: 'status', type: 'text' },
103
+ { name: 'reason', type: 'text' },
104
+ ],
105
+ retries: 2,
106
+ handler: createRegenerateDocumentHandler(resolvedConfig),
107
+ } as any)
108
+
109
+ // Register regeneration endpoints
110
+ if (!config.endpoints) config.endpoints = []
111
+
112
+ config.endpoints.push({
113
+ path: '/image-optimizer/regenerate',
114
+ method: 'post',
115
+ handler: createRegenerateHandler(resolvedConfig),
116
+ })
117
+
118
+ config.endpoints.push({
119
+ path: '/image-optimizer/regenerate',
120
+ method: 'get',
121
+ handler: createRegenerateStatusHandler(resolvedConfig),
122
+ })
123
+
124
+ return config
125
+ }
@@ -0,0 +1,3 @@
1
+ declare module 'next/image' {
2
+ export { default, ImageProps } from 'next/dist/shared/lib/image-external'
3
+ }
@@ -0,0 +1,59 @@
1
+ import sharp from 'sharp'
2
+ import { rgbaToThumbHash } from 'thumbhash'
3
+
4
+ export async function stripAndResize(
5
+ buffer: Buffer,
6
+ maxDimensions: { width: number; height: number },
7
+ stripMetadata: boolean,
8
+ ): Promise<{ buffer: Buffer; width: number; height: number; size: number }> {
9
+ let pipeline = sharp(buffer)
10
+ .rotate()
11
+ .resize(maxDimensions.width, maxDimensions.height, {
12
+ fit: 'inside',
13
+ withoutEnlargement: true,
14
+ })
15
+
16
+ if (!stripMetadata) {
17
+ pipeline = pipeline.keepMetadata()
18
+ }
19
+
20
+ const { data, info } = await pipeline.toBuffer({ resolveWithObject: true })
21
+
22
+ return {
23
+ buffer: data,
24
+ width: info.width,
25
+ height: info.height,
26
+ size: info.size,
27
+ }
28
+ }
29
+
30
+ export async function generateThumbHash(buffer: Buffer): Promise<string> {
31
+ const { data, info } = await sharp(buffer)
32
+ .resize(100, 100, { fit: 'inside' })
33
+ .raw()
34
+ .ensureAlpha()
35
+ .toBuffer({ resolveWithObject: true })
36
+
37
+ const thumbHash = rgbaToThumbHash(info.width, info.height, data)
38
+ return Buffer.from(thumbHash).toString('base64')
39
+ }
40
+
41
+ export async function convertFormat(
42
+ buffer: Buffer,
43
+ format: 'webp' | 'avif',
44
+ quality: number,
45
+ ): Promise<{ buffer: Buffer; width: number; height: number; size: number; mimeType: string }> {
46
+ const { data, info } = await sharp(buffer)
47
+ .toFormat(format, { quality })
48
+ .toBuffer({ resolveWithObject: true })
49
+
50
+ const mimeType = format === 'webp' ? 'image/webp' : 'image/avif'
51
+
52
+ return {
53
+ buffer: data,
54
+ width: info.width,
55
+ height: info.height,
56
+ size: info.size,
57
+ mimeType,
58
+ }
59
+ }
@@ -0,0 +1,107 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+
4
+ import type { CollectionSlug } from 'payload'
5
+
6
+ import type { ResolvedImageOptimizerConfig } from '../types.js'
7
+ import { resolveCollectionConfig } from '../defaults.js'
8
+ import { convertFormat } from '../processing/index.js'
9
+
10
+ export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
11
+ return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {
12
+ try {
13
+ const doc = await req.payload.findByID({
14
+ collection: input.collectionSlug as CollectionSlug,
15
+ id: input.docId,
16
+ })
17
+
18
+ const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config
19
+
20
+ let staticDir: string =
21
+ typeof collectionConfig.upload === 'object' ? collectionConfig.upload.staticDir || '' : ''
22
+ if (!staticDir) {
23
+ throw new Error(`No staticDir configured for collection "${input.collectionSlug}"`)
24
+ }
25
+ if (!path.isAbsolute(staticDir)) {
26
+ staticDir = path.resolve(process.cwd(), staticDir)
27
+ }
28
+
29
+ // Sanitize filename to prevent path traversal
30
+ const safeFilename = path.basename(doc.filename)
31
+ const filePath = path.join(staticDir, safeFilename)
32
+ const fileBuffer = await fs.readFile(filePath)
33
+
34
+ const variants: Array<{
35
+ filename: string
36
+ filesize: number
37
+ format: string
38
+ height: number
39
+ mimeType: string
40
+ url: string
41
+ width: number
42
+ }> = []
43
+
44
+ const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)
45
+
46
+ // When replaceOriginal is on, the main file is already in the primary format —
47
+ // skip it and only generate variants for the remaining formats.
48
+ const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0
49
+ ? perCollectionConfig.formats.slice(1)
50
+ : perCollectionConfig.formats
51
+
52
+ for (const format of formatsToGenerate) {
53
+ const result = await convertFormat(fileBuffer, format.format, format.quality)
54
+ const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`
55
+
56
+ await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)
57
+
58
+ variants.push({
59
+ format: format.format,
60
+ filename: variantFilename,
61
+ filesize: result.size,
62
+ width: result.width,
63
+ height: result.height,
64
+ mimeType: result.mimeType,
65
+ url: `/api/${input.collectionSlug}/file/${variantFilename}`,
66
+ })
67
+ }
68
+
69
+ await req.payload.update({
70
+ collection: input.collectionSlug as CollectionSlug,
71
+ id: input.docId,
72
+ data: {
73
+ imageOptimizer: {
74
+ status: 'complete',
75
+ variants,
76
+ },
77
+ },
78
+ context: { imageOptimizer_skip: true },
79
+ })
80
+
81
+ return { output: { variantsGenerated: variants.length } }
82
+ } catch (err) {
83
+ const errorMessage = err instanceof Error ? err.message : String(err)
84
+
85
+ try {
86
+ await req.payload.update({
87
+ collection: input.collectionSlug as CollectionSlug,
88
+ id: input.docId,
89
+ data: {
90
+ imageOptimizer: {
91
+ status: 'error',
92
+ error: errorMessage,
93
+ },
94
+ },
95
+ context: { imageOptimizer_skip: true },
96
+ })
97
+ } catch (updateErr) {
98
+ req.payload.logger.error(
99
+ { err: updateErr },
100
+ 'Failed to persist error status for image optimizer',
101
+ )
102
+ }
103
+
104
+ throw err
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,177 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+
4
+ import type { CollectionSlug } from 'payload'
5
+
6
+ import type { ResolvedImageOptimizerConfig } from '../types.js'
7
+ import { resolveCollectionConfig } from '../defaults.js'
8
+ import { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'
9
+
10
+ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
11
+ return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {
12
+ try {
13
+ const doc = await req.payload.findByID({
14
+ collection: input.collectionSlug as CollectionSlug,
15
+ id: input.docId,
16
+ })
17
+
18
+ // Skip non-image documents
19
+ if (!doc.mimeType || !doc.mimeType.startsWith('image/')) {
20
+ return { output: { status: 'skipped', reason: 'not-image' } }
21
+ }
22
+
23
+ const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config
24
+
25
+ let staticDir: string =
26
+ typeof collectionConfig.upload === 'object' ? collectionConfig.upload.staticDir || '' : ''
27
+ if (!staticDir) {
28
+ throw new Error(`No staticDir configured for collection "${input.collectionSlug}"`)
29
+ }
30
+ if (!path.isAbsolute(staticDir)) {
31
+ staticDir = path.resolve(process.cwd(), staticDir)
32
+ }
33
+
34
+ // Sanitize filename to prevent path traversal
35
+ const safeFilename = path.basename(doc.filename)
36
+ const filePath = path.join(staticDir, safeFilename)
37
+
38
+ let fileBuffer: Buffer
39
+ try {
40
+ fileBuffer = await fs.readFile(filePath)
41
+ } catch {
42
+ // If file not on disk, try fetching from URL
43
+ if (doc.url) {
44
+ const url = doc.url.startsWith('http')
45
+ ? doc.url
46
+ : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`
47
+ const response = await fetch(url)
48
+ fileBuffer = Buffer.from(await response.arrayBuffer())
49
+ } else {
50
+ throw new Error(`File not found: ${filePath}`)
51
+ }
52
+ }
53
+
54
+ const originalSize = fileBuffer.length
55
+ const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)
56
+
57
+ // Step 1: Strip metadata + resize
58
+ const processed = await stripAndResize(
59
+ fileBuffer,
60
+ perCollectionConfig.maxDimensions,
61
+ resolvedConfig.stripMetadata,
62
+ )
63
+
64
+ let mainBuffer = processed.buffer
65
+ let mainSize = processed.size
66
+ let newFilename = safeFilename
67
+ let newMimeType: string | undefined
68
+
69
+ // Step 1b: If replaceOriginal, convert main file to primary format
70
+ if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {
71
+ const primaryFormat = perCollectionConfig.formats[0]
72
+ const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)
73
+ mainBuffer = converted.buffer
74
+ mainSize = converted.size
75
+ newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`
76
+ newMimeType = converted.mimeType
77
+ }
78
+
79
+ // Write optimized file to disk
80
+ const newFilePath = path.join(staticDir, newFilename)
81
+ await fs.writeFile(newFilePath, mainBuffer)
82
+
83
+ // Clean up old file if filename changed
84
+ if (newFilename !== safeFilename) {
85
+ await fs.unlink(filePath).catch(() => {})
86
+ }
87
+
88
+ // Step 2: Generate ThumbHash
89
+ let thumbHash: string | undefined
90
+ if (resolvedConfig.generateThumbHash) {
91
+ thumbHash = await generateThumbHash(mainBuffer)
92
+ }
93
+
94
+ // Step 3: Convert to configured formats (skip primary when replaceOriginal)
95
+ const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0
96
+ ? perCollectionConfig.formats.slice(1)
97
+ : perCollectionConfig.formats
98
+
99
+ const variants: Array<{
100
+ filename: string
101
+ filesize: number
102
+ format: string
103
+ height: number
104
+ mimeType: string
105
+ url: string
106
+ width: number
107
+ }> = []
108
+
109
+ for (const format of formatsToGenerate) {
110
+ const result = await convertFormat(mainBuffer, format.format, format.quality)
111
+ const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`
112
+ await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)
113
+
114
+ variants.push({
115
+ format: format.format,
116
+ filename: variantFilename,
117
+ filesize: result.size,
118
+ width: result.width,
119
+ height: result.height,
120
+ mimeType: result.mimeType,
121
+ url: `/api/${input.collectionSlug}/file/${variantFilename}`,
122
+ })
123
+ }
124
+
125
+ // Step 4: Update the document with all optimization data
126
+ const updateData: Record<string, any> = {
127
+ imageOptimizer: {
128
+ originalSize,
129
+ optimizedSize: mainSize,
130
+ status: 'complete',
131
+ thumbHash,
132
+ variants,
133
+ error: null,
134
+ },
135
+ }
136
+
137
+ // Update filename, mimeType, and filesize when replaceOriginal changed them
138
+ if (newFilename !== safeFilename) {
139
+ updateData.filename = newFilename
140
+ updateData.filesize = mainSize
141
+ updateData.mimeType = newMimeType
142
+ }
143
+
144
+ await req.payload.update({
145
+ collection: input.collectionSlug as CollectionSlug,
146
+ id: input.docId,
147
+ data: updateData,
148
+ context: { imageOptimizer_skip: true },
149
+ })
150
+
151
+ return { output: { status: 'complete' } }
152
+ } catch (err) {
153
+ const errorMessage = err instanceof Error ? err.message : String(err)
154
+
155
+ try {
156
+ await req.payload.update({
157
+ collection: input.collectionSlug as CollectionSlug,
158
+ id: input.docId,
159
+ data: {
160
+ imageOptimizer: {
161
+ status: 'error',
162
+ error: errorMessage,
163
+ },
164
+ },
165
+ context: { imageOptimizer_skip: true },
166
+ })
167
+ } catch (updateErr) {
168
+ req.payload.logger.error(
169
+ { err: updateErr },
170
+ 'Failed to persist error status for image optimizer regeneration',
171
+ )
172
+ }
173
+
174
+ throw err
175
+ }
176
+ }
177
+ }