@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.
- package/AGENT_DOCS.md +383 -0
- package/package.json +34 -60
- package/src/components/ImageBox.tsx +80 -0
- package/src/components/OptimizationStatus.tsx +137 -0
- package/src/components/RegenerationButton.tsx +356 -0
- package/src/defaults.ts +36 -0
- package/src/endpoints/regenerate.ts +125 -0
- package/src/exports/client.ts +6 -0
- package/src/exports/rsc.ts +1 -0
- package/src/fields/imageOptimizerField.ts +70 -0
- package/src/hooks/afterChange.ts +77 -0
- package/src/hooks/beforeChange.ts +64 -0
- package/src/index.ts +125 -0
- package/src/next-image.d.ts +3 -0
- package/src/processing/index.ts +59 -0
- package/src/tasks/convertFormats.ts +107 -0
- package/src/tasks/regenerateDocument.ts +177 -0
- package/src/types.ts +38 -0
- package/src/utilities/getImageOptimizerProps.ts +58 -0
- package/src/utilities/thumbhash.ts +15 -0
|
@@ -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,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
|
+
}
|